可以可视化配置 大小模型,供应商,模型,agent
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenCode 配置生成器 v2.3</title>
<style>
:root {
--background: #ffffff; --foreground: #09090b;
--muted: #f4f4f5; --muted-foreground: #71717a;
--border: #e2e8f0; --input: #e2e8f0;
--primary: #18181b; --primary-foreground: #ffffff;
--radius: 0.5rem; --error: #ef4444;
--accent-blue: #3b82f6;
--success: #22c55e;
}
* { box-sizing: border-box; font-family: "Inter", -apple-system, sans-serif; }
body { background: var(--background); color: var(--foreground); margin: 0; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
header { border-bottom: 1px solid var(--border); padding: 0.75rem 2rem; display: flex; justify-content: space-between; align-items: center; background: #fff; z-index: 50; }
.logo { font-weight: 800; font-size: 1.1rem; letter-spacing: -0.02em; }
.logo span { font-weight: 300; color: var(--muted-foreground); }
main { display: flex; flex: 1; overflow: hidden; }
#scroll-container { flex: 1; overflow-y: auto; padding: 2rem 5% 10rem 5%; scroll-behavior: smooth; }
#sidebar { width: 420px; border-left: 1px solid var(--border); background: var(--muted); display: flex; flex-direction: column; }
.section { margin-bottom: 4rem; }
.section-header { margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.75rem; }
.section-header h2 { font-size: 1.25rem; margin: 0; font-weight: 700; }
.section-header p { font-size: 0.875rem; color: var(--muted-foreground); margin: 0.25rem 0 0 0; }
.card { border: 1px solid var(--border); border-radius: var(--radius); padding: 1.5rem; background: #fff; margin-bottom: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.02); }
.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; }
.field { display: flex; flex-direction: column; gap: 0.4rem; }
.field label { font-size: 0.875rem; font-weight: 600; display: flex; justify-content: space-between; align-items: center; }
.field .desc { font-size: 0.75rem; color: var(--muted-foreground); line-height: 1.4; }
.tag-required { color: var(--error); font-size: 0.7rem; font-weight: 400; }
input, select, textarea { padding: 0.5rem 0.75rem; border: 1px solid var(--input); border-radius: 0.375rem; font-size: 0.875rem; outline: none; background: #fff; width: 100%; transition: border 0.2s; }
input:focus, select:focus, textarea:focus { border-color: var(--foreground); box-shadow: 0 0 0 2px rgba(0,0,0,0.05); }
textarea { min-height: 80px; resize: vertical; }
/* 供应商表格 */
.table-container { margin-top: 1rem; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
th { background: var(--muted); padding: 0.75rem 0.5rem; text-align: left; font-weight: 600; border-bottom: 1px solid var(--border); }
td { padding: 0.5rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
/* 详情抽屉 */
.model-details { background: #fafafa; padding: 1.5rem; border-radius: 6px; margin-top: 0.5rem; display: none; border: 1px solid var(--border); }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; }
.detail-group { border-bottom: 1px solid #e2e8f0; padding-bottom: 1rem; margin-bottom: 1rem; }
.detail-group:last-child { border-bottom: none; }
.detail-title { font-weight: 700; font-size: 0.7rem; margin-bottom: 0.75rem; text-transform: uppercase; color: #666; letter-spacing: 0.05em; }
/* 按钮 */
.btn { padding: 0.5rem 1rem; border-radius: 0.375rem; font-size: 0.875rem; font-weight: 500; cursor: pointer; border: 1px solid var(--border); transition: all 0.15s; display: inline-flex; align-items: center; gap: 6px; background: #fff; }
.btn-black { background: var(--primary); color: #fff; border: none; }
.btn-black:hover { opacity: 0.9; }
.btn-ghost { background: transparent; border: none; color: var(--muted-foreground); }
.btn-ghost:hover { color: var(--foreground); background: var(--muted); }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
/* 权限矩阵 */
.perm-matrix { width: 100%; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; margin-top: 1rem; }
.perm-row { display: grid; grid-template-columns: 160px 1fr 1fr 1fr; border-bottom: 1px solid var(--border); background: #fff; }
.perm-row:last-child { border-bottom: none; }
.perm-row div { padding: 0.6rem; text-align: center; font-size: 0.8rem; border-right: 1px solid var(--border); }
.perm-row div:last-child { border-right: none; }
.perm-head { background: var(--muted); font-weight: 700; font-size: 0.75rem; color: #444; }
.perm-label { text-align: left !important; padding-left: 1rem !important; font-weight: 500; color: #666; }
/* 侧边栏 */
.side-tabs { display: flex; border-bottom: 1px solid var(--border); background: #fff; }
.side-tab { flex: 1; padding: 1rem; text-align: center; font-size: 0.85rem; cursor: pointer; border-bottom: 2px solid transparent; color: var(--muted-foreground); }
.side-tab.active { color: var(--foreground); font-weight: 600; border-bottom-color: var(--foreground); }
#json-pre { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 0.75rem; padding: 1.5rem; margin: 0; line-height: 1.5; tab-size: 2; overflow: auto; height: calc(100vh - 120px); }
.agent-info { font-size: 0.75rem; color: var(--accent-blue); background: #eff6ff; padding: 0.5rem 0.75rem; border-radius: 4px; margin-bottom: 1rem; display: block; border: 1px solid #dbeafe; }
/* ── 新增:供应商 ID 编辑行 ── */
.prov-id-bar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1.2rem; }
.prov-id-bar .id-display { font-weight: 800; font-size: 1rem; letter-spacing: 0.05em; flex: 1; }
.prov-id-input { font-weight: 800; font-size: 1rem; display: none; flex: 1; }
.prov-id-bar.editing .id-display { display: none; }
.prov-id-bar.editing .prov-id-input { display: block; }
.id-conflict { color: var(--error); font-size: 0.75rem; display: none; }
/* ── 新增:导入面板 ── */
.import-panel { padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; height: calc(100vh - 120px); overflow-y: auto; }
.import-textarea { font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.72rem; min-height: 200px; border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem; resize: vertical; line-height: 1.5; }
.import-textarea.error { border-color: var(--error); }
.import-textarea.ok { border-color: var(--success); }
.import-msg { font-size: 0.75rem; padding: 0.5rem 0.75rem; border-radius: 4px; display: none; }
.import-msg.error { background: #fef2f2; color: var(--error); border: 1px solid #fecaca; display: block; }
.import-msg.success { background: #f0fdf4; color: #16a34a; border: 1px solid #bbf7d0; display: block; }
/* ── 新增:Toast ── */
#toast { position: fixed; bottom: 2rem; left: 50%; transform: translateX(-50%); background: #18181b; color: #fff; padding: 0.6rem 1.25rem; border-radius: 999px; font-size: 0.85rem; opacity: 0; pointer-events: none; transition: opacity 0.3s; z-index: 999; }
#toast.show { opacity: 1; }
</style>
</head>
<body>
<header>
<div class="logo">OPENCODE <span>CONFIG GENERATOR</span></div>
<div style="display: flex; gap: 0.75rem;">
<button class="btn btn-ghost" onclick="resetAll()">重置</button>
<button class="btn" onclick="copyJSON()">复制 JSON</button>
<button class="btn btn-black" onclick="downloadJSON()">导出 config.json</button>
</div>
</header>
<main>
<div id="scroll-container">
<!-- 1. 核心设置 -->
<div class="section">
<div class="section-header">
<h2>核心设置</h2>
<p>配置系统默认使用的模型与用户名</p>
</div>
<div class="card form-grid">
<div class="field">
<label>主模型 (model) <span class="tag-required">推荐设置</span></label>
<select id="global-model" onchange="syncToState('model', this.value)"></select>
<div class="desc">系统默认执行任务使用的模型 (provider/id)。</div>
</div>
<div class="field">
<label>辅助小模型 (small_model)</label>
<select id="global-small-model" onchange="syncToState('small_model', this.value)"></select>
<div class="desc">用于标题、摘要生成等背景任务。</div>
</div>
<div class="field">
<label>默认智能体 (default_agent)</label>
<input type="text" id="default_agent" placeholder="build" oninput="syncToState('default_agent', this.value)">
<div class="desc">未手动切换角色时使用的 Agent ID。</div>
</div>
<div class="field">
<label>自定义用户名 (username)</label>
<input type="text" id="username" placeholder="User" oninput="syncToState('username', this.value)">
<div class="desc">在对话界面显示的名称。</div>
</div>
</div>
</div>
<!-- 2. 供应商 -->
<div class="section">
<div class="section-header" style="display:flex; justify-content:space-between; align-items:flex-end;">
<div>
<h2>供应商 (Providers)</h2>
<p>管理 API 秘钥、Base URL 及具体模型能力定义</p>
</div>
<button class="btn btn-black btn-sm" onclick="addProvider()">+ 添加供应商</button>
</div>
<div id="providers-list"></div>
</div>
<!-- 3. Agents -->
<div class="section">
<div class="section-header">
<h2>智能体设置 (Agents)</h2>
<p>定义 7 个核心内置角色的独立逻辑与权限边界</p>
</div>
<div id="agents-list"></div>
</div>
</div>
<!-- 侧边栏 -->
<div id="sidebar">
<div class="side-tabs">
<div class="side-tab active" id="tab-btn-json" onclick="setTab('json')">实时配置预览</div>
<!-- ── 新增:导入标签 ── -->
<div class="side-tab" id="tab-btn-import" onclick="setTab('import')">导入配置</div>
<div class="side-tab" id="tab-btn-ref" onclick="setTab('ref')">常用 ID</div>
</div>
<div id="side-json">
<pre id="json-pre"></pre>
</div>
<!-- ── 新增:导入面板 HTML ── -->
<div id="side-import" style="display:none;">
<div class="import-panel">
<div style="font-size:0.8rem; font-weight:600;">粘贴 JSON 配置</div>
<div style="font-size:0.75rem; color:var(--muted-foreground);">将已有的 config.json 内容粘贴到下方,系统将深度合并到当前配置中。</div>
<textarea id="import-textarea" class="import-textarea" placeholder='{ "model": "anthropic/claude-3-5-sonnet", "provider": { ... } }'></textarea>
<div id="import-msg" class="import-msg"></div>
<div style="display:flex; gap:0.5rem;">
<button class="btn btn-black" style="flex:1" onclick="doImport()">合并导入</button>
<label class="btn" style="cursor:pointer; flex:1; justify-content:center;">
上传文件
<input type="file" accept=".json,application/json" style="display:none" onchange="handleFileUpload(event)">
</label>
</div>
<div style="font-size:0.7rem; color:var(--muted-foreground); border-top:1px solid var(--border); padding-top:0.75rem; line-height:1.6;">
<b>合并规则:</b><br>
· 已有字段将被导入值覆盖<br>
· 未导入的字段保持原值不变<br>
· Agent 权限矩阵按角色独立合并
</div>
</div>
</div>
<div id="side-ref" style="display:none; padding:1rem;">
<div id="ref-list"></div>
</div>
</div>
</main>
<!-- ── 新增:Toast DOM ── -->
<div id="toast"></div>
<script>
// ─────────────────────────────────────────────
// 常量 & 元数据(原封不动)
// ─────────────────────────────────────────────
const AGENT_METADATA = {
build: { name: "构建 (Build)", desc: "核心代码编写 Agent,拥有最高文件读写权限。" },
plan: { name: "规划 (Plan)", desc: "负责任务拆解与架构设计,倾向于读权限。" },
explore: { name: "探索 (Explore)", desc: "用于代码库检索、文件搜索与理解。" },
summary: { name: "摘要 (Summary)", desc: "负责对长会话进行上下文压缩与摘要提取。" },
title: { name: "标题 (Title)", desc: "专用于为新会话生成简洁的标题。" },
compaction: { name: "压缩 (Compaction)", desc: "当上下文溢出时,负责精简 Tool Outputs。" },
general: { name: "通用 (General)", desc: "处理不涉及复杂工具调用的普通闲谈任务。" }
};
const PERMISSION_KEYS = ['read', 'edit', 'bash', 'websearch', 'lsp', 'skill'];
// ─────────────────────────────────────────────
// 状态:config + ── 新增:baseline 快照 ──
// ─────────────────────────────────────────────
let config = {
model: "", small_model: "", default_agent: "build", username: "",
provider: {},
agent: {}
};
// baseline:记录上一次导入的原始值,用于 Delta 导出
// 初始为空对象(意味着所有字段都视为"新增")
let baseline = {};
Object.keys(AGENT_METADATA).forEach(key => {
config.agent[key] = { model: "", prompt: "", steps: null, permission: {} };
});
// ─────────────────────────────────────────────
// 工具函数
// ─────────────────────────────────────────────
/** 深克隆 */
function deepClone(obj) { return JSON.parse(JSON.stringify(obj)); }
/** 深度合并:将 src 合并入 dst,返回 dst(就地修改) */
function deepMerge(dst, src) {
if (typeof src !== 'object' || src === null) return src;
if (typeof dst !== 'object' || dst === null) dst = Array.isArray(src) ? [] : {};
Object.keys(src).forEach(k => {
if (typeof src[k] === 'object' && src[k] !== null && !Array.isArray(src[k])) {
if (typeof dst[k] !== 'object' || dst[k] === null) dst[k] = {};
deepMerge(dst[k], src[k]);
} else {
dst[k] = src[k];
}
});
return dst;
}
/**
* ── 新增:Delta 计算 ──
* 递归对比 current 与 base,仅返回有差异的部分。
* 若 base 为空对象,则返回完整 current(首次导出)。
*/
function computeDelta(current, base) {
if (Object.keys(base).length === 0) return current; // 无 baseline,全量导出
const delta = {};
function diff(cur, bas, out) {
if (typeof cur !== 'object' || cur === null) return;
Object.keys(cur).forEach(k => {
const cv = cur[k], bv = bas ? bas[k] : undefined;
if (typeof cv === 'object' && cv !== null && !Array.isArray(cv)) {
const sub = {};
diff(cv, bv || {}, sub);
if (Object.keys(sub).length > 0) out[k] = sub;
} else {
if (JSON.stringify(cv) !== JSON.stringify(bv)) {
out[k] = cv;
}
}
});
}
diff(current, base, delta);
return delta;
}
/** 递归清理空值(原封不动) */
function prune(obj) {
if (Array.isArray(obj)) {
const arr = obj.map(prune).filter(v => v !== undefined && v !== "");
return arr.length ? arr : undefined;
}
if (typeof obj === 'object' && obj !== null) {
const result = {};
Object.keys(obj).forEach(key => {
const val = prune(obj[key]);
if (val !== undefined && val !== "" && (typeof val !== 'object' || Object.keys(val).length > 0)) {
result[key] = val;
}
});
return Object.keys(result).length ? result : undefined;
}
return (obj === "" || obj === null || obj === undefined) ? undefined : obj;
}
/** ── 新增:构建最终导出对象(含 $schema + Delta) */
function buildExportObject() {
const pruned = prune(config) || {};
const delta = computeDelta(pruned, prune(baseline) || {});
return {
"$schema": "https://opencode.ai/config.json",
...delta
};
}
/** ── 新增:Toast 提示 */
function showToast(msg, duration = 2500) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), duration);
}
// ─────────────────────────────────────────────
// 渲染(原封不动,除 renderProviders 的供应商 ID 行)
// ─────────────────────────────────────────────
function refreshUI() {
renderProviders();
renderAgents();
updateModelDropdowns();
updatePreview();
}
function updateModelDropdowns() {
const options = ['<option value="">-- 请选择或留空 --</option>'];
Object.keys(config.provider).forEach(pId => {
const models = config.provider[pId].models || {};
Object.keys(models).forEach(mId => {
const val = `${pId}/${mId}`;
options.push(`<option value="${val}">${val}</option>`);
});
});
['global-model', 'global-small-model'].forEach(id => {
const el = document.getElementById(id);
const current = id === 'global-model' ? config.model : config.small_model;
el.innerHTML = options.join('');
el.value = current;
});
// 同步文本输入框
const da = document.getElementById('default_agent');
if (da) da.value = config.default_agent || '';
const un = document.getElementById('username');
if (un) un.value = config.username || '';
}
function renderProviders() {
const list = document.getElementById('providers-list');
list.innerHTML = '';
Object.keys(config.provider).forEach(pId => {
const p = config.provider[pId];
const card = document.createElement('div');
card.className = 'card';
card.id = `prov-card-${pId}`;
card.innerHTML = `
<!-- ── 新增:供应商 ID / Name 编辑行 ── -->
<div class="prov-id-bar" id="prov-id-bar-${pId}">
<span class="id-display">ID: ${pId}</span>
<input class="prov-id-input input" type="text" value="${pId}"
onkeydown="if(event.key==='Enter')commitProvId('${pId}',this)"
oninput="validateProvId('${pId}',this)">
<span class="id-conflict" id="id-conflict-${pId}">ID 已存在</span>
<button class="btn btn-ghost btn-sm" onclick="startEditProvId('${pId}')">编辑 ID</button>
<button class="btn btn-ghost btn-sm" id="prov-id-confirm-${pId}" style="display:none;color:var(--accent-blue)"
onclick="commitProvId('${pId}', document.querySelector('#prov-id-bar-${pId} .prov-id-input'))">确认</button>
<button class="btn btn-ghost btn-sm" id="prov-id-cancel-${pId}" style="display:none"
onclick="cancelEditProvId('${pId}')">取消</button>
<button class="btn btn-ghost btn-sm" style="color:var(--error)" onclick="removeProv('${pId}')">移除</button>
</div>
<!-- ── 新增:显示名称字段 ── -->
<div class="form-grid" style="margin-bottom:1.2rem;">
<div class="field">
<label>供应商显示名称 (name)</label>
<input type="text" placeholder="${pId}" value="${p.name||''}" oninput="updateProvField('${pId}','name',this.value)">
</div>
<div class="field">
<label>驱动 (npm)</label>
<select onchange="config.provider['${pId}'].npm=this.value; refreshUI()">
<option value="">-- 请选择 --</option>
<option value="@ai-sdk/openai" ${p.npm==='@ai-sdk/openai'?'selected':''}>OpenAI / Generic</option>
<option value="@ai-sdk/anthropic" ${p.npm==='@ai-sdk/anthropic'?'selected':''}>Anthropic</option>
<option value="@ai-sdk/google" ${p.npm==='@ai-sdk/google'?'selected':''}>Google Gemini</option>
</select>
</div>
<div class="field">
<label>Base URL</label>
<input type="text" placeholder="https://api.openai.com/v1" value="${p.options?.baseURL||''}" oninput="updateProvOpt('${pId}','baseURL',this.value)">
</div>
<div class="field">
<label>API Key</label>
<input type="password" placeholder="sk-..." value="${p.options?.apiKey||''}" oninput="updateProvOpt('${pId}','apiKey',this.value)">
</div>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th style="width:180px">模型 ID</th>
<th>显示名称</th>
<th style="width:45px">推理</th>
<th style="width:45px">工具</th>
<th style="width:110px">操作</th>
</tr>
</thead>
<tbody>
${Object.keys(p.models||{}).map(mId => `
<tr>
<td><input type="text" value="${mId}" onchange="renameModel('${pId}','${mId}',this.value)"></td>
<td><input type="text" value="${p.models[mId].name||''}" oninput="updateModel('${pId}','${mId}','name',this.value)"></td>
<td><input type="checkbox" ${p.models[mId].reasoning?'checked':''} onchange="updateModel('${pId}','${mId}','reasoning',this.checked)"></td>
<td><input type="checkbox" ${p.models[mId].tool_call?'checked':''} onchange="updateModel('${pId}','${mId}','tool_call',this.checked)"></td>
<td>
<button class="btn btn-ghost btn-sm" onclick="toggleDetails('${pId}','${mId}')">配置</button>
<button class="btn btn-ghost btn-sm" onclick="removeModel('${pId}','${mId}')">×</button>
</td>
</tr>
<tr><td colspan="5" style="padding:0"><div id="details-${pId}-${mId}" class="model-details">${renderModelDetails(pId, mId)}</div></td></tr>
`).join('')}
</tbody>
</table>
</div>
<button class="btn btn-ghost btn-sm" style="margin-top:1rem; color:var(--accent-blue)" onclick="addModel('${pId}')">+ 添加新模型</button>
`;
list.appendChild(card);
});
}
// 原封不动
function renderModelDetails(pId, mId) {
const m = config.provider[pId].models[mId];
return `
<div class="detail-group">
<div class="detail-title">计费设置 (每 1M tokens)</div>
<div class="detail-grid">
<div class="field"><label>输入</label><input type="number" step="0.01" value="${m.cost?.input||''}" oninput="updateModelCost('${pId}','${mId}','input',this.value)"></div>
<div class="field"><label>输出</label><input type="number" step="0.01" value="${m.cost?.output||''}" oninput="updateModelCost('${pId}','${mId}','output',this.value)"></div>
</div>
</div>
<div class="detail-group">
<div class="detail-title">上下文限制</div>
<div class="detail-grid">
<div class="field"><label>窗口大小</label><input type="number" value="${m.limit?.context||''}" oninput="updateModelLimit('${pId}','${mId}','context',this.value)"></div>
<div class="field"><label>最大输出</label><input type="number" value="${m.limit?.output||''}" oninput="updateModelLimit('${pId}','${mId}','output',this.value)"></div>
</div>
</div>
`;
}
// 原封不动
function renderAgents() {
const list = document.getElementById('agents-list');
list.innerHTML = '';
Object.keys(AGENT_METADATA).forEach(key => {
const meta = AGENT_METADATA[key];
const a = config.agent[key];
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
<span style="font-weight:700; color:#333; font-size:1rem;">${meta.name} <code style="font-weight:400; opacity:0.5; font-size:0.8rem;">@${key}</code></span>
<button class="btn btn-ghost btn-sm" onclick="toggleAgent('${key}')">展开/折叠</button>
</div>
<div id="agent-body-${key}">
<span class="agent-info">${meta.desc}</span>
<div class="form-grid">
<div class="field"><label>模型覆盖 (Model Override)</label><input type="text" placeholder="provider/id" value="${a.model||''}" oninput="updateAgent('${key}','model',this.value)"></div>
<div class="field"><label>最大迭代次数 (Steps)</label><input type="number" placeholder="默认无限" value="${a.steps||''}" oninput="updateAgent('${key}','steps',this.value)"></div>
</div>
<div class="field" style="margin-top:1.5rem">
<label>系统提示词 (Prompt)</label>
<textarea placeholder="输入该 Agent 的自定义指令..." oninput="updateAgent('${key}','prompt',this.value)">${a.prompt||''}</textarea>
</div>
<div class="perm-matrix">
<div class="perm-row perm-head"><div class="perm-label">权限矩阵</div><div>允许</div><div>询问</div><div>拒绝</div></div>
${PERMISSION_KEYS.map(pk => `
<div class="perm-row">
<div class="perm-label">${pk}</div>
<div><input type="radio" name="${key}-${pk}" ${a.permission[pk]==='allow'?'checked':''} onclick="updateAgentPerm('${key}','${pk}','allow')"></div>
<div><input type="radio" name="${key}-${pk}" ${a.permission[pk]==='ask'?'checked':''} onclick="updateAgentPerm('${key}','${pk}','ask')"></div>
<div><input type="radio" name="${key}-${pk}" ${a.permission[pk]==='deny'?'checked':''} onclick="updateAgentPerm('${key}','${pk}','deny')"></div>
</div>
`).join('')}
</div>
</div>
`;
list.appendChild(card);
});
}
// ─────────────────────────────────────────────
// 数据同步(原封不动)
// ─────────────────────────────────────────────
function syncToState(key, val) { config[key] = val; updatePreview(); }
function updateProvOpt(id, k, v) { if(!config.provider[id].options) config.provider[id].options={}; config.provider[id].options[k]=v; updatePreview(); }
function updateModel(pId, mId, k, v) { config.provider[pId].models[mId][k]=v; updatePreview(); }
function updateModelLimit(pId, mId, k, v) { if(!config.provider[pId].models[mId].limit) config.provider[pId].models[mId].limit={}; config.provider[pId].models[mId].limit[k]=v?parseInt(v):null; updatePreview(); }
function updateModelCost(pId, mId, k, v) { if(!config.provider[pId].models[mId].cost) config.provider[pId].models[mId].cost={}; config.provider[pId].models[mId].cost[k]=v?parseFloat(v):null; updatePreview(); }
function updateAgent(key, k, v) { config.agent[key][k] = (k==='steps' && v) ? parseInt(v) : v; updatePreview(); }
function updateAgentPerm(aKey, pKey, v) { config.agent[aKey].permission[pKey]=v; updatePreview(); }
// ── 新增:供应商 name 字段更新
function updateProvField(pId, k, v) { config.provider[pId][k] = v; updatePreview(); }
function addProvider() { const id = prompt("供应商 ID (例如: deepseek, anthropic):"); if(id) { config.provider[id] = { npm:"@ai-sdk/openai", options:{}, models:{} }; refreshUI(); } }
function removeProv(id) { delete config.provider[id]; refreshUI(); }
function addModel(pId) { const mId = prompt("模型 ID (例如: claude-3-5-sonnet):"); if(mId) { config.provider[pId].models[mId] = { name: mId, tool_call: true }; refreshUI(); } }
function removeModel(pId, mId) { delete config.provider[pId].models[mId]; refreshUI(); }
function renameModel(pId, oldId, newId) { if(newId && oldId !== newId) { config.provider[pId].models[newId] = config.provider[pId].models[oldId]; delete config.provider[pId].models[oldId]; refreshUI(); } }
// ─────────────────────────────────────────────
// ── 新增:供应商 ID 编辑逻辑
// ─────────────────────────────────────────────
function startEditProvId(pId) {
const bar = document.getElementById(`prov-id-bar-${pId}`);
bar.classList.add('editing');
document.getElementById(`prov-id-confirm-${pId}`).style.display = 'inline-flex';
document.getElementById(`prov-id-cancel-${pId}`).style.display = 'inline-flex';
bar.querySelector('.prov-id-input').focus();
bar.querySelector('[onclick*="startEdit"]').style.display = 'none';
}
function cancelEditProvId(pId) {
const bar = document.getElementById(`prov-id-bar-${pId}`);
bar.classList.remove('editing');
bar.querySelector('.prov-id-input').value = pId;
document.getElementById(`prov-id-confirm-${pId}`).style.display = 'none';
document.getElementById(`prov-id-cancel-${pId}`).style.display = 'none';
document.getElementById(`id-conflict-${pId}`).style.display = 'none';
bar.querySelector('[onclick*="startEdit"]').style.display = 'inline-flex';
}
function validateProvId(pId, input) {
const newId = input.value.trim();
const conflict = document.getElementById(`id-conflict-${pId}`);
const confirmBtn = document.getElementById(`prov-id-confirm-${pId}`);
if (newId && newId !== pId && config.provider[newId]) {
conflict.style.display = 'inline';
confirmBtn.disabled = true;
} else {
conflict.style.display = 'none';
confirmBtn.disabled = false;
}
}
/**
* ── 新增:提交供应商 ID 更名
* 同时级联更新 model / small_model 中对应的前缀
*/
function commitProvId(oldId, inputEl) {
const newId = inputEl.value.trim();
if (!newId || newId === oldId) { cancelEditProvId(oldId); return; }
if (config.provider[newId]) { showToast('⚠ ID 已存在,请换一个'); return; }
// 重命名 provider key
config.provider[newId] = config.provider[oldId];
delete config.provider[oldId];
// ── 级联更新全局 model / small_model
['model', 'small_model'].forEach(k => {
if (config[k] && config[k].startsWith(`${oldId}/`)) {
config[k] = config[k].replace(`${oldId}/`, `${newId}/`);
}
});
// ── 级联更新 agent model override
Object.keys(config.agent).forEach(aKey => {
const m = config.agent[aKey].model;
if (m && m.startsWith(`${oldId}/`)) {
config.agent[aKey].model = m.replace(`${oldId}/`, `${newId}/`);
}
});
showToast(`✓ 供应商 ID 已从 "${oldId}" 改为 "${newId}"`);
refreshUI();
}
// ─────────────────────────────────────────────
// ── 新增:导入逻辑
// ─────────────────────────────────────────────
/** 读取文件后塞进 textarea 并自动触发导入 */
function handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
const ta = document.getElementById('import-textarea');
ta.value = e.target.result;
doImport();
setTab('import'); // 切到导入面板查看结果
};
reader.readAsText(file);
}
/** 解析并深度合并导入的 JSON */
function doImport() {
const ta = document.getElementById('import-textarea');
const msg = document.getElementById('import-msg');
ta.classList.remove('error', 'ok');
msg.className = 'import-msg';
let parsed;
try {
parsed = JSON.parse(ta.value.trim());
} catch(e) {
ta.classList.add('error');
msg.className = 'import-msg error';
msg.textContent = `JSON 解析失败:${e.message}`;
return;
}
// 过滤掉 $schema 字段,避免污染 config
const { $schema, ...rest } = parsed;
// ── 将导入值快照为新 baseline
baseline = deepClone(rest);
// ── 深度合并进 config
// 先处理顶层简单字段
const TOP_KEYS = ['model','small_model','default_agent','username'];
TOP_KEYS.forEach(k => { if (rest[k] !== undefined) config[k] = rest[k]; });
// ── 合并 provider
if (rest.provider) {
deepMerge(config.provider, rest.provider);
}
// ── 合并 agent(按角色独立合并,不覆盖未提及的角色)
if (rest.agent) {
Object.keys(rest.agent).forEach(aKey => {
if (!config.agent[aKey]) {
config.agent[aKey] = { model:"", prompt:"", steps:null, permission:{} };
}
const src = rest.agent[aKey];
if (src.model !== undefined) config.agent[aKey].model = src.model;
if (src.prompt !== undefined) config.agent[aKey].prompt = src.prompt;
if (src.steps !== undefined) config.agent[aKey].steps = src.steps;
if (src.permission) deepMerge(config.agent[aKey].permission, src.permission);
});
}
// 统计变更数量
const changedKeys = Object.keys(rest).filter(k => k !== '$schema');
ta.classList.add('ok');
msg.className = 'import-msg success';
msg.textContent = `✓ 成功合并 ${changedKeys.length} 个顶层配置项`;
showToast(`✓ 导入成功,已合并 ${changedKeys.length} 个配置项`);
refreshUI();
}
// ─────────────────────────────────────────────
// UI 辅助(原封不动,updatePreview 改用 buildExportObject)
// ─────────────────────────────────────────────
function toggleDetails(pId, mId) {
const el = document.getElementById(`details-${pId}-${mId}`);
el.style.display = el.style.display === 'block' ? 'none' : 'block';
}
function toggleAgent(key) {
const el = document.getElementById(`agent-body-${key}`);
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
/** ── 更新预览(注入 $schema,应用 Delta) */
function updatePreview() {
document.getElementById('json-pre').textContent =
JSON.stringify(buildExportObject(), null, 2);
}
function setTab(t) {
['json','import','ref'].forEach(name => {
document.getElementById(`side-${name}`).style.display = name===t ? 'block' : 'none';
const btn = document.getElementById(`tab-btn-${name}`);
if (btn) btn.classList.toggle('active', name===t);
});
if(t==='ref') {
document.getElementById('ref-list').innerHTML = `
<div style="font-size:0.7rem; color:#888; margin-bottom:1rem; padding:0.5rem; background:#fff; border:1px dashed #ccc;">点击 ID 可直接复制</div>
${[
{n:"GPT-4o", id:"openai/gpt-4o"},
{n:"Claude 3.5 Sonnet", id:"anthropic/claude-3-5-sonnet-20240620"},
{n:"DeepSeek Chat", id:"deepseek/deepseek-chat"},
{n:"Llama 3.1 70B", id:"groq/llama-3.1-70b-versatile"}
].map(d => `
<div style="background:#fff; padding:0.75rem; border-radius:4px; margin-bottom:0.5rem; border:1px solid #e2e8f0; cursor:pointer;"
onclick="navigator.clipboard.writeText('${d.id}');showToast('已复制 ${d.id}')">
<div style="font-weight:700; font-size:0.8rem">${d.n}</div>
<div style="color:var(--muted-foreground); font-size:0.7rem">${d.id}</div>
</div>
`).join('')}
`;
}
}
function copyJSON() {
navigator.clipboard.writeText(document.getElementById('json-pre').textContent);
showToast('✓ 配置已复制到剪贴板');
}
function downloadJSON() {
const blob = new Blob([document.getElementById('json-pre').textContent], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'config.json'; a.click();
}
function resetAll() {
if(confirm("确定要清空所有输入并重置吗?")) location.reload();
}
// ─────────────────────────────────────────────
// 初始化(原封不动)
// ─────────────────────────────────────────────
refreshUI();
Object.keys(AGENT_METADATA).forEach(k => { if(k!=='build') toggleAgent(k); });
</script>
</body>
</html>

