|
|
@@ -88,6 +88,13 @@ body {
|
|
|
}
|
|
|
.session-item:hover .btn-delete { opacity: 1; }
|
|
|
.btn-delete:hover { color: var(--red); }
|
|
|
+.btn-pin {
|
|
|
+ background: none; border: none; color: var(--text-muted); cursor: pointer;
|
|
|
+ font-size: 13px; padding: 0 2px; opacity: 0; transition: opacity .1s, color .15s;
|
|
|
+}
|
|
|
+.session-item:hover .btn-pin { opacity: 1; }
|
|
|
+.btn-pin:hover { color: var(--yellow); }
|
|
|
+.btn-pin.pinned { opacity: 1; color: var(--yellow); }
|
|
|
|
|
|
/* Main Chat Area */
|
|
|
.main {
|
|
|
@@ -422,6 +429,224 @@ body {
|
|
|
/* Responsive: hide detail panel on small screens */
|
|
|
@media (max-width: 1000px) { .detail-panel { display: none; } }
|
|
|
@media (max-width: 700px) { .sidebar { display: none; } }
|
|
|
+
|
|
|
+/* ============================================================
|
|
|
+ Model Selector — dropdown in chat header
|
|
|
+ ============================================================ */
|
|
|
+.model-selector {
|
|
|
+ position: relative; margin-left: auto; font-size: 12px;
|
|
|
+ user-select: none; z-index: 100;
|
|
|
+}
|
|
|
+.model-selector .current {
|
|
|
+ display: flex; align-items: center; gap: 6px;
|
|
|
+ padding: 5px 10px; border-radius: var(--radius-sm);
|
|
|
+ background: var(--bg-surface); border: 1px solid var(--border);
|
|
|
+ cursor: pointer; color: var(--text-secondary);
|
|
|
+ transition: border-color .15s, background .15s;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+.model-selector .current:hover {
|
|
|
+ border-color: var(--accent); background: var(--bg-hover);
|
|
|
+}
|
|
|
+.model-selector .current .model-icon { font-size: 13px; }
|
|
|
+.model-selector .current .model-name { font-weight: 500; }
|
|
|
+.model-selector .current .model-arrow {
|
|
|
+ font-size: 9px; color: var(--text-muted); transition: transform .2s;
|
|
|
+}
|
|
|
+.model-selector.open .current .model-arrow { transform: rotate(180deg); }
|
|
|
+.model-selector .dropdown {
|
|
|
+ position: absolute; top: calc(100% + 4px); right: 0;
|
|
|
+ min-width: 200px; background: var(--bg-secondary);
|
|
|
+ border: 1px solid var(--border); border-radius: var(--radius);
|
|
|
+ box-shadow: 0 8px 24px rgba(0,0,0,.4); overflow: hidden;
|
|
|
+ animation: dropdownIn .15s ease;
|
|
|
+}
|
|
|
+@keyframes dropdownIn { from{opacity:0;transform:translateY(-4px)} to{opacity:1;transform:translateY(0)} }
|
|
|
+.model-selector .dropdown .model-option {
|
|
|
+ display: flex; align-items: center; gap: 8px;
|
|
|
+ padding: 9px 14px; cursor: pointer; color: var(--text-secondary);
|
|
|
+ transition: background .1s; border-bottom: 1px solid var(--border);
|
|
|
+}
|
|
|
+.model-selector .dropdown .model-option:last-child { border-bottom: none; }
|
|
|
+.model-selector .dropdown .model-option:hover { background: var(--bg-hover); }
|
|
|
+.model-selector .dropdown .model-option.active { color: var(--accent); background: var(--bg-surface); }
|
|
|
+.model-selector .dropdown .model-option .opt-tag {
|
|
|
+ font-size: 10px; padding: 2px 6px; border-radius: 3px;
|
|
|
+ font-weight: 600; text-transform: uppercase; flex-shrink: 0;
|
|
|
+}
|
|
|
+.model-selector .dropdown .model-option .opt-tag.pro { background: rgba(203,166,247,.2); color: var(--purple); }
|
|
|
+.model-selector .dropdown .model-option .opt-tag.flash { background: rgba(166,227,161,.2); color: var(--green); }
|
|
|
+.model-selector .dropdown .model-option .opt-name { flex: 1; }
|
|
|
+.model-selector .dropdown .model-option .opt-check { color: var(--accent); font-size: 12px; }
|
|
|
+
|
|
|
+/* Thinking Level Toggle — button group */
|
|
|
+.thinking-toggle {
|
|
|
+ display: flex; align-items: center; gap: 2px;
|
|
|
+ background: var(--bg-surface); border: 1px solid var(--border);
|
|
|
+ border-radius: var(--radius-sm); padding: 2px;
|
|
|
+ font-size: 11px; user-select: none;
|
|
|
+}
|
|
|
+.thinking-toggle .thinking-label {
|
|
|
+ padding: 0 6px; color: var(--text-muted); font-size: 12px;
|
|
|
+}
|
|
|
+.thinking-toggle button {
|
|
|
+ padding: 3px 8px; border: none; border-radius: 3px;
|
|
|
+ background: transparent; color: var(--text-muted);
|
|
|
+ font-family: var(--font-sans); font-size: 11px; cursor: pointer;
|
|
|
+ transition: background .12s, color .12s;
|
|
|
+}
|
|
|
+.thinking-toggle button:hover { color: var(--text-primary); }
|
|
|
+.thinking-toggle button.active {
|
|
|
+ background: var(--accent); color: var(--bg-tertiary); font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+/* ============================================================
|
|
|
+ Skill Manager Modal
|
|
|
+ ============================================================ */
|
|
|
+.modal-overlay {
|
|
|
+ position: fixed; inset: 0; z-index: 2000;
|
|
|
+ background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center;
|
|
|
+ animation: fadeIn .15s ease;
|
|
|
+}
|
|
|
+.modal {
|
|
|
+ background: var(--bg-secondary); border: 1px solid var(--border);
|
|
|
+ border-radius: var(--radius); width: 720px; max-width: 95vw;
|
|
|
+ max-height: 82vh; display: flex; flex-direction: column;
|
|
|
+ box-shadow: 0 16px 48px rgba(0,0,0,.5);
|
|
|
+}
|
|
|
+.modal-header {
|
|
|
+ display: flex; align-items: center; justify-content: space-between;
|
|
|
+ padding: 14px 18px; border-bottom: 1px solid var(--border);
|
|
|
+}
|
|
|
+.modal-header h3 { font-size: 15px; font-weight: 600; color: var(--accent); }
|
|
|
+.modal-header .modal-actions { display: flex; gap: 8px; }
|
|
|
+.modal-body {
|
|
|
+ flex: 1; overflow-y: auto; padding: 16px 18px;
|
|
|
+}
|
|
|
+.modal-body .skill-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
|
+.modal-body .skill-table th, .modal-body .skill-table td {
|
|
|
+ padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border);
|
|
|
+ vertical-align: middle;
|
|
|
+}
|
|
|
+.modal-body .skill-table th { color: var(--text-muted); font-weight: 600; font-size: 11px; text-transform: uppercase; }
|
|
|
+.modal-body .skill-table td { color: var(--text-secondary); }
|
|
|
+.modal-body .skill-table .skill-desc { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
+.modal-body .skill-table .skill-agents { font-size: 10px; color: var(--text-muted); }
|
|
|
+.modal-body .skill-table .skill-name { color: var(--accent); font-weight: 500; cursor: pointer; }
|
|
|
+.modal-body .skill-table .skill-name:hover { text-decoration: underline; }
|
|
|
+.modal-body .status-badge {
|
|
|
+ display: inline-block; padding: 2px 8px; border-radius: 3px;
|
|
|
+ font-size: 10px; font-weight: 600; text-transform: uppercase;
|
|
|
+}
|
|
|
+.modal-body .status-badge.enabled { background: rgba(166,227,161,.2); color: var(--green); }
|
|
|
+.modal-body .status-badge.disabled { background: rgba(243,139,168,.2); color: var(--red); }
|
|
|
+.modal-body .skill-detail {
|
|
|
+ margin-top: 12px; border: 1px solid var(--border); border-radius: var(--radius);
|
|
|
+ background: var(--bg-surface); padding: 14px 18px; font-size: 12px;
|
|
|
+ max-height: 300px; overflow-y: auto; line-height: 1.6;
|
|
|
+ display: none;
|
|
|
+}
|
|
|
+.modal-body .skill-detail h1,.modal-body .skill-detail h2,.modal-body .skill-detail h3 { color: var(--accent); margin: 12px 0 6px; }
|
|
|
+.modal-body .skill-detail code {
|
|
|
+ font-family: var(--font-mono); background: var(--bg-tertiary); padding: 1px 5px; border-radius: 3px; font-size: 11px;
|
|
|
+}
|
|
|
+.upload-zone {
|
|
|
+ margin-top: 12px; border: 2px dashed var(--border); border-radius: var(--radius);
|
|
|
+ padding: 20px; text-align: center; color: var(--text-muted); font-size: 12px;
|
|
|
+ cursor: pointer; transition: border-color .15s, background .15s;
|
|
|
+}
|
|
|
+.upload-zone:hover { border-color: var(--accent); background: var(--bg-surface); }
|
|
|
+.upload-zone.dragover { border-color: var(--green); background: rgba(166,227,161,.05); }
|
|
|
+.upload-zone input[type="file"] { display: none; }
|
|
|
+
|
|
|
+/* ============================================================
|
|
|
+ Mode Selector — permission mode dropdown
|
|
|
+ ============================================================ */
|
|
|
+.mode-selector {
|
|
|
+ position: relative; font-size: 11px; user-select: none; z-index: 99;
|
|
|
+}
|
|
|
+.mode-selector .mode-current {
|
|
|
+ display: flex; align-items: center; gap: 5px;
|
|
|
+ padding: 4px 8px; border-radius: var(--radius-sm);
|
|
|
+ background: var(--bg-surface); border: 1px solid var(--border);
|
|
|
+ cursor: pointer; color: var(--text-secondary); white-space: nowrap;
|
|
|
+ transition: border-color .15s, background .15s;
|
|
|
+}
|
|
|
+.mode-selector .mode-current:hover { border-color: var(--accent); }
|
|
|
+.mode-selector .mode-dot {
|
|
|
+ width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
|
|
+}
|
|
|
+.mode-selector .mode-dot.plan { background: var(--purple); }
|
|
|
+.mode-selector .mode-dot.full { background: var(--red); }
|
|
|
+.mode-selector .mode-dropdown {
|
|
|
+ display: none; position: absolute; top: calc(100% + 4px); right: 0;
|
|
|
+ min-width: 180px; background: var(--bg-secondary);
|
|
|
+ border: 1px solid var(--border); border-radius: var(--radius);
|
|
|
+ box-shadow: 0 8px 24px rgba(0,0,0,.4); overflow: hidden;
|
|
|
+ animation: dropdownIn .15s ease;
|
|
|
+}
|
|
|
+.mode-selector.open .mode-dropdown { display: block; }
|
|
|
+.mode-selector .mode-option {
|
|
|
+ display: flex; align-items: center; gap: 8px;
|
|
|
+ padding: 9px 14px; cursor: pointer; color: var(--text-secondary);
|
|
|
+ transition: background .1s; border-bottom: 1px solid var(--border);
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+.mode-selector .mode-option:last-child { border-bottom: none; }
|
|
|
+.mode-selector .mode-option:hover { background: var(--bg-hover); }
|
|
|
+.mode-selector .mode-option.active { color: var(--accent); background: var(--bg-surface); }
|
|
|
+.mode-selector .mode-option .mode-opt-name { flex: 1; font-weight: 500; }
|
|
|
+.mode-selector .mode-option .mode-opt-desc { font-size: 10px; color: var(--text-muted); }
|
|
|
+.mode-selector .mode-option .mode-opt-check { color: var(--accent); font-size: 11px; }
|
|
|
+
|
|
|
+/* Interrupt approval card */
|
|
|
+.interrupt-card {
|
|
|
+ margin: 8px 0; padding: 12px 16px; border-radius: var(--radius);
|
|
|
+ border: 1px solid var(--yellow); background: rgba(249,226,175,.06);
|
|
|
+ display: flex; align-items: center; gap: 12px; font-size: 12px;
|
|
|
+}
|
|
|
+.interrupt-card .interrupt-msg { flex: 1; color: var(--yellow); }
|
|
|
+.interrupt-card button {
|
|
|
+ padding: 5px 14px; border-radius: var(--radius-sm);
|
|
|
+ border: none; font-size: 12px; cursor: pointer; font-weight: 500;
|
|
|
+ transition: background .15s;
|
|
|
+}
|
|
|
+.interrupt-card .btn-approve { background: var(--green); color: var(--bg-tertiary); }
|
|
|
+.interrupt-card .btn-approve:hover { background: #8fd98f; }
|
|
|
+.interrupt-card .btn-reject { background: var(--red); color: #fff; }
|
|
|
+.interrupt-card .btn-reject:hover { background: #e06c80; }
|
|
|
+
|
|
|
+/* ============================================================
|
|
|
+ Context Capacity Meter — detail panel
|
|
|
+ ============================================================ */
|
|
|
+.context-meter { margin-bottom: 12px; }
|
|
|
+.context-meter .cm-header {
|
|
|
+ display: flex; align-items: center; justify-content: space-between;
|
|
|
+ margin-bottom: 4px; font-size: 11px;
|
|
|
+}
|
|
|
+.context-meter .cm-header .cm-label { color: var(--text-muted); text-transform: uppercase; font-weight: 600; }
|
|
|
+.context-meter .cm-header .cm-value { color: var(--text-secondary); font-weight: 500; }
|
|
|
+.context-meter .cm-bar-outer {
|
|
|
+ height: 6px; background: var(--bg-tertiary); border-radius: 3px; overflow: hidden; margin-bottom: 8px;
|
|
|
+}
|
|
|
+.context-meter .cm-bar-inner {
|
|
|
+ height: 100%; border-radius: 3px; transition: width .4s ease, background .4s ease;
|
|
|
+}
|
|
|
+.context-meter .cm-bar-inner.low { background: var(--green); }
|
|
|
+.context-meter .cm-bar-inner.medium { background: var(--yellow); }
|
|
|
+.context-meter .cm-bar-inner.high { background: var(--orange); }
|
|
|
+.context-meter .cm-bar-inner.crit { background: var(--red); }
|
|
|
+.context-meter .cm-breakdown {
|
|
|
+ display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; font-size: 10px;
|
|
|
+}
|
|
|
+.context-meter .cm-breakdown .cm-item {
|
|
|
+ display: flex; justify-content: space-between; color: var(--text-muted);
|
|
|
+}
|
|
|
+.context-meter .cm-breakdown .cm-item .cm-cat { flex: 1; }
|
|
|
+.context-meter .cm-breakdown .cm-item .cm-num { font-weight: 500; color: var(--text-secondary); }
|
|
|
+.context-meter .cm-empty {
|
|
|
+ font-size: 10px; color: var(--text-muted); text-align: center; padding: 8px 0;
|
|
|
+}
|
|
|
</style>
|
|
|
</head>
|
|
|
<body>
|
|
|
@@ -431,7 +656,10 @@ body {
|
|
|
<aside class="sidebar">
|
|
|
<div class="sidebar-header">
|
|
|
<h2>💨 通风智能助手</h2>
|
|
|
- <button class="btn-new" onclick="newSession()" title="新建会话">+</button>
|
|
|
+ <div style="display:flex;gap:6px;">
|
|
|
+ <button class="btn-new" onclick="openSkillManager()" title="技能管理" style="font-size:14px;">📋</button>
|
|
|
+ <button class="btn-new" onclick="newSession()" title="新建会话">+</button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
<div class="session-list" id="session-list"></div>
|
|
|
</aside>
|
|
|
@@ -441,6 +669,31 @@ body {
|
|
|
<div class="chat-header">
|
|
|
<span class="status-dot" id="status-dot"></span>
|
|
|
<span id="chat-title">新会话</span>
|
|
|
+ <!-- Model Selector -->
|
|
|
+ <div class="model-selector" id="model-selector">
|
|
|
+ <div class="current" onclick="toggleModelDropdown()">
|
|
|
+ <span class="model-icon">🧠</span>
|
|
|
+ <span class="model-name" id="current-model-label">deepseek-v4-pro</span>
|
|
|
+ <span class="model-arrow">▼</span>
|
|
|
+ </div>
|
|
|
+ <div class="dropdown" id="model-dropdown" style="display:none"></div>
|
|
|
+ </div>
|
|
|
+ <!-- Thinking Level Toggle -->
|
|
|
+ <div class="thinking-toggle" id="thinking-toggle">
|
|
|
+ <span class="thinking-label">💭</span>
|
|
|
+ <button data-level="off" class="active" onclick="switchThinking('off')">关闭</button>
|
|
|
+ <button data-level="high" onclick="switchThinking('high')">高</button>
|
|
|
+ <button data-level="highest" onclick="switchThinking('highest')">最高</button>
|
|
|
+ </div>
|
|
|
+ <!-- Mode Selector -->
|
|
|
+ <div class="mode-selector" id="mode-selector">
|
|
|
+ <div class="mode-current" onclick="toggleModeDropdown()">
|
|
|
+ <span class="mode-dot plan" id="mode-dot"></span>
|
|
|
+ <span id="mode-label">计划模式</span>
|
|
|
+ <span style="font-size:9px;color:var(--text-muted)">▼</span>
|
|
|
+ </div>
|
|
|
+ <div class="mode-dropdown" id="mode-dropdown"></div>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
<div class="messages" id="messages">
|
|
|
<div class="empty-state">
|
|
|
@@ -472,6 +725,24 @@ body {
|
|
|
<!-- Toast container -->
|
|
|
<div id="toast-container"></div>
|
|
|
|
|
|
+<!-- ============ Skill Manager Modal ============ -->
|
|
|
+<div class="modal-overlay" id="skill-modal" style="display:none;" onclick="if(event.target===this) closeSkillModal()">
|
|
|
+ <div class="modal">
|
|
|
+ <div class="modal-header">
|
|
|
+ <h3>📋 技能管理</h3>
|
|
|
+ <div class="modal-actions">
|
|
|
+ <button class="btn-send" onclick="document.getElementById('skill-upload-input').click()" style="width:auto;padding:6px 14px;font-size:12px;">📤 上传技能</button>
|
|
|
+ <button class="btn-send" onclick="closeSkillModal()" style="width:auto;padding:6px 14px;font-size:12px;background:var(--bg-surface);border:1px solid var(--border);color:var(--text-secondary);">✕ 关闭</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="modal-body" id="skill-modal-body">
|
|
|
+ <div style="text-align:center;color:var(--text-muted);padding:40px;">加载中...</div>
|
|
|
+ </div>
|
|
|
+ <!-- Hidden upload input -->
|
|
|
+ <input type="file" id="skill-upload-input" accept=".zip" onchange="uploadSkill(this)" style="display:none;">
|
|
|
+ </div>
|
|
|
+</div>
|
|
|
+
|
|
|
<script>
|
|
|
// ============================================================
|
|
|
// State
|
|
|
@@ -484,20 +755,310 @@ let toolLogs = [];
|
|
|
let activeThinkBlock = null;
|
|
|
let pendingDownloadUrl = null;
|
|
|
|
|
|
+// ── 认证令牌 ──
|
|
|
+// 支持从 localStorage(key: x-access-token)或 URL 参数获取
|
|
|
+function getAccessToken() {
|
|
|
+ const fromStorage = localStorage.getItem('x-access-token');
|
|
|
+ if (fromStorage) return fromStorage;
|
|
|
+ const params = new URLSearchParams(window.location.search);
|
|
|
+ return params.get('token') || '';
|
|
|
+}
|
|
|
+
|
|
|
+function authHeaders(extra = {}) {
|
|
|
+ const token = getAccessToken();
|
|
|
+ const headers = { ...extra };
|
|
|
+ if (token) headers['x-access-token'] = token;
|
|
|
+ return headers;
|
|
|
+}
|
|
|
+
|
|
|
// ============================================================
|
|
|
// Init
|
|
|
// ============================================================
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
loadSessions();
|
|
|
if (currentSessionId) loadHistory(currentSessionId);
|
|
|
+ fetchModels();
|
|
|
+ if (currentSessionId) fetchSessionMode();
|
|
|
});
|
|
|
|
|
|
+// ============================================================
|
|
|
+// Model Switching
|
|
|
+// ============================================================
|
|
|
+const MODEL_TAGS = {
|
|
|
+ 'deepseek-v4-pro': { tag: 'PRO', cls: 'pro', desc: '深度推理 · 高精度' },
|
|
|
+ 'deepseek-v4-flash': { tag: 'FLASH', cls: 'flash', desc: '快速响应 · 低延迟' },
|
|
|
+};
|
|
|
+
|
|
|
+async function fetchModels() {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API}/model`);
|
|
|
+ const data = await res.json();
|
|
|
+ const current = data.current || 'deepseek-v4-pro';
|
|
|
+ const available = data.available || ['deepseek-v4-pro', 'deepseek-v4-flash'];
|
|
|
+ const thinkLevel = data.thinking_level || 'off';
|
|
|
+
|
|
|
+ // Update current display
|
|
|
+ document.getElementById('current-model-label').textContent = current;
|
|
|
+
|
|
|
+ // Update thinking toggle buttons
|
|
|
+ document.querySelectorAll('#thinking-toggle button').forEach(btn => {
|
|
|
+ btn.classList.toggle('active', btn.dataset.level === thinkLevel);
|
|
|
+ });
|
|
|
+
|
|
|
+ // Render dropdown options
|
|
|
+ const dropdown = document.getElementById('model-dropdown');
|
|
|
+ dropdown.innerHTML = available.map(m => {
|
|
|
+ const info = MODEL_TAGS[m] || { tag: m, cls: '', desc: '' };
|
|
|
+ const isActive = m === current;
|
|
|
+ return `<div class="model-option${isActive ? ' active' : ''}" onclick="switchModel('${m}')">
|
|
|
+ <span class="opt-tag ${info.cls}">${info.tag}</span>
|
|
|
+ <span class="opt-name">${m}</span>
|
|
|
+ ${isActive ? '<span class="opt-check">✓</span>' : ''}
|
|
|
+ <span style="font-size:10px;color:var(--text-muted)">${info.desc}</span>
|
|
|
+ </div>`;
|
|
|
+ }).join('');
|
|
|
+ } catch(e) {
|
|
|
+ console.error('获取模型列表失败', e);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function toggleModelDropdown() {
|
|
|
+ const selector = document.getElementById('model-selector');
|
|
|
+ const dropdown = document.getElementById('model-dropdown');
|
|
|
+ const isOpen = dropdown.style.display !== 'none';
|
|
|
+ if (isOpen) {
|
|
|
+ dropdown.style.display = 'none';
|
|
|
+ selector.classList.remove('open');
|
|
|
+ } else {
|
|
|
+ dropdown.style.display = 'block';
|
|
|
+ selector.classList.add('open');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function switchModel(modelName) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API}/model/switch`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ model: modelName }),
|
|
|
+ });
|
|
|
+ const data = await res.json();
|
|
|
+
|
|
|
+ if (data.error) {
|
|
|
+ showToast(data.error, 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Update display
|
|
|
+ document.getElementById('current-model-label').textContent = data.current;
|
|
|
+ document.getElementById('model-dropdown').style.display = 'none';
|
|
|
+ document.getElementById('model-selector').classList.remove('open');
|
|
|
+
|
|
|
+ // Refresh dropdown state
|
|
|
+ await fetchModels();
|
|
|
+
|
|
|
+ showToast(data.message || `已切换到 ${modelName}`, 'info');
|
|
|
+ } catch(e) {
|
|
|
+ showToast(`切换失败: ${e.message}`, 'error');
|
|
|
+ console.error(e);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Click outside to close dropdown
|
|
|
+document.addEventListener('click', (e) => {
|
|
|
+ const selector = document.getElementById('model-selector');
|
|
|
+ if (selector && !selector.contains(e.target)) {
|
|
|
+ document.getElementById('model-dropdown').style.display = 'none';
|
|
|
+ selector.classList.remove('open');
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+async function switchThinking(level) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API}/model/thinking`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ level: level }),
|
|
|
+ });
|
|
|
+ const data = await res.json();
|
|
|
+
|
|
|
+ if (data.error) {
|
|
|
+ showToast(data.error, 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Update button states
|
|
|
+ document.querySelectorAll('#thinking-toggle button').forEach(btn => {
|
|
|
+ btn.classList.toggle('active', btn.dataset.level === level);
|
|
|
+ });
|
|
|
+
|
|
|
+ showToast(data.message || `思考级别已切换为「${level}」`, 'info');
|
|
|
+ } catch(e) {
|
|
|
+ showToast(`切换失败: ${e.message}`, 'error');
|
|
|
+ console.error(e);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ============================================================
|
|
|
+// Permission Mode
|
|
|
+// ============================================================
|
|
|
+const MODES = {
|
|
|
+ plan: { label: '计划模式', dot: 'plan', desc: '先生成计划,确认后执行' },
|
|
|
+ full: { label: '完全访问', dot: 'full', desc: '无限制自动执行' },
|
|
|
+};
|
|
|
+let currentMode = 'plan';
|
|
|
+let pendingInterruptSession = null;
|
|
|
+
|
|
|
+async function fetchSessionMode() {
|
|
|
+ if (!currentSessionId) { currentMode = 'plan'; updateModeUI(); return; }
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API}/sessions/${currentSessionId}/mode`, {
|
|
|
+ headers: authHeaders(),
|
|
|
+ });
|
|
|
+ const data = await res.json();
|
|
|
+ currentMode = data.mode || 'plan';
|
|
|
+ updateModeUI();
|
|
|
+ renderModeDropdown();
|
|
|
+ } catch(e) { console.error('获取会话模式失败', e); }
|
|
|
+}
|
|
|
+
|
|
|
+function updateModeUI() {
|
|
|
+ const info = MODES[currentMode] || MODES['plan'];
|
|
|
+ document.getElementById('mode-label').textContent = info.label;
|
|
|
+ const dot = document.getElementById('mode-dot');
|
|
|
+ dot.className = 'mode-dot ' + info.dot;
|
|
|
+}
|
|
|
+
|
|
|
+function renderModeDropdown() {
|
|
|
+ const dd = document.getElementById('mode-dropdown');
|
|
|
+ dd.innerHTML = Object.entries(MODES).map(([k, v]) => {
|
|
|
+ const active = k === currentMode;
|
|
|
+ return `<div class="mode-option${active ? ' active' : ''}" onclick="switchMode('${k}')">
|
|
|
+ <span class="mode-dot ${v.dot}"></span>
|
|
|
+ <span class="mode-opt-name">${v.label}</span>
|
|
|
+ <span class="mode-opt-desc">${v.desc}</span>
|
|
|
+ ${active ? '<span class="mode-opt-check">✓</span>' : ''}
|
|
|
+ </div>`;
|
|
|
+ }).join('');
|
|
|
+}
|
|
|
+
|
|
|
+function toggleModeDropdown() {
|
|
|
+ const sel = document.getElementById('mode-selector');
|
|
|
+ sel.classList.toggle('open');
|
|
|
+ if (sel.classList.contains('open')) renderModeDropdown();
|
|
|
+}
|
|
|
+
|
|
|
+async function switchMode(mode) {
|
|
|
+ // 无会话时仅更新本地 UI 状态,模式会在首次 /chat 时随请求传入
|
|
|
+ if (!currentSessionId) {
|
|
|
+ currentMode = mode;
|
|
|
+ updateModeUI();
|
|
|
+ renderModeDropdown();
|
|
|
+ document.getElementById('mode-selector').classList.remove('open');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API}/sessions/${currentSessionId}/mode`, {
|
|
|
+ method: 'PUT',
|
|
|
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
|
+ body: JSON.stringify({ mode: mode }),
|
|
|
+ });
|
|
|
+ const data = await res.json();
|
|
|
+ if (data.error) { showToast(data.error, 'error'); return; }
|
|
|
+ currentMode = mode;
|
|
|
+ updateModeUI();
|
|
|
+ renderModeDropdown();
|
|
|
+ document.getElementById('mode-selector').classList.remove('open');
|
|
|
+ showToast(data.message || `已切换为「${MODES[mode]?.label || mode}」`, 'info');
|
|
|
+ } catch(e) { showToast(`切换失败: ${e.message}`, 'error'); }
|
|
|
+}
|
|
|
+
|
|
|
+// Close mode dropdown on outside click
|
|
|
+document.addEventListener('click', (e) => {
|
|
|
+ const sel = document.getElementById('mode-selector');
|
|
|
+ if (sel && !sel.contains(e.target)) sel.classList.remove('open');
|
|
|
+});
|
|
|
+
|
|
|
+// ── Interrupt handling ──
|
|
|
+
|
|
|
+function showInterruptCard(message, node) {
|
|
|
+ const bubble = getOrCreateAssistantMsg();
|
|
|
+ // Remove any existing interrupt card
|
|
|
+ bubble.querySelectorAll('.interrupt-card').forEach(el => el.remove());
|
|
|
+
|
|
|
+ const card = document.createElement('div');
|
|
|
+ card.className = 'interrupt-card';
|
|
|
+ const nodeLabel = node === 'tools' ? '工具执行' : '继续执行';
|
|
|
+ card.innerHTML = `<span class="interrupt-msg">⚠️ ${message || nodeLabel + '等待审批'}</span>
|
|
|
+ <button class="btn-approve" onclick="resumeAfterInterrupt('approve')">✓ 批准</button>
|
|
|
+ <button class="btn-reject" onclick="resumeAfterInterrupt('reject')">✕ 拒绝</button>`;
|
|
|
+ bubble.appendChild(card);
|
|
|
+ scrollToBottom();
|
|
|
+}
|
|
|
+
|
|
|
+async function resumeAfterInterrupt(action) {
|
|
|
+ if (!pendingInterruptSession) return;
|
|
|
+ const sid = pendingInterruptSession;
|
|
|
+ pendingInterruptSession = null;
|
|
|
+
|
|
|
+ // Remove interrupt card
|
|
|
+ document.querySelectorAll('.interrupt-card').forEach(el => el.remove());
|
|
|
+
|
|
|
+ if (action === 'reject') {
|
|
|
+ showToast('已拒绝执行', 'info');
|
|
|
+ clearStatus();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ setStatus('已批准,继续执行...');
|
|
|
+ document.getElementById('status-dot').classList.add('streaming');
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API}/chat/resume`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
|
+ body: JSON.stringify({ session_id: sid, action: 'approve' }),
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!res.ok) {
|
|
|
+ const errText = await res.text();
|
|
|
+ throw new Error(`HTTP ${res.status}: ${errText}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ const reader = res.body.getReader();
|
|
|
+ const decoder = new TextDecoder();
|
|
|
+ let buffer = '';
|
|
|
+
|
|
|
+ while (true) {
|
|
|
+ const { done, value } = await reader.read();
|
|
|
+ if (done) break;
|
|
|
+ buffer += decoder.decode(value, { stream: true });
|
|
|
+ const lines = buffer.split('\n');
|
|
|
+ buffer = lines.pop() || '';
|
|
|
+ for (const line of lines) {
|
|
|
+ if (!line.startsWith('data: ')) continue;
|
|
|
+ try {
|
|
|
+ const event = JSON.parse(line.slice(6));
|
|
|
+ handleSSEEvent(event);
|
|
|
+ } catch(e) {}
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch(e) {
|
|
|
+ if (e.name !== 'AbortError') {
|
|
|
+ showToast(`恢复执行失败: ${e.message}`, 'error');
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ document.getElementById('status-dot').classList.remove('streaming');
|
|
|
+ clearStatus();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
// ============================================================
|
|
|
// Session Management
|
|
|
// ============================================================
|
|
|
async function loadSessions() {
|
|
|
try {
|
|
|
- const res = await fetch(`${API}/sessions`);
|
|
|
+ const res = await fetch(`${API}/sessions`, { headers: authHeaders() });
|
|
|
const data = await res.json();
|
|
|
renderSessionList(data.sessions || []);
|
|
|
} catch(e) { console.error('加载会话列表失败', e); }
|
|
|
@@ -514,6 +1075,7 @@ function renderSessionList(sessions) {
|
|
|
onclick="switchSession('${s.session_id}')">
|
|
|
<span class="title">${escHtml(s.title || s.session_id?.slice(0,8) || '新会话')}</span>
|
|
|
<span class="count">${s.message_count || 0}</span>
|
|
|
+ <button class="btn-pin ${s.sort_order > 0 ? 'pinned' : ''}" onclick="event.stopPropagation();togglePin('${s.session_id}')" title="${s.sort_order > 0 ? '取消置顶' : '置顶'}">📌</button>
|
|
|
<button class="btn-delete" onclick="event.stopPropagation();deleteSession('${s.session_id}')">×</button>
|
|
|
</div>
|
|
|
`).join('');
|
|
|
@@ -534,8 +1096,12 @@ function newSession() {
|
|
|
document.getElementById('detail-content').innerHTML = '<div style="color:var(--text-muted);font-size:12px;text-align:center;margin-top:40px;">发送消息后<br>此处显示任务进度和工具调用</div>';
|
|
|
todoItems = [];
|
|
|
toolLogs = [];
|
|
|
+ contextData = null;
|
|
|
pendingDownloadUrl = null;
|
|
|
loadSessions();
|
|
|
+ currentMode = 'plan';
|
|
|
+ updateModeUI();
|
|
|
+ renderModeDropdown();
|
|
|
}
|
|
|
|
|
|
async function switchSession(sid) {
|
|
|
@@ -546,18 +1112,34 @@ async function switchSession(sid) {
|
|
|
document.getElementById('chat-title').textContent = sid.slice(0,8) + '...';
|
|
|
loadSessions();
|
|
|
await loadHistory(sid);
|
|
|
+ fetchSessionMode();
|
|
|
+ fetchContextUsage();
|
|
|
}
|
|
|
|
|
|
async function deleteSession(sid) {
|
|
|
abortStream();
|
|
|
- await fetch(`${API}/sessions/${sid}`, { method: 'DELETE' });
|
|
|
+ await fetch(`${API}/sessions/${sid}`, {
|
|
|
+ method: 'DELETE',
|
|
|
+ headers: authHeaders(),
|
|
|
+ });
|
|
|
if (currentSessionId === sid) newSession();
|
|
|
else loadSessions();
|
|
|
}
|
|
|
|
|
|
+async function togglePin(sid) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API}/sessions/${sid}/pin`, {
|
|
|
+ method: 'PUT',
|
|
|
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
|
+ });
|
|
|
+ if (!res.ok) return;
|
|
|
+ await loadSessions();
|
|
|
+ } catch(e) { /* 静默 */ }
|
|
|
+}
|
|
|
+
|
|
|
async function loadHistory(sid) {
|
|
|
try {
|
|
|
- const res = await fetch(`${API}/chat/history/${sid}`);
|
|
|
+ const res = await fetch(`${API}/chat/history/${sid}`, { headers: authHeaders() });
|
|
|
const data = await res.json();
|
|
|
const msgsEl = document.getElementById('messages');
|
|
|
msgsEl.innerHTML = '';
|
|
|
@@ -832,10 +1414,64 @@ function updateTodoList(todos) {
|
|
|
updateDetailPanel();
|
|
|
}
|
|
|
|
|
|
+// ── 上下文容量 ──
|
|
|
+let contextData = null;
|
|
|
+
|
|
|
+async function fetchContextUsage() {
|
|
|
+ if (!currentSessionId) { contextData = null; updateDetailPanel(); return; }
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API}/sessions/${currentSessionId}/context`, {
|
|
|
+ headers: authHeaders(),
|
|
|
+ });
|
|
|
+ if (!res.ok) return;
|
|
|
+ contextData = await res.json();
|
|
|
+ updateDetailPanel();
|
|
|
+ } catch(e) { /* 静默 */ }
|
|
|
+}
|
|
|
+
|
|
|
+function renderContextMeter() {
|
|
|
+ if (!contextData || contextData.total_limit == null) {
|
|
|
+ return '<div class="context-meter"><div class="cm-empty">📊 暂无上下文数据<br><span style="font-size:9px">发送消息后自动统计</span></div></div>';
|
|
|
+ }
|
|
|
+
|
|
|
+ const pct = contextData.usage_pct || 0;
|
|
|
+ const limit = contextData.total_limit;
|
|
|
+ const used = contextData.current_usage;
|
|
|
+ const bd = contextData.breakdown || {};
|
|
|
+
|
|
|
+ let cls = 'low';
|
|
|
+ if (pct > 75) cls = 'crit';
|
|
|
+ else if (pct > 50) cls = 'high';
|
|
|
+ else if (pct > 25) cls = 'medium';
|
|
|
+
|
|
|
+ const fmt = (n) => n != null ? (n >= 1000 ? (n/1000).toFixed(1)+'K' : n) : '—';
|
|
|
+
|
|
|
+ return `<div class="context-meter">
|
|
|
+ <div class="cm-header">
|
|
|
+ <span class="cm-label">🧠 上下文容量</span>
|
|
|
+ <span class="cm-value">${fmt(used)} / ${fmt(limit)} (${pct}%)</span>
|
|
|
+ </div>
|
|
|
+ <div class="cm-bar-outer">
|
|
|
+ <div class="cm-bar-inner ${cls}" style="width:${Math.min(pct,100)}%"></div>
|
|
|
+ </div>
|
|
|
+ <div class="cm-breakdown">
|
|
|
+ <div class="cm-item"><span class="cm-cat">💬 消息</span><span class="cm-num">${fmt(bd.messages)}</span></div>
|
|
|
+ <div class="cm-item"><span class="cm-cat">🔧 系统工具</span><span class="cm-num">${fmt(bd.tools)}</span></div>
|
|
|
+ <div class="cm-item"><span class="cm-cat">📋 技能</span><span class="cm-num">${fmt(bd.skills)}</span></div>
|
|
|
+ <div class="cm-item"><span class="cm-cat">🔌 MCP</span><span class="cm-num">${fmt(bd.mcp)}</span></div>
|
|
|
+ <div class="cm-item"><span class="cm-cat">📝 系统提示</span><span class="cm-num">${fmt(bd.system_prompt)}</span></div>
|
|
|
+ <div class="cm-item"><span class="cm-cat">📦 其他</span><span class="cm-num">${fmt(bd.other)}</span></div>
|
|
|
+ </div>
|
|
|
+ </div>`;
|
|
|
+}
|
|
|
+
|
|
|
function updateDetailPanel() {
|
|
|
const el = document.getElementById('detail-content');
|
|
|
let html = '';
|
|
|
|
|
|
+ // ── 上下文容量(始终在最上方)──
|
|
|
+ html += renderContextMeter();
|
|
|
+
|
|
|
if (todoItems.length) {
|
|
|
html += '<div class="section"><div class="section-title">📝 任务进度</div>';
|
|
|
todoItems.forEach(t => {
|
|
|
@@ -967,6 +1603,7 @@ async function sendMessage() {
|
|
|
const fd = new FormData();
|
|
|
fd.append('message', message || (file ? '请审查这份文件' : ''));
|
|
|
if (currentSessionId) fd.append('session_id', currentSessionId);
|
|
|
+ fd.append('mode', currentMode);
|
|
|
if (file) {
|
|
|
fd.append('file', file);
|
|
|
document.getElementById('btn-attach').classList.add('has-file');
|
|
|
@@ -995,6 +1632,7 @@ async function sendMessage() {
|
|
|
const res = await fetch(`${API}/chat`, {
|
|
|
method: 'POST',
|
|
|
body: fd,
|
|
|
+ headers: authHeaders(),
|
|
|
signal: currentAbortController.signal,
|
|
|
});
|
|
|
|
|
|
@@ -1009,6 +1647,7 @@ async function sendMessage() {
|
|
|
currentSessionId = sid;
|
|
|
localStorage.setItem('vent_session_id', sid);
|
|
|
loadSessions();
|
|
|
+ fetchSessionMode();
|
|
|
}
|
|
|
|
|
|
// Stream reader
|
|
|
@@ -1165,7 +1804,7 @@ function handleSSEEvent(event) {
|
|
|
if (event.download_url) {
|
|
|
pendingDownloadUrl = event.download_url;
|
|
|
}
|
|
|
- // 最终渲染 markdown(仅更新 .msg-markdown,保留 Agent 卡片和工具卡片)
|
|
|
+ // 最终渲染 markdown ……
|
|
|
{
|
|
|
const bubble = getOrCreateAssistantMsg();
|
|
|
if (bubble) {
|
|
|
@@ -1193,6 +1832,7 @@ function handleSSEEvent(event) {
|
|
|
}
|
|
|
activeThinkBlock = null;
|
|
|
loadSessions();
|
|
|
+ fetchContextUsage();
|
|
|
break;
|
|
|
|
|
|
case 'error':
|
|
|
@@ -1203,6 +1843,12 @@ function handleSSEEvent(event) {
|
|
|
case 'generating':
|
|
|
setStatus('正在生成报告...');
|
|
|
break;
|
|
|
+
|
|
|
+ case 'interrupt':
|
|
|
+ // LangGraph 中断:需要用户审批
|
|
|
+ pendingInterruptSession = currentSessionId;
|
|
|
+ showInterruptCard(event.message || '智能体暂停,等待审批...', event.node);
|
|
|
+ break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -1276,6 +1922,148 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
input.style.height = Math.min(input.scrollHeight, 150) + 'px';
|
|
|
});
|
|
|
});
|
|
|
+
|
|
|
+// ============================================================
|
|
|
+// Skill Manager
|
|
|
+// ============================================================
|
|
|
+async function openSkillManager() {
|
|
|
+ document.getElementById('skill-modal').style.display = 'flex';
|
|
|
+ await refreshSkillList();
|
|
|
+}
|
|
|
+
|
|
|
+function closeSkillModal() {
|
|
|
+ document.getElementById('skill-modal').style.display = 'none';
|
|
|
+}
|
|
|
+
|
|
|
+// ESC to close
|
|
|
+document.addEventListener('keydown', (e) => {
|
|
|
+ if (e.key === 'Escape') closeSkillModal();
|
|
|
+});
|
|
|
+
|
|
|
+async function refreshSkillList() {
|
|
|
+ const body = document.getElementById('skill-modal-body');
|
|
|
+ body.innerHTML = '<div style="text-align:center;color:var(--text-muted);padding:40px;">加载中...</div>';
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API}/skills`);
|
|
|
+ const data = await res.json();
|
|
|
+ const skills = data.skills || [];
|
|
|
+
|
|
|
+ if (!skills.length) {
|
|
|
+ body.innerHTML = '<div style="text-align:center;color:var(--text-muted);padding:40px;">暂无技能</div>';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ let html = `<table class="skill-table">
|
|
|
+ <thead><tr>
|
|
|
+ <th>技能名称</th><th>描述</th><th>所属智能体</th><th>状态</th><th>操作</th>
|
|
|
+ </tr></thead><tbody>`;
|
|
|
+
|
|
|
+ skills.forEach(s => {
|
|
|
+ const statusCls = s.enabled ? 'enabled' : 'disabled';
|
|
|
+ const statusText = s.enabled ? '启用' : '禁用';
|
|
|
+ const toggleLabel = s.enabled ? '停用' : '启用';
|
|
|
+ html += `<tr>
|
|
|
+ <td><span class="skill-name" onclick="viewSkill('${s.name}')">${escHtml(s.display_name || s.name)}</span></td>
|
|
|
+ <td class="skill-desc" title="${escHtml(s.description)}">${escHtml(s.description) || '—'}</td>
|
|
|
+ <td class="skill-agents">${s.agents.map(a => escHtml(a)).join('<br>')}</td>
|
|
|
+ <td><span class="status-badge ${statusCls}">${statusText}</span></td>
|
|
|
+ <td>
|
|
|
+ <button class="btn-send" onclick="viewSkill('${s.name}')" style="width:auto;padding:3px 10px;font-size:11px;background:var(--bg-surface);border:1px solid var(--border);color:var(--text-secondary);margin-right:4px;">查看</button>
|
|
|
+ <button class="btn-send" onclick="toggleSkill('${s.name}')" style="width:auto;padding:3px 10px;font-size:11px;background:var(--bg-surface);border:1px solid var(--border);color:var(--text-secondary);">${toggleLabel}</button>
|
|
|
+ </td>
|
|
|
+ </tr>`;
|
|
|
+ });
|
|
|
+
|
|
|
+ html += '</tbody></table>';
|
|
|
+
|
|
|
+ // Upload zone
|
|
|
+ html += `<div class="upload-zone" id="skill-upload-zone" onclick="document.getElementById('skill-upload-input').click()">
|
|
|
+ 📤 点击上传新技能(.zip 格式)
|
|
|
+ </div>`;
|
|
|
+
|
|
|
+ // Detail section
|
|
|
+ html += '<div class="skill-detail" id="skill-detail"></div>';
|
|
|
+
|
|
|
+ body.innerHTML = html;
|
|
|
+ } catch(e) {
|
|
|
+ body.innerHTML = `<div style="text-align:center;color:var(--red);padding:40px;">加载失败: ${escHtml(e.message)}</div>`;
|
|
|
+ console.error(e);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function viewSkill(name) {
|
|
|
+ const detail = document.getElementById('skill-detail');
|
|
|
+ if (!detail) return;
|
|
|
+
|
|
|
+ detail.style.display = 'block';
|
|
|
+ detail.innerHTML = '<div style="text-align:center;color:var(--text-muted);padding:20px;">加载中...</div>';
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API}/skills/${name}`);
|
|
|
+ if (!res.ok) {
|
|
|
+ const err = await res.json();
|
|
|
+ detail.innerHTML = `<div style="color:var(--red);padding:20px;">${escHtml(err.error || '加载失败')}</div>`;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const data = await res.json();
|
|
|
+
|
|
|
+ // Strip YAML frontmatter before rendering
|
|
|
+ let content = data.content || '';
|
|
|
+ content = content.replace(/^---[\s\S]*?---\n*/, '');
|
|
|
+
|
|
|
+ detail.innerHTML = `<div style="margin-bottom:8px;color:var(--accent);font-weight:600;">📄 ${escHtml(name)} / SKILL.md</div>
|
|
|
+ <div>${renderMarkdown(content)}</div>`;
|
|
|
+ } catch(e) {
|
|
|
+ detail.innerHTML = `<div style="color:var(--red);padding:20px;">加载失败: ${escHtml(e.message)}</div>`;
|
|
|
+ console.error(e);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function toggleSkill(name) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API}/skills/${name}/toggle`, { method: 'POST' });
|
|
|
+ const data = await res.json();
|
|
|
+ if (data.error) {
|
|
|
+ showToast(data.error, 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ showToast(data.message, 'info');
|
|
|
+ await refreshSkillList();
|
|
|
+ } catch(e) {
|
|
|
+ showToast(`操作失败: ${e.message}`, 'error');
|
|
|
+ console.error(e);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function uploadSkill(input) {
|
|
|
+ const file = input.files[0];
|
|
|
+ if (!file) return;
|
|
|
+
|
|
|
+ const fd = new FormData();
|
|
|
+ fd.append('file', file);
|
|
|
+
|
|
|
+ try {
|
|
|
+ showToast('正在上传技能...', 'info');
|
|
|
+ const res = await fetch(`${API}/skills/upload`, {
|
|
|
+ method: 'POST',
|
|
|
+ body: fd,
|
|
|
+ });
|
|
|
+ const data = await res.json();
|
|
|
+
|
|
|
+ if (!res.ok) {
|
|
|
+ showToast(data.error || '上传失败', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ showToast(data.message, 'info');
|
|
|
+ input.value = '';
|
|
|
+ await refreshSkillList();
|
|
|
+ } catch(e) {
|
|
|
+ showToast(`上传失败: ${e.message}`, 'error');
|
|
|
+ console.error(e);
|
|
|
+ }
|
|
|
+}
|
|
|
</script>
|
|
|
</body>
|
|
|
</html>
|