【造轮子】opencode配置网页

可以可视化配置 大小模型,供应商,模型,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>

11 个赞

赞一个。opencode我基本当个壳子,都用OMO。opencode确实不好配置。

2 个赞

点个赞,opencode全靠ai给我写,装了oh-my-opencode之后配置更麻烦了

我直接让它自己写,囧

佬,OMO你值得拥有,哈哈。OMO作者说:后续模型会越来越便宜,咱需要指挥一直军队。哈哈。

www好棒!

好 作者大概多久搞好

1 个赞