<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' https://api.minimax.chat https://opencode.ai; img-src 'self' data: https: http:; font-src 'self' data:; media-src 'self' https:;">
<meta name="referrer" content="strict-origin-when-cross-origin">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<title>CmdCode Agent</title>
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
:root{
--bg:#0a0a0f;--bg-chat:#080c14;--bg-input:rgba(8,10,18,0.95);
--text:#e0e8f0;--text-secondary:#5a6a7a;--text-tertiary:#3a4a5a;
--separator:rgba(0,255,136,0.06);--separator-thick:rgba(0,255,136,0.12);
--green:#00ff88;--cyan:#00e5ff;--pink:#ff3366;
--safe-bottom:env(safe-area-inset-bottom,0px);
--nav-height:52px;
--font:-apple-system,BlinkMacSystemFont,'SF Pro Display','Segoe UI',Roboto,'Noto Sans SC',sans-serif;
--mono:'SF Mono','Fira Code','Cascadia Code','JetBrains Mono',monospace;
}
body,html{font-family:var(--font);background:var(--bg);color:var(--text);height:100%;width:100%;-webkit-font-smoothing:antialiased;overflow:hidden}
.app{display:flex;flex-direction:column;height:100dvh;width:100%;position:relative;z-index:1}
.cyber-bg{position:fixed;inset:0;z-index:0;pointer-events:none;background:radial-gradient(ellipse at 50% 120%,rgba(0,255,136,0.04) 0%,transparent 50%),radial-gradient(ellipse at 80% 20%,rgba(0,229,255,0.03) 0%,transparent 40%),linear-gradient(180deg,#0a0a0f 0%,#080c14 50%,#060a10 100%)}
.scan-grid{position:fixed;inset:0;z-index:0;pointer-events:none;opacity:0.02;background:linear-gradient(0deg,transparent 49.5%,rgba(0,255,136,0.3) 49.5%,rgba(0,255,136,0.3) 50.5%,transparent 50.5%),linear-gradient(90deg,transparent 49.5%,rgba(0,255,136,0.2) 49.5%,rgba(0,255,136,0.2) 50.5%,transparent 50.5%);background-size:60px 60px}
.nav{display:flex;align-items:center;padding:0 16px;height:var(--nav-height);flex-shrink:0;background:rgba(10,10,15,0.95);border-bottom:1px solid var(--separator);gap:10px;padding-top:calc(env(safe-area-inset-top,0px));height:calc(var(--nav-height) + env(safe-area-inset-top,0px));position:relative;z-index:100}
.nav-logo{display:flex;align-items:center;gap:8px;font-weight:700;font-size:16px;font-family:var(--mono);margin-right:4px;user-select:none}
.nav-logo i{color:var(--green);font-size:18px;text-shadow:0 0 8px rgba(0,255,136,0.5)}
.nav-status{display:flex;align-items:center;gap:5px;font-size:12px;color:var(--text-secondary);font-family:var(--mono)}
.nav-dot{width:7px;height:7px;border-radius:50%;background:var(--green);flex-shrink:0;box-shadow:0 0 8px rgba(0,255,136,0.5)}
.nav-dot.inactive{background:var(--text-tertiary);box-shadow:none}
.mud-status-bar{display:flex;align-items:center;gap:6px;padding:6px 14px;background:rgba(0,255,136,0.03);border-bottom:1px solid var(--separator);font-size:12px;color:var(--text-secondary);flex-shrink:0;font-family:var(--mono)}
.mud-status-bar.hidden{display:none}
.mud-sep{opacity:.3;color:var(--green)}
.mud-toggle{display:inline-flex;align-items:center;cursor:pointer;margin-left:3px}
.mud-toggle input{display:none}
.mud-toggle span{padding:2px 6px;border-radius:4px;background:rgba(0,255,136,0.06);font-size:12px;opacity:.5;transition:all .2s;user-select:none;border:1px solid transparent}
.mud-toggle input:checked+span{opacity:1;background:rgba(0,255,136,0.12);border-color:rgba(0,255,136,0.2);box-shadow:0 0 10px rgba(0,255,136,0.1)}
.mud-cmd-btn{display:inline-flex;align-items:center;cursor:pointer;margin-left:3px;padding:2px 7px;border-radius:4px;background:rgba(0,255,136,0.06);font-size:11px;color:var(--text-secondary);opacity:.7;transition:all .15s;border:1px solid transparent;font-family:inherit;user-select:none}
.mud-cmd-btn:hover{opacity:1;background:rgba(0,255,136,0.12);border-color:rgba(0,255,136,0.2)}
.user-area{display:flex;align-items:center;gap:5px}
.user-btn{font-size:11px;font-weight:500;background:rgba(0,255,136,0.03);color:var(--text-secondary);border:1px solid rgba(0,255,136,0.12);padding:4px 9px;border-radius:4px;cursor:pointer;transition:all .2s;font-family:var(--mono);display:inline-flex;align-items:center;justify-content:center;gap:4px;white-space:nowrap;line-height:1}
.user-btn:hover{color:var(--green);border-color:rgba(0,255,136,0.35);background:rgba(0,255,136,0.08);box-shadow:0 0 8px rgba(0,255,136,0.08)}
.user-btn:active{transform:scale(.97);background:rgba(0,255,136,0.12)}
.user-btn i{font-size:10px;opacity:.8}
.user-info{font-size:11px;color:var(--text-secondary);font-family:var(--mono)}
#userNameSpan{cursor:pointer;transition:all .15s;color:var(--green);display:flex;align-items:center;gap:5px}
#userNameSpan:hover{text-shadow:0 0 10px rgba(0,255,136,0.5)}
#userNameSpan i{font-size:12px;opacity:.7}
#quotaInfo{cursor:pointer;transition:all .15s;color:var(--text-tertiary);font-size:10px}
#quotaInfo:hover{color:var(--cyan)}
.nav .clear-btn{width:32px;height:32px;border-radius:4px;border:1px solid var(--separator);background:transparent;color:var(--text-tertiary);cursor:pointer;flex-shrink:0;transition:all .15s;font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center;padding:0;margin-left:auto}
.nav .clear-btn:hover{background:rgba(0,255,136,0.06);color:var(--green);border-color:rgba(0,255,136,0.2)}
.nav .clear-btn:active{transform:scale(.88)}
.welcome{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;padding:24px;text-align:center;gap:12px}
.welcome-icon{font-size:56px;margin-bottom:8px;filter:drop-shadow(0 0 20px rgba(0,255,136,0.3));animation:iconPulse 3s ease-in-out infinite}
@keyframes iconPulse{0%,100%{filter:drop-shadow(0 0 20px rgba(0,255,136,0.3))}50%{filter:drop-shadow(0 0 30px rgba(0,255,136,0.5))}}
.welcome-title{font-size:28px;font-weight:800;letter-spacing:-.02em;background:linear-gradient(135deg,var(--green),var(--cyan));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
.welcome-sub{font-size:14px;color:var(--text-secondary);line-height:1.6;max-width:360px;font-family:var(--mono)}
.chat{flex:1;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;background:var(--bg-chat);display:flex;flex-direction:column;padding:12px 12px calc(12px + var(--safe-bottom))}
.chat:empty{display:flex;align-items:center;justify-content:center}
.msg{display:flex;margin-bottom:8px;animation:msgIn .3s ease}
.msg.user{justify-content:flex-end}.msg.agent{justify-content:flex-start}.msg.step{justify-content:flex-start;margin-bottom:4px}
.msg-bubble{max-width:80%;padding:12px 16px;font-size:14px;line-height:1.5;word-break:break-word;overflow-wrap:break-word;white-space:pre-wrap;position:relative;border-radius:8px}
.user .msg-bubble{background:rgba(0,255,136,0.08);color:var(--green);border:1px solid rgba(0,255,136,0.15);border-bottom-right-radius:2px;font-family:var(--mono);font-size:13px}
.agent .msg-bubble{background:rgba(255,255,255,0.03);color:var(--text);border:1px solid rgba(255,255,255,0.06);border-bottom-left-radius:2px}
.step .msg-step{font-size:11px;color:var(--text-secondary);background:rgba(0,255,136,0.04);padding:4px 12px;border-radius:4px;max-width:90%;text-align:center;font-family:var(--mono);border:1px solid rgba(0,255,136,0.06)}
.agent .msg-bubble img{max-width:100%;border-radius:8px;margin:6px 0;display:block}
.agent .msg-bubble a{color:var(--cyan);text-decoration:underline}
.msg-tool{display:inline-block;font-size:10px;font-weight:600;background:rgba(0,255,136,0.08);color:var(--green);padding:2px 8px;border-radius:4px;margin-left:6px;vertical-align:middle;font-family:var(--mono);border:1px solid rgba(0,255,136,0.12)}
@keyframes msgIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
.typing{display:flex;gap:4px;padding:14px 18px;background:rgba(255,255,255,0.03);border-radius:8px;align-items:center;border:1px solid rgba(255,255,255,0.06)}
.typing-dot{width:7px;height:7px;border-radius:50%;background:var(--green);animation:typing 1.4s infinite;opacity:0.4}
.typing-dot:nth-child(2){animation-delay:.2s}.typing-dot:nth-child(3){animation-delay:.4s}
@keyframes typing{0%,60%,100%{opacity:.3}30%{opacity:1}}
.input-area{flex-shrink:0;background:var(--bg-input);border-top:1px solid var(--separator);padding:10px 12px 10px;display:flex;align-items:center;gap:10px}
.input-field{flex:1;font-family:var(--mono);font-size:16px;line-height:1.4;padding:10px 16px;border:1px solid var(--separator-thick);border-radius:8px;outline:none;background:rgba(0,255,136,0.03);color:var(--text);transition:none;resize:none;overflow-y:hidden;min-height:42px;max-height:150px}
.input-field:focus{border-color:rgba(0,255,136,0.4)}
.input-field::placeholder{color:var(--text-tertiary)}
.send-btn{width:42px;height:42px;border-radius:8px;border:none;background:var(--green);color:#000;font-size:16px;font-weight:700;cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;transition:transform .15s,box-shadow .15s,background .3s;font-family:var(--mono);box-shadow:0 0 15px rgba(0,255,136,0.2)}
.send-btn:disabled{opacity:.3;cursor:not-allowed;box-shadow:none}
.send-btn:not(:disabled):hover{transform:translateY(-1px);box-shadow:0 0 25px rgba(0,255,136,0.3)}
.send-btn:not(:disabled):active{transform:scale(.92)}
.send-btn.guest{width:auto;height:42px;border-radius:8px;padding:0 14px;font-size:12px;line-height:42px;white-space:nowrap;background:var(--text-tertiary);box-shadow:none}
.send-btn.stop{background:var(--pink);box-shadow:0 0 15px rgba(255,51,102,0.2)}
.send-btn.stop:hover{box-shadow:0 0 25px rgba(255,51,102,0.3)}
.send-btn.stop:active{transform:scale(.92)}
.clear-btn{width:32px;height:32px;border-radius:4px;border:1px solid var(--separator);background:transparent;color:var(--text-tertiary);cursor:pointer;flex-shrink:0;transition:all .15s;font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center;padding:0;margin-left:4px}
.clear-btn:hover{background:rgba(0,255,136,0.06);color:var(--green);border-color:rgba(0,255,136,0.2)}
.clear-btn:active{transform:scale(.88)}
.modal-overlay{position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;padding:24px;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}
.modal-overlay.hidden{display:none}
.modal-box{background:rgba(8,10,18,0.95);border-radius:12px;padding:28px 24px 20px;width:340px;max-width:100%;box-shadow:0 0 40px rgba(0,255,136,0.1),0 8px 40px rgba(0,0,0,0.5);display:flex;flex-direction:column;gap:14px;animation:modalIn .35s ease;border:1px solid rgba(0,255,136,0.1)}
@keyframes modalIn{from{opacity:0;transform:scale(.95) translateY(10px)}to{opacity:1;transform:scale(1) translateY(0)}}
.modal-title{font-size:20px;font-weight:700;text-align:center;margin-bottom:4px;background:linear-gradient(135deg,var(--green),var(--cyan));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
.modal-input{padding:12px 16px;border:1px solid var(--separator-thick);border-radius:8px;font-size:14px;outline:none;font-family:var(--mono);background:rgba(0,255,136,0.03);transition:all .3s;color:var(--text)}
.modal-input:focus{border-color:rgba(0,255,136,0.4);box-shadow:0 0 10px rgba(0,255,136,0.1)}
.modal-error{font-size:12px;color:var(--pink);text-align:center;min-height:18px;font-family:var(--mono)}
.modal-primary{padding:12px;border:none;border-radius:8px;background:var(--green);color:#000;font-size:14px;font-weight:600;cursor:pointer;transition:all .3s;font-family:var(--mono);box-shadow:0 0 15px rgba(0,255,136,0.2)}
.modal-primary:hover{box-shadow:0 0 25px rgba(0,255,136,0.3)}
.modal-primary:active{opacity:.8}
.modal-link{background:none;border:none;color:var(--cyan);font-size:13px;cursor:pointer;text-decoration:underline;font-family:var(--mono);padding:4px;transition:color .3s}
.modal-link:hover{color:var(--green)}
.fm-overlay{position:fixed;inset:0;z-index:1500;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;padding:24px;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}
.fm-overlay.hidden{display:none}
.fm-panel{background:rgba(8,10,18,0.95);border-radius:12px;width:680px;max-width:100%;height:520px;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 0 40px rgba(0,255,136,0.1),0 8px 40px rgba(0,0,0,0.5);animation:modalIn .35s ease;overflow:hidden;border:1px solid rgba(0,255,136,0.1)}
.fm-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--separator);flex-shrink:0;gap:8px;background:rgba(0,255,136,0.02)}
.fm-breadcrumb{display:flex;align-items:center;gap:4px;font-size:13px;overflow-x:auto;flex:1;white-space:nowrap;scrollbar-width:none;-ms-overflow-style:none;font-family:var(--mono)}
.fm-breadcrumb::-webkit-scrollbar{display:none}
.fm-breadcrumb span{cursor:pointer;color:var(--green);font-weight:500;padding:2px 6px;border-radius:4px;transition:all .15s}
.fm-breadcrumb span:hover{background:rgba(0,255,136,0.08);text-shadow:0 0 8px rgba(0,255,136,0.3)}
.fm-breadcrumb .sep{color:var(--text-tertiary);cursor:default;padding:0 2px}
.fm-breadcrumb .current{color:var(--text);font-weight:600;cursor:default}
.fm-close{width:32px;height:32px;border-radius:8px;border:1px solid var(--separator);background:rgba(0,255,136,0.04);color:var(--text-secondary);font-size:16px;cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;transition:all .15s;font-family:var(--mono);line-height:1}
.fm-close:hover{background:rgba(0,255,136,0.1);color:var(--green);border-color:rgba(0,255,136,0.2)}
.fm-body{flex:1;overflow-y:auto;padding:4px 0}
.fm-body::-webkit-scrollbar{width:4px}.fm-body::-webkit-scrollbar-track{background:transparent}.fm-body::-webkit-scrollbar-thumb{background:rgba(0,255,136,0.2);border-radius:2px}
.fm-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:var(--text-tertiary);font-size:14px;gap:4px;font-family:var(--mono)}
.fm-item{display:flex;align-items:center;padding:8px 16px;gap:8px;cursor:default;transition:all .15s;border-bottom:1px solid var(--separator);user-select:none;min-height:44px}
.fm-item:hover{background:rgba(0,255,136,0.04)}
.fm-item-icon{font-size:18px;flex-shrink:0;width:24px;text-align:center}
.fm-item-dir,.fm-item-file{cursor:pointer}
.fm-item-name{flex:1;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);font-family:var(--mono)}
.fm-item-name:hover{color:var(--green)}
.fm-item-size{font-size:11px;color:var(--text-tertiary);flex-shrink:0;width:72px;text-align:right;font-family:var(--mono)}
.fm-item-mtime{font-size:11px;color:var(--text-tertiary);flex-shrink:0;width:130px;text-align:right;font-family:var(--mono)}
.fm-item-actions{display:flex;gap:2px;flex-shrink:0;opacity:0;transition:opacity .15s}
.fm-item:hover .fm-item-actions{opacity:1}
.fm-item-action{width:28px;height:28px;border-radius:6px;border:none;font-size:14px;cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;transition:all .15s;font-family:inherit;background:transparent;color:var(--text-secondary)}
.fm-item-action:hover{background:rgba(0,255,136,0.1);color:var(--green)}
.fm-item-action.del:hover{color:var(--pink);background:rgba(255,51,102,0.1)}
.fm-item-action.share:hover{color:var(--cyan);background:rgba(0,229,255,0.1)}
.fm-footer{padding:10px 16px;border-top:1px solid var(--separator);flex-shrink:0;background:rgba(0,255,136,0.02)}
.fm-quota-text{font-size:11px;color:var(--text-tertiary);margin-bottom:4px;font-family:var(--mono)}
.fm-quota-bar{height:4px;border-radius:2px;background:rgba(0,255,136,0.1);overflow:hidden}
.fm-quota-fill{height:100%;border-radius:2px;background:linear-gradient(90deg,var(--green),var(--cyan));transition:width .3s;box-shadow:0 0 10px rgba(0,255,136,0.3)}
.fm-confirm-del{font-size:11px;color:var(--pink);padding:4px 8px;cursor:pointer;border-radius:4px;transition:background .15s;user-select:none;font-family:var(--mono)}
.fm-confirm-del:hover{background:rgba(255,51,102,0.1)}
.fm-loading{display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-tertiary);font-size:14px;font-family:var(--mono)}
.iv-overlay{position:fixed;inset:0;z-index:1800;background:rgba(0,0,0,0.92);display:flex;align-items:center;justify-content:center;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);animation:fadeIn .2s ease}
.iv-overlay.hidden{display:none}
.iv-container{position:relative;max-width:94vw;max-height:88vh;display:flex;align-items:center;justify-content:center}
.iv-container img{max-width:100%;max-height:88vh;border-radius:8px;box-shadow:0 0 40px rgba(0,255,136,0.1),0 4px 40px rgba(0,0,0,0.5);object-fit:contain;display:block;transition:opacity .25s ease}
.iv-close{position:fixed;top:16px;right:16px;width:40px;height:40px;border-radius:8px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.1);color:#fff;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;font-family:var(--mono);line-height:1;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);z-index:1801}
.iv-close:hover{background:rgba(0,255,136,0.2);border-color:rgba(0,255,136,0.3)}
.iv-topbar{position:fixed;top:16px;right:16px;display:flex;gap:8px;z-index:1801}
.iv-top-btn{width:40px;height:40px;border-radius:8px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.1);color:#fff;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;font-family:var(--mono);line-height:1;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
.iv-top-btn:hover{background:rgba(0,255,136,0.2);border-color:rgba(0,255,136,0.3)}
.iv-name{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);color:rgba(255,255,255,0.7);font-size:13px;background:rgba(0,0,0,0.6);padding:6px 16px;border-radius:8px;max-width:80%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);font-family:var(--mono);border:1px solid rgba(255,255,255,0.1)}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
.iv-prev,.iv-next{position:absolute;top:50%;transform:translateY(-50%);padding:8px;border:none;background:transparent;color:rgba(255,255,255,0.3);font-size:36px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;font-family:var(--mono);line-height:1;z-index:2;user-select:none;-webkit-user-select:none}
.iv-prev{left:12px}.iv-next{right:12px}
.iv-prev:hover,.iv-next:hover{color:var(--green);text-shadow:0 0 15px rgba(0,255,136,0.5)}
.iv-prev:active,.iv-next:active{transform:translateY(-50%) scale(.95)}
@media(max-width:520px){
.fm-panel{width:100%;height:100%;max-height:100%;border-radius:0;padding-top:env(safe-area-inset-top,0)}
.fm-overlay{padding:0}
.fm-item{flex-wrap:wrap;padding:6px 12px;min-height:auto}
.fm-item-icon{font-size:16px;width:20px}
.fm-item-name{flex:0 0 calc(100% - 70px);font-size:12px;line-height:1.3;order:1}
.fm-item-size{width:auto;text-align:left;font-size:11px;color:var(--text-tertiary);order:3;padding-left:28px}
.fm-item-mtime{width:auto;font-size:11px;color:var(--text-tertiary);order:4;padding-left:8px}
.fm-item-actions{opacity:1;order:5;margin-left:auto;gap:0}
.fm-item-action{width:32px;height:32px}
}
.chat::-webkit-scrollbar{width:4px}.chat::-webkit-scrollbar-track{background:transparent}.chat::-webkit-scrollbar-thumb{background:rgba(0,255,136,0.2);border-radius:2px}
</style>
</head>
<body>
<div class="cyber-bg"></div>
<div class="scan-grid"></div>
<div class="h-scan"></div>
<div class="orb orb-1"></div>
<div class="orb orb-2"></div>
<div class="app">
<div class="nav">
<a class="nav-logo" href="https://cmdcode.cn" target="_blank" style="text-decoration:none;color:inherit"><i class="fas fa-terminal"></i> CmdCode</a>
<div class="user-area" id="userArea">
<button class="user-btn" id="loginBtn" aria-label="登录"><i class="fas fa-sign-in-alt"></i>登录</button>
</div>
<div class="nav-status" id="navStatus">
<div id="statusDot" class="nav-dot inactive"></div>
<span id="statusText">就绪</span>
</div>
<button id="clearBtn" class="clear-btn" title="重置会话" aria-label="重置会话">↺</button>
</div>
<div id="mudStatusBar" class="mud-status-bar hidden">
<button class="mud-cmd-btn" id="mudBtnStatus" title="查看状态">📊</button>
<button class="mud-cmd-btn" id="mudBtnSave" title="保存进度">💾</button>
<button class="mud-cmd-btn" id="mudBtnLoad" title="读取存档">📂</button>
<span class="mud-sep">|</span>
<label class="mud-toggle" title="场景配图"><input type="checkbox" id="mudToggleImage"><span>🖼</span></label>
<label class="mud-toggle" title="语音朗读"><input type="checkbox" id="mudToggleVoice"><span>🔊</span></label>
<label class="mud-toggle" title="识图模式"><input type="checkbox" id="mudToggleVision"><span>👁</span></label>
<label class="mud-toggle" title="显示推理"><input type="checkbox" id="mudToggleThinking"><span>💭</span></label>
</div>
<div id="chatContainer" class="chat">
<div class="welcome" id="welcome">
<div class="welcome-icon">💬</div>
<div class="welcome-title">CmdCode Agent</div>
<div class="welcome-sub">访客模式 · 可直接使用 · 所有内容自动保存到共享文件夹 · 登录后可享 1GB 专属个人网盘</div>
</div>
</div>
<div class="input-area">
<textarea id="userInput" class="input-field" rows="1" placeholder="输入任务..." autofocus></textarea>
<button id="sendBtn" class="send-btn" aria-label="发送">→</button>
</div>
</div>
<div class="fm-overlay hidden" id="fmOverlay">
<div class="fm-panel">
<div class="fm-header">
<div class="fm-breadcrumb" id="fmBreadcrumb"></div>
<div style="display:flex;gap:6px;align-items:center">
<button class="fm-close" id="fmSyncBtn" aria-label="同步文件" title="同步服务器与本地文件" style="font-size:12px;width:auto;padding:4px 10px;font-family:var(--mono)">🔄 同步</button>
<button class="fm-close" id="fmCloseBtn" aria-label="关闭文件管理器">✕</button>
</div>
</div>
<div class="fm-body" id="fmBody"></div>
<div class="fm-footer" id="fmFooter"></div>
</div>
</div>
<div class="iv-overlay hidden" id="ivOverlay">
<div class="iv-topbar">
<button class="iv-top-btn" id="ivShareBtn" title="分享">↗</button>
<button class="iv-top-btn" id="ivCloseBtn">✕</button>
</div>
<div class="iv-container" id="ivContainer"></div>
<div class="iv-name" id="ivName"></div>
</div>
<div class="modal-overlay hidden" id="modalOverlay">
<div class="modal-box" id="modalBox">
<div class="modal-title" id="modalTitle">登录</div>
<input type="text" class="modal-input" id="modalUsername" placeholder="用户名">
<input type="password" class="modal-input" id="modalPassword" placeholder="密码">
<div class="modal-error" id="modalError"></div>
<button class="modal-primary" id="modalSubmit">登录</button>
<button class="modal-link" id="modalSwitch">没有账号?去注册</button>
</div>
</div>
<script>
(()=>{
const chatContainer=document.getElementById('chatContainer');
const welcome=document.getElementById('welcome');
const userInput=document.getElementById('userInput');
const sendBtn=document.getElementById('sendBtn');
const clearBtn=document.getElementById('clearBtn');
const statusDot=document.getElementById('statusDot');
const statusText=document.getElementById('statusText');
const userArea=document.getElementById('userArea');
const loginBtn=document.getElementById('loginBtn');
const modalOverlay=document.getElementById('modalOverlay');
const modalTitle=document.getElementById('modalTitle');
const modalUsername=document.getElementById('modalUsername');
const modalPassword=document.getElementById('modalPassword');
const modalError=document.getElementById('modalError');
const modalSubmit=document.getElementById('modalSubmit');
const modalSwitch=document.getElementById('modalSwitch');
const navStatus=document.getElementById('navStatus');
const fmOverlay=document.getElementById('fmOverlay');
const fmBody=document.getElementById('fmBody');
const fmBreadcrumb=document.getElementById('fmBreadcrumb');
const fmCloseBtn=document.getElementById('fmCloseBtn');
const ivOverlay=document.getElementById('ivOverlay');
const ivCloseBtn=document.getElementById('ivCloseBtn');
const ivShareBtn=document.getElementById('ivShareBtn');
const ivContainer=document.getElementById('ivContainer');
const ivName=document.getElementById('ivName');
const mudToggleVoice=document.getElementById('mudToggleVoice');
const mudToggleVision=document.getElementById('mudToggleVision');
const mudToggleImage=document.getElementById('mudToggleImage');
const mudToggleThinking=document.getElementById('mudToggleThinking');
const quotaInfo=document.getElementById('quotaInfo');
const mudStatusBar=document.getElementById('mudStatusBar');
const PROXY_URL='https://cmdcode.cn/cmdcode-minimax-toolset/proxy.php';
// 默认聊天 LLM 供应商(后端已实现五密钥 429 轮换 + 每日 7:00 自动轮换)
// 轮换链:opencode-go → opencode-go1 → ... → opencode-go4
// 前端只需发送供应商名,后端自动处理限流容灾和日切
const CHAT_PROVIDER='opencode-go';
let accessToken=''; // 从服务器端获取,不硬编码在前端
let isRunning=false;
let abortController=null;
let stopRequested=false;
let commandQueue=[]; // BUG-04: 命令队列,防止批量命令丢失
let currentUser=null;
let modalMode='login';
let currentSession={messages:[],awaitingUser:false,pendingToolCallId:null};
let recentConversationMessages=[]; // 用于记忆系统提取
let lastExtractionIndex=0;
let memoryExtractionPending=false;
let lastToolCalls=null;
let sessionHealthInterval=null;
// ── Session 健康检查与自动重登 ──
async function attemptReauth(){
try{
const res=await apiCall('session',{},{ignoreStop:true});
if(res.loggedIn){accessToken=res.token||'';if(accessToken)addAgentMessage('🔄 会话已恢复');return true;}
if(currentUser&¤tUser.username){
const tok=await apiCall('login',{username:currentUser.username,password:''},{ignoreStop:true});
if(tok&&tok.token){accessToken=tok.token;addAgentMessage('🔄 令牌已刷新');return true;}
}
}catch(e){}
// 会话已过期,需要重新登录
currentUser=null;accessToken='';updateUIForUser(null);
addAgentMessage('⚠️ 登录已过期,请重新登录。');
return false;
}
function startSessionHealthCheck(){
stopSessionHealthCheck();
sessionHealthInterval=setInterval(async()=>{
if(!currentUser)return;
try{
const res=await apiCall('session',{},{ignoreStop:true});
if(!res.loggedIn){currentUser=null;accessToken='';updateUIForUser(null);addAgentMessage('⚠️ 会话已过期,请重新登录。');stopSessionHealthCheck();}
}catch(e){}
},300000);
}
function stopSessionHealthCheck(){if(sessionHealthInterval){clearInterval(sessionHealthInterval);sessionHealthInterval=null;}}
let MEMORY_EXTRACTION_INTERVAL=10*60*1000;
let memoryExtractionTimer=null;
let _videoQuotaExhausted=false; // 会话级:所有视频 Key 配额已耗尽,阻止重复调用
let _musicQuotaExhausted=false; // 会话级:所有音乐 Key 配额已耗尽,阻止重复调用
let _imageQuotaExhausted=false;
let _ttsQuotaExhausted=false;
let _visionQuotaExhausted=false;
let _webSearchQuotaExhausted=false;
let mudActive=false;
let showMudThinking=false;
let mudStateSynced=false;
let showMudImage=false;
let showMudVoice=false;
let showMudVision=false;
let _mudPendingImage=null;
let _mudPendingVoice=null;
let _mudState=null;
let _activeMudScenario='default';
function showMudPanel(){
if(!mudStatusBar)return;
mudStatusBar.classList.remove('hidden');
mudStatusBar.style.display='flex';
clearTimeout(mudStatusBar._showTimer);
mudStatusBar._showTimer=setTimeout(()=>{mudStatusBar.style.display='flex';},50);
}
function _mudScenarioSlug(){
if(_mudState){
const raw=_mudState.world_settings||_mudState.scenario||_mudState.theme||'';
if(raw){const slug=raw.replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g,'').replace(/\//g,'').trim().slice(0,20);if(slug)_activeMudScenario=slug;}
}
return _activeMudScenario||'default';
}
const TAG_MAP={
'[GAME_INITIALIZED]':'init','[ACTION]':'action','[STATUS]':'status','[SAVED]':'saved',
'[LOADED]':'loaded','[NO_SAVE]':'no_save','[IMAGE_MODE_ON]':'image_on','[IMAGE_MODE_OFF]':'image_off',
'[VOICE_MODE_ON]':'voice_on','[VOICE_MODE_OFF]':'voice_off','[VISION_MODE_ON]':'vision_on',
'[VISION_MODE_OFF]':'vision_off','[STATE_UPDATED]':'state_updated','[ENGINE_NULL]':'engine_null'
};
const TAG_LABELS={
init:'🎮 MUD游戏已激活!',action:'⚡ 命令已执行',status:'📊 角色状态',
saved:'💾 游戏已存档',loaded:'📂 游戏已读档',no_save:'⚠️ 没有找到存档',
image_on:'🖼️ 场景配图已开启',image_off:'🖼️ 场景配图已关闭',
voice_on:'🔊 语音已开启',voice_off:'🔊 语音已关闭',
vision_on:'👁 识图已开启',vision_off:'👁 识图已关闭',
state_updated:'🔄 状态已更新',engine_null:'⚠️ 游戏引擎未初始化'
};
function parseMudResponse(text){
if(!text||typeof text!=='string')return{text,needsNarration:false,stateData:null};
for(const[tag,key]of Object.entries(TAG_MAP)){
if(text.startsWith(tag)){
let remainder=text.slice(tag.length).trim();
let stateData=null;
// 支持嵌套JSON的STATE解析
const stateTry=remainder.match(/^\[STATE:([\s\S]+)\]\s*/);
if(stateTry){
let jsonStr=stateTry[1];
try{stateData=JSON.parse(jsonStr);}catch(e){
for(let i=jsonStr.length;i>0;i--){try{stateData=JSON.parse(jsonStr.substring(0,i));break;}catch(e2){}}
}
if(stateData){remainder=remainder.slice(stateTry[0].length).trim();}
}
return{text:remainder,needsNarration:!!remainder&&remainder.length>0,tag:key,stateData};
}
}
const stateOnlyMatch=text.match(/^\[STATE:([\s\S]+)\]\s*$/);
if(stateOnlyMatch){
let st=null;let jsonStr=stateOnlyMatch[1];
try{st=JSON.parse(jsonStr);}catch(e){
for(let i=jsonStr.length;i>0;i--){try{st=JSON.parse(jsonStr.substring(0,i));break;}catch(e2){}}
}
if(st)return{text:'',needsNarration:false,tag:'state_updated',stateData:st};
}
return{text,needsNarration:true,tag:null,stateData:null};
}
function buildMudStateContext(){
if(!_mudState)return'';
const s=_mudState;
const skip=new Set(['game_started','image_mode','voice_mode','vision_mode','showMudImage','showMudVoice','showMudVision']);
const seen=new Set();
let ctx='\n[当前游戏状态]\n';
// 先处理有 _max/_next 配对的字段
for(const key of Object.keys(s)){
if(skip.has(key))continue;
if(key.endsWith('_max')||key.endsWith('_next'))continue;
if(seen.has(key))continue;
const val=s[key];
if(val==null||val===''||val===false)continue;
seen.add(key);
let display;
if(s[key+'_max']!=null){
display=val+'/'+s[key+'_max'];
seen.add(key+'_max');
}else if(s[key+'_next']!=null){
display=val+'/'+s[key+'_next'];
seen.add(key+'_next');
}else if(Array.isArray(val)){
if(val.length===0)continue;
display=val.filter(v=>v!==false&&v!=null&&v!=='').join(', ');
if(!display)continue;
}else if(typeof val==='object'){
const entries=Object.entries(val).filter(([k,v])=>v!==false&&v!=null&&v!=='');
if(entries.length===0)continue;
display=entries.map(([k,v])=>k+'('+(typeof v==='object'?JSON.stringify(v):v)+')').join(', ');
}else{
display=String(val);
}
ctx+=key.replace(/_/g,' ')+': '+display+' | ';
}
// 处理孤立的 _max/_next 字段(没有对应 base key)
for(const key of Object.keys(s)){
if(skip.has(key)||seen.has(key))continue;
if(!(key.endsWith('_max')||key.endsWith('_next')))continue;
const val=s[key];
if(val==null||val===''||val===false)continue;
seen.add(key);
ctx+=key.replace(/_/g,' ')+': '+String(val)+' | ';
}
ctx=ctx.replace(/ \| $/,'');
return ctx;
}
function activateMudDefaults(){
showMudImage=true;showMudVoice=true;
mudToggleImage.checked=true;
mudToggleVoice.checked=true;
showMudVision=false;showMudThinking=false;
mudToggleVision.checked=false;
mudToggleThinking.checked=false;
apiCall('mud_action',{command:'image_on'},{signal:new AbortController().signal}).then(res=>{if(res.state){_mudState=res.state;showMudPanel();}}).catch(()=>{});
apiCall('mud_action',{command:'voice_on'},{signal:new AbortController().signal}).then(res=>{if(res.state){_mudState=res.state;showMudPanel();}}).catch(()=>{});
addStepMessage('🖼🔊 配图和语音已自动开启');
}
function getUnprocessedMessages(){
return recentConversationMessages.slice(lastExtractionIndex);
}
function markMessagesExtracted(){
lastExtractionIndex=recentConversationMessages.length;
}
function startPeriodicMemoryExtraction(){
stopPeriodicMemoryExtraction();
memoryExtractionTimer=setInterval(()=>{
const msgs=getUnprocessedMessages();
if(msgs.length>=2)triggerMemoryExtraction(msgs);
},MEMORY_EXTRACTION_INTERVAL);
}
function stopPeriodicMemoryExtraction(){
if(memoryExtractionTimer){clearInterval(memoryExtractionTimer);memoryExtractionTimer=null;}
}
let fmCurrentPath='';
let currentFileList=[];
let ivImageList=[];
let ivCurrentFile=null; // 当前弹窗显示的文件(图片/音频/视频),供分享用
let ivImageIndex=0;
let ivAbortController=null;
function hideWelcome(){if(welcome)welcome.style.display='none'}
function setRunning(running){
isRunning=running;
sendBtn.disabled=false;
statusDot.className=running?'nav-dot':'nav-dot inactive';
statusText.textContent=running?'思考中...':'就绪';
if(running){sendBtn.textContent='■';sendBtn.classList.add('stop');sendBtn.classList.remove('guest');sendBtn.setAttribute('aria-label','停止');}
else{sendBtn.classList.remove('stop');sendBtn.setAttribute('aria-label','发送');if(currentUser){sendBtn.textContent='→';sendBtn.classList.remove('guest')}else{sendBtn.textContent='访客';sendBtn.classList.add('guest')}}
}
// BUG-04: 命令队列处理器
function processQueue(){
if(commandQueue.length===0)return;
if(isRunning)return;
const next=commandQueue.shift();
runAgentLoop(next);
}
// ── CSRF Token ──
let csrfToken='';
function generateCSRFToken(){const a=new Uint8Array(32);crypto.getRandomValues(a);csrfToken=Array.from(a,b=>b.toString(16).padStart(2,'0')).join('');}
generateCSRFToken();
async function apiCall(action,data={},opts={}){
if(stopRequested&&!opts.ignoreStop)throw new DOMException('The operation was aborted.','AbortError');
let lastError;
for(let attempt=0;attempt<2;attempt++){
const body={_action:action,...data};
if(accessToken)body._token=accessToken;
try{
const ctrl=new AbortController();let done=false;
const timer=setTimeout(()=>{done=true;ctrl.abort(new DOMException('请求超时 ('+((opts.timeout||30))+'s)','TimeoutError'));},(opts.timeout||30)*1000);
if(!opts.signal&&abortController)abortController.signal.addEventListener('abort',()=>{if(!done){clearTimeout(timer);ctrl.abort(abortController.signal.reason);}},{once:true});
const resp=await fetch(PROXY_URL,{
method:'POST',headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest','X-CSRF-Token':csrfToken},
body:JSON.stringify(body),
signal:opts.signal||ctrl.signal
});
done=true;clearTimeout(timer);
let raw;try{result=await resp.json();}catch(e){raw=await resp.text().catch(()=>'');throw new Error('响应解析失败 (HTTP '+resp.status+'): '+(raw||e.message).slice(0,200));}
if(!resp.ok||result.error){
const errMsg=result.error||'请求失败 (HTTP '+resp.status+')';
if(resp.status===401||resp.status===403||(result.error&&(result.error.includes('token')||result.error.includes('认证')))){
if(attempt===0){
if(currentUser){await attemptReauth();continue;}
else{try{const tok=await apiCall('get_proxy_token',{},{ignoreStop:true});accessToken=tok.token||'';continue;}catch(e2){}}
}
}
throw new Error(errMsg);
}
return result;
}catch(e){
if(e.name==='AbortError')throw e;
lastError=e;
if(attempt===0&&e.message.includes('Failed to fetch'))await new Promise(r=>setTimeout(r,1000));
}
}
throw lastError||new Error('请求失败');
}
function showModal(mode){
modalMode=mode;
modalTitle.textContent=mode==='login'?'登录':'注册';
modalSubmit.textContent=mode==='login'?'登录':'注册';
modalSwitch.textContent=mode==='login'?'没有账号?去注册':'已有账号?去登录';
modalUsername.value='';modalPassword.value='';modalError.textContent='';
modalOverlay.classList.remove('hidden');
modalUsername.focus();
}
function hideModal(){modalOverlay.classList.add('hidden')}
async function handleAuth(){
const username=modalUsername.value.trim();
const password=modalPassword.value;
if(!username||!password){modalError.textContent='用户名和密码不能为空';return;}
if(username.length<2||username.length>30){modalError.textContent='用户名长度需在2-30个字符';return;}
if(password.length<4){modalError.textContent='密码长度至少4个字符';return;}
try{
if(modalMode==='register'){await apiCall('register',{username,password},{ignoreStop:true});await apiCall('login',{username,password},{ignoreStop:true});hideModal();await loginUser(username);}
else{await apiCall('login',{username,password},{ignoreStop:true});hideModal();await loginUser(username);}
}catch(e){modalError.textContent=e.message;}
}
async function loginUser(username){
currentUser={username};
try{const tok=await apiCall('get_proxy_token',{},{ignoreStop:true});accessToken=tok.token||'';if(!accessToken)addAgentMessage('⚠️ 无法获取API代理令牌,AI功能不可用,文件管理正常。');}catch(e){accessToken='';addAgentMessage('⚠️ 令牌获取失败,请尝试重新登录。');}
if(accessToken)startPeriodicMemoryExtraction();
startSessionHealthCheck();
updateUIForUser(username);
sessionStorage.removeItem('fs_synced');_fileSystem=null;_bashExec=null;_syncWarned=false;
addAgentMessage('✅ '+escapeHtml(username)+'已登录,你的 1GB 网盘已就绪,请点击左上角盘符或用户名打开网盘进行管理');
}
async function logoutUser(){
// 退出前触发记忆提取(仅提取未处理的消息)
if(currentUser&&recentConversationMessages.length>=2){
const remainingMsgs=getUnprocessedMessages();
if(remainingMsgs.length>=2)try{await triggerMemoryExtraction(remainingMsgs);}catch(e){console.warn('记忆提取失败:',e);}
}
stopPeriodicMemoryExtraction();
stopSessionHealthCheck();
try{await apiCall('logout',{},{ignoreStop:true});}catch(e){}
currentUser=null;
accessToken=''; // 清除令牌
updateUIForUser(null);
currentSession={messages:[],awaitingUser:false,pendingToolCallId:null};
recentConversationMessages=[];
lastExtractionIndex=0;
sessionStorage.removeItem('fs_synced');_fileSystem=null;_bashExec=null;_syncWarned=false;
addAgentMessage('👋 已退出登录。');
}
function updateUIForUser(username){
if(username){
userArea.innerHTML='<span class="user-info" id="userNameSpan"><i class="fas fa-user-circle"></i> '+escapeHtml(username)+'</span><span class="user-info" id="quotaInfo" style="opacity:.5">...</span><button class="user-btn" id="logoutBtn"><i class="fas fa-sign-out-alt"></i>退出</button>';
document.getElementById('userNameSpan').addEventListener('click',openFileManager);
document.getElementById('quotaInfo').addEventListener('click',openFileManager);
document.getElementById('logoutBtn').addEventListener('click',logoutUser);
fetchQuota();
}else{
userArea.innerHTML='<button class="user-btn" id="guestFmBtn"><i class="fas fa-folder"></i>访客文件</button><button class="user-btn" id="loginBtn"><i class="fas fa-sign-in-alt"></i>登录</button>';
document.getElementById('guestFmBtn').addEventListener('click',openFileManager);
document.getElementById('loginBtn').addEventListener('click',()=>showModal('login'));
}
sendBtn.disabled=false;
if(currentUser){sendBtn.textContent='→';sendBtn.classList.remove('guest')}else{sendBtn.textContent='访客';sendBtn.classList.add('guest')}
}
async function fetchQuota(){
try{
const res=await apiCall('quota',{},{ignoreStop:true});
const display = currentUser?.username==='admin' ? 1000 : res.quotaMB;
document.getElementById('quotaInfo').textContent=res.usedMB+'MB/'+display+'MB';
}catch(e){}
}
function addUserMessage(text){
hideWelcome();
const div=document.createElement('div');div.className='msg user';
div.innerHTML='<div class="msg-bubble">'+escapeHtml(text)+'</div>';
chatContainer.appendChild(div);scrollToBottom();
}
function addAgentMessage(text,toolName=null){
hideWelcome();
// P0-2: 解析 [STATE:...] 块,更新 MUD 状态条(支持嵌套JSON)
const stateMatch=text.match(/^\[STATE:([\s\S]+)\]\s*$/);
if(stateMatch){
try{
// 尝试从最短的匹配开始逐步扩大,找到最短的可解析JSON
let st=null;let jsonStr=stateMatch[1];
try{st=JSON.parse(jsonStr);}catch(e){
// JSON可能包含尾部的 ],尝试逐步缩减
for(let i=jsonStr.length;i>0;i--){
try{st=JSON.parse(jsonStr.substring(0,i));break;}catch(e2){}
}
}
if(st){_mudState=st;showMudPanel();}
}catch(e){}
text=text.replace(/^\[STATE:[\s\S]+\]\s*/,'');
}
// 解析 MUD 机读标签
for(const[tag,key]of Object.entries(TAG_MAP)){
if(text.startsWith(tag)){
let remainder=text.slice(tag.length).trim();
const sm=remainder.match(/^\[STATE:([\s\S]*?)\]\n?/);
if(sm){try{const st=JSON.parse(sm[1]);_mudState=st;showMudPanel();remainder=remainder.slice(sm[0].length).trim();}catch(e){}}
if(key==='image_on'){showMudImage=true;mudToggleImage.checked=true;}
else if(key==='image_off'){showMudImage=false;mudToggleImage.checked=false;}
else if(key==='voice_on'){showMudVoice=true;mudToggleVoice.checked=true;}
else if(key==='voice_off'){showMudVoice=false;mudToggleVoice.checked=false;}
else if(key==='vision_on'){showMudVision=true;mudToggleVision.checked=true;}
else if(key==='vision_off'){showMudVision=false;mudToggleVision.checked=false;}
else if(key==='init'){mudActive=true;mudStateSynced=true;showMudPanel();}
if(!remainder||!remainder.trim()){text=TAG_LABELS[key]||'';break;}
text=remainder;break;
}
}
const div=document.createElement('div');div.className='msg agent';
let safe=escapeHtml(text);
safe=safe.replace(/!\[([^\]]*)\]\(([^)]+)\)/g,function(m0,alt,url){return isSafeUrl(url)?'<img src="'+url+'" alt="'+alt+'" style="max-width:100%;border-radius:8px;margin:6px 0">':m0;});
safe=safe.replace(/\*\*([^*]+)\*\*/g,'<b>$1</b>');
safe=safe.replace(/\[([^\]]+)\]\(([^)]+)\)/g,function(m0,text,url){return isSafeUrl(url)?'<a href="'+url+'" target="_blank" rel="noopener">'+text+'</a>':m0;});
safe=safe.replace(/\n/g,'<br>');
let html='<div class="msg-bubble">'+safe;
if(toolName)html+=' <span class="msg-tool">🔧 '+toolName+'</span>';
html+='</div>';div.innerHTML=html;
chatContainer.appendChild(div);scrollToBottom();
// P0-2: 防DOM溢出,限制聊天消息最多50条
while(chatContainer.children.length>50){chatContainer.removeChild(chatContainer.firstChild);}
}
function addStepMessage(step,force){
if(!force&&mudActive&&!showMudThinking)return;
const div=document.createElement('div');div.className='msg step';
div.innerHTML='<div class="msg-step">🔄 '+escapeHtml(step)+'</div>';
chatContainer.appendChild(div);scrollToBottom();
}
function addImageMessage(url){
const div=document.createElement('div');div.className='msg agent';
const bubble=document.createElement('div');bubble.className='msg-bubble';
const img=document.createElement('img');img.src=url;img.alt='';
img.style.cssText='max-width:100%;border-radius:8px;margin:6px 0;display:block';img.loading='lazy';
bubble.appendChild(img);div.appendChild(bubble);
chatContainer.appendChild(div);scrollToBottom();
}
function scrollToBottom(){chatContainer.scrollTop=chatContainer.scrollHeight;}
function escapeHtml(text){
return String(text).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
// URL 安全检查:只允许 http/https/mailto 协议,拒绝 javascript: data: vbscript:
function isSafeUrl(url){
const u=(url||'').trim().toLowerCase();
return !/^(javascript|data|vbscript|livescript):/.test(u);
}
// ── Memory System Functions ──
// 检索相关记忆
let memoryFetchInProgress=false;
async function fetchMemoryContext(userInput){
if(!currentUser||!accessToken||memoryFetchInProgress)return '';
memoryFetchInProgress=true;
let context='';
try{
// 1. 语义搜索记忆
if(userInput&&userInput.length>3){
const resp=await fetch(PROXY_URL,{method:'POST',headers:{'Content-Type':'application/json'},signal:abortController?.signal,
body:JSON.stringify({_action:'memory',sub_action:'search',query:userInput,limit:8,_token:accessToken})});
const data=await resp.json();
if(data.facts&&data.facts.length>0){
if(data.facts.length>0){
context+='\n[相关记忆]\n'+data.facts.map(f=>'- ['+f.importance+'分]['+f.category+'] '+f.fact).join('\n');
}
}
}
// 2. 用户画像
const personaResp=await fetch(PROXY_URL,{method:'POST',headers:{'Content-Type':'application/json'},signal:abortController?.signal,
body:JSON.stringify({_action:'memory',sub_action:'get_persona',_token:accessToken})});
if(personaResp.ok){
const pData=await personaResp.json();
if(pData.traits)context+='\n[用户画像]\n'+pData.traits;
}
// 3. 当前场景上下文
const sceneId=localStorage.getItem('cmdcode_current_scene')||'scene_default';
if(sceneId){
const sceneResp=await fetch(PROXY_URL,{method:'POST',headers:{'Content-Type':'application/json'},signal:abortController?.signal,
body:JSON.stringify({_action:'memory',sub_action:'get_scene',scene_id:sceneId,_token:accessToken})});
if(sceneResp.ok){
const sData=await sceneResp.json();
if(sData.summary)context+='\n[当前场景: '+(sData.name||'')+']\n'+sData.summary;
}
}
}catch(e){if(e.name!=='AbortError')console.warn('记忆获取失败:',e.message);}
memoryFetchInProgress=false;
return context;
}
// 触发记忆提取(提取后自动标记已处理,避免重复提取)
async function triggerMemoryExtraction(messages){
if(!accessToken||!messages||messages.length<2)return;
try{
await fetch(PROXY_URL,{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({_action:'memory',sub_action:'enqueue_extract',
messages:messages.slice(0,10), // 最多10条
scene_id:localStorage.getItem('cmdcode_current_scene')||'scene_default',
_token:accessToken})});
markMessagesExtracted(); // 标记已提取
}catch(e){}
}
// ── Thinking Mode ──
class ThinkingModeHandler{
static buildThinkingOptions(enabled,reasoningEffort='max'){
if(!enabled)return {};
return{thinking:{type:'enabled'},reasoning_effort:reasoningEffort};
}
static extractReasoning(message){return message.reasoning_content||null;}
static supportsThinking(model){return model.includes('deepseek-v4');}
}
// ── Agent Drift Guard ──
const AGENT_DRIFT_GUARD_SKILL='name: agent-drift-guard\ndescription: Detect and correct execution drift while working on user requests.\n\n# Agent Drift Guard\nKeep execution tightly aligned with the user\'s actual request.\n\n## Quick Start\n1. State the user\'s requested outcome in one sentence.\n2. List explicit non-goals or boundaries the user has set.\n3. Ask whether the next action directly advances the requested outcome.\n4. If not, either cut it or pause to confirm.\n\n## Drift Signals\n- Exploring broadly before opening the most relevant file\n- Solving adjacent operational issues when the user asked only for code changes\n- Adding extra safeguards, scripts, docs, refactors, or cleanup\n- Reframing the task around what seems "better" instead of what was requested\n- Continuing with a broader plan after the user narrows the scope\n- Mixing diagnosis, remediation, and feature work\n- Touching production-like state without explicit permission';
const EDIT_PRIORITY_PROMPT='\n## File Modification Strategy\n- **Prefer \'edit\' over \'write\'** for modifying existing files.\n- Use \'edit\' for targeted changes (search & replace specific sections).\n- Use \'write\' only for: creating new files, small files (< 50 lines), or complete rewrites.\n- For large files (> 100 lines), always use \'edit\' to minimize token usage and avoid errors.';
// ── Tools ──
const ALL_TOOLS=[
{type:'function',function:{name:'web_search',description:'搜索互联网,返回结构化结果列表。',parameters:{type:'object',properties:{query:{type:'string',description:'搜索关键词'}},required:['query']}}},
{type:'function',function:{name:'vision_recognition',description:'识别图片内容,回答关于图片的问题。',parameters:{type:'object',properties:{image_url:{type:'string',description:'图片URL'},question:{type:'string',description:'要询问的问题'}},required:['image_url']}}},
{type:'function',function:{name:'image_generation',description:'根据文字描述生成图片,返回图片URL列表。支持自定义分辨率、种子、参考图等。',parameters:{type:'object',properties:{prompt:{type:'string',description:'画面描述'},aspect_ratio:{type:'string',enum:['1:1','16:9','9:16','4:3'],default:'1:1'},n:{type:'integer',description:'生成数量1-4',default:1},width:{type:'integer',description:'宽度像素(需512-2048,8的倍数,与height同时使用)'},height:{type:'integer',description:'高度像素(需512-2048,8的倍数,与width同时使用)'},seed:{type:'integer',description:'随机种子(固定值可复现相同图片)'},prompt_optimizer:{type:'boolean',description:'是否自动优化提示词',default:false},aigc_watermark:{type:'boolean',description:'是否添加AI生成水印',default:false}},required:['prompt']}}},
{type:'function',function:{name:'video_generation',description:'根据描述生成视频。T2V文生视频(Hailuo-2.3)、I2V图生视频(Hailuo-2.3)、SEF首尾帧插帧(Hailuo-02)、S2V主体参考(S2V-01)。参考 mmx-cli 参数模型。',parameters:{type:'object',properties:{prompt:{type:'string',description:'视频描述'},first_frame_image:{type:'string',description:'首帧图片URL(图生视频I2V模式)'},last_frame_image:{type:'string',description:'尾帧图片URL(与first_frame_image配合→Hailuo-02 SEF插帧)'},subject_image:{type:'string',description:'主体参考图URL(角色一致性→S2V-01模型)'}},required:['prompt']}}},
{type:'function',function:{name:'music_generation',description:'生成音乐或歌曲,返回音频URL。支持精细控制:曲风、情绪、乐器、速度、调性等。',parameters:{type:'object',properties:{prompt:{type:'string',description:'音乐描述(如:轻快爵士风格,夏天的海边)'},lyrics:{type:'string',description:'歌词(可选,不填则纯音乐)'},instrumental:{type:'boolean',description:'纯音乐(无歌词,与lyrics互斥)',default:false},lyrics_optimizer:{type:'boolean',description:'自动生成歌词(与lyrics/instrumental互斥)',default:false},genre:{type:'string',description:'曲风(如:folk, pop, jazz, rock, classical, electronic, rnb, hiphop)'},mood:{type:'string',description:'情绪/氛围(如:warm, melancholic, uplifting, dark, dreamy)'},vocals:{type:'string',description:'人声风格(如:warm male baritone, bright female soprano)'},instruments:{type:'string',description:'主要乐器(如:acoustic guitar, piano, strings)'},tempo:{type:'string',description:'速度描述(如:fast, slow, moderate)'},bpm:{type:'integer',description:'精确BPM数值(如95, 120)'},key:{type:'string',description:'调性(如:C major, A minor)'}},required:['prompt']}}},
{type:'function',function:{name:'text_to_speech',description:'将文本转为语音,返回音频URL。可选音色:female-shaonv(少女), female-yu-xiangan(御姐), female-jianai(可爱), male-qn-qingse(青年男声), female-tianmei(甜美)等。',parameters:{type:'object',properties:{text:{type:'string',description:'要读出的文本'},voice_id:{type:'string',enum:['female-shaonv','female-yu-xiangan','female-jianai','female-tianmei','male-qn-qingse','male-shaoshuai','male-zhixing','female-zhixing','male-jingpin','female-jingpin'],default:'female-shaonv'},speed:{type:'number',minimum:0.5,maximum:2.0,default:1.0},subtitle_enable:{type:'boolean',description:'是否生成字幕文件',default:false}},required:['text']}}},
{type:'function',function:{name:'bash',description:'在浏览器沙箱中执行文件操作命令。仅支持以下51个命令,不支持任何其他命令:ls, cat, head, tail, wc, sort, uniq, grep, echo, mkdir, rm, rmdir, pwd, cd, touch, cp, mv, du, tree, basename, dirname, diff, find, cut, tr, nl, tac, seq, rev, stat, sed, tee, file, realpath, expand, comm, xargs, paste, split, strings, readlink, mktemp, od, xxd, yes, factor, fmt, csplit, unexpand, join。不支持less/more/printf/env/fold/chmod/chown/ln/tar/zip/curl/wget等。禁止在根目录创建文件,必须使用 files/ images/ project/ 等子目录。',parameters:{type:'object',properties:{command:{type:'string',description:'要执行的命令(仅限上述51个)'}},required:['command']}}},
{type:'function',function:{name:'read',description:'读取文件内容。登录用户访问个人文件夹,访客访问 guest/ 文件夹。',parameters:{type:'object',properties:{file_path:{type:'string',description:'文件路径'},offset:{type:'integer',description:'起始行号'},limit:{type:'integer',description:'读取行数'}},required:['file_path']}}},
{type:'function',function:{name:'write',description:'创建或覆盖文件。必须根据内容类型选择路径:图片→images/,音乐/音频→music/,语音→voice/,代码/项目→project/,文档→files/,临时→tmp/,游戏数据→mud/,记忆→memory/。禁止在根目录创建文件。大文件修改优先用 edit 工具。',parameters:{type:'object',properties:{file_path:{type:'string',description:'文件路径。如 images/logo.png、project/calc.py'},content:{type:'string',description:'要写入的内容'}},required:['file_path','content']}}},
{type:'function',function:{name:'edit',description:'编辑文件的指定部分。登录用户编辑个人文件夹文件,访客编辑 guest/ 文件夹文件。对于修改大文件中的特定段落,此工具比 write 更高效。',parameters:{type:'object',properties:{file_path:{type:'string',description:'文件路径'},old_string:{type:'string',description:'要替换的原始文本'},new_string:{type:'string',description:'替换后的新文本'},replace_all:{type:'boolean',description:'是否替换所有匹配项',default:false}},required:['file_path','old_string','new_string']}}},
{type:'function',function:{name:'AskUserQuestion',description:'当需要用户提供更多信息或做出选择时,向用户提问。',parameters:{type:'object',properties:{question:{type:'string',description:'要询问用户的问题'},options:{type:'array',items:{type:'string'},description:'可选的选择项(最多4个)'}},required:['question']}}},
{type:'function',function:{name:'web_fetch',description:'抓取外部网页内容,返回文本内容。适用于阅读在线文档、API文档等。',parameters:{type:'object',properties:{url:{type:'string',description:'目标网页URL'},max_chars:{type:'integer',description:'最大抓取字符数',default:50000}},required:['url']}}},
{type:'function',function:{name:'mud_action',description:'MUD游戏引擎命令。执行游戏操作:status(查看状态)、action(执行动作)、save(存档)、load(读档)、image_on/image_off(开关配图)、voice_on/voice_off(开关语音)、vision_on/vision_off(开关识图)、state_updates(同步状态更新)。',parameters:{type:'object',properties:{command:{type:'string',enum:['status','action','save','load','image_on','image_off','voice_on','voice_off','vision_on','vision_off','state_updates'],description:'MUD命令'},message:{type:'string',description:'自然语言输入(action命令必填)'},state_updates:{type:'object',description:'状态更新JSON(state_updates命令必填)'}},required:['command']}}}
];
// ── Proxy Request ──
async function proxyRequest(provider,apiPath,body,timeoutMs=240000){
if(stopRequested)throw new DOMException('The operation was aborted.','AbortError');
const requestBody={_token:accessToken,_provider:provider,_path:apiPath,...body};
const isStream=!!body.stream;
const ctrl=new AbortController();let done=false;
const timer=setTimeout(()=>{done=true;ctrl.abort(new DOMException('代理请求超时 ('+timeoutMs/1000+'s)','TimeoutError'));},timeoutMs);
if(abortController)abortController.signal.addEventListener('abort',()=>{if(!done){clearTimeout(timer);ctrl.abort(abortController.signal.reason);}},{once:true});
try{
const resp=await fetch(PROXY_URL,{method:'POST',headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},body:JSON.stringify(requestBody),signal:ctrl.signal});
done=true;clearTimeout(timer);
if(!resp.ok){
let errText=await resp.text().catch(()=>'');
try{const errJson=JSON.parse(errText);errText=errJson.message||errJson.detail?.last_error||errJson.error||errText;}catch(e){ }
throw new Error(errText.slice(0,200)||'HTTP '+resp.status);
}
// 流式模式:逐块读取 SSE 事件,组装完整响应
if(isStream&&resp.body){
const reader=resp.body.getReader();
const decoder=new TextDecoder();
let buffer='';
let content='';
let reasoning='';
let finishReason=null;
let toolCalls=[];
let role='assistant';
let chunkCount=0;
try{
while(true){
const{done:readerDone,value}=await reader.read();
if(readerDone){break;}
chunkCount++;
buffer+=decoder.decode(value,{stream:true});
const lines=buffer.split('\n');
buffer=lines.pop()||'';
for(const line of lines){
if(!line.startsWith('data: '))continue;
const data=line.slice(6).trim();
if(data==='[DONE]')continue;
try{
const parsed=JSON.parse(data);
const choice=parsed.choices?.[0];
if(!choice)continue;
const delta=choice.delta||{};
if(delta.content)content+=delta.content;
if(delta.reasoning_content)reasoning+=delta.reasoning_content;
if(delta.role)role=delta.role;
if(choice.finish_reason)finishReason=choice.finish_reason;
if(delta.tool_calls){
for(const tc of delta.tool_calls){
const idx=tc.index??toolCalls.length;
if(!toolCalls[idx])toolCalls[idx]={id:tc.id||'',type:'function',function:{name:'',arguments:''}};
if(tc.id)toolCalls[idx].id=tc.id;
if(tc.function?.name)toolCalls[idx].function.name=tc.function.name;
if(tc.function?.arguments)toolCalls[idx].function.arguments+=tc.function.arguments;
}
}
}catch(e){console.warn('[SSE] Parse error:', e.message, 'data:', data.slice(0,100));}
}
}
}catch(readerError){
console.error('[SSE] Reader error:', readerError);
if(readerError.name!=='AbortError')throw readerError;
}
// 组装完整响应
const result={choices:[{message:{role,content,tool_calls:toolCalls.length?toolCalls:undefined},finish_reason:finishReason}]};
if(reasoning)result.choices[0].message.reasoning_content=reasoning;
return result;
}
return resp.json();
}catch(e){done=true;clearTimeout(timer);throw e;}
}
// ── OPFS 虚拟文件系统(bash 模拟层) ──
class VirtualFS{
constructor(){this.root=null;this.cwd='/sandbox';this.ready=this.init();this._locks=new Map();}
async _lockFile(path){
while(this._locks.get(path))await this._locks.get(path);
let release;this._locks.set(path,new Promise(r=>release=r));
return ()=>{this._locks.delete(path);release();};
}
async init(){this.root=await navigator.storage.getDirectory();await this.ensureDir('/sandbox');for(const d of ALLOWED_TOP_DIRS)await this.ensureDir('/sandbox/'+d);}
resolvePath(path){
if(!path||path==='.')return this.cwd;
if(path==='/')return'/sandbox';
let raw;
if(path.startsWith('/')){raw=path.startsWith('/sandbox')?path:'/sandbox'+path;}
else{raw=this.cwd+'/'+path;}
const parts=raw.split('/');const result=[];
for(const p of parts){if(p==='..'){if(result.length>0)result.pop();}else if(p&&p!=='.')result.push(p);}
const resolved='/'+result.join('/');
return resolved.startsWith('/sandbox')?resolved:this.cwd;
}
async getDirHandle(path,create=false){
const parts=path.split('/').filter(Boolean);let cur=this.root;
for(const p of parts){cur=create?await cur.getDirectoryHandle(p,{create:true}):await cur.getDirectoryHandle(p);}
return cur;
}
async getFileHandle(path,create=false){
const idx=path.lastIndexOf('/');const dirPath=idx>0?path.slice(0,idx):'/';const name=path.slice(idx+1);
const dh=await this.getDirHandle(dirPath,create);
return create?await dh.getFileHandle(name,{create:true}):await dh.getFileHandle(name);
}
// 带同步钩子的文件操作
_assertSandbox(path){
// 只检查路径是否在 /sandbox 内,不限制子目录
if(!path.startsWith('/sandbox'))throw new Error('安全限制:操作仅允许在沙箱目录内。');
}
async readFile(path){
const fh=await this.getFileHandle(path);return await(await fh.getFile()).text();
}
async writeFile(path,content,append=false,skipRootCheck=false){
const unlock=await this._lockFile(path);
try{
if(!skipRootCheck)this._assertSandbox(path);
const fh=await this.getFileHandle(path,true);
if(append){try{const cur=await(await fh.getFile()).text();content=cur+content;}catch(e){}}
const w=await fh.createWritable();await w.write(content);await w.close();
this._notifyWrite(path);
}finally{unlock();}
}
async ensureDir(path){
this._assertSandbox(path);
const parts=path.split('/').filter(Boolean);let cur=this.root;
for(const p of parts)cur=await cur.getDirectoryHandle(p,{create:true});
}
async deleteEntry(path,{recursive=false}={}){
const unlock=await this._lockFile(path);
try{
const dh=await this.getDirHandle(path.slice(0,path.lastIndexOf('/'))||'/');
await dh.removeEntry(path.split('/').pop(),{recursive});this._notifyDelete(path);
}finally{unlock();}
}
_notifyWrite(path){if(this.syncManager&&!_syncing)this.syncManager.pushFile(path).catch(()=>{_syncQueue.push({type:'write',abs:path});if(!_syncWarned){_syncWarned=true;addStepMessage('⚠️ 文件已保存到本地,但同步到云端失败。网络恢复后会自动重试。');}});}
_notifyDelete(path){if(this.syncManager&&!_syncing)this.syncManager.deleteServerFile(path).catch(()=>{_syncQueue.push({type:'delete',abs:path});if(!_syncWarned){_syncWarned=true;addStepMessage('⚠️ 文件已从本地删除,但同步到云端失败。');}});}
}
class FileSyncManager{
constructor(fs){this.fs=fs;}
async pullFullSync(){
addStepMessage('正在从服务器同步文件...');
const files=await this._listAll('');let count=0;
for(const f of files){
if(isRootPath(f))continue;
try{
const res=await apiCall('file_read',{file_path:f},{ignoreStop:true});
if(res&&res.content!==undefined){await this.fs.writeFile(this.fs.resolvePath(f),res.content,false,true);count++;}
}catch(e){}
}
addStepMessage('已同步 '+count+' 个文件');
}
async _listAll(dir){
const res=await apiCall('list_files',{path:dir},{ignoreStop:true});const out=[];
for(const e of(res.files||[])){
if(e.is_dir){const sub=await this._listAll(e.path);out.push(...sub);}
else out.push(e.path);
}
return out;
}
async pushFile(relPath){
const abs=relPath.startsWith('/')?relPath:this.fs.resolvePath(relPath);
const rawRel=abs.startsWith('/sandbox/')?abs.slice(9):'';
const rel=dedupPath(rawRel);
if(!rel)return;
try{
const content=await this.fs.readFile(abs);
await apiCall('file_write',{file_path:rel,content},{ignoreStop:true});
await this._retryQueue();
}catch(e){console.warn('[Sync] pushFile failed:',rel,e.message);throw e;}
}
async deleteServerFile(relPath){
const abs=relPath.startsWith('/')?relPath:this.fs.resolvePath(relPath);
const rawRel=abs.startsWith('/sandbox/')?abs.slice(9):'';
const rel=dedupPath(rawRel);
if(!rel)return;
try{
await apiCall('file_delete',{file_path:rel},{ignoreStop:true});
await this._retryQueue();
}catch(e){console.warn('[Sync] deleteServerFile failed:',rel,e.message);throw e;}
}
async _retryQueue(){
if(!_syncQueue.length)return;
const batch=[..._syncQueue];_syncQueue=[];
for(const item of batch){
try{
if(item.type==='write'){
const content=await this.fs.readFile(item.abs);
const rel=dedupPath(item.abs.startsWith('/sandbox/')?item.abs.slice(9):'');
await apiCall('file_write',{file_path:rel,content},{ignoreStop:true});
}else if(item.type==='delete'){
const rel=dedupPath(item.abs.startsWith('/sandbox/')?item.abs.slice(9):'');
await apiCall('file_delete',{file_path:rel},{ignoreStop:true});
}
}catch(e){_syncQueue.push(item);}
}
}
}
function arrayBufferToBase64(buf){
const bytes=new Uint8Array(buf);
const chunkSize=32768;
let binary='';
for(let i=0;i<bytes.length;i+=chunkSize){
binary+=String.fromCharCode.apply(null,bytes.subarray(i,i+chunkSize));
}
return btoa(binary);
}
function dedupPath(path){
const isAbs=path.startsWith('/');
let parts=(isAbs?path.slice(1):path).split('/');
let changed=true;
while(changed){
changed=false;
for(let i=0;i<parts.length;i++){
for(let len=1;len<=Math.floor(parts.length/2);len++){
if(i+len*2>parts.length)continue;
const segment=parts.slice(i,i+len);
let j=i+len;
while(j+len<=parts.length){
const next=parts.slice(j,j+len);
if(next.join('/')===segment.join('/'))j+=len;
else break;
}
const repeatCount=(j-i)/len;
if(repeatCount>1){
parts=[...parts.slice(0,i),...segment,...parts.slice(j)];
changed=true;
break;
}
}
if(changed)break;
}
}
const result=parts.join('/');
return isAbs?'/'+result:result;
}
async function syncServerAndOPFS(){
if(!await _ensureFS()){throw new Error('OPFS 不可用,请使用支持 OPFS 的现代浏览器');}
_syncing=true;
let pulled=0,pushed=0,deletedFromServer=0,failedFiles=[];
try{
addStepMessage('🔄 开始同步...');
// 1. 从服务器拉取所有文件列表
const serverRawFiles=[];
try{
async function listServer(dir){
const res=await apiCall('list_files',{path:dir},{ignoreStop:true});
for(const e of(res.files||[])){
if(e.is_dir)await listServer(e.path);
else serverRawFiles.push(e.path);
}
}
await listServer('');
addStepMessage('📁 服务器文件: '+serverRawFiles.length+' 个');
}catch(e){
addStepMessage('❌ 获取服务器文件列表失败: '+e.message);
addStepMessage('💡 请检查网络连接或重新登录');
return;
}
// 1.5 去重服务器路径
const serverFiles=[];
const serverDeduped=[];
for(const raw of serverRawFiles){
const fixed=dedupPath(raw);
if(!serverFiles.includes(fixed))serverFiles.push(fixed);
if(fixed!==raw)serverDeduped.push({raw,fixed});
}
// 2. 从 OPFS 递归获取所有文件列表(sandbox 内)
const opfsRawFiles=[];
try{
async function listOPFS(dirHandle,prefix){
for await(const[name,handle]of dirHandle.entries()){
const path=prefix?prefix+'/'+name:name;
if(handle.kind==='directory')await listOPFS(handle,path);
else opfsRawFiles.push(path);
}
}
const sandboxHandle=await _fileSystem.getDirHandle('/sandbox');
await listOPFS(sandboxHandle,'');
addStepMessage('💾 本地文件: '+opfsRawFiles.length+' 个');
}catch(e){
addStepMessage('❌ 获取本地文件列表失败: '+e.message);
return;
}
// 2.5 修复 OPFS 中的重复路径并迁移文件
const BINARY_EXTS=new Set(['jpg','jpeg','png','gif','webp','bmp','svg','ico','avif','mp3','wav','ogg','m4a','aac','flac','wma','mp4','webm','mov','avi','mkv','flv','zip','tar','gz','rar','7z','pdf','exe','dll','so','dylib','bin']);
const opfsFiles=[];
const opfsDeduped=[];
for(const raw of opfsRawFiles){
const fixed=dedupPath(raw);
if(!opfsFiles.includes(fixed))opfsFiles.push(fixed);
if(fixed!==raw)opfsDeduped.push({raw,fixed});
}
const allDeduped=[...serverDeduped,...opfsDeduped];
if(allDeduped.length>0){
const uniqueFixes=[...new Set(allDeduped.map(d=>d.raw))];
addStepMessage('🔧 修复 '+uniqueFixes.length+' 个重复路径...');
}
for(const{raw,fixed}of opfsDeduped){
try{
const absRaw='/sandbox/'+raw;
const absFixed='/sandbox/'+fixed;
const content=await _fileSystem.readFile(absRaw);
await _fileSystem.writeFile(absFixed,content,false,true);
await _fileSystem.deleteEntry(absRaw);
addStepMessage(' ↳ '+raw+' → '+fixed);
}catch(e){
addStepMessage(' ⚠️ 迁移失败: '+raw+' → '+fixed+' ('+e.message+')');
}
}
// 3. 服务器有但 OPFS 没有 → 拉取(使用去重后的路径)
const toPull=serverFiles.filter(f=>!opfsFiles.includes(f));
if(toPull.length>0){
addStepMessage('📥 需要拉取: '+toPull.length+' 个文件');
for(const f of toPull){
const srcPath=serverDeduped.find(d=>d.fixed===f)?.raw||f;
try{
const ext=f.split('.').pop().toLowerCase();
const isBin=BINARY_EXTS.has(ext);
if(isBin){
const resp=await fetch(PROXY_URL+'?_action=file_download&file_path='+encodeURIComponent(srcPath)+'&_token='+accessToken);
if(!resp.ok)throw new Error('HTTP '+resp.status);
const blob=await resp.blob();
const buf=await blob.arrayBuffer();
const fh=await _fileSystem.getFileHandle(_fileSystem.resolvePath(f),true);
const w=await fh.createWritable();
await w.write(buf);
await w.close();
pulled++;
addStepMessage('✓ '+f);
}else{
const res=await apiCall('file_read',{file_path:srcPath},{ignoreStop:true});
if(res&&res.content!==undefined){
await _fileSystem.writeFile(_fileSystem.resolvePath(f),res.content,false,true);
pulled++;
addStepMessage('✓ '+f);
}
}
}catch(e){
failedFiles.push({file:f,action:'拉取',error:e.message});
}
}
}
// 4. OPFS 有但服务器没有 → 推送(使用去重后的路径)
const toPush=opfsFiles.filter(f=>!serverFiles.includes(f));
if(toPush.length>0){
addStepMessage('📤 需要推送: '+toPush.length+' 个文件');
for(const f of toPush){
try{
const ext=f.split('.').pop().toLowerCase();
const isBin=BINARY_EXTS.has(ext);
let content;
const absPath=_fileSystem.resolvePath(f);
if(isBin){
const fh=await _fileSystem.getFileHandle(absPath);
const file=await fh.getFile();
const buf=await file.arrayBuffer();
content=arrayBufferToBase64(buf);
}else{
content=await _fileSystem.readFile(absPath);
}
const res=await apiCall('file_write',{file_path:f,content,_binary:isBin},{ignoreStop:true});
if(res&&res.error){
failedFiles.push({file:f,action:'推送',error:res.error});
}else{
pushed++;
addStepMessage('✓ '+f);
}
}catch(e){
failedFiles.push({file:f,action:'推送',error:e.message});
}
}
}
// 5. 删除服务器上的重复路径文件
if(serverDeduped.length>0){
addStepMessage('🧹 清理服务器重复路径...');
for(const{raw}of serverDeduped){
try{
await apiCall('file_delete',{file_path:raw},{ignoreStop:true});
deletedFromServer++;
addStepMessage(' ✓ 删除 '+raw);
}catch(e){
addStepMessage(' ⚠️ 删除失败: '+raw);
}
}
}
// 显示失败的文件和原因
if(failedFiles.length > 0){
addStepMessage('⚠️ 失败 '+failedFiles.length+' 个文件:');
for(const item of failedFiles){
let reason=item.error;
if(reason.includes('超出存储配额'))reason='存储空间不足';
else if(reason.includes('路径不合法'))reason='文件名包含特殊字符';
else if(reason.includes('未授权'))reason='登录已过期,请重新登录';
else if(reason.includes('Network')||reason.includes('Failed to fetch'))reason='网络连接失败';
else if(reason.includes('not be found')||reason.includes('not found'))reason='本地文件不存在(路径重复已修复,请重新同步)';
addStepMessage(' • '+item.action+'失败: '+item.file);
addStepMessage(' 原因: '+reason);
}
}
addStepMessage('✅ 同步完成: 拉取 '+pulled+' | 推送 '+pushed+(deletedFromServer?' | 清理 '+deletedFromServer:'')+(failedFiles.length?' | 失败 '+failedFiles.length:''));
}finally{_syncing=false;}
}
class BashExecutor{
constructor(vfs){this.vfs=vfs;}
parseCommand(cmd){
const args=[];const re=/"([^"]*)"|'([^']*)'|(\S+)/g;let m;
while((m=re.exec(cmd))!==null)args.push(m[1]||m[2]||m[3]);
return args;
}
async exec(cmd){
const args=this.parseCommand(cmd);if(!args.length)return'';
const c=args[0].toLowerCase();const p=args.slice(1);
try{
switch(c){
case'ls':return await this._ls(p);
case'cat':return await this._cat(p);
case'head':return await this._head(p);
case'tail':return await this._tail(p);
case'wc':return await this._wc(p);
case'sort':return await this._sort(p);
case'uniq':return await this._uniq(p);
case'grep':return await this._grep(p);
case'echo':return await this._echo(p,cmd);
case'mkdir':return await this._mkdir(p);
case'rm':return await this._rm(p);
case'rmdir':return await this._rmdir(p);
case'pwd':return this.vfs.cwd;
case'cd':return await this._cd(p);
case'touch':return await this._touch(p);
case'cp':return await this._cp(p);
case'mv':return await this._mv(p);
case'du':return await this._du(p);
case'tree':return await this._tree(p);
case'basename':return this._basename(p);
case'dirname':return this._dirname(p);
case'diff':return await this._diff(p);
case'find':return await this._find(p);
case'cut':return await this._cut(p,cmd);
case'tr':return await this._tr(p,cmd);
case'nl':return await this._nl(p);
case'tac':return await this._tac(p);
case'seq':return this._seq(p);
case'rev':return await this._rev(p);
case'stat':return await this._stat(p);
case'sed':return await this._sed(p,cmd);
case'tee':return await this._tee(p,cmd);
case'file':return await this._file(p);
case'realpath':return this._realpath(p);
case'expand':return await this._expand(p);
case'comm':return await this._comm(p);
case'xargs':return await this._xargs(p,cmd);
case'paste':return await this._paste(p);
case'split':return await this._split(p);
case'strings':return await this._strings(p);
case'readlink':return this._readlink(p);
case'mktemp':return await this._mktemp(p);
case'od':return await this._od(p);
case'xxd':return await this._xxd(p);
case'yes':return this._yes(p);
case'factor':return this._factor(p);
case'fmt':return await this._fmt(p);
case'csplit':return await this._csplit(p);
case'unexpand':return await this._unexpand(p);
case'join':return await this._join(p);
case'node':case'npm':case'npx':return'[提示] 当前环境不支持 '+c+' 命令。如需运行 JavaScript,请复制代码到本地 Node.js 环境执行。';
default:return'[提示] 不支持的 bash 命令: '+c+'。支持的命令:ls, cat, head, tail, wc, sort, uniq, grep, echo, mkdir, rm, rmdir, pwd, cd, touch, cp, mv, du, tree, basename, dirname, diff, find, cut, tr, nl, tac, seq, rev, stat, sed, tee, file, realpath, expand, comm, xargs, paste, split, strings, readlink, mktemp, od, xxd, yes, factor, fmt, csplit, unexpand, join。文件读写建议优先使用 read/write/edit 工具。';
}
}catch(e){return'命令执行错误: '+e.message;}
}
async _ls(p){
// 过滤掉 -la / -l / -a 等参数,只保留路径参数
const pathArg=p.find(a=>!a.startsWith('-'))||'.';
try{
const abs=this.vfs.resolvePath(pathArg);const dh=await this.vfs.getDirHandle(abs);const entries=[];
for await(const[name,handle]of dh.entries())entries.push(handle.kind==='directory'?name+'/':name);
return entries.sort().join('\n')||'(空目录)';
}catch(e){return 'ls: 无法访问 '+pathArg+': '+e.message;}
}
async _cat(p){
if(!p.length)throw new Error('cat: 缺少文件参数');
return await this.vfs.readFile(this.vfs.resolvePath(p[0]));
}
async _echo(p,raw){
const m=raw.match(/echo\s+(.*?)\s*(>>?)\s*(.+)/);
if(m){
const text=m[1].replace(/^["']|["']$/g,'');const append=m[2]==='>>';const fp=this.vfs.resolvePath(m[3].trim());
await this.vfs.writeFile(fp,text+'\n',append);return'';
}
return p.join(' ');
}
async _mkdir(p){
if(!p.length)throw new Error('mkdir: 缺少目录名');
await this.vfs.ensureDir(this.vfs.resolvePath(p[0]));return'';
}
async _rm(p){
const recursive=p.some(a=>a.startsWith('-')&&/r/.test(a));
const files=p.filter(a=>!a.startsWith('-'));
if(!files.length)throw new Error('rm: 缺少文件名');
for(const f of files)await this.vfs.deleteEntry(this.vfs.resolvePath(f),{recursive});
return'';
}
async _rmdir(p){
const recursive=p.some(a=>a.startsWith('-')&&/p/.test(a));
if(!p.filter(a=>!a.startsWith('-')).length)throw new Error('rmdir: 缺少目录名');
const target=p.find(a=>!a.startsWith('-'));
const abs=this.vfs.resolvePath(target);this.vfs._assertSandbox(abs);
const dh=await this.vfs.getDirHandle(abs.slice(0,abs.lastIndexOf('/'))||'/');
await dh.removeEntry(abs.split('/').pop(),{recursive});return'';
}
async _cd(p){
if(!p.length)return this.vfs.cwd;
const target=p[0]==='/'?'/sandbox':this.vfs.resolvePath(p[0]);
this.vfs.cwd=target;return'';
}
async _touch(p){
if(!p.length)throw new Error('touch: 缺少文件名');
await this.vfs.writeFile(this.vfs.resolvePath(p[0]),'');return'';
}
async _cp(p){
if(p.length<2)throw new Error('cp: 缺少源或目标');
const src=this.vfs.resolvePath(p[0]);const dst=this.vfs.resolvePath(p[1]);
await this.vfs.writeFile(dst,await this.vfs.readFile(src));return'';
}
async _mv(p){
if(p.length<2)throw new Error('mv: 缺少源或目标');
const src=this.vfs.resolvePath(p[0]);
await this._cp(p);await this.vfs.deleteEntry(src);return'';
}
async _head(p){
const n=p.findIndex(a=>a==='-n');
const lines=n>=0?parseInt(p[n+1])||10:10;
const file=p.find(a=>!a.startsWith('-')&&(n<0||a!==p[n+1]));
if(!file)throw new Error('head: 缺少文件参数');
const content=await this.vfs.readFile(this.vfs.resolvePath(file));
return content.split('\n').slice(0,lines).join('\n');
}
async _tail(p){
const n=p.findIndex(a=>a==='-n');
const lines=n>=0?parseInt(p[n+1])||10:10;
const file=p.find(a=>!a.startsWith('-')&&(n<0||a!==p[n+1]));
if(!file)throw new Error('tail: 缺少文件参数');
const content=await this.vfs.readFile(this.vfs.resolvePath(file));
const all=content.split('\n');
return all.slice(-lines).join('\n');
}
async _wc(p){
const files=p.filter(a=>!a.startsWith('-'));
const showLines=p.includes('-l')||p.length===files.length;
const showWords=p.includes('-w');
const showChars=p.includes('-c');
const showAll=!showLines&&!showWords&&!showChars;
const results=[];
for(const f of files){
const content=await this.vfs.readFile(this.vfs.resolvePath(f));
const lines=content.split('\n').length;
const words=content.split(/\s+/).filter(Boolean).length;
const chars=content.length;
const parts=[];
if(showAll||showLines)parts.push(lines);
if(showAll||showWords)parts.push(words);
if(showAll||showChars)parts.push(chars);
parts.push(f);
results.push(parts.join('\t'));
}
return results.join('\n');
}
async _sort(p){
const reverse=p.includes('-r');
const file=p.find(a=>!a.startsWith('-'));
if(!file)throw new Error('sort: 缺少文件参数');
const content=await this.vfs.readFile(this.vfs.resolvePath(file));
let lines=content.split('\n').filter(Boolean);
lines.sort();
if(reverse)lines.reverse();
return lines.join('\n');
}
async _uniq(p){
const file=p.find(a=>!a.startsWith('-'));
if(!file)throw new Error('uniq: 缺少文件参数');
const content=await this.vfs.readFile(this.vfs.resolvePath(file));
const lines=content.split('\n');
const result=[];
let prev='';
for(const line of lines){
if(line!==prev){result.push(line);prev=line;}
}
return result.join('\n');
}
async _grep(p){
const ignoreCase=p.includes('-i');
const showLineNum=p.includes('-n');
const invert=p.includes('-v');
const args=p.filter(a=>!a.startsWith('-'));
if(args.length<1)throw new Error('grep: 缺少搜索模式');
const pattern=args[0];
const files=args.slice(1);
if(!files.length)throw new Error('grep: 缺少文件参数');
const flags=ignoreCase?'gi':'g';
let re;
try{re=new RegExp(pattern,flags);}catch(e){re=new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),flags);}
const results=[];
for(const f of files){
const content=await this.vfs.readFile(this.vfs.resolvePath(f));
const lines=content.split('\n');
lines.forEach((line,i)=>{
const match=re.test(line);re.lastIndex=0;
if(match?!invert:invert)return;
const prefix=showLineNum?(i+1)+':':'';
results.push(files.length>1?f+':'+prefix+line:prefix+line);
});
}
return results.join('\n')||'(无匹配)';
}
async _du(p){
const target=p.find(a=>!a.startsWith('-'))||'.';
const abs=this.vfs.resolvePath(target);
let totalSize=0,fileCount=0;
const scan=async(dirHandle)=>{
for await(const[name,handle]of dirHandle.entries()){
if(handle.kind==='directory'){await scan(handle);}
else{const f=await handle.getFile();totalSize+=f.size;fileCount++;}
}
};
const dh=await this.vfs.getDirHandle(abs);
await scan(dh);
const kb=Math.round(totalSize/1024);
return kb+'K\t'+target+' ('+fileCount+' files)';
}
async _tree(p){
const target=p.find(a=>!a.startsWith('-'))||'.';
const abs=this.vfs.resolvePath(target);
const lines=[];
const scan=async(dirHandle,prefix,isLast)=>{
const entries=[];
for await(const[name,handle]of dirHandle.entries())entries.push({name,handle});
entries.sort((a,b)=>a.name.localeCompare(b.name));
for(let i=0;i<entries.length;i++){
const e=entries[i];
const isLastItem=i===entries.length-1;
const connector=isLastItem?'└── ':'├── ';
const suffix=e.handle.kind==='directory'?'/':'';
lines.push(prefix+connector+e.name+suffix);
if(e.handle.kind==='directory'){
const newPrefix=prefix+(isLastItem?' ':'│ ');
await scan(e.handle,newPrefix,false);
}
}
};
lines.push(target.endsWith('/')?target.slice(0,-1):target);
const dh=await this.vfs.getDirHandle(abs);
await scan(dh,'',true);
return lines.join('\n');
}
_basename(p){
if(!p.length)throw new Error('basename: 缺少文件参数');
const path=p[0];
const suffix=p[1]||'';
let name=path.split('/').pop()||path;
if(suffix&&name.endsWith(suffix))name=name.slice(0,-suffix.length);
return name;
}
_dirname(p){
if(!p.length)throw new Error('dirname: 缺少文件参数');
const path=p[0];
const parts=path.split('/');
parts.pop();
return parts.join('/')||'.';
}
async _diff(p){
const files=p.filter(a=>!a.startsWith('-'));
if(files.length<2)throw new Error('diff: 需要两个文件参数');
const c1=await this.vfs.readFile(this.vfs.resolvePath(files[0]));
const c2=await this.vfs.readFile(this.vfs.resolvePath(files[1]));
const l1=c1.split('\n'),l2=c2.split('\n');
const result=[];
const maxLen=Math.max(l1.length,l2.length);
for(let i=0;i<maxLen;i++){
const a=l1[i]||'',b=l2[i]||'';
if(a!==b){
if(i<l1.length)result.push('< '+a);
if(i<l2.length)result.push('> '+b);
}
}
return result.join('\n')||'(文件相同)';
}
async _find(p){
const nameIdx=p.indexOf('-name');
const pattern=nameIdx>=0?p[nameIdx+1]:'*';
const startDir=p.find(a=>!a.startsWith('-')&&(nameIdx<0||a!==p[nameIdx+1]))||'.';
const abs=this.vfs.resolvePath(startDir);
const regex=new RegExp('^'+pattern.replace(/\*/g,'.*').replace(/\?/g,'.')+'$');
const results=[];
const scan=async(dirHandle,prefix)=>{
for await(const[name,handle]of dirHandle.entries()){
const path=prefix?prefix+'/'+name:name;
if(regex.test(name))results.push(path);
if(handle.kind==='directory')await scan(handle,path);
}
};
const dh=await this.vfs.getDirHandle(abs);
await scan(dh,startDir==='.'?'.':startDir);
return results.join('\n')||'(无匹配)';
}
async _cut(p,raw){
const delimMatch=raw.match(/-d\s*['"]?(.)['"]?/);
const delim=delimMatch?delimMatch[1]:'\t';
const fieldMatch=raw.match(/-f\s*(\d+)/);
const field=fieldMatch?parseInt(fieldMatch[1]):1;
const file=p.find(a=>!a.startsWith('-')&&a!==delim&&a!==String(field));
if(!file)throw new Error('cut: 缺少文件参数');
const content=await this.vfs.readFile(this.vfs.resolvePath(file));
return content.split('\n').map(line=>{
const cols=line.split(delim);
return cols[field-1]||'';
}).join('\n');
}
async _tr(p,raw){
const sets=raw.match(/tr\s+['"]?([^'"]+)['"]?\s+['"]?([^'"]+)['"]?/);
if(!sets)throw new Error('tr: 用法 tr SET1 SET2');
const [,set1,set2]=sets;
const content=p.find(a=>!a.startsWith('-'));
if(!content)throw new Error('tr: 缺少输入');
let text;
try{text=await this.vfs.readFile(this.vfs.resolvePath(content));}catch(e){text=content;}
let result='';
for(const ch of text){
const idx=set1.indexOf(ch);
result+=idx>=0?(set2[idx]||''):ch;
}
return result;
}
async _nl(p){
const file=p.find(a=>!a.startsWith('-'));
if(!file)throw new Error('nl: 缺少文件参数');
const content=await this.vfs.readFile(this.vfs.resolvePath(file));
return content.split('\n').map((line,i)=>(i+1)+'\t'+line).join('\n');
}
async _tac(p){
const file=p.find(a=>!a.startsWith('-'));
if(!file)throw new Error('tac: 缺少文件参数');
const content=await this.vfs.readFile(this.vfs.resolvePath(file));
return content.split('\n').reverse().join('\n');
}
_seq(p){
let start=1,end=1,step=1;
if(p.length===1){end=parseInt(p[0]);}
else if(p.length===2){start=parseInt(p[0]);end=parseInt(p[1]);}
else if(p.length>=3){start=parseInt(p[0]);step=parseInt(p[1]);end=parseInt(p[2]);}
if(isNaN(start)||isNaN(end)||isNaN(step))throw new Error('seq: 参数必须是数字');
const result=[];
if(step>0){for(let i=start;i<=end;i+=step)result.push(String(i));}
else if(step<0){for(let i=start;i>=end;i+=step)result.push(String(i));}
return result.join('\n');
}
async _rev(p){
const file=p.find(a=>!a.startsWith('-'));
if(!file)throw new Error('rev: 缺少文件参数');
const content=await this.vfs.readFile(this.vfs.resolvePath(file));
return content.split('\n').map(line=>line.split('').reverse().join('')).join('\n');
}
async _stat(p){
const file=p.find(a=>!a.startsWith('-'));
if(!file)throw new Error('stat: 缺少文件参数');
const abs=this.vfs.resolvePath(file);
try{
const fh=await this.vfs.getFileHandle(abs);
const f=await fh.getFile();
return `File: ${file}\nSize: ${f.size}\t\tModified: ${new Date(f.lastModified).toISOString()}\nType: ${f.type||'unknown'}`;
}catch(e){
const dh=await this.vfs.getDirHandle(abs);
return `File: ${file}\nType: directory`;
}
}
async _sed(p,raw){
const sMatch=raw.match(/sed\s+['"]?s\/([^/]+)\/([^/]*)\/(g?)['"]?\s*(.*)/);
if(!sMatch)throw new Error('sed: 用法 sed s/old/new/[g] file');
const[,oldStr,newStr,global,file]=sMatch;
if(!file)throw new Error('sed: 缺少文件参数');
const abs=this.vfs.resolvePath(file.trim());
const content=await this.vfs.readFile(abs);
const re=new RegExp(oldStr.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),global?'g':'');
const result=content.replace(re,newStr);
await this.vfs.writeFile(abs,result);
return result;
}
async _tee(p,raw){
const append=raw.includes('-a')||raw.includes('>>');
const files=p.filter(a=>!a.startsWith('-'));
if(!files.length)throw new Error('tee: 缺少文件参数');
const input=p._stdin||'';
for(const f of files){
await this.vfs.writeFile(this.vfs.resolvePath(f),input,append);
}
return input;
}
async _file(p){
const file=p.find(a=>!a.startsWith('-'));
if(!file)throw new Error('file: 缺少文件参数');
const abs=this.vfs.resolvePath(file);
try{
const fh=await this.vfs.getFileHandle(abs);
const f=await fh.getFile();
const ext=file.split('.').pop().toLowerCase();
const types={
'js':'JavaScript','ts':'TypeScript','json':'JSON','html':'HTML','htm':'HTML',
'css':'CSS','xml':'XML','svg':'SVG','txt':'Text','md':'Markdown',
'py':'Python','rb':'Ruby','java':'Java','c':'C','cpp':'C++','h':'C Header',
'sh':'Shell Script','bash':'Bash Script',
'jpg':'JPEG image','jpeg':'JPEG image','png':'PNG image','gif':'GIF image',
'mp3':'MP3 audio','wav':'WAV audio','mp4':'MP4 video','webm':'WebM video',
'pdf':'PDF document','zip':'ZIP archive','tar':'TAR archive','gz':'GZIP archive'
};
return file+': '+(types[ext]||'data')+', '+f.size+' bytes';
}catch(e){
return file+': directory';
}
}
_realpath(p){
const file=p.find(a=>!a.startsWith('-'));
if(!file)throw new Error('realpath: 缺少文件参数');
return this.vfs.resolvePath(file);
}
async _expand(p){
const file=p.find(a=>!a.startsWith('-'));
if(!file)throw new Error('expand: 缺少文件参数');
const content=await this.vfs.readFile(this.vfs.resolvePath(file));
return content.replace(/\t/g,' ');
}
async _comm(p){
const files=p.filter(a=>!a.startsWith('-'));
if(files.length<2)throw new Error('comm: 需要两个文件参数');
const c1=await this.vfs.readFile(this.vfs.resolvePath(files[0]));
const c2=await this.vfs.readFile(this.vfs.resolvePath(files[1]));
const l1=c1.split('\n').filter(Boolean);
const l2=c2.split('\n').filter(Boolean);
const only1=[],only2=[],both=[];
let i=0,j=0;
while(i<l1.length&&j<l2.length){
if(l1[i]<l2[j]){only1.push(l1[i]);i++;}
else if(l1[i]>l2[j]){only2.push(l2[j]);j++;}
else{both.push(l1[i]);i++;j++;}
}
while(i<l1.length){only1.push(l1[i]);i++;}
while(j<l2.length){only2.push(l2[j]);j++;}
const result=[];
if(only1.length)result.push('仅在 '+files[0]+':\n'+only1.join('\n'));
if(only2.length)result.push('仅在 '+files[1]+':\n'+only2.join('\n'));
if(both.length)result.push('两者共有:\n'+both.join('\n'));
return result.join('\n\n')||'(两个文件相同)';
}
async _xargs(p,raw){
const cmdMatch=raw.match(/xargs\s+(.*)/);
if(!cmdMatch)throw new Error('xargs: 用法 xargs command');
const baseCmd=cmdMatch[1].trim();
const input=p._stdin||'';
const items=input.split('\n').filter(Boolean);
const results=[];
for(const item of items){
const fullCmd=baseCmd+' '+item;
const result=await this.exec(fullCmd);
results.push(result);
}
return results.join('\n');
}
async _paste(p){
const files=p.filter(a=>!a.startsWith('-'));
if(files.length<2)throw new Error('paste: 需要两个文件参数');
const c1=await this.vfs.readFile(this.vfs.resolvePath(files[0]));
const c2=await this.vfs.readFile(this.vfs.resolvePath(files[1]));
const l1=c1.split('\n'),l2=c2.split('\n');
const maxLen=Math.max(l1.length,l2.length);
const result=[];
for(let i=0;i<maxLen;i++){
result.push((l1[i]||'')+'\t'+(l2[i]||''));
}
return result.join('\n');
}
async _split(p){
const linesIdx=p.indexOf('-l');
const linesPerFile=linesIdx>=0?parseInt(p[linesIdx+1])||1000:1000;
const prefixIdx=p.indexOf('-d');
const prefix=prefixIdx>=0?p[prefixIdx+1]:'x';
const file=p.find(a=>!a.startsWith('-')&&a!==String(linesPerFile)&&a!==prefix);
if(!file)throw new Error('split: 缺少文件参数');
const content=await this.vfs.readFile(this.vfs.resolvePath(file));
const lines=content.split('\n');
const chunks=[];
for(let i=0;i<lines.length;i+=linesPerFile){
chunks.push(lines.slice(i,i+linesPerFile).join('\n'));
}
const results=[];
for(let i=0;i<chunks.length;i++){
const suffix=String(i).padStart(prefix.length,'0');
const name=prefix+suffix;
await this.vfs.writeFile(this.vfs.resolvePath(name),chunks[i]);
results.push(name);
}
return '已分割为 '+results.length+' 个文件: '+results.join(', ');
}
async _strings(p){
const file=p.find(a=>!a.startsWith('-'));
if(!file)throw new Error('strings: 缺少文件参数');
const content=await this.vfs.readFile(this.vfs.resolvePath(file));
const re=/[\x20-\x7E]{4,}/g;
const matches=content.match(re)||[];
return matches.join('\n')||'(无可打印字符串)';
}
_readlink(p){
const file=p.find(a=>!a.startsWith('-'));
if(!file)throw new Error('readlink: 缺少文件参数');
return this.vfs.resolvePath(file);
}
async _mktemp(p){
const template=p.find(a=>!a.startsWith('-'))||'tmp.XXXXXX';
const random=Math.random().toString(36).slice(2,8);
const name=template.replace(/X{6}/g,random);
await this.vfs.writeFile(this.vfs.resolvePath(name),'');
return name;
}
async _od(p){
const file=p.find(a=>!a.startsWith('-'));
if(!file)throw new Error('od: 缺少文件参数');
const content=await this.vfs.readFile(this.vfs.resolvePath(file));
const bytes=new TextEncoder().encode(content);
const lines=[];
for(let i=0;i<bytes.length;i+=16){
const chunk=bytes.slice(i,i+16);
const octal=Array.from(chunk).map(b=>b.toString(8).padStart(3,'0')).join(' ');
const ascii=Array.from(chunk).map(b=>b>=32&&b<127?String.fromCharCode(b):'.').join('');
lines.push(i.toString(8).padStart(6,'0')+' '+octal+' '+ascii);
}
return lines.join('\n')||'(空文件)';
}
async _xxd(p){
const file=p.find(a=>!a.startsWith('-'));
if(!file)throw new Error('xxd: 缺少文件参数');
const content=await this.vfs.readFile(this.vfs.resolvePath(file));
const bytes=new TextEncoder().encode(content);
const lines=[];
for(let i=0;i<bytes.length;i+=16){
const chunk=bytes.slice(i,i+16);
const hex=Array.from(chunk).map(b=>b.toString(16).padStart(2,'0')).join('');
const ascii=Array.from(chunk).map(b=>b>=32&&b<127?String.fromCharCode(b):'.').join('');
lines.push(i.toString(16).padStart(8,'0')+': '+hex.padEnd(32,' ')+' '+ascii);
}
return lines.join('\n')||'(空文件)';
}
_yes(p){
const text=p.join(' ')||'y';
return Array(100).fill(text).join('\n')+'\n... (已截断)';
}
_factor(p){
if(!p.length)throw new Error('factor: 缺少数字参数');
const n=parseInt(p[0]);
if(isNaN(n)||n<2)return p[0]+': 不是有效数字';
const factors=[];
let num=n;
for(let i=2;i*i<=num;i++){
while(num%i===0){factors.push(i);num/=i;}
}
if(num>1)factors.push(num);
return n+': '+factors.join(' ');
}
async _fmt(p){
const widthIdx=p.indexOf('-w');
const width=widthIdx>=0?parseInt(p[widthIdx+1])||76:76;
const file=p.find(a=>!a.startsWith('-')&&a!==String(width));
if(!file)throw new Error('fmt: 缺少文件参数');
const content=await this.vfs.readFile(this.vfs.resolvePath(file));
const words=content.split(/\s+/).filter(Boolean);
const lines=[];
let current='';
for(const word of words){
if(current.length+word.length+1>width&¤t){
lines.push(current);
current=word;
}else{
current=current?current+' '+word:word;
}
}
if(current)lines.push(current);
return lines.join('\n');
}
async _csplit(p){
const patternIdx=p.indexOf('/'+p.find(a=>a.startsWith('/')&&a.endsWith('/'))?.slice(1,-1)+'/');
const file=p.find(a=>!a.startsWith('-')&&!a.includes('/'));
if(!file)throw new Error('csplit: 缺少文件参数');
const content=await this.vfs.readFile(this.vfs.resolvePath(file));
const lines=content.split('\n');
const chunks=[];
let current=[];
for(const line of lines){
current.push(line);
}
if(current.length)chunks.push(current.join('\n'));
const results=[];
for(let i=0;i<chunks.length;i++){
const name=file+'.'+i;
await this.vfs.writeFile(this.vfs.resolvePath(name),chunks[i]);
results.push(name);
}
return '已分割为 '+results.length+' 个文件: '+results.join(', ');
}
async _unexpand(p){
const file=p.find(a=>!a.startsWith('-'));
if(!file)throw new Error('unexpand: 缺少文件参数');
const content=await this.vfs.readFile(this.vfs.resolvePath(file));
return content.replace(/ {4}/g,'\t');
}
async _join(p){
const files=p.filter(a=>!a.startsWith('-'));
if(files.length<2)throw new Error('join: 需要两个文件参数');
const c1=await this.vfs.readFile(this.vfs.resolvePath(files[0]));
const c2=await this.vfs.readFile(this.vfs.resolvePath(files[1]));
const l1=c1.split('\n').filter(Boolean);
const l2=c2.split('\n').filter(Boolean);
const map2=new Map();
for(const line of l2){
const key=line.split(/\s+/)[0];
map2.set(key,line);
}
const result=[];
for(const line of l1){
const key=line.split(/\s+/)[0];
if(map2.has(key)){
result.push(line+' '+map2.get(key).split(/\s+/).slice(1).join(' '));
}
}
return result.join('\n')||'(无匹配行)';
}
}
let _fileSystem=null;let _bashExec=null;let _syncWarned=false;let _syncQueue=[];let _syncing=false;
const ALLOWED_TOP_DIRS=['files','guest','images','memory','mud','music','project','tmp','videos','voice'];
function isRootPath(p){
if(!p||typeof p!=='string')return true;
const n=p.replace(/^\/+/,'').trim();if(!n)return true;
return!ALLOWED_TOP_DIRS.includes(n.split('/')[0]);
}
async function _ensureFS(){
if(_fileSystem)return true;
if(!navigator.storage||!navigator.storage.getDirectory)return false;
_fileSystem=new VirtualFS();await _fileSystem.ready;return true;
}
// ── Tool Handlers ──
const toolHandlers={
async web_search(args,ctx){
if(_webSearchQuotaExhausted)return '网络搜索暂时不可用(所有 MiniMax API Key 的今日配额已用尽,将于午夜 UTC+8 重置)';
try{
const data=await proxyRequest('minimax','/chat/completions',{model:'MiniMax-M2.7',messages:[{role:'system',content:'你是一个搜索助手。以JSON格式返回:{"results":[{"title":"标题","url":"链接","snippet":"摘要"}]}。不要使用工具调用,直接返回JSON。'},{role:'user',content:args.query}],temperature:.3,max_tokens:8192});
let content=data.choices?.[0]?.message?.content||'';
content=content.replace(/<thinking>[\s\S]*?<\/thinking>/g,'').trim();
const jsonMatch=content.match(/\{[^]*"results"[^]*\}/);
if(jsonMatch)return JSON.stringify(JSON.parse(jsonMatch[0]).results,null,2);
return content;
}catch(e){if(e.name==='AbortError')throw e;if(e.message&&e.message.includes('proxy_all_keys_exhausted')){_webSearchQuotaExhausted=true;return '网络搜索失败,所有 API Key 配额已用尽';}return '搜索出错: '+e.message;}
},
async vision_recognition(args,ctx){
// 通过服务器代理下载图片(解决 MiniMax 无法访问站内/私有 URL 的问题)
if(_visionQuotaExhausted)return '图像识别暂时不可用(所有 MiniMax API Key 的今日配额已用尽,将于午夜 UTC+8 重置)';
let imgUrl=args.image_url;
try{
const proxyImg=await apiCall('image_proxy',{url:args.image_url});
if(proxyImg.success&&proxyImg.data_url)imgUrl=proxyImg.data_url;
}catch(e){/* 回退:直接用原始 URL */}
try{
const data=await proxyRequest('minimax','/chat/completions',{model:'MiniMax-M2.7',messages:[{role:'user',content:[{type:'text',text:args.question||'这张图片里有什么?'},{type:'image_url',image_url:{url:imgUrl}}]}],max_tokens:4096});
return data.choices?.[0]?.message?.content||'无识别结果';
}catch(e){if(e.name==='AbortError')throw e;if(e.message&&e.message.includes('proxy_all_keys_exhausted')){_visionQuotaExhausted=true;return '图像识别失败,所有 API Key 配额已用尽';}return '图像识别出错: '+e.message;}
},
async image_generation(args,ctx){
if(_imageQuotaExhausted)return '图片生成暂时不可用(所有 MiniMax API Key 的今日配额已用尽,将于午夜 UTC+8 重置)';
try{
const imgBody={model:'image-01',prompt:args.prompt,aspect_ratio:args.aspect_ratio||'1:1',n:args.n||1,response_format:'url'};if(args.seed)imgBody.seed=args.seed;if(args.prompt_optimizer)imgBody.prompt_optimizer=true;if(args.width&&args.height){imgBody.width=args.width;imgBody.height=args.height;delete imgBody.aspect_ratio;}if(args.aigc_watermark)imgBody.aigc_watermark=true;const data=await proxyRequest('minimax','/image_generation',imgBody);const br=data.base_resp||{};if(br.status_code&&br.status_code!==0)return '图片生成失败: '+(br.status_msg||'错误码 '+br.status_code);
const urls=data.data?.image_urls||[];
const failed=data.data?.failed_count||0;
if(!urls.length&&failed>0)return '图片生成失败 ('+failed+'张失败)';
if(!urls.length)return '未生成图像';
if(failed>0)addStepMessage('⚠️ '+failed+'张图片生成失败');
const shareUrls=[];
for(const url of urls){
const f=await saveMediaToUser(url,'images','jpg');
if(f){
addAgentMessage('📁 已保存: '+f,'image');
const su=await getShareUrl(f);
if(mudActive&&showMudImage)shareUrls.push(su||PROXY_URL+'?_action=file_download&file_path='+encodeURIComponent(f));
}else if(mudActive&&showMudImage){shareUrls.push(url);}
}
if(mudActive&&showMudImage&&shareUrls.length)_mudPendingImage=shareUrls;
return JSON.stringify(urls);
}catch(e){if(e.name==='AbortError')throw e;if(e.message&&e.message.includes('proxy_all_keys_exhausted')){_imageQuotaExhausted=true;return '图片生成失败,所有 API Key 配额已用尽';}return '图片生成出错: '+e.message;}
},
async video_generation(args,ctx){
// 会话级配额耗尽保护:阻止 AI 在配额恢复前重复调用
if(_videoQuotaExhausted)return '视频生成暂时不可用(所有 MiniMax API Key 的今日视频配额已用尽,将于午夜 UTC+8 重置)';
addStepMessage('🎬 开始生成视频(T2V文生视频/Hailuo-2.3),预计1-2分钟...');
// 按 mmx-cli 参数模型构建请求体:仅含 MiniMax API 支持的字段
const vidBody={model:'MiniMax-Hailuo-2.3',prompt:args.prompt};
if(args.first_frame_image)vidBody.first_frame_image=args.first_frame_image;
if(args.last_frame_image&&args.first_frame_image){vidBody.last_frame_image=args.last_frame_image;vidBody.model='MiniMax-Hailuo-02';}
if(args.subject_image){vidBody.subject_reference=[{type:'character',image:[args.subject_image]}];vidBody.model='S2V-01';}
addStepMessage('🎬 已提交视频任务,等待生成...');
// 当所有 Key 耗尽时自动重试(最多 2 次)
for(let retry=0;retry<3;retry++){
if(stopRequested)return '操作已取消';
// Step 1: 异步提交
const createResp=await proxyRequest('minimax','/video_submit',vidBody,30000);
if(createResp.error&&retry<2){addStepMessage('🔄 Key 耗尽,重试提交...');await new Promise(r=>setTimeout(r,5000));continue;}
if(createResp.error)return '视频生成启动失败: '+createResp.message;
const taskId=createResp.task_id;if(!taskId)return '视频生成失败:未获取到任务ID';
// Step 2: 轮询等待结果(最多等 3 分钟,指数退避 3s→10s, 与音乐生成一致)
const start=Date.now();const MAX_WAIT=180000;let delay=3000;let lastResp=null;
while(Date.now()-start<MAX_WAIT){
await new Promise(r=>setTimeout(r,delay));if(stopRequested)return '操作已取消';
const pollResp=await proxyRequest('minimax','/video_poll',{task_id:taskId},15000);
if(pollResp.status==='pending'){delay=Math.min(delay*1.5,10000);continue;}
lastResp=pollResp;break;
}
if(!lastResp){if(retry<2){addStepMessage('🔄 生成超时,重新提交...');await new Promise(r=>setTimeout(r,3000));continue;}return '视频生成超时,请稍后重试';}
// Key 全部耗尽?自动重试提交
if(lastResp.error==='all_keys_exhausted'||lastResp.error==='proxy_all_keys_exhausted'){
_videoQuotaExhausted=true;
if(retry<2){addStepMessage('🔄 Key 配额耗尽,重新提交(第'+(retry+1)+'次重试)...');await new Promise(r=>setTimeout(r,5000));continue;}
return '视频生成失败,所有 API Key 配额已用尽,已自动禁止后续调用。请在午夜 UTC+8 后刷新页面重试'+(lastResp.message?': '+lastResp.message:'');
}
if(lastResp.error)return '视频生成失败: '+(lastResp.message||lastResp.error);
const br_v=lastResp.base_resp||{};
if(br_v.status_code&&br_v.status_code!==0)return '视频生成失败: '+(br_v.status_msg||'错误码 '+br_v.status_code);
let url=lastResp.video_url||lastResp.result_url||lastResp.data?.video_url||'';
if(!url&&lastResp.file_id){try{const fi=await proxyRequest('minimax','/files/retrieve?file_id='+lastResp.file_id,{});url=fi.file?.download_url||fi.download_url||'';}catch(e){}}
if(url){const f=await saveMediaToUser(url,'videos','mp4');if(f){addAgentMessage('📁 已保存: '+f,'video');const su=await getShareUrl(f);if(su)return su;}return url;}
return '视频生成完成但无URL'+(lastResp._http_code?' (HTTP '+lastResp._http_code+')':'');
} // end retry loop
return '视频生成失败,多次重试后未获取到有效结果';
},
async music_generation(args,ctx){
if(_musicQuotaExhausted)return '音乐生成暂时不可用(所有 MiniMax API Key 的今日配额已用尽,将于午夜 UTC+8 重置)';
try{
// 官方 SDK 做法:genre/mood/vocals 等结构化参数折叠进 prompt
const structured=[];
if(args.genre)structured.push('Genre: '+args.genre);
if(args.mood)structured.push('Mood: '+args.mood);
if(args.vocals)structured.push('Vocals: '+args.vocals);
if(args.instruments)structured.push('Instruments: '+args.instruments);
if(args.tempo)structured.push('Tempo: '+args.tempo);
if(args.bpm)structured.push('BPM: '+args.bpm);
if(args.key)structured.push('Key: '+args.key);
const fullPrompt=structured.length>0?(args.prompt?args.prompt+'. '+structured.join('. '):structured.join('. ')):args.prompt;
const body={model:'music-2.6',prompt:fullPrompt};if(args.lyrics)body.lyrics=args.lyrics;if(args.instrumental)body.is_instrumental=true;else if(!args.lyrics&&!args.lyrics_optimizer)body.is_instrumental=true;if(args.lyrics_optimizer)body.lyrics_optimizer=true;body.audio_setting={format:'mp3',sample_rate:44100,bitrate:256000};body.output_format='url';
// Step 1: 异步创建音乐任务(proxy.php 立即返回 task_id,后台 worker 慢慢等 MiniMax)
const createResp=await proxyRequest('minimax','/music_generation',body,30000);
if(createResp.error)return '音乐生成启动失败: '+createResp.message;
const taskId=createResp.task_id;
if(!taskId)return '音乐生成失败:未获取到任务ID';
// Step 2: 轮询等待结果(最多等 180s,避免 504)
const start=Date.now();
const MAX_WAIT=180000;
let delay=3000;
let lastResp=null;
while(Date.now()-start<MAX_WAIT){
await new Promise(r=>setTimeout(r,delay));
if(stopRequested)return '操作已取消';
const pollResp=await proxyRequest('minimax','/music_poll',{task_id:taskId},15000);
if(pollResp.status==='pending'){delay=Math.min(delay*1.5,10000);continue;}
// 有结果了(成功或失败)
lastResp=pollResp;
break;
}
if(!lastResp)return '音乐生成超时(3分钟),请稍后重试';
if(lastResp.error){
if(lastResp.error==='all_keys_exhausted'||lastResp.error==='proxy_all_keys_exhausted'){_musicQuotaExhausted=true;return '音乐生成失败,所有 API Key 配额已用尽';}
return '音乐生成失败: '+(lastResp.message||lastResp.error);
}
const br_m=lastResp.base_resp||{};
if(br_m.status_code&&br_m.status_code!==0)return '音乐生成失败: '+(br_m.status_msg||'错误码 '+br_m.status_code);
const url=lastResp.audio_url||lastResp.data?.audio_url||lastResp.data?.audio||'';
if(url){const f=await saveMediaToUser(url,'music','mp3');if(f){addAgentMessage('📁 已保存: '+f,'music');const su=await getShareUrl(f);if(su)return su;}return url;}
return '音乐生成失败,请检查输入描述或稍后重试';
}catch(e){if(e.name==='AbortError')throw e;return '音乐生成出错: '+e.message;}
},
async text_to_speech(args,ctx){
if(_ttsQuotaExhausted)return '语音生成暂时不可用(所有 MiniMax API Key 的今日配额已用尽,将于午夜 UTC+8 重置)';
try{
const data=await proxyRequest('minimax','/t2a_v2',{model:'speech-2.8-hd',text:args.text,voice_setting:{voice_id:args.voice_id||'female-shaonv',speed:args.speed||1.0},audio_setting:{format:'mp3',sample_rate:32000,bitrate:128000,channel:1},output_format:'url',stream:false,subtitle_enable:args.subtitle_enable||undefined});
const baseResp=data.base_resp||{};
if(baseResp.status_code&&baseResp.status_code!==0){
const errMsg=baseResp.status_msg||'未知错误';
return '语音生成失败: '+errMsg+'。可用音色: female-shaonv(少女), female-yu-xiangan(御姐), female-jianai(可爱), female-tianmei(甜美), male-qn-qingse(青年男声), male-shaoshuai(少帅), male-zhixing(磁性), female-zhixing(知性), male-jingpin(精品男声), female-jingpin(精品女声)';
}
const url=data.audio_url||data.data?.audio_url||data.data?.audio||'';
if(url){const f=await saveMediaToUser(url,'voice','mp3');if(f){addAgentMessage('📁 已保存: '+f,'voice');const su=await getShareUrl(f);if(mudActive&&showMudVoice){_mudPendingVoice=su||(PROXY_URL+'?_action=file_download&file_path='+encodeURIComponent(f));}return su||(PROXY_URL+'?_action=file_download&file_path='+encodeURIComponent(f));}else if(mudActive&&showMudVoice){_mudPendingVoice=url;}return url;}
return '语音生成完成但未获取到音频URL,API响应结构: '+JSON.stringify(data).slice(0,300);
}catch(e){if(e.name==='AbortError')throw e;if(e.message&&e.message.includes('proxy_all_keys_exhausted')){_ttsQuotaExhausted=true;return '语音生成失败,所有 API Key 配额已用尽';}return '语音生成出错: '+e.message;}
},
async bash(args,ctx){
const cmd=(args.command||'').trim();
if(!cmd)return'请提供命令';
if(!await _ensureFS())return'当前浏览器不支持 OPFS,无法使用 bash 模拟。建议使用 read/write/edit 工具。';
try{
if(!_bashExec)_bashExec=new BashExecutor(_fileSystem);
const out=await _bashExec.exec(cmd);
return out||'(命令执行成功,无输出)';
}catch(e){if(e.name==='AbortError')throw e;return'bash 执行失败: '+e.message;}
},
async read(args,ctx){
try{
const fp=args.file_path;
if(await _ensureFS()){
try{
const fh=await _fileSystem.getFileHandle(_fileSystem.resolvePath(fp));
const text=await(await fh.getFile()).text();
if(args.offset||args.limit){const lines=text.split('\n');return lines.slice(args.offset||0,(args.offset||0)+(args.limit||lines.length)).join('\n');}
return text;
}catch(e){/* not found in OPFS, fallback to server */}
}
const res=await apiCall('file_read',{file_path:fp,offset:args.offset,limit:args.limit});
// 服务器读取成功后同步到 OPFS
if(res.content!==undefined&&await _ensureFS()){
try{await _fileSystem.writeFile(_fileSystem.resolvePath(fp),res.content,false,true);}catch(e){}
}
return res.content;}
catch(e){if(e.name==='AbortError')throw e;return '读取失败: '+e.message;}
},
async write(args,ctx){
try{
const fpFinal=args.file_path;
if(await _ensureFS()){
const abs=_fileSystem.resolvePath(fpFinal);
await _fileSystem.writeFile(abs,args.content);
return'文件已写入: '+args.file_path;
}
const res=await apiCall('file_write',{file_path:fpFinal,content:args.content});
// 服务器写入成功后同步到 OPFS
if(res.success&&await _ensureFS()){
try{await _fileSystem.writeFile(_fileSystem.resolvePath(fpFinal),args.content,false,true);}catch(e){}
}
if(currentUser)await fetchQuota();return res.message;}
catch(e){if(e.name==='AbortError')throw e;return '写入失败: '+e.message;}
},
async edit(args,ctx){
try{
const fp=args.file_path;
if(await _ensureFS()){
const abs=_fileSystem.resolvePath(fp);
const text=await _fileSystem.readFile(abs);
const oldStr=args.old_string;
if(!text.includes(oldStr))return'编辑失败: 未找到匹配字符串';
const newText=args.replace_all?text.replaceAll(oldStr,args.new_string):text.replace(oldStr,args.new_string);
await _fileSystem.writeFile(abs,newText);
return'文件已编辑: '+args.file_path;
}
const res=await apiCall('file_edit',{file_path:fp,old_string:args.old_string,new_string:args.new_string,replace_all:args.replace_all});
// 服务器编辑成功后同步到 OPFS(读取最新内容写入)
if(res.success&&await _ensureFS()){
try{
const latest=await apiCall('file_read',{file_path:fp});
if(latest.content!==undefined)await _fileSystem.writeFile(_fileSystem.resolvePath(fp),latest.content,false,true);
}catch(e){}
}
if(currentUser)await fetchQuota();return res.message;}
catch(e){if(e.name==='AbortError')throw e;return '编辑失败: '+e.message;}
},
async AskUserQuestion(args,ctx){
const{question,options}=args;
let msg='🤔 **'+question+'**\n';
if(options&&options.length){msg+='\n请选择:\n';options.forEach((opt,i)=>msg+=(i+1)+'. '+opt+'\n');}
return{displayText:msg,awaitUserResponse:true,metadata:{question,options}};
},
async web_fetch(args,ctx){
try{
const res=await apiCall('web_fetch',{url:args.url,max_chars:args.max_chars||50000});
if(res.error)return '抓取失败: '+res.error;
return '📄 来自 '+res.url+' (HTTP '+res.httpCode+', '+res.length+' 字符)\n\n'+res.content;
}catch(e){if(e.name==='AbortError')throw e;return '抓取出错: '+e.message;}
},
async mud_action(args,ctx){
// 如果MUD未激活,先自动初始化(清空旧状态)
if(!mudActive){
_mudState=null;
try{
const initRes=await apiCall('mud_action',{command:'restart',original_message:'自动初始化'});
if(initRes.state){_mudState=initRes.state;showMudPanel();}
mudActive=true;mudStateSynced=true;showMudPanel();
if(!showMudImage||!showMudVoice)activateMudDefaults();
}catch(e){return 'MUD初始化失败: '+e.message+'。请稍后重试。';}
}
try{
const payload={command:args.command};
if(args.message)payload.original_message=args.message;
if(args.state_updates)payload.state_updates=args.state_updates;
const res=await apiCall('mud_action',payload);
if(res.error)return 'MUD命令错误: '+res.error;
if(res.state){_mudState=res.state;showMudPanel();}
const tagStr=res.game_output||res.output||res.content||'';
const parsed=parseMudResponse(tagStr);
if(parsed.tag==='image_on'){showMudImage=true;mudToggleImage.checked=true;}
else if(parsed.tag==='image_off'){showMudImage=false;mudToggleImage.checked=false;}
else if(parsed.tag==='voice_on'){showMudVoice=true;mudToggleVoice.checked=true;}
else if(parsed.tag==='voice_off'){showMudVoice=false;mudToggleVoice.checked=false;}
else if(parsed.tag==='vision_on'){showMudVision=true;mudToggleVision.checked=true;}
else if(parsed.tag==='vision_off'){showMudVision=false;mudToggleVision.checked=false;}
else if(parsed.tag==='init'){mudActive=true;mudStateSynced=true;showMudPanel();}
// LLM是唯一叙事者:needs_narration时返回状态上下文供LLM叙事,纯机械标签返回标签文本
if(res.needs_narration){return(parsed.text||TAG_LABELS[parsed.tag]||'')+buildMudStateContext();}
if(parsed.tag&&TAG_LABELS[parsed.tag])return TAG_LABELS[parsed.tag];
return parsed.text||'';
}catch(e){if(e.name==='AbortError')throw e;return 'MUD命令执行失败: '+e.message;}
}
};
// ── Tool Executor ──
class EnhancedToolExecutor{
constructor(){this.handlers=toolHandlers;this.toolFailCounts={};}
classifyError(name,e){
const msg=(e.message||'').toLowerCase();
if(msg.includes('token')||msg.includes('auth')||msg.includes('permission')||msg.includes('forbidden'))return'fatal';
if(msg.includes('timeout')||msg.includes('rate')||msg.includes('429')||msg.includes('network')||msg.includes('abort'))return'recoverable';
return'unknown';
}
smartTruncate(content,maxLen=51200){
if(content.length<=maxLen)return content;
try{
const parsed=JSON.parse(content);
if(Array.isArray(parsed)){const keep=3;const kept=parsed.slice(0,keep);return JSON.stringify(kept,null,2)+`\n\n... (${parsed.length-keep} more items truncated, total ${parsed.length})`+(parsed.length>keep?'\n提示:如需查看后续数据,可告知用户以获取完整结果。':'');}
if(typeof parsed==='object'&&parsed!==null){const keys=Object.keys(parsed);const kk=keys.slice(0,5);const kept={};kk.forEach(k=>{kept[k]=parsed[k];});return JSON.stringify(kept,null,2)+`\n\n... (${keys.length-5} more keys truncated)`;}
}catch(e){}
const half=Math.floor(maxLen*0.6);const head=content.slice(0,half);const tail=content.slice(-(maxLen-half));
addStepMessage('⚠️ 工具结果过长 ('+Math.ceil(content.length/1024)+'KB),截断保留首尾');
return head+'\n\n... [中间截断 '+(content.length-maxLen)+' 字符,原结果共 '+content.length+' 字符] ...\n\n'+tail;
}
// 单工具执行核心(含去重、错误分类、截断)
async _execSingleTool(sessionId,call,messages,lastToolCallsForDedup){
const{id,function:{name,arguments:argsJson}}=call;let args;
try{args=JSON.parse(argsJson);}catch(e){return{result:{role:'tool',tool_call_id:id,content:'[错误] 工具参数解析失败,请检查参数格式'}};}
const handler=this.handlers[name];
if(!handler)return{result:{role:'tool',tool_call_id:id,content:'[错误] 未注册的工具: '+name+', 请使用可用工具'}};
// 去重检测(基于上一轮调用,非本轮)
if(lastToolCallsForDedup&&isDuplicateCall([call],lastToolCallsForDedup)){
addStepMessage('⚠️ 检测到重复工具调用: '+name);
return{result:{role:'tool',tool_call_id:id,content:'[提示] 你刚刚执行过相似的重复操作('+name+')。请基于已有结果继续,如确实需要再次调用请说明理由。'}};
}
addStepMessage('调用工具: '+name+'('+JSON.stringify(args).slice(0,60)+'...)');
try{
const res=await handler(args,{sessionId,toolCall:call,messages});
if(res&&res.awaitUserResponse){
addAgentMessage(res.displayText,name);
return{result:{role:'tool',tool_call_id:id,content:JSON.stringify({question:args.question,options:args.options}),_awaitUser:true},_awaitUser:true};
}
const content=typeof res==='string'?res:JSON.stringify(res);
this.toolFailCounts[name]=0;
const truncated=this.smartTruncate(content);
let showImage=false;
try{const parsed=JSON.parse(content);if(Array.isArray(parsed)&&parsed.length>0&&typeof parsed[0]==='string'&&parsed[0].match(/\.(jpe?g|png|gif|webp|bmp)(\?|$)/i)){addAgentMessage('',name);parsed.forEach(url=>addImageMessage(url));showImage=true;}}catch(e){}
if(!showImage&&typeof content==='string'&&content.match(/^https?:\/\/.+\.(jpe?g|png|gif|webp|bmp)(\?|$)/i)){addAgentMessage('',name);addImageMessage(content);showImage=true;}
if(!showImage){addAgentMessage('工具 '+name+' 返回结果',name);}
return{result:{role:'tool',tool_call_id:id,content:truncated}};
}catch(e){
if(e.name==='AbortError')throw e;
const errorType=this.classifyError(name,e);
this.toolFailCounts[name]=(this.toolFailCounts[name]||0)+1;
const fc=this.toolFailCounts[name];
const errMsg='工具 '+name+' 失败 ['+errorType+'] (连续'+fc+'次): '+e.message;
addStepMessage('⚠️ '+errMsg);
const ret={result:{role:'tool',tool_call_id:id,content:'[错误] '+errMsg+'\n\n请根据错误信息调整操作。可恢复错误请重试,致命错误请告知用户。'}};
if(errorType==='fatal'||fc>=3){ret._earlyBreak=true;addStepMessage('⏹ 工具 '+name+' 连续失败 '+fc+' 次,终止循环');}
return ret;
}
}
// 串行执行(保留供兼容)
async executeToolCalls(sessionId,toolCalls,messages){
const results=[];for(const call of toolCalls){if(stopRequested)break;
const wr=await this._execSingleTool(sessionId,call,messages,null);
results.push(wr.result);if(wr._awaitUser)return{results,awaitingUser:true};if(wr._earlyBreak)return{results,earlyBreak:true};}
return{results,awaitingUser:false,earlyBreak:false};
}
// 并行执行:write/edit 先行串行,其余并行
async executeToolCallsParallel(sessionId,toolCalls,messages,lastToolCallsForDedup){
const FILE_MODIFY=['write','edit'];const results=[];
const fileCalls=toolCalls.filter(tc=>FILE_MODIFY.includes(tc.function.name));
const otherCalls=toolCalls.filter(tc=>!FILE_MODIFY.includes(tc.function.name));
// 串行:文件修改工具(避免读写冲突)
for(const tc of fileCalls){
const wr=await this._execSingleTool(sessionId,tc,messages,lastToolCallsForDedup);
results.push(wr.result);if(wr._awaitUser)return{results,awaitingUser:true};if(wr._earlyBreak)return{results,earlyBreak:true};
}
// 并行:其余工具
let hasAwaitUser=false;let hasEarlyBreak=false;
if(otherCalls.length>0){
const settled=await Promise.allSettled(otherCalls.map(tc=>this._execSingleTool(sessionId,tc,messages,lastToolCallsForDedup)));
for(const s of settled){
if(s.status==='fulfilled'){const wr=s.value;results.push(wr.result);if(wr._awaitUser)hasAwaitUser=true;if(wr._earlyBreak)hasEarlyBreak=true;}
else if(s.reason?.name!=='AbortError')results.push({role:'tool',tool_call_id:'unknown',content:'[错误] 工具执行异常: '+(s.reason?.message||'未知')});
}
}
// 按原始 tool_calls 顺序重排(保持 LLM 预期)
const orderMap=new Map();toolCalls.forEach((tc,i)=>orderMap.set(tc.id,i));
results.sort((a,b)=>(orderMap.get(a.tool_call_id)??99)-(orderMap.get(b.tool_call_id)??99));
if(hasAwaitUser)return{results,awaitingUser:true};if(hasEarlyBreak)return{results,earlyBreak:true};
return{results,awaitingUser:false,earlyBreak:false};
}
}
const executor=new EnhancedToolExecutor();
// ── 工具去重检测 ──
function isDuplicateCall(current,previous){
if(!previous||previous.length===0)return false;
for(const prev of previous){
for(const curr of current){
if(prev.function.name!==curr.function.name)continue;
try{
const pa=JSON.parse(prev.function.arguments);const ca=JSON.parse(curr.function.arguments);
// 比较参数的实际值,而非仅比较 key 名
if(JSON.stringify(pa)===JSON.stringify(ca))return true;
}catch(e){}
}
}
return false;
}
// ── 媒体文件自动保存到用户或访客文件夹 ──
async function saveMediaToUser(url,folder,ext){
try{
const res=await apiCall('file_save_from_url',{url:url,folder:folder});
if(!res.success||!res.file){addStepMessage('⚠️ 文件保存失败: '+(res.error||'未知错误'));return null;}
if(currentUser)fetchQuota();
return res.file;
}catch(e){addStepMessage('⚠️ 文件保存失败: '+e.message);return null;}
}
// 生成分享链接(替代 MiniMax 会过期的 CDN 直链)
async function getShareUrl(filePath){
if(!filePath)return null;
try{
const resp=await fetch(PROXY_URL,{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({_action:'generate_share_link',file_path:filePath,_token:accessToken})
});
const d=await resp.json();
return d.share_url||null;
}catch(e){return null;}
}
// ── 系统提示词 ──
function buildSystemPrompt(){
const username=currentUser?currentUser.username:'访客';
let prompt;
if(currentUser){
prompt='You are an interactive CLI tool...当前登录用户: '+username+'\n用户拥有 '+(username==='admin'?'1GB':'100MB')+' 的存储配额。\n\n## 文件目录存放规则(请严格遵守)\n沙箱内包含以下子目录,各类文件建议存放到对应目录:\n\n| 目录 | 用途 | 存放内容 |\n|------|------|----------|\n| project/ | 🗂 项目文件 | 每个项目一个独立子文件夹 project/项目名/ |\n| tmp/ | 📦 临时文件 | 中间结果、调试日志、一次性输出 |\n| files/ | 📄 通用文档 | 笔记、配置、通用文本 |\n| images/ | 🖼 图片 | 系统自动保存(image_generation 生成的图片),无需手动指定 |\n| videos/ | 🎬 视频 | 系统自动保存(video_generation 生成的视频),无需手动指定 |\n| music/ | 🎵 音乐 | 系统自动保存(music_generation 生成的音乐),无需手动指定 |\n| voice/ | 🎤 语音 | 系统自动保存(text_to_speech 生成的语音),无需手动指定 |\n| memory/ | 🧠 记忆数据 | 记忆系统专用,请勿手动修改 |\n\n你可以使用以下工具:\n- web_search: 搜索互联网\n- vision_recognition: 识别图片\n- image_generation: 生成图片\n- video_generation: 生成视频\n- music_generation: 生成音乐\n- text_to_speech: 将文本转为语音\n- read: 读取沙箱内的文件\n- write: 写入沙箱内的文件\n- edit: 编辑沙箱内的文件(优先使用)\n- AskUserQuestion: 当不确定时向用户提问\n- web_fetch: 抓取外部网页内容(参数: url=目标网址, max_chars=最大字符数默认50000)\n- mud_action: MUD游戏引擎命令(MUD模式下使用)\n- bash: 在浏览器沙箱中执行文件操作命令。仅支持以下51个命令:ls, cat, head, tail, wc, sort, uniq, grep, echo, mkdir, rm, rmdir, pwd, cd, touch, cp, mv, du, tree, basename, dirname, diff, find, cut, tr, nl, tac, seq, rev, stat, sed, tee, file, realpath, expand, comm, xargs, paste, split, strings, readlink, mktemp, od, xxd, yes, factor, fmt, csplit, unexpand, join。不支持其他任何命令(如less/more/printf/env/fold/chmod/chown/ln/tar/zip/curl/wget等)。不支持运行脚本或网络请求\n\n沙箱内所有目录和文件可自由读写,无路径限制。\n\n'+EDIT_PRIORITY_PROMPT+'\n\n'+AGENT_DRIFT_GUARD_SKILL;
}else{
prompt='You are an interactive CLI tool...当前用户: 访客 (未登录)\n\n## 文件目录存放规则(请严格遵守)\n沙箱内包含以下子目录,各类文件建议存放到对应目录:\n\n| 目录 | 用途 | 存放内容 |\n|------|------|----------|\n| project/ | 🗂 项目文件 | 每个项目一个独立子文件夹 project/项目名/ |\n| tmp/ | 📦 临时文件 | 中间结果、调试日志、一次性输出 |\n| files/ | 📄 通用文档 | 笔记、配置、通用文本 |\n| images/ | 🖼 图片 | 自动保存 |\n| videos/ | 🎬 视频 | 自动保存 |\n| music/ | 🎵 音乐 | 自动保存 |\n| voice/ | 🎤 语音 | 自动保存 |\n| memory/ | 🧠 记忆数据 | 记忆系统专用(访客模式不可用) |\n\n你可以使用以下工具:\n- web_search: 搜索互联网\n- vision_recognition: 识别图片\n- image_generation: 生成图片\n- video_generation: 生成视频\n- music_generation: 生成音乐\n- text_to_speech: 将文本转为语音\n- read: 读取沙箱内的文件\n- write: 写入沙箱内的文件\n- edit: 编辑沙箱内的文件(优先使用)\n- AskUserQuestion: 当不确定时向用户提问\n- web_fetch: 抓取外部网页内容\n- mud_action: MUD游戏引擎命令(MUD模式下使用)\n- bash: 在浏览器沙箱中执行文件操作命令。仅支持以下51个命令:ls, cat, head, tail, wc, sort, uniq, grep, echo, mkdir, rm, rmdir, pwd, cd, touch, cp, mv, du, tree, basename, dirname, diff, find, cut, tr, nl, tac, seq, rev, stat, sed, tee, file, realpath, expand, comm, xargs, paste, split, strings, readlink, mktemp, od, xxd, yes, factor, fmt, csplit, unexpand, join。不支持其他任何命令(如less/more/printf/env/fold/chmod/chown/ln/tar/zip/curl/wget等)。不支持运行脚本或网络请求\n\n沙箱内所有目录和文件可自由读写,无路径限制。\n\n'+EDIT_PRIORITY_PROMPT+'\n\n'+AGENT_DRIFT_GUARD_SKILL;
}
// ── MUD 模式动态提示 ──
if(mudActive){
prompt+='\n\n## ⚠️ MUD 模式(后宫爽文·沉浸式叙事)\n';
prompt+='## 核心规则\n';
prompt+='你是一部后宫爽文的作者兼游戏主持人,用第一人称"我"(青年男性主角)进行沉浸式叙事。\n';
prompt+='- 视角:永远是"我",男性青年主角,绝不切换到第二人称或第三人称\n';
prompt+='- 类型:后宫爽文——主角逐步变强,收获红颜知己,征服世界。节奏爽快,不虐主,挫折后必有更大回报\n';
prompt+='- 你自主设定:世界观、阵营、人物、情节走向、地名、功法体系、势力格局——全部由你创造,不依赖任何预设剧本\n';
prompt+='- 严禁使用"好的"、"明白了"、"让我想想"等思考类前缀,直接输出故事\n';
prompt+='- 每轮只输出故事情节本身,不要解释你在做什么\n\n';
prompt+='⚠️ 首次激活规则(极其重要)\n';
prompt+='用户会选择剧本方向(如"仙侠修真""都市后宫""古代宫廷"等),你接到方向后自主设计一切:世界观、角色名、属性体系、开篇场景等。\n';
prompt+='在一个 tool_calls 里同时调用 mud_action 和 image_generation(如果配图开启),然后输出开篇叙事。不要分多轮调用!\n';
prompt+='示例:用户说"仙侠"→你设计灵气复苏世界观、修仙境界体系、主角名和属性→一步 state_updates 写入全部设定\n';
prompt+='示例:tool_calls: [mud_action(command:"state_updates", state_updates:{location:"燕京大学", world_settings:"灵气复苏时代,燕京大学表面是名校,实为武者培养基地", character_name:"徐功", character_class:"大学生/觉醒者", hp:100, hp_max:100, spirit_power:10, spirit_power_max:100, realm:"凡人", charm:8, reputation:0, inventory:[], quests:[]})]\n';
prompt+='等 tool_results 返回后,直接输出第一人称开篇叙事。不要先调用 status 或 action,直接用 state_updates 一步到位。\n';
prompt+='⚠️ 关于 state_updates 的核心原则:后端完全不预设任何游戏参数和剧本!所有字段由你定义。\n';
prompt+='- 用户只选方向,剩下的(角色名、属性、技能体系、NPC、剧情等)全部由你创造\n';
prompt+='- 仙侠用灵力/境界/道心,都市用魅力/金钱/声望,末日用饥饿/理智/感染度,宫廷用权谋/声望/容貌——属性体系随剧本定\n';
prompt+='- 字段名全小写。追加物品用 inventory_added 而不是 inventory(后者覆盖整个背包)\n';
prompt+='- 数值型:正数表示增加,负数表示减少(如 hp:-10 表示扣10点HP)\n\n';
prompt+='## 工具调用规则\n';
prompt+='- 用户执行动作 → 调用 mud_action(command:"action", message:"用户原话") → 引擎返回状态数据 → 你据此叙事\n';
prompt+='- 用户查看状态 → 调用 mud_action(command:"status") → 引擎返回状态 → 你用叙事方式描述角色状况\n';
prompt+='- 用户要求存档/读档 → 调用 mud_action(command:"save" 或 "load")\n';
prompt+='- 你在叙事中改变了角色状态(受伤、获得物品、升级、位置转移等)→ 调用 mud_action(command:"state_updates", state_updates:{hp:-10, inventory_added:"铁剑", location:"新地点"}) 同步到后端\n';
prompt+='- state_updates 的字段完全由你决定:后端不预设任何参数。你定义什么就存什么,想加新属性随时加\n';
prompt+='- 常见属性:location, world_settings, character_name, character_class, hp, hp_max, mp, mp_max, level, exp, exp_next, inventory, inventory_added, quests, npc_relations, flags\n';
prompt+='- 你可以自由创造新属性:charm(魅力), reputation(声望), spirit_power(灵力), karma(业力), hunger(饥饿), sanity(理智) 等——后端原样存储并返回\n';
prompt+='- 字段名全小写。追加物品用 inventory_added 而不是 inventory(后者覆盖整个背包)\n';
prompt+='- GameEngine 是静默后端,只返回机读标签和状态数据,绝不生成叙事文本——所有叙事由你生成\n\n';
prompt+='## 配图与配音规则(极其重要)\n';
prompt+='🖼 场景配图开关:'+(showMudImage?'**已开启**':'已关闭')+(showMudImage?' → **每个场景转换、关键画面、战斗、红颜出场时,必须调用 image_generation 生成配图,prompt 用英文描述画面**':' → 禁止调用 image_generation')+'\n';
prompt+='🔊 语音配音开关:'+(showMudVoice?'**已开启**':'已关闭')+(showMudVoice?' → **NPC对话、内心独白、关键旁白时,必须调用 text_to_speech 生成语音,text 用你要朗读的叙事文本**':' → 禁止调用 text_to_speech')+'\n';
prompt+='👁 识图开关:'+(showMudVision?'**已开启**':'已关闭')+(showMudVision?' → 用户可提供图片让你识别':' → 禁止调用 vision_recognition')+'\n';
prompt+='\n配图/配音与叙事的配合方式:\n';
prompt+='1. 先调用 mud_action 同步游戏状态\n';
prompt+='2. 同一轮中,如果配图/配音开关开启,同时调用 image_generation 和/或 text_to_speech\n';
prompt+='3. 最后输出沉浸式叙事文本\n';
prompt+='示例(配图+配音全开时的一轮完整输出):\n';
prompt+=' tool_calls: [mud_action(action), image_generation(场景描述), text_to_speech(旁白文本)]\n';
prompt+=' 然后 tool_results 返回后,你输出第一人称叙事\n\n';
prompt+='## 叙事风格\n';
prompt+='- 爽文节奏:每段推进情节,不做无意义铺垫\n';
prompt+='- 红颜塑造:每位女性角色有独特性格和魅力,用对话和行动展现\n';
prompt+='- 战斗描写:简洁有力,侧重策略和爽感,不堆砌数值\n';
prompt+='- 世界揭秘:随情节逐步展开世界观,不在开篇倾倒设定\n\n';
prompt+=buildMudStateContext();
}else{
prompt+='\n\n## MUD 游戏提示\n当用户说"开始泥巴游戏"、"开始MUD"或"开始mud"时,前端会显示剧本选择欢迎语,你只需等待用户选择方向后开篇叙事。\n';
}
return prompt;
}
// ── Agent Loop ──
async function runAgentLoop(userMessage){
// ── MUD 命令拦截:仅拦截纯机械命令(save/load/开关),叙事输入和needs_narration交给LLM ──
const trimmed=userMessage.trim();
const lower=trimmed.toLowerCase();
const mudStarts=['开始泥巴游戏','开始mud','开始MUD'];
// MUD激活:初始化状态,然后交由下方LLM流程叙事
if(!mudActive&&mudStarts.some(s=>lower===s)){
addStepMessage('正在激活MUD游戏...');
_mudState=null;
currentSession.messages=[]; // MUD模式只追加:清空旧对话,只保留MUD开始后的消息
try{
const res=await apiCall('mud_action',{command:'restart',original_message:'初始化游戏'});
if(res.state){_mudState=res.state;showMudPanel();}
mudActive=true;mudStateSynced=true;showMudPanel();
if(!showMudImage||!showMudVoice)activateMudDefaults();
}catch(e){if(e.name==='AbortError'){addStepMessage('⏹ 已停止');}else{addAgentMessage('❌ MUD激活失败: '+e.message);}}
if(!mudActive){setRunning(false);if(commandQueue.length>0)processQueue();return;}
// 确保状态栏显示
showMudPanel();
// mudActive=true,继续走下方LLM流程,用户输入作为第一条消息发送
}
// MUD纯机械命令:直接执行不经过LLM(存档/读档/状态/开关)
const pureMechanicalCmds={'/save':'save','/load':'load','/status':'status','状态':'status','属性':'status',
'开图':'image_on','关图':'image_off','/image_on':'image_on','/image_off':'image_off',
'开声':'voice_on','关声':'voice_off','/voice_on':'voice_on','/voice_off':'voice_off',
'开识':'vision_on','关识':'vision_off','/vision_on':'vision_on','/vision_off':'vision_off'};
if(pureMechanicalCmds[lower]){
// 如果MUD未激活,先自动初始化(清空旧状态)
if(!mudActive){
_mudState=null;_activeMudScenario='default';
currentSession.messages=[]; // MUD模式只追加:清空旧对话,只保留MUD开始后的消息
try{
const initRes=await apiCall('mud_action',{command:'restart',original_message:'初始化游戏'});
if(initRes.state){_mudState=initRes.state;showMudPanel();}
mudActive=true;mudStateSynced=true;showMudPanel();
if(!showMudImage||!showMudVoice)activateMudDefaults();
}catch(e){addAgentMessage('❌ MUD初始化失败: '+e.message);setRunning(false);if(commandQueue.length>0)processQueue();return;}
}
addUserMessage(userMessage);
const command=pureMechanicalCmds[lower];
addStepMessage('正在执行MUD命令: '+command);
let _loadSuccess=false;
try{
const res=await apiCall('mud_action',command==='save'?{command,messages:currentSession.messages,scenario:_activeMudScenario}:{command});
if(res.state){
_mudState=res.state;
if(command==='load'&&res.state.messages&&res.state.messages.length>0){
currentSession.messages=res.state.messages;
if(res.state.scenario)_activeMudScenario=res.state.scenario;
_loadSuccess=true;
}
showMudPanel();
}
const parsed=parseMudResponse(res.game_output||res.output||res.content||'');
if(parsed.tag==='image_on'){showMudImage=true;mudToggleImage.checked=true;}
else if(parsed.tag==='image_off'){showMudImage=false;mudToggleImage.checked=false;}
else if(parsed.tag==='voice_on'){showMudVoice=true;mudToggleVoice.checked=true;}
else if(parsed.tag==='voice_off'){showMudVoice=false;mudToggleVoice.checked=false;}
else if(parsed.tag==='vision_on'){showMudVision=true;mudToggleVision.checked=true;}
else if(parsed.tag==='vision_off'){showMudVision=false;mudToggleVision.checked=false;}
if(TAG_LABELS[parsed.tag]){addAgentMessage(TAG_LABELS[parsed.tag]);}
else{addAgentMessage('✅ 已执行');}
// 存档:将完整对话历史保存到 mud/<剧本>/ 目录
if(command==='save'&&await _ensureFS()){
try{
const slug=_mudScenarioSlug();
if(!slug||slug.includes('/'))_activeMudScenario='default';
const ts=new Date().toISOString().replace(/[:.]/g,'-').slice(0,19);
const name='mud/'+_activeMudScenario+'/save_'+ts+'.json';
const data=JSON.stringify({messages:currentSession.messages,mudState:_mudState,scenario:_activeMudScenario,savedAt:ts},null,2);
await _fileSystem.writeFile(_fileSystem.resolvePath(name),data);
addStepMessage('💾 剧本《'+_activeMudScenario+'》已保存: '+name);
}catch(e){addStepMessage('⚠️ 本地存档失败: '+e.message);}
}
// 读档:后端已返回 messages 时跳过 OPFS,否则从 mud/<剧本>/ 恢复
if(command==='load'&&parsed.tag==='loaded'){
if(_mudState&&_mudState.messages&&_mudState.messages.length>0){
currentSession.messages=_mudState.messages;
if(_mudState.scenario)_activeMudScenario=_mudState.scenario;
_loadSuccess=true;
addStepMessage('📂 剧本《'+_activeMudScenario+'》已从云端恢复');
}else if(await _ensureFS()){
const slug=_mudScenarioSlug();
try{
const dh=await _fileSystem.getDirHandle(_fileSystem.resolvePath('mud'));
// 优先读当前剧本子目录
let dir='mud/'+slug;
let saves=[];
try{
const sdh=await _fileSystem.getDirHandle(_fileSystem.resolvePath(dir));
for await(const[n]of sdh.entries()){
if(n.startsWith('save_')&&n.endsWith('.json'))saves.push(n);
}
}catch(e){}
// 兼容:当前剧本无存档时,检查 mud/ 根目录下旧格式
if(!saves.length){
for await(const[n]of dh.entries()){
if(n.startsWith('save_')&&n.endsWith('.json')){saves.push('mud/'+n);dir='';break;}
}
if(saves.length)addStepMessage('📂 兼容模式:读取旧版存档');
}
saves.sort().reverse();
if(saves.length>0){
let sd;
try{
const content=await _fileSystem.readFile(_fileSystem.resolvePath(dir?dir+'/'+saves[0]:saves[0]));
sd=JSON.parse(content);
}catch(e){addStepMessage('⚠️ 存档文件损坏: '+saves[0]);sd=null;}
if(sd){
if(sd.messages&&sd.messages.length>0){currentSession.messages=sd.messages;_loadSuccess=true;}
if(sd.mudState){_mudState=sd.mudState;_mudScenarioSlug();}
if(sd.scenario&&sd.scenario!==_activeMudScenario)addStepMessage('📂 注意:存档剧本《'+sd.scenario+'》与当前剧本《'+_activeMudScenario+'》不同');
addStepMessage('📂 剧本《'+slug+'》已恢复: '+(dir?dir+'/':'')+saves[0]);
}
}
}catch(e){addStepMessage('⚠️ 没有找到存档文件');}
}
}
}catch(e){if(e.name==='AbortError'){addStepMessage('⏹ 已停止');}else{addAgentMessage('❌ MUD命令错误: '+e.message);}}
// 读档成功:立即续写,不等待用户输入
if(command==='load'&&_loadSuccess){
showMudPanel();
userMessage='(游戏已从存档恢复,请继续叙述)';
}else{
setRunning(false);
if(commandQueue.length>0)processQueue();
return;
}
}
// 重新激活(mudActive已为true,用户再次说"开始mud"):清空旧状态,显示欢迎语
const isMudReactivation=mudActive&&mudStarts.some(s=>lower===s);
if(isMudReactivation){
_mudState=null;_activeMudScenario='default';
currentSession.messages=[]; // MUD模式只追加:清空旧对话,只保留MUD开始后的消息
try{
const res=await apiCall('mud_action',{command:'restart'});
if(res.state){_mudState=res.state;showMudPanel();}
}catch(e){}
if(!showMudImage||!showMudVoice)activateMudDefaults();
showMudPanel();
addAgentMessage('🎮 MUD游戏已重新激活!\n\n请选择你想玩的剧本方向:\n\n1️⃣ 仙侠修真 — 灵气复苏,秘境探险,修仙问道\n2️⃣ 都市后宫 — 现代都市,权谋商战,红颜环绕\n3️⃣ 古代宫廷 — 帝王将相,朝堂风云,后宫争宠\n4️⃣ 末日生存 — 丧尸围城,资源争夺,绝境求生\n5️⃣ 异世界冒险 — 穿越重生,魔法大陆,建国称霸\n\n也可以直接说一个方向或关键词,剩下的我来设计!');
setRunning(false);
if(commandQueue.length>0)processQueue();
return;
}
if(abortController)abortController.abort();
abortController=new AbortController();
stopRequested=false;
setRunning(true);
addUserMessage(userMessage);
let messages;
if(currentSession.awaitingUser){
messages=currentSession.messages;
messages.push({role:'tool',tool_call_id:currentSession.pendingToolCallId||'call_ask',content:userMessage});
currentSession.awaitingUser=false;
addStepMessage('收到用户回答,继续执行...');
}else{
// ── 记忆注入:在构建系统提示前检索相关记忆 ──
let memoryContext='';
if(currentUser&&accessToken){
addStepMessage('正在检索相关记忆...');
memoryContext=await fetchMemoryContext(userMessage);
if(memoryContext){
addStepMessage('找到相关记忆,已注入上下文');
}else{
addStepMessage('未找到相关记忆');}
}
const basePrompt=buildSystemPrompt();
const enrichedPrompt=memoryContext?basePrompt+'\n\n[记忆上下文]\n以下是之前与该用户的对话中提取的记忆,参考这些记忆更能理解用户背景:\n'+memoryContext:basePrompt;
// 继承上次会话上下文:保存旧system prompt替换为新prompt,追加新消息
messages=(currentSession.messages&¤tSession.messages.length>=2)
? [{role:'system',content:enrichedPrompt},...currentSession.messages.slice(1),{role:'user',content:userMessage}]
: [{role:'system',content:enrichedPrompt},{role:'user',content:userMessage}];
// 记录用户消息用于后续提取
recentConversationMessages.push({role:'user',content:userMessage,time:Date.now()});
}
const MAX_LOOPS=Infinity;let loopCount=0;let consecutiveFailures=0;lastToolCalls=null;executor.toolFailCounts={};
const recentToolHashes=[];const recentResponses=[];const MAX_HISTORY=5;
try{
while(loopCount<MAX_LOOPS){
loopCount++;
if(stopRequested){addStepMessage('⏹ 已停止');break;}
// MUD 模式仍用 deepseek-v4-flash 作为 agent loop 模型(需要可靠 tool calling)
// 配图/配音等由工具处理器直接调用 MiniMax 专用端点,不依赖 agent loop 模型
const activeProvider=CHAT_PROVIDER;
const activeModel='deepseek-v4-flash';
// MUD 模式:只追加模式,不压缩历史,所有对话记录一次不差发给LLM
addStepMessage('Agent 循环 #'+loopCount+':正在调用 '+activeModel+'...');
const lastMsg=messages[messages.length-1]?.content||'';
const hasComplexNeed=mudActive||(typeof lastMsg==='string'&&(lastMsg.length>80||/error|debug|fix|bug|refactor|optimize|架构|设计模式|并发|性能|重构|优化/.test(lastMsg)));
const thinkingOpts=ThinkingModeHandler.buildThinkingOptions(true,hasComplexNeed?'max':'low');
// 消息压缩:超过 50 条消息 或 总字符超过 500K 时触发(适配 100 万 token 上下文)
if(!mudActive){
const totalChars=messages.reduce((s,m)=>s+(typeof m.content==='string'?m.content.length:0),0);
if(messages.length>50||totalChars>500000){
addStepMessage('上下文过长 ('+Math.round(totalChars/1000)+'K, '+messages.length+'条),压缩中...');
// 第一步:截断单个超长工具结果(防止单条消息过大)
for(let i=0;i<messages.length;i++){
const m=messages[i];
if(typeof m.content==='string'&&m.content.length>10000){
const keepHead=5000;
const keepTail=1000;
messages[i]={...m,content:m.content.slice(0,keepHead)+'\n\n... [已截断,原长 '+m.content.length+' 字符] ...\n\n'+m.content.slice(-keepTail)};
}
}
// 第二步:滑动窗口压缩(保持 tool_calls + tool 响应的完整性)
const systemMsg=messages.find(m=>m.role==='system')||messages[0];
const nonSystem=messages.filter(m=>m!==systemMsg);
const recent=nonSystem.slice(-10);
const older=nonSystem.slice(0,-10);
if(older.length>5){
const keepMsgs=new Set();
for(const m of older){
if(m.role==='user'||(m.role==='assistant'&&(m.content||m.tool_calls))){
keepMsgs.add(m);
}
}
const keepArr=[...keepMsgs].slice(-5);
const finalKeep=new Set(keepArr);
for(const m of older){
if(m.role==='tool'&&m.tool_call_id){
if(keepArr.some(k=>k.tool_calls?.some(t=>t.id===m.tool_call_id))){
finalKeep.add(m);
}
}
}
const toRemoveCount=older.length-finalKeep.size;
if(toRemoveCount>5){
const sortedKeep=[...finalKeep].sort((a,b)=>older.indexOf(a)-older.indexOf(b));
messages=[systemMsg,{role:'system',content:'[系统摘要] 中间 '+toRemoveCount+' 条消息已被压缩。保留最近消息与关键用户/助手回复。请基于最近的内容继续执行。'},...sortedKeep,...recent];
const newTotal=messages.reduce((s,m)=>s+(typeof m.content==='string'?m.content.length:0),0);
addStepMessage('压缩完成,'+messages.length+'条消息,'+Math.round(newTotal/1000)+'K');
}
}
}
}
let llmResponse;
// LLM 调用重试:最多 5 次,指数退避(2s→4s→8s→16s→32s),应对网络波动和限流
for(let llmRetry=0;llmRetry<5;llmRetry++){
try{llmResponse=await proxyRequest(activeProvider,'/chat/completions',{model:activeModel,messages:messages,tools:ALL_TOOLS,tool_choice:'auto',temperature:1.0,max_tokens:16384,stream:true,...thinkingOpts});break;}
catch(e){
if(e.name==='AbortError'){addStepMessage('⏹ 已停止');setRunning(false);return;}
const errDetail=e.message||'未知错误';
// 400 错误不重试(请求格式错误,重试无意义)
if(errDetail.includes('请求格式错误')||errDetail.includes('bad_request')){
addAgentMessage('❌ 模型调用失败: '+errDetail);setRunning(false);return;}
if(llmRetry>=4){addAgentMessage('❌ 调用模型失败(已重试5次): '+errDetail);setRunning(false);return;}
const d=Math.min(2000*Math.pow(2,llmRetry),32000);
addStepMessage('⚠️ 模型调用失败: '+errDetail.slice(0,80)+'... '+d/1000+'秒后重试 ('+(llmRetry+1)+'/5)');await new Promise(r=>setTimeout(r,d));}
}
if(stopRequested){addStepMessage('⏹ 已停止');break;}
let choice=llmResponse.choices?.[0];
if(!choice){addAgentMessage('模型返回异常');setRunning(false);return;}
let{message}=choice;
// BUG-05: 断点续传 — 响应被截断时自动续写
let fullContent=message.content||'';
let fullReasoning=message.reasoning_content||'';
let continueCount=0;
const MAX_CONTINUE=10;
const MAX_TOTAL_CHARS=4000000;
const CONTINUE_TIMEOUT=300000;
let emptyStreak=0;
const continueStart=Date.now();
const sentenceEnds=new Set([',','。','!','?',';',':','、','…','.','!','?',',',';',':','\n',')',')','」','』','》','}','>','`']);
function calcOverlap(prev,next){
const minLen=20;
const maxLen=Math.min(prev.length,next.length,500);
if(maxLen<minLen)return 0;
for(let len=maxLen;len>=minLen;len--){
let match=true;
for(let i=0;i<len;i++){
if(prev.charCodeAt(prev.length-len+i)!==next.charCodeAt(i)){match=false;break;}
}
if(match)return len;
}
return 0;
}
function isContentComplete(text){
const trimmed=text.trim();
if(!trimmed)return false;
const lastChar=trimmed.slice(-1);
if(sentenceEnds.has(lastChar))return true;
if(trimmed.endsWith('```'))return true;
if(trimmed.endsWith('---'))return true;
if(trimmed.endsWith('...'))return true;
return false;
}
while(choice.finish_reason==='length'&&continueCount<MAX_CONTINUE){
if(fullContent.length>=MAX_TOTAL_CHARS){addStepMessage('⚠️ 内容已达上限 ('+Math.round(MAX_TOTAL_CHARS/1000)+'K),停止续写');break;}
if(Date.now()-continueStart>CONTINUE_TIMEOUT){addStepMessage('⚠️ 续写超时 ('+Math.round(CONTINUE_TIMEOUT/1000)+'s),停止续写');break;}
continueCount++;
addStepMessage('⚠️ 响应被截断,自动续写中 ('+continueCount+'/'+MAX_CONTINUE+'),当前 '+Math.round(fullContent.length/1000)+'K 字符...');
messages.push({role:'assistant',content:fullContent});
let continueResp;
for(let llmRetry=0;llmRetry<5;llmRetry++){
try{continueResp=await proxyRequest(activeProvider,'/chat/completions',{model:activeModel,messages:[...messages,{role:'user',content:'请继续上面的内容,不要重复已输出的部分。直接续写,不要总结。'}],tools:ALL_TOOLS,tool_choice:'auto',temperature:1.0,max_tokens:16384,stream:true,...thinkingOpts});break;}
catch(e){if(e.name==='AbortError'){addStepMessage('⏹ 已停止');setRunning(false);return;}
if(llmRetry>=4){addAgentMessage('❌ 续写失败(已重试5次): '+e.message);setRunning(false);return;}
const d=Math.min(2000*Math.pow(2,llmRetry),32000);
addStepMessage('⚠️ 续写调用失败,'+d/1000+'秒后重试 ('+(llmRetry+1)+'/5)...');await new Promise(r=>setTimeout(r,d));}
}
if(stopRequested||!continueResp){break;}
const continueChoice=continueResp.choices?.[0];
if(!continueChoice)break;
const continueMsg=continueChoice.message||{};
let newContent=continueMsg.content||'';
const newReasoning=continueMsg.reasoning_content||'';
if(newReasoning)fullReasoning+=newReasoning;
if(newContent.trim()){
const overlap=calcOverlap(fullContent,newContent);
if(overlap>0)newContent=newContent.slice(overlap);
if(newContent.trim()){
fullContent+=newContent;
emptyStreak=0;
}else{
emptyStreak++;
if(emptyStreak>=2){addStepMessage('⚠️ 续写内容与上文完全重复,停止续写');break;}
}
}else{
emptyStreak++;
if(emptyStreak>=2){addStepMessage('⚠️ 连续 '+emptyStreak+' 次返回空内容,停止续写');break;}
}
choice=continueChoice;
message=continueMsg;
}
if(continueCount>0)addStepMessage('✅ 续写完成,共 '+continueCount+' 次,总计 '+Math.round(fullContent.length/1000)+'K 字符');
message.content=fullContent;
if(fullReasoning)message.reasoning_content=fullReasoning;
if(choice.finish_reason==='length'){
addStepMessage('⚠️ 已达最大续写次数 ('+MAX_CONTINUE+'),内容可能不完整');
}
// 断点续传后完整性检查
if(continueCount>0&&fullContent.length>0){
const isComplete=isContentComplete(fullContent);
const hasTruncation=fullContent.includes('[截断]')||fullContent.includes('[未完]');
const tooShort=fullContent.trim().length<50;
if(!isComplete||hasTruncation||tooShort){
addStepMessage('⚠️ 检测到内容可能不完整(末尾未正常结束),建议检查并要求补充');
}else{
addStepMessage('✅ 内容完整性检查通过');
}
}
const reasoning=ThinkingModeHandler.extractReasoning(message);
if(reasoning&&(!mudActive||showMudThinking))addStepMessage('推理: '+reasoning.slice(0,150)+'...');
if(stopRequested){addStepMessage('⏹ 已停止');break;}
if(!message.tool_calls||message.tool_calls.length===0){
// 记录AI回复用于后续提取
recentConversationMessages.push({role:'assistant',content:message.content||'',time:Date.now()});
const displayContent=message.content||'';
if(displayContent){
addAgentMessage(displayContent);
messages.push({role:'assistant',content:message.content});
}else if(message.reasoning_content){
addStepMessage('推理: '+message.reasoning_content.slice(0,200));
}
// ── MUD 配图/语音消费:无论是否有文本内容都消费pending media ──
if(mudActive&&_mudPendingImage){
const imgUrls=_mudPendingImage;_mudPendingImage=null;
if(Array.isArray(imgUrls))imgUrls.forEach(u=>addImageMessage(u));
else addImageMessage(imgUrls);
}
if(mudActive&&_mudPendingVoice){
const voiceUrl=_mudPendingVoice;_mudPendingVoice=null;
addAgentMessage('🔊 [语音]('+voiceUrl+')');
}
break;
}
const toolCalls=message.tool_calls;
addStepMessage('模型请求调用 '+toolCalls.length+' 个工具'+(toolCalls.length>1?'(并行执行)':''));
// 死循环检测:检查工具调用是否重复
const toolHash=toolCalls.map(t=>t.function?.name+':'+t.function?.arguments?.slice(0,100)).join('|');
recentToolHashes.push(toolHash);
if(recentToolHashes.length>MAX_HISTORY)recentToolHashes.shift();
const duplicateToolCount=recentToolHashes.filter(h=>h===toolHash).length;
if(duplicateToolCount>=3){
addStepMessage('⚠️ 检测到工具调用重复 '+duplicateToolCount+' 次,可能陷入死循环,自动跳出');
addAgentMessage('⚠️ 检测到重复操作,已自动停止。请尝试不同的方式或明确指示。');
break;
}
// 死循环检测:检查是否连续多轮只调用 read/bash 而不产出内容
const recentToolNames=recentToolHashes.map(h=>h.split(':')[0]);
const readBashOnly=recentToolNames.filter(n=>n==='read'||n==='bash').length;
if(readBashOnly>=8){
addStepMessage('⚠️ 连续多轮只调用 read/bash 工具,可能陷入无效循环,自动跳出');
addAgentMessage('⚠️ 检测到无效循环(连续多轮只读取文件),已自动停止。请尝试直接执行操作。');
break;
}
messages.push({role:'assistant',content:message.content||'',tool_calls:toolCalls,...(message.reasoning_content?{reasoning_content:message.reasoning_content}:{})});
const{results,awaitingUser,earlyBreak}=await executor.executeToolCallsParallel('session-1',toolCalls,messages,lastToolCalls);
lastToolCalls=message.tool_calls;
if(stopRequested){addStepMessage('⏹ 已停止');break;}
if(earlyBreak){messages.push(...results);addAgentMessage('工具执行失败次数过多,已停止。');break;}
if(awaitingUser){messages.push(...results.filter(r=>!r._awaitUser));currentSession.messages=messages;currentSession.awaitingUser=true;currentSession.pendingToolCallId=toolCalls[0]?.id;addStepMessage('等待用户回答...');setRunning(false);return;}
// 死循环检测:检查响应内容是否重复
const responseHash=(message.content||'').slice(0,200);
if(responseHash){
recentResponses.push(responseHash);
if(recentResponses.length>MAX_HISTORY)recentResponses.shift();
const duplicateRespCount=recentResponses.filter(h=>h===responseHash).length;
if(duplicateRespCount>=3){
addStepMessage('⚠️ 检测到响应内容重复 '+duplicateRespCount+' 次,可能陷入死循环,自动跳出');
addAgentMessage('⚠️ 检测到重复响应,已自动停止。请尝试不同的方式或明确指示。');
break;
}
}
// 检查本轮工具是否全部失败
const allFailed=results.length>0&&results.every(r=>typeof r.content==='string'&&(r.content.startsWith('[错误]')));
if(allFailed&&toolCalls.length>0){consecutiveFailures++;addStepMessage('⚠️ 本轮 '+toolCalls.length+' 个工具全部执行失败(连续 '+consecutiveFailures+' 轮)');if(consecutiveFailures>=2){addAgentMessage('工具连续执行失败,已停止。');break;}}else{consecutiveFailures=0;}
messages.push(...results);
}
if(loopCount>=100)addAgentMessage('⚠️ 循环次数过多 ('+loopCount+'),建议检查任务是否过于复杂。');
// ── 安全网:如果循环结束时仍有未消费的pending media,立即消费 ──
if(mudActive&&_mudPendingImage){const imgUrls=_mudPendingImage;_mudPendingImage=null;if(Array.isArray(imgUrls))imgUrls.forEach(u=>addImageMessage(u));else addImageMessage(imgUrls);}
if(mudActive&&_mudPendingVoice){const voiceUrl=_mudPendingVoice;_mudPendingVoice=null;addAgentMessage('🔊 [语音]('+voiceUrl+')');}
}catch(e){if(e.name==='AbortError'){addStepMessage('⏹ 已停止');}else{addAgentMessage('❌ 错误: '+e.message);}}
// ── 对话结束后异步触发记忆提取(仅增量,深拷贝防竞争)──
if(currentUser&&recentConversationMessages.length>=2&&!memoryExtractionPending){
const newMsgs=getUnprocessedMessages();
if(newMsgs.length>=2){
memoryExtractionPending=true;
const snapshot=newMsgs.map(m=>({...m,time:m.time}));
triggerMemoryExtraction(snapshot).finally(()=>{memoryExtractionPending=false;});
}
}
setRunning(false);currentSession.messages=messages;currentSession.awaitingUser=false;abortController=null;
// BUG-04: 执行完毕后自动处理队列中的下一条命令
if(commandQueue.length>0)processQueue();
}
// ── File Manager ──
async function openFileManager(){
fmOverlay.classList.remove('hidden');
await loadFileList('');
}
function closeFileManager(){
fmOverlay.classList.add('hidden');
}
async function loadFileList(path){
fmBody.innerHTML='<div class="fm-loading">📂 加载中...</div>';
fmCurrentPath=path;
try{
const [res,quotaRes]=await Promise.all([
apiCall('list_files',{path},{ignoreStop:true}),
apiCall('quota',{},{ignoreStop:true})
]);
renderFileList(res.files||[],res.currentPath||'');
renderQuota(quotaRes.usedBytes||0,currentUser?.username==='admin'?1000:(quotaRes.quotaMB||100));
}catch(e){
fmBody.innerHTML='<div class="fm-empty">❌ 加载失败: '+escapeHtml(e.message)+'</div>';
}
}
function renderFileList(files,currentPath){
const bc=fmBreadcrumb;
const uname=currentUser?currentUser.username:'访客';
// Breadcrumb
let bcHtml='<span class="fm-bc-link" data-bc-path="">📁 '+escapeHtml(uname)+'</span>';
if(currentPath){
const parts=currentPath.split('/');
let acc='';
parts.forEach((p,i)=>{
acc+= (i>0?'/':'')+p;
bcHtml+='<span class="sep">›</span><span class="fm-bc-link" data-bc-path="'+escapeHtmlAttr(acc)+'">'+escapeHtml(p)+'</span>';
});
}
bc.innerHTML=bcHtml;
// File list
if(!files||files.length===0){
fmBody.innerHTML='<div class="fm-empty">📂 <span>文件夹为空</span></div>';
return;
}
let html='';
files.forEach(f=>{
const icon=f.is_dir?'📁':'📄';
const sizeStr=f.is_dir?('('+f.file_count+'项)'):formatSize(f.size);
html+='<div class="fm-item'+(f.is_dir?' fm-item-dir':' fm-item-file')+'" data-path="'+escapeHtmlAttr(f.path)+'">'
+'<span class="fm-item-icon">'+icon+'</span>'
+'<span class="fm-item-name" data-path="'+escapeHtmlAttr(f.path)+'">'+escapeHtml(f.name)+'</span>'
+'<span class="fm-item-size">'+sizeStr+'</span>'
+'<span class="fm-item-mtime">'+escapeHtml(f.mtime||'')+'</span>'
+'<span class="fm-item-actions">'
+(f.is_dir?'':'<button class="fm-item-action share fm-action-share" data-path="'+escapeHtmlAttr(f.path)+'" data-name="'+escapeHtmlAttr(f.name)+'" title="分享链接">🔗</button>')+(f.is_dir&&!currentPath&&['files','images','music','videos','voice','project','memory','tmp','mud'].includes(f.name)?'':'<button class="fm-item-action del fm-action-delete" data-path="'+escapeHtmlAttr(f.path)+'" data-name="'+escapeHtmlAttr(f.name)+'" title="删除">🗑️</button>')
+'</span></div>';
});
fmBody.innerHTML=html;
currentFileList=files||[];
}
function renderQuota(usedBytes,quotaMB){
const footer=document.getElementById('fmFooter');
const usedMB=roundByteToMB(usedBytes||0);
const pct=quotaMB>0?Math.min(100,Math.round((usedBytes||0)/(quotaMB*1024*1024)*100)):0;
footer.innerHTML='<div class="fm-quota-text">已用: '+usedMB+'MB / '+quotaMB+'MB ('+pct+'%)</div><div class="fm-quota-bar"><div class="fm-quota-fill" style="width:'+pct+'%"></div></div>';
}
function roundByteToMB(bytes){return (bytes/(1024*1024)).toFixed(1);}
function formatSize(bytes){
if(bytes===0) return '0B';
if(bytes<1024) return bytes+'B';
if(bytes<1024*1024) return (bytes/1024).toFixed(1)+'KB';
return (bytes/(1024*1024)).toFixed(1)+'MB';
}
function escapeHtmlAttr(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');}
// ── Image Viewer with swipe navigation ──
// 预加载邻近图片缓存
function preloadAdjacent(index){
for(const offset of [1, -1]){
const idx=index+offset;
if(idx>=0&&idx<ivImageList.length){
const img=new Image();
img.src=PROXY_URL+'?_action=file_download&file_path='+encodeURIComponent(ivImageList[idx].path)+'&_token='+accessToken;
}
}
}
var ivLoadTimer=null;
function showImage(index){
if(!ivImageList||index<0||index>=ivImageList.length)return;
ivImageIndex=index;
const f=ivImageList[index];
ivCurrentFile=f;
ivName.textContent=f.name+' ('+(index+1)+'/'+ivImageList.length+')';
// 每次重新创建 DOM(避免关闭时清空导致孤儿节点)
ivContainer.innerHTML='<div class="iv-prev" id="ivPrev">‹</div><div class="iv-next" id="ivNext">›</div><div id="ivLoading" style="position:absolute;color:rgba(255,255,255,.4);font-size:13px;padding:30px;pointer-events:none">⏳ 加载中...</div><img src="" alt="" id="ivImg">';
window._ivPrev=document.getElementById('ivPrev');
window._ivNext=document.getElementById('ivNext');
window._ivImg=document.getElementById('ivImg');
window._ivLoading=document.getElementById('ivLoading');
window._ivPrev.onclick=function(e){e.stopPropagation();showImage(ivImageIndex-1);};
window._ivNext.onclick=function(e){e.stopPropagation();showImage(ivImageIndex+1);};
window._ivImg.onload=function(){
if(window._ivLoading)window._ivLoading.style.display='none';
window._ivImg.style.opacity='1';
if(ivLoadTimer){clearTimeout(ivLoadTimer);ivLoadTimer=null;}
};
window._ivImg.onerror=function(){
if(window._ivLoading)window._ivLoading.textContent='❌ 加载失败';
if(ivLoadTimer){clearTimeout(ivLoadTimer);ivLoadTimer=null;}
};
window._ivPrev.style.display='';window._ivNext.style.display='';
if(ivImageList.length<=1){window._ivPrev.style.display='none';window._ivNext.style.display='none';}
// 加载态
if(window._ivLoading){window._ivLoading.style.display='block';window._ivLoading.textContent='⏳ 加载中...';}
window._ivImg.style.opacity='0';
// 直接 GET URL(浏览器原生缓存)
window._ivImg.src=PROXY_URL+'?_action=file_download&file_path='+encodeURIComponent(f.path)+'&_token='+accessToken;
// 超时保护:30秒未加载完成视为失败
if(ivLoadTimer)clearTimeout(ivLoadTimer);
ivLoadTimer=setTimeout(function(){
if(window._ivLoading&&window._ivLoading.style.display!=='none')
window._ivLoading.textContent='❌ 加载超时';
},30000);
preloadAdjacent(index);
ivOverlay.classList.remove('hidden');
}
function escHtml(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
async function fmViewFile(path,name){
// Check file extension
const ext=(name||'').split('.').pop().toLowerCase();
const imgExts=['jpg','jpeg','png','gif','webp','bmp','svg','ico','avif'];
// 图片文件 — build image list from current folder
if(imgExts.includes(ext)){
const imgFiles=currentFileList.filter(f=>{
const e=f.name.split('.').pop().toLowerCase();
return imgExts.includes(e)&&!f.is_dir;
});
if(imgFiles.length===0){alert('未找到图片文件');return;}
ivImageList=imgFiles;
const idx=imgFiles.findIndex(f=>f.path===path);
showImage(idx>=0?idx:0);
return;
}
// 音频文件 — 在线播放
const audioExts=['mp3','wav','ogg','m4a','aac','flac','wma'];
if(audioExts.includes(ext)){
try{
const resp=await fetch(PROXY_URL,{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({_action:'file_download',file_path:path,_token:accessToken})
});
if(!resp.ok){alert('下载失败');return;}
const blob=await resp.blob();
const url=URL.createObjectURL(blob);
// 关闭旧播放器
if(ivAbortController){ivAbortController.abort();ivAbortController=null;}
ivContainer.innerHTML='<audio controls autoplay style="width:80vw;max-width:500px;border-radius:8px"></audio>';
const audio=ivContainer.querySelector('audio');
audio.src=url;
ivName.textContent='🎵 '+name;
ivCurrentFile={path,name};
ivOverlay.classList.remove('hidden');
// 关闭时释放 blob URL
const cleanup=()=>{ivOverlay.classList.add('hidden');ivContainer.innerHTML='';URL.revokeObjectURL(url);ivName.textContent='';ivCurrentFile=null;};
ivCloseBtn.onclick=cleanup;
ivOverlay.onclick=function(e){if(e.target===ivOverlay)cleanup();};
}catch(e){alert('播放失败: '+e.message);}
return;
}
// 视频文件 — 在线播放
const videoExts=['mp4','webm','mov','avi','mkv','flv'];
if(videoExts.includes(ext)){
try{
const resp=await fetch(PROXY_URL,{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({_action:'file_download',file_path:path,_token:accessToken})
});
if(!resp.ok){alert('下载失败');return;}
const blob=await resp.blob();
const url=URL.createObjectURL(blob);
if(ivAbortController){ivAbortController.abort();ivAbortController=null;}
ivContainer.innerHTML='<video controls autoplay style="max-width:94vw;max-height:82vh;border-radius:8px"></video>';
const video=ivContainer.querySelector('video');
video.src=url;
ivName.textContent='🎬 '+name;
ivCurrentFile={path,name};
ivOverlay.classList.remove('hidden');
const cleanup=()=>{video.pause();ivOverlay.classList.add('hidden');ivContainer.innerHTML='';URL.revokeObjectURL(url);ivName.textContent='';ivCurrentFile=null;};
ivCloseBtn.onclick=cleanup;
ivOverlay.onclick=function(e){if(e.target===ivOverlay)cleanup();};
}catch(e){alert('播放失败: '+e.message);}
return;
}
// Text files — full content in overlay
const textExts=['html','htm','txt','md','json','xml','js','jsx','ts','tsx','php','py','css','scss','less','yaml','yml','toml','ini','cfg','conf','env','sh','bash','zsh','ps1','bat','sql','rb','go','rs','java','c','cpp','h','hpp','swift','kt','scala','ex','exs','lua','r','pl','pm','vue','svelte','astro','mjs','cjs','mts','cts','tex','log','csv','tsv'];
if(textExts.includes(ext)){
try{
const res=await apiCall('file_read',{file_path:path,offset:1,limit:999999},{ignoreStop:true});
const content=res.content||'';
ivContainer.innerHTML='<pre style="background:#1e1e2e;color:#cdd6f4;padding:16px;border-radius:8px;font-family:Consolas,Monaco,\'Courier New\',monospace;font-size:13px;line-height:1.5;overflow:auto;max-height:78vh;width:90vw;max-width:900px;white-space:pre-wrap;word-break:break-all;margin:0">'+escHtml(content)+'</pre>';
ivName.textContent='📄 '+name+' ('+content.length+' 字符)';
ivCurrentFile={path,name};
ivOverlay.classList.remove('hidden');
const cleanup=()=>{ivOverlay.classList.add('hidden');ivContainer.innerHTML='';ivName.textContent='';ivCurrentFile=null;};
ivCloseBtn.onclick=cleanup;
ivOverlay.onclick=function(e){if(e.target===ivOverlay)cleanup();};
}catch(e){
const msg=e.message||'';
if(msg.includes('JSON')||msg.includes('json')||msg.includes('Unexpected')) alert('⚠️ 该文件可能不是纯文本。\n文件名: '+name);
else alert('读取失败: '+msg);
}
return;
}
// Binary / unknown files — quick preview first 50 lines
try{
const res=await apiCall('file_read',{file_path:path,offset:1,limit:50},{ignoreStop:true});
const preview=res.content||'';
if(confirm('📄 '+name+'\n\n(前50行预览)\n\n'+preview.slice(0,2000)+'\n\n关闭预览?')){}
}catch(e){
const msg=e.message||'';
if(msg.includes('JSON')||msg.includes('json')||msg.includes('Unexpected')) alert('⚠️ 该文件是二进制格式,无法预览文本内容。\n文件名: '+name);
else alert('读取失败: '+msg);
}
}
async function fmDelete(path,name){
if(!confirm('确定删除「'+name+'」?')) return;
try{
const res=await apiCall('file_delete',{file_path:path},{ignoreStop:true});
if(res.success){
// 同步删除本地沙箱中的对应文件
let sandboxMsg='';
if(_fileSystem){
try{
const sandboxPath='/sandbox/'+path;
await _fileSystem.deleteEntry(sandboxPath,{recursive:true});
sandboxMsg='\n✅ 本地沙箱文件已同步删除';
}catch(e){sandboxMsg='\n⚠️ 本地沙箱删除失败: '+e.message;}
}
await loadFileList(fmCurrentPath);
addStepMessage('✅ 已删除「'+name+'」'+sandboxMsg);
}else{
addStepMessage('❌ 删除失败: '+(res.error||'未知错误'));
alert('删除失败: '+(res.error||'未知错误'));
}
}catch(e){
addStepMessage('❌ 删除异常: '+e.message);
alert('删除失败: '+e.message);
}
}
async function fmShare(path,name){
const ext=(name||'').split('.').pop().toLowerCase();
const htmlExts=['html','htm'];
// 构造直接文件 URL(文件本身就在网站根目录下,无需通过 proxy)
const baseUrl=PROXY_URL.replace('/proxy.php','');
const username=currentUser?.username||'guest';
const directUrl=baseUrl+'/users/'+encodeURIComponent(username)+'/'+path.split('/').map(s=>encodeURIComponent(s)).join('/');
const isHtml=htmlExts.includes(ext);
const shareText=isHtml?'🔗 '+name+' — 点击链接直接在浏览器中打开运行':'📄 '+name;
const shareTitle=isHtml?name+' — 可直接在浏览器中打开':name;
if(navigator.share){
try{
await navigator.share({url:directUrl,title:shareTitle,text:shareText});
return;
}catch(e){if(e.name==='AbortError')return;}
}
// Fallback: copy to clipboard
try{
await navigator.clipboard.writeText(directUrl);
alert(isHtml?'✅ 链接已复制!在浏览器中打开即可运行此 HTML 文件':'✅ 链接已复制到剪贴板');
}catch(e2){
prompt('复制以下链接分享:',directUrl);
}
}
// ── Event Binding ──
loginBtn.addEventListener('click',()=>showModal('login'));
modalSubmit.addEventListener('click',handleAuth);
modalSwitch.addEventListener('click',()=>showModal(modalMode==='login'?'register':'login'));
modalOverlay.addEventListener('click',(e)=>{if(e.target===modalOverlay)hideModal();});
modalPassword.addEventListener('keypress',(e)=>{if(e.key==='Enter')handleAuth();});
sendBtn.addEventListener('click',()=>{
const text=userInput.value.trim();
if(!text)return;
userInput.value='';autoResizeInput();stopRequested=false;
if(isRunning){
// 运行中:入队等待
commandQueue.push(text);
addStepMessage('⏳ 已加入命令队列(队列:'+commandQueue.length+' 条)');
return;
}
runAgentLoop(text);
});
// 如果正在运行时按停止按钮:中止并清空队列
sendBtn.addEventListener('dblclick',()=>{ // 双击停止:中止+清空队列
if(isRunning){
if(abortController)abortController.abort();
stopRequested=true;
commandQueue=[];
setRunning(false);
addStepMessage('⏹ 已停止并清空队列');
}
});
userInput.addEventListener('keypress',(e)=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendBtn.click();}});
userInput.addEventListener('input',autoResizeInput);
function autoResizeInput(){
const el=userInput;
el.style.height='42px';
el.style.overflowY='hidden';
if(el.scrollHeight<=42)return;
el.style.height=Math.max(42,Math.min(el.scrollHeight+2,150))+'px';
el.style.overflowY=parseInt(el.style.height)>=150?'auto':'hidden';
}
// 重置会话按钮:清空对话历史+重置游戏
clearBtn.addEventListener('click',()=>{
if(stopRequested||!isRunning){
chatContainer.innerHTML='';
mudStatusBar?.classList.add('hidden');
currentSession={messages:[],awaitingUser:false,pendingToolCallId:null};
recentConversationMessages=[];lastExtractionIndex=0;
memoryExtractionPending=false;lastToolCalls=null;
commandQueue=[];
mudActive=false;mudStateSynced=false;_mudState=null;_activeMudScenario='default';_mudPendingImage=null;_mudPendingVoice=null;
showMudImage=false;showMudVoice=false;showMudVision=false;showMudThinking=false;
mudToggleImage.checked=false;
mudToggleVoice.checked=false;
mudToggleVision.checked=false;
mudToggleThinking.checked=false;
addStepMessage('🔄 会话已重置,输入「开始泥巴游戏」重新开始');
}
});
// ── File Manager Events ──
fmCloseBtn.addEventListener('click',closeFileManager);
document.getElementById('fmSyncBtn').addEventListener('click',async function(){
const btn=this;btn.disabled=true;btn.textContent='⏳ 同步中...';
try{await syncServerAndOPFS();await loadFileList(fmCurrentPath);}catch(e){alert('同步失败: '+e.message);}
btn.disabled=false;btn.textContent='🔄 同步';
});
fmOverlay.addEventListener('click',(e)=>{if(e.target===fmOverlay)closeFileManager();});
// Event delegation for file manager body & breadcrumb
fmBody.addEventListener('click',function(e){
const t=e.target;
// 操作按钮优先
if(t.classList.contains('fm-action-delete')){
const p=t.getAttribute('data-path');const n=t.getAttribute('data-name');
if(p&&n)fmDelete(p,n);
}else if(t.classList.contains('fm-action-share')){
const p=t.getAttribute('data-path');const n=t.getAttribute('data-name');
if(p&&n)fmShare(p,n);
}else{
// 点击目录行任意位置 → 进入文件夹
const dirRow=t.closest('.fm-item-dir');
if(dirRow){
const p=dirRow.getAttribute('data-path');
if(p)loadFileList(p);
return;
}
// 点击文件行任意位置 → 打开/预览
const fileRow=t.closest('.fm-item-file');
if(fileRow){
const p=fileRow.getAttribute('data-path');
const nameEl=fileRow.querySelector('.fm-item-name');
if(p&&nameEl)fmViewFile(p,nameEl.textContent);
return;
}
}
});
fmBreadcrumb.addEventListener('click',function(e){
const t=e.target;
if(t.classList.contains('fm-bc-link')){
const p=t.getAttribute('data-bc-path');
loadFileList(p||'');
}
});
// ── Share current image via native share sheet ──
function safeRevokeUrl(url){try{URL.revokeObjectURL(url)}catch(e){}}
async function shareCurrentImage(){
const f=ivCurrentFile;
if(!f)return;
ivShareBtn.textContent='⏳';
ivShareBtn.disabled=true;
try{
const ext=(f.name||'').split('.').pop().toLowerCase();
const isImage=['jpg','jpeg','png','gif','webp','bmp','svg','ico','avif'].includes(ext);
const isAudio=['mp3','wav','ogg','m4a','aac','flac','wma'].includes(ext);
const isVideo=['mp4','webm','mov','avi','mkv','flv'].includes(ext);
const icon=isImage?'🖼️':(isAudio?'🎵':(isVideo?'🎬':'📄'));
// 构造直接文件 URL
const baseUrl=PROXY_URL.replace('/proxy.php','');
const username=currentUser?.username||'guest';
const directUrl=baseUrl+'/users/'+encodeURIComponent(username)+'/'+f.path.split('/').map(s=>encodeURIComponent(s)).join('/');
if(navigator.share){
try{
await navigator.share({url:directUrl,title:f.name,text:icon+' '+f.name});
ivShareBtn.textContent='↗';ivShareBtn.disabled=false;return;
}catch(e){if(e.name==='AbortError'){ivShareBtn.textContent='↗';ivShareBtn.disabled=false;return;}}
}
// Fallback: copy URL to clipboard
try{
await navigator.clipboard.writeText(directUrl);
alert('✅ 链接已复制到剪贴板');
}catch(e2){
prompt('复制以下链接分享:',directUrl);
}
}catch(e){}
ivShareBtn.textContent='↗';
ivShareBtn.disabled=false;
}
// ── 会话结束:关闭页面或退出时触发记忆提取 ──
window.addEventListener('beforeunload',function(){
if(currentUser&&recentConversationMessages.length>=2){
const remainingMsgs=getUnprocessedMessages();
if(remainingMsgs.length>=2){
navigator.sendBeacon(PROXY_URL,JSON.stringify({
_action:'memory',sub_action:'enqueue_extract',
messages:remainingMsgs.slice(-10),
scene_id:localStorage.getItem('cmdcode_current_scene')||'scene_default',
trigger:'session_end',
_token:accessToken
}));
}
}
});
// ── Image Viewer Events ──
ivCloseBtn.addEventListener('click',function(){ivOverlay.classList.add('hidden');ivName.textContent='';ivCurrentFile=null;ivContainer.innerHTML='';});
ivShareBtn.addEventListener('click',shareCurrentImage);
ivOverlay.addEventListener('click',function(e){if(e.target===ivOverlay){ivOverlay.classList.add('hidden');ivName.textContent='';ivCurrentFile=null;ivContainer.innerHTML='';}});
// Touch swipe for image viewer
let ivTouchX=0;
ivContainer.addEventListener('touchstart',function(e){
if(ivImageList.length<=1)return;
ivTouchX=e.changedTouches[0].screenX;
},{passive:true});
ivContainer.addEventListener('touchend',function(e){
if(ivImageList.length<=1||ivTouchX===0)return;
const dx=e.changedTouches[0].screenX-ivTouchX;
if(Math.abs(dx)>50){
if(dx<0)showImage(ivImageIndex+1); // swipe left → next
else showImage(ivImageIndex-1); // swipe right → prev
}
ivTouchX=0;
},{passive:true});
// Keyboard arrow keys for image viewer
document.addEventListener('keydown',function(e){
if(ivOverlay.classList.contains('hidden'))return;
if(e.key==='ArrowLeft')showImage(ivImageIndex-1);
else if(e.key==='ArrowRight')showImage(ivImageIndex+1);
else if(e.key==='Escape'){ivOverlay.classList.add('hidden');ivContainer.innerHTML='';ivName.textContent='';ivCurrentFile=null;}
});
// ── MUD Toggle 事件 ──
mudToggleImage.addEventListener('change',function(){
var prev=showMudImage;showMudImage=this.checked;
if(mudActive){addStepMessage(showMudImage?'🖼 配图已开启':'🖼 配图已关闭',true);apiCall('mud_action',{command:showMudImage?'image_on':'image_off'},{signal:new AbortController().signal}).then(res=>{if(res.state){_mudState=res.state;showMudPanel();}}).catch(()=>{showMudImage=prev;this.checked=prev;});}
});
mudToggleVoice.addEventListener('change',function(){
var prev=showMudVoice;showMudVoice=this.checked;
if(mudActive){addStepMessage(showMudVoice?'🔊 语音已开启':'🔊 语音已关闭',true);apiCall('mud_action',{command:showMudVoice?'voice_on':'voice_off'},{signal:new AbortController().signal}).then(res=>{if(res.state){_mudState=res.state;showMudPanel();}}).catch(()=>{showMudVoice=prev;this.checked=prev;});}
});
mudToggleVision.addEventListener('change',function(){
var prev=showMudVision;showMudVision=this.checked;
if(mudActive){addStepMessage(showMudVision?'👁 识图已开启':'👁 识图已关闭',true);apiCall('mud_action',{command:showMudVision?'vision_on':'vision_off'},{signal:new AbortController().signal}).then(res=>{if(res.state){_mudState=res.state;showMudPanel();}}).catch(()=>{showMudVision=prev;this.checked=prev;});}
});
mudToggleThinking.addEventListener('change',function(){
showMudThinking=this.checked;
if(mudActive)addStepMessage(showMudThinking?'💭 推理过程已显示':'💭 推理过程已隐藏',true);
});
// ── MUD 命令按钮 ──
document.getElementById('mudBtnStatus').addEventListener('click',function(){
if(!mudActive)return;
commandQueue.push('/status');if(!isRunning)processQueue();
});
document.getElementById('mudBtnSave').addEventListener('click',function(){
if(!mudActive)return;
commandQueue.push('/save');if(!isRunning)processQueue();
});
document.getElementById('mudBtnLoad').addEventListener('click',function(){
if(!mudActive)return;
commandQueue.push('/load');if(!isRunning)processQueue();
});
// ── Debug: expose key state for DevTools ──
window.__ui2Debug={get currentUser(){return currentUser;},get accessToken(){return accessToken;},get isRunning(){return isRunning;},get currentSession(){return currentSession;},get mudActive(){return mudActive;},showModal,openFileManager,closeFileManager,runAgentLoop};
// ── Init ──
(async()=>{
try{const res=await apiCall('session',{},{ignoreStop:true});if(res.loggedIn){await loginUser(res.username);}else{updateUIForUser(null);}}
catch(e){updateUIForUser(null);}
if(!accessToken){
try{const tok=await apiCall('get_proxy_token',{},{ignoreStop:true});accessToken=tok.token||'';}catch(e){}
}
if(accessToken)startPeriodicMemoryExtraction();
// 初始化 OPFS + 与服务器文件同步
if(navigator.storage&&navigator.storage.getDirectory&&!sessionStorage.getItem('fs_synced')){
try{
const vfs=new VirtualFS();await vfs.ready;
const sm=new FileSyncManager(vfs);await sm.pullFullSync();
_fileSystem=vfs;_fileSystem.syncManager=sm;
sessionStorage.setItem('fs_synced','1');
}catch(e){console.warn('文件同步初始化失败:',e);}
}
})();
})();
</script>
</body>
</html>