vibe-ai-c 2.1.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/demo.html +180 -0
- package/package.json +3 -1
- package/vibe-ai.js +414 -261
- package/vibe-ai.min.js +1 -0
- package/LLM.txt +0 -71
package/demo.html
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>VibeAI v3.0 体验版</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { --primary: #007AFF; --bg: #f5f5f7; }
|
|
9
|
+
body { font-family: system-ui, sans-serif; background: var(--bg); margin: 0; display: flex; flex-direction: column; height: 100vh; color: #333; }
|
|
10
|
+
|
|
11
|
+
/* 顶部导航 */
|
|
12
|
+
header { background: white; padding: 15px 30px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 1px 10px rgba(0,0,0,0.05); }
|
|
13
|
+
.logo { font-weight: 800; font-size: 20px; color: var(--primary); }
|
|
14
|
+
|
|
15
|
+
/* 主界面 */
|
|
16
|
+
main { flex: 1; display: flex; gap: 20px; padding: 20px; overflow: hidden; }
|
|
17
|
+
.chat-column { flex: 1; background: white; border-radius: 16px; display: flex; flex-direction: column; box-shadow: 0 4px 20px rgba(0,0,0,0.08); overflow: hidden; }
|
|
18
|
+
|
|
19
|
+
/* 聊天头部 */
|
|
20
|
+
.chat-header { padding: 15px; border-bottom: 1px solid #eee; display: flex; flex-direction: column; gap: 10px; }
|
|
21
|
+
.select-wrapper { display: flex; align-items: center; gap: 10px; }
|
|
22
|
+
select { flex: 1; padding: 8px; border-radius: 8px; border: 1px solid #ddd; outline: none; }
|
|
23
|
+
|
|
24
|
+
/* 消息区域 */
|
|
25
|
+
.messages { flex: 1; padding: 15px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; background: #fafafa; }
|
|
26
|
+
.msg { max-width: 85%; padding: 10px 14px; border-radius: 12px; font-size: 14px; line-height: 1.5; }
|
|
27
|
+
.msg.user { align-self: flex-end; background: var(--primary); color: white; }
|
|
28
|
+
.msg.ai { align-self: flex-start; background: #e9e9eb; color: #333; }
|
|
29
|
+
.img-preview { max-width: 100px; border-radius: 8px; margin-top: 5px; display: block; }
|
|
30
|
+
|
|
31
|
+
/* 输入区域 */
|
|
32
|
+
.input-area { padding: 15px; border-top: 1px solid #eee; display: flex; flex-direction: column; gap: 10px; }
|
|
33
|
+
.input-row { display: flex; gap: 10px; }
|
|
34
|
+
textarea { flex: 1; border: 1px solid #ddd; border-radius: 8px; padding: 10px; resize: none; height: 40px; outline: none; }
|
|
35
|
+
.btn { padding: 8px 16px; border-radius: 8px; border: none; cursor: pointer; font-weight: 600; transition: 0.2s; }
|
|
36
|
+
.btn-send { background: var(--primary); color: white; }
|
|
37
|
+
.btn-send:disabled { background: #ccc; }
|
|
38
|
+
.btn-file { background: #eee; color: #333; position: relative; overflow: hidden; font-size: 12px; }
|
|
39
|
+
input[type="file"] { position: absolute; left: 0; top: 0; opacity: 0; cursor: pointer; }
|
|
40
|
+
|
|
41
|
+
.btn-setup { background: #333; color: white; }
|
|
42
|
+
</style>
|
|
43
|
+
</head>
|
|
44
|
+
<body>
|
|
45
|
+
|
|
46
|
+
<header>
|
|
47
|
+
<div class="logo">VibeAI <span style="font-weight: 300; font-size: 14px;">v3.0</span></div>
|
|
48
|
+
<button id="setup-btn" class="btn btn-setup">模型配置</button>
|
|
49
|
+
</header>
|
|
50
|
+
|
|
51
|
+
<main>
|
|
52
|
+
<!-- 左屏 -->
|
|
53
|
+
<section class="chat-column">
|
|
54
|
+
<div class="chat-header">
|
|
55
|
+
<div class="select-wrapper">
|
|
56
|
+
<strong>左屏实例:</strong>
|
|
57
|
+
<select id="select-left"></select>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="messages" id="msgs-left">
|
|
61
|
+
<div class="msg ai">你好!我是左屏 AI。请在上方选择模型。</div>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="input-area">
|
|
64
|
+
<div id="preview-left"></div>
|
|
65
|
+
<div class="input-row">
|
|
66
|
+
<button class="btn btn-file">
|
|
67
|
+
📎 图片
|
|
68
|
+
<input type="file" accept="image/*" onchange="handleFile(this, 'left')">
|
|
69
|
+
</button>
|
|
70
|
+
<textarea id="input-left" placeholder="输入对话..."></textarea>
|
|
71
|
+
<button class="btn btn-send" onclick="sendChat('left')">发送</button>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</section>
|
|
75
|
+
|
|
76
|
+
<!-- 右屏 -->
|
|
77
|
+
<section class="chat-column">
|
|
78
|
+
<div class="chat-header">
|
|
79
|
+
<div class="select-wrapper">
|
|
80
|
+
<strong>右屏实例:</strong>
|
|
81
|
+
<select id="select-right"></select>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="messages" id="msgs-right">
|
|
85
|
+
<div class="msg ai">你好!我是右屏 AI。我可以独立选择另一个模型对比。</div>
|
|
86
|
+
</div>
|
|
87
|
+
<div class="input-area">
|
|
88
|
+
<div id="preview-right"></div>
|
|
89
|
+
<div class="input-row">
|
|
90
|
+
<button class="btn btn-file">
|
|
91
|
+
📎 图片
|
|
92
|
+
<input type="file" accept="image/*" onchange="handleFile(this, 'right')">
|
|
93
|
+
</button>
|
|
94
|
+
<textarea id="input-right" placeholder="输入对话..."></textarea>
|
|
95
|
+
<button class="btn btn-send" onclick="sendChat('right')">发送</button>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</section>
|
|
99
|
+
</main>
|
|
100
|
+
|
|
101
|
+
<script type="module">
|
|
102
|
+
import { vibeAI } from './vibe-ai.js';
|
|
103
|
+
|
|
104
|
+
// 1. 初始化
|
|
105
|
+
vibeAI.init({ setupBtnId: 'setup-btn' });
|
|
106
|
+
|
|
107
|
+
// 2. 绑定多个模型选择器(实现双屏独立选择)
|
|
108
|
+
vibeAI.bindModelSelect('select-left');
|
|
109
|
+
vibeAI.bindModelSelect('select-right');
|
|
110
|
+
|
|
111
|
+
// 存储当前选中的图片
|
|
112
|
+
const pendingFiles = { left: null, right: null };
|
|
113
|
+
|
|
114
|
+
// 处理图片预览
|
|
115
|
+
window.handleFile = async (input, side) => {
|
|
116
|
+
const file = input.files[0];
|
|
117
|
+
if (!file) return;
|
|
118
|
+
|
|
119
|
+
pendingFiles[side] = await vibeAI.fileToDataURL(file);
|
|
120
|
+
const preview = document.getElementById(`preview-${side}`);
|
|
121
|
+
preview.innerHTML = `<img src="${pendingFiles[side]}" class="img-preview">`;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// 发送对话
|
|
125
|
+
window.sendChat = async (side) => {
|
|
126
|
+
const inputEl = document.getElementById(`input-${side}`);
|
|
127
|
+
const msgsEl = document.getElementById(`msgs-left`); // 注意:如果是 demo 请对应 side
|
|
128
|
+
const targetMsgsEl = document.getElementById(`msgs-${side}`);
|
|
129
|
+
const text = inputEl.value.trim();
|
|
130
|
+
|
|
131
|
+
if (!text && !pendingFiles[side]) return;
|
|
132
|
+
|
|
133
|
+
// 构建消息内容
|
|
134
|
+
let content = text;
|
|
135
|
+
if (pendingFiles[side]) {
|
|
136
|
+
content = [
|
|
137
|
+
{ type: "text", text: text || "这张图里有什么?" },
|
|
138
|
+
{ type: "image_url", image_url: { url: pendingFiles[side] } }
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// UI: 用户消息
|
|
143
|
+
const userMsgDiv = document.createElement('div');
|
|
144
|
+
userMsgDiv.className = 'msg user';
|
|
145
|
+
userMsgDiv.innerText = text || "[图片]";
|
|
146
|
+
targetMsgsEl.appendChild(userMsgDiv);
|
|
147
|
+
|
|
148
|
+
// UI: AI 消息容器
|
|
149
|
+
const aiMsgDiv = document.createElement('div');
|
|
150
|
+
aiMsgDiv.className = 'msg ai';
|
|
151
|
+
aiMsgDiv.innerText = '...';
|
|
152
|
+
targetMsgsEl.appendChild(aiMsgDiv);
|
|
153
|
+
targetMsgsEl.scrollTop = targetMsgsEl.scrollHeight;
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const stream = await vibeAI.chat({
|
|
157
|
+
instanceId: `select-${side}`, // 动态对应绑定的 select ID
|
|
158
|
+
messages: [{ role: 'user', content: content }],
|
|
159
|
+
stream: true
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
aiMsgDiv.innerText = '';
|
|
163
|
+
for await (const chunk of stream) {
|
|
164
|
+
aiMsgDiv.innerText += chunk;
|
|
165
|
+
targetMsgsEl.scrollTop = targetMsgsEl.scrollHeight;
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
aiMsgDiv.style.color = 'red';
|
|
169
|
+
aiMsgDiv.innerText = '错误: ' + err.message;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 清理
|
|
173
|
+
inputEl.value = '';
|
|
174
|
+
pendingFiles[side] = null;
|
|
175
|
+
document.getElementById(`preview-${side}`).innerHTML = '';
|
|
176
|
+
};
|
|
177
|
+
</script>
|
|
178
|
+
|
|
179
|
+
</body>
|
|
180
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibe-ai-c",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "一个极简的、面向浏览器的多供应商 AI SDK 与 UI 管理工具。支持 OpenAI 兼容协议。",
|
|
5
5
|
"main": "vibe-ai.js",
|
|
6
6
|
"type": "module",
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
"dependencies": {},
|
|
23
23
|
"devDependencies": {},
|
|
24
24
|
"files": [
|
|
25
|
+
"demo.html",
|
|
25
26
|
"vibe-ai.js",
|
|
27
|
+
"vibe-ai.min.js",
|
|
26
28
|
"llm.txt",
|
|
27
29
|
"README.md"
|
|
28
30
|
]
|
package/vibe-ai.js
CHANGED
|
@@ -1,279 +1,432 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* VibeAI
|
|
2
|
+
* VibeAI v3.3 - Browser-based AI SDK & UI Management
|
|
3
|
+
* Zero-dependency, Box-sizing Fixed, Context-aware Search.
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
|
-
const
|
|
6
|
+
const LOCAL_STORAGE_KEY_V2 = 'vibe_ai_v2_config';
|
|
7
|
+
const LOCAL_STORAGE_KEY_V3 = 'vibe_ai_v3_config';
|
|
8
|
+
|
|
9
|
+
const PRESETS = [
|
|
10
|
+
{ name: 'OpenAI', url: 'https://api.openai.com/v1' },
|
|
11
|
+
{ name: 'DeepSeek', url: 'https://api.deepseek.com/v1' },
|
|
12
|
+
{ name: 'Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai' },
|
|
13
|
+
{ name: 'Groq', url: 'https://api.groq.com/openai/v1' },
|
|
14
|
+
{ name: 'Qwen', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1' }
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const STYLE = `
|
|
18
|
+
.vibe-modal * { box-sizing: border-box; }
|
|
19
|
+
.vibe-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(8px); display: none; align-items: center; justify-content: center; z-index: 9999; font-family: system-ui, sans-serif; }
|
|
20
|
+
.vibe-card { background: rgba(255,255,255,0.95); width: 90%; max-width: 600px; max-height: 85vh; border-radius: 20px; box-shadow: 0 20px 50px rgba(0,0,0,0.3); display: flex; flex-direction: column; overflow: hidden; color: #333; }
|
|
21
|
+
.vibe-header { padding: 20px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; }
|
|
22
|
+
.vibe-body { padding: 20px; overflow-y: auto; flex: 1; }
|
|
23
|
+
.vibe-footer { padding: 15px; border-top: 1px solid #eee; display: flex; justify-content: space-between; gap: 10px; flex-shrink: 0; }
|
|
24
|
+
.vibe-profile { border: 1px solid #ddd; border-radius: 12px; padding: 15px; margin-bottom: 15px; background: #fff; width: 100%; overflow: hidden; }
|
|
25
|
+
.vibe-profile-head { display: flex; gap: 10px; margin-bottom: 10px; }
|
|
26
|
+
.vibe-input { border: 1px solid #ccc; border-radius: 6px; padding: 8px 12px; font-size: 14px; flex: 1; min-width: 0; outline: none; width: 100%; }
|
|
27
|
+
.vibe-input:focus { border-color: #007AFF; box-shadow: 0 0 0 2px rgba(0,122,255,0.1); }
|
|
28
|
+
.vibe-btn { border: none; border-radius: 8px; padding: 8px 16px; cursor: pointer; font-weight: 500; font-size: 13px; transition: 0.2s; white-space: nowrap; }
|
|
29
|
+
.vibe-btn-primary { background: #007AFF; color: white; }
|
|
30
|
+
.vibe-btn-danger { background: #FF3B30; color: white; }
|
|
31
|
+
.vibe-btn-ghost { background: #f0f0f0; color: #333; }
|
|
32
|
+
.vibe-badge { padding: 2px 8px; background: #f5f5f5; border-radius: 4px; font-size: 11px; cursor: pointer; border: 1px solid transparent; }
|
|
33
|
+
.vibe-badge:hover { border-color: #007AFF; color: #007AFF; }
|
|
34
|
+
.vibe-models-area { margin-top: 12px; border-top: 1px dashed #eee; padding-top: 12px; }
|
|
35
|
+
.vibe-model-tools { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
|
36
|
+
.vibe-models-grid { display: flex; flex-wrap: wrap; gap: 6px; max-height: 200px; overflow-y: auto; padding: 4px; }
|
|
37
|
+
.vibe-tag { font-size: 11px; padding: 4px 10px; border-radius: 14px; border: 1px solid #ddd; cursor: pointer; background: #fff; white-space: nowrap; transition: 0.1s; }
|
|
38
|
+
.vibe-tag.active { background: #E1F5FE; border-color: #007AFF; color: #007AFF; }
|
|
39
|
+
.vibe-tag.vibe-hidden { display: none; }
|
|
40
|
+
.vibe-lock-status { font-size: 12px; color: #666; display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
|
41
|
+
.vibe-status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
|
42
|
+
`;
|
|
6
43
|
|
|
7
44
|
class VibeAI {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
'DeepSeek': 'https://api.deepseek.com',
|
|
14
|
-
'Gemini': 'https://generativelanguage.googleapis.com/v1beta/openai',
|
|
15
|
-
'Qwen': 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
|
16
|
-
};
|
|
17
|
-
this._createModal();
|
|
18
|
-
this.modelSelectElement = null;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// --- 初始化 ---
|
|
22
|
-
init({ setupBtnId, modelSelectId } = {}) {
|
|
23
|
-
if (setupBtnId) document.getElementById(setupBtnId).onclick = () => this.openSettings();
|
|
24
|
-
if (modelSelectId) {
|
|
25
|
-
this.modelSelectElement = document.getElementById(modelSelectId);
|
|
26
|
-
this.modelSelectElement.onchange = (e) => {
|
|
27
|
-
this.data.activeModel = e.target.value;
|
|
28
|
-
this._save();
|
|
29
|
-
};
|
|
30
|
-
this._refreshExternalSelect();
|
|
45
|
+
constructor() {
|
|
46
|
+
this.config = { version: '3.3', isEncrypted: false, profiles: [], instanceStates: {} };
|
|
47
|
+
this.sessionKey = null;
|
|
48
|
+
this.boundSelects = new Set();
|
|
49
|
+
this._initStyle();
|
|
31
50
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (!profile) { this.openSettings(); throw new Error('请先配置 AI 供应商'); }
|
|
39
|
-
|
|
40
|
-
const response = await fetch(`${profile.baseUrl}/chat/completions`, {
|
|
41
|
-
method: 'POST',
|
|
42
|
-
headers: {
|
|
43
|
-
'Content-Type': 'application/json',
|
|
44
|
-
'Authorization': `Bearer ${profile.apiKey}`
|
|
45
|
-
},
|
|
46
|
-
body: JSON.stringify({
|
|
47
|
-
model: this.data.activeModel || profile.selectedModels[0].id,
|
|
48
|
-
...payload
|
|
49
|
-
})
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
if (!response.ok) {
|
|
53
|
-
const errData = await response.json().catch(() => ({}));
|
|
54
|
-
throw new Error(errData.error?.message || `请求失败: ${response.status}`);
|
|
51
|
+
|
|
52
|
+
async init({ setupBtnId }) {
|
|
53
|
+
this._migrate();
|
|
54
|
+
this._load();
|
|
55
|
+
if (setupBtnId) document.getElementById(setupBtnId)?.addEventListener('click', () => this.openSettings());
|
|
56
|
+
window.vibeAI = this;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
if (!
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
59
|
+
bindModelSelect(id) {
|
|
60
|
+
const el = document.getElementById(id);
|
|
61
|
+
if (!el) return;
|
|
62
|
+
this.boundSelects.add(id);
|
|
63
|
+
this._renderSelect(el);
|
|
64
|
+
el.addEventListener('change', (e) => {
|
|
65
|
+
this.config.instanceStates[id] = e.target.value;
|
|
66
|
+
this._save();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async chat({ instanceId, messages, stream = false, ...rest }) {
|
|
71
|
+
const target = this.config.instanceStates[instanceId];
|
|
72
|
+
if (!target) throw new Error("未选择模型");
|
|
73
|
+
const [pId, mId] = target.split('|');
|
|
74
|
+
const p = this.config.profiles.find(x => x.id === pId);
|
|
75
|
+
if (!p) throw new Error("供应商配置失效");
|
|
76
|
+
|
|
77
|
+
let key = p.apiKey;
|
|
78
|
+
if (this.config.isEncrypted) key = await this._getDecryptedKey(key);
|
|
79
|
+
|
|
80
|
+
const response = await fetch(`${p.baseUrl}/chat/completions`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${key}` },
|
|
83
|
+
body: JSON.stringify({ model: mId, messages, stream, ...rest })
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
const err = await response.json().catch(() => ({}));
|
|
88
|
+
throw new Error(err.error?.message || `HTTP ${response.status}`);
|
|
89
|
+
}
|
|
90
|
+
return stream ? this._handleStream(response) : response.json();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
openSettings() {
|
|
94
|
+
this._renderModal();
|
|
95
|
+
document.getElementById('vibe-modal').style.display = 'flex';
|
|
78
96
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
this._refreshExternalSelect();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
_getActiveProfile() {
|
|
112
|
-
return this.data.profiles.find(p => p.id === this.data.activeProfileId);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// --- UI 构建 ---
|
|
116
|
-
_createModal() {
|
|
117
|
-
this.modal = document.createElement('div');
|
|
118
|
-
this.modal.className = 'vaic-overlay';
|
|
119
|
-
const presetsHTML = Object.keys(this.presets)
|
|
120
|
-
.map(name => `<span class="vaic-badge" data-url="${this.presets[name]}">${name}</span>`)
|
|
121
|
-
.join('');
|
|
122
|
-
|
|
123
|
-
this.modal.innerHTML = `
|
|
124
|
-
<div class="vaic-card">
|
|
125
|
-
<div class="vaic-header">
|
|
126
|
-
<h3>AI 资产管理</h3>
|
|
127
|
-
<button class="vaic-close-x" id="vaic-close">✕</button>
|
|
128
|
-
</div>
|
|
129
|
-
<div class="vaic-body">
|
|
130
|
-
<div id="vaic-profile-list" class="vaic-list"></div>
|
|
131
|
-
<hr>
|
|
132
|
-
<div class="vaic-form">
|
|
133
|
-
<div class="vaic-presets">${presetsHTML}</div>
|
|
134
|
-
<input type="text" id="vaic-name" placeholder="供应商名称 (如: DeepSeek)">
|
|
135
|
-
<input type="text" id="vaic-base" placeholder="Base URL (包含 /v1)">
|
|
136
|
-
<p class="vaic-tip">提示:通常需以 /v1 结尾(如:https://api.deepseek.com/v1)</p>
|
|
137
|
-
<input type="password" id="vaic-key" placeholder="API Key">
|
|
138
|
-
<div class="vaic-model-picker">
|
|
139
|
-
<select id="vaic-all-models" multiple title="按住 Ctrl/Cmd 可多选"></select>
|
|
140
|
-
<button id="vaic-fetch-btn">获取模型</button>
|
|
97
|
+
|
|
98
|
+
// --- Private UI ---
|
|
99
|
+
|
|
100
|
+
_renderModal() {
|
|
101
|
+
let m = document.getElementById('vibe-modal');
|
|
102
|
+
if (!m) {
|
|
103
|
+
m = document.createElement('div');
|
|
104
|
+
m.id = 'vibe-modal';
|
|
105
|
+
m.className = 'vibe-modal';
|
|
106
|
+
document.body.appendChild(m);
|
|
107
|
+
}
|
|
108
|
+
const isEncrypted = this.config.isEncrypted;
|
|
109
|
+
m.innerHTML = `
|
|
110
|
+
<div class="vibe-card">
|
|
111
|
+
<div class="vibe-header">
|
|
112
|
+
<strong style="font-size:18px">VibeAI v3.3</strong>
|
|
113
|
+
<div class="vibe-lock-status" onclick="window.vibeAI._toggleEncryption()">
|
|
114
|
+
<span class="vibe-status-dot" style="background:${isEncrypted ? '#34C759' : '#ccc'}"></span>
|
|
115
|
+
${isEncrypted ? '加密已开启' : '明文存储 (点击加密)'}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
<div class="vibe-body" id="vibe-profiles-container"></div>
|
|
119
|
+
<div class="vibe-footer">
|
|
120
|
+
<div>
|
|
121
|
+
<button class="vibe-btn vibe-btn-ghost" onclick="window.vibeAI._exportConfig()">导出</button>
|
|
122
|
+
<button class="vibe-btn vibe-btn-ghost" onclick="window.vibeAI._importConfig()">导入</button>
|
|
123
|
+
</div>
|
|
124
|
+
<button class="vibe-btn vibe-btn-primary" onclick="window.vibeAI._closeModal()">保存并关闭</button>
|
|
125
|
+
</div>
|
|
141
126
|
</div>
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
this.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
127
|
+
`;
|
|
128
|
+
this._renderProfiles();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_renderProfiles() {
|
|
132
|
+
const container = document.getElementById('vibe-profiles-container');
|
|
133
|
+
container.innerHTML = this.config.profiles.map((p, i) => `
|
|
134
|
+
<div class="vibe-profile" data-index="${i}">
|
|
135
|
+
<div class="vibe-profile-head">
|
|
136
|
+
<input type="text" class="vibe-input vibe-name" placeholder="名称" value="${p.name}" oninput="window.vibeAI._updateConfig(${i}, 'name', this.value)">
|
|
137
|
+
<input type="password" class="vibe-input vibe-key" placeholder="${this.config.isEncrypted ? '已加密' : 'API Key'}" value="${this.config.isEncrypted ? '' : p.apiKey}" oninput="window.vibeAI._updateKey(${i}, this.value)">
|
|
138
|
+
<button class="vibe-btn vibe-btn-danger" onclick="window.vibeAI._removeProfile(${i})">删除</button>
|
|
139
|
+
</div>
|
|
140
|
+
<input type="text" class="vibe-input vibe-url" style="margin-bottom:10px;" placeholder="Base URL" value="${p.baseUrl}" oninput="window.vibeAI._updateConfig(${i}, 'baseUrl', this.value)">
|
|
141
|
+
<div style="display:flex; gap:5px; flex-wrap:wrap;">
|
|
142
|
+
${PRESETS.map(pre => `<span class="vibe-badge" onclick="window.vibeAI._applyPreset(${i},'${pre.name}','${pre.url}')">${pre.name}</span>`).join('')}
|
|
143
|
+
</div>
|
|
144
|
+
<div class="vibe-models-area" id="vibe-models-area-${i}"></div>
|
|
145
|
+
<button class="vibe-btn vibe-btn-ghost" style="width:100%; margin-top:10px" onclick="window.vibeAI._fetchModels(${i})">获取并校验资产</button>
|
|
146
|
+
</div>
|
|
147
|
+
`).join('') + `<button class="vibe-btn vibe-btn-ghost" style="width:100%; border:2px dashed #ccc; padding:12px" onclick="window.vibeAI._addProfile()">+ 添加供应商</button>`;
|
|
148
|
+
|
|
149
|
+
this.config.profiles.forEach((p, i) => this._renderModelList(i));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
_renderModelList(idx) {
|
|
153
|
+
const area = document.getElementById(`vibe-models-area-${idx}`);
|
|
154
|
+
const p = this.config.profiles[idx];
|
|
155
|
+
if (!area || !p) return;
|
|
156
|
+
|
|
157
|
+
area.innerHTML = `
|
|
158
|
+
${p.models.length >= 10 ? `
|
|
159
|
+
<div class="vibe-model-tools">
|
|
160
|
+
<input type="text" class="vibe-input vibe-search" placeholder="在 ${p.models.length} 个模型中搜索..." oninput="window.vibeAI._onModelSearch(${idx}, this.value)">
|
|
161
|
+
<button class="vibe-btn vibe-btn-ghost" style="padding:4px 8px" onclick="window.vibeAI._batchSelect(${idx}, true)">全选</button>
|
|
162
|
+
<button class="vibe-btn vibe-btn-ghost" style="padding:4px 8px" onclick="window.vibeAI._batchSelect(${idx}, false)">清空</button>
|
|
163
|
+
</div>
|
|
164
|
+
` : ''}
|
|
165
|
+
<div class="vibe-models-grid">
|
|
166
|
+
${p.models.map(m => `
|
|
167
|
+
<div class="vibe-tag ${m.selected ? 'active' : ''}" data-id="${m.id}" onclick="window.vibeAI._toggleModel(${idx}, '${m.id}', this)">
|
|
168
|
+
${m.id} ${m.status === 'pass' ? '✅' : ''}
|
|
169
|
+
</div>
|
|
170
|
+
`).join('') || '<div style="font-size:12px;color:#999">未获取模型资产</div>'}
|
|
171
|
+
</div>
|
|
172
|
+
`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- Logic Actions ---
|
|
176
|
+
|
|
177
|
+
_updateConfig(i, key, val) { this.config.profiles[i][key] = val; }
|
|
178
|
+
|
|
179
|
+
async _updateKey(i, val) {
|
|
180
|
+
if (this.config.isEncrypted && val) {
|
|
181
|
+
if (!this.sessionKey) this.sessionKey = await this._deriveKey(prompt("设置主密码:") || "");
|
|
182
|
+
val = "ENC:" + await this._encrypt(val);
|
|
183
|
+
}
|
|
184
|
+
this.config.profiles[i].apiKey = val;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
_applyPreset(idx, name, url) {
|
|
188
|
+
const p = this.config.profiles[idx];
|
|
189
|
+
const dom = document.querySelector(`.vibe-profile[data-index="${idx}"]`);
|
|
190
|
+
p.baseUrl = url;
|
|
191
|
+
dom.querySelector('.vibe-url').value = url;
|
|
192
|
+
if (p.name === '新供应商' || !p.name) {
|
|
193
|
+
p.name = name;
|
|
194
|
+
dom.querySelector('.vibe-name').value = name;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async _fetchModels(idx) {
|
|
199
|
+
const p = this.config.profiles[idx];
|
|
200
|
+
const area = document.getElementById(`vibe-models-area-${idx}`);
|
|
201
|
+
area.innerHTML = `<div style="font-size:12px;color:#007AFF">正在连接终端并获取资产清单...</div>`;
|
|
202
|
+
|
|
203
|
+
const fetchFn = async (url) => {
|
|
204
|
+
let key = p.apiKey;
|
|
205
|
+
if (this.config.isEncrypted) key = await this._getDecryptedKey(key);
|
|
206
|
+
const r = await fetch(`${url}/models`, { headers: { 'Authorization': `Bearer ${key}` } });
|
|
207
|
+
if (!r.ok) {
|
|
208
|
+
const errBody = await r.json().catch(() => ({}));
|
|
209
|
+
throw new Error(errBody.error?.message || `HTTP ${r.status}`);
|
|
210
|
+
}
|
|
211
|
+
return r.json();
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
let res, base = p.baseUrl.replace(/\/+$/, '');
|
|
216
|
+
try {
|
|
217
|
+
res = await fetchFn(base);
|
|
218
|
+
} catch (e) {
|
|
219
|
+
// 仅当 URL 不以 /v1 结尾时尝试补全
|
|
220
|
+
if (!base.toLowerCase().endsWith('/v1')) {
|
|
221
|
+
const fixed = base + '/v1';
|
|
222
|
+
res = await fetchFn(fixed);
|
|
223
|
+
p.baseUrl = fixed;
|
|
224
|
+
document.querySelector(`.vibe-profile[data-index="${idx}"] .vibe-url`).value = fixed;
|
|
225
|
+
} else throw e;
|
|
226
|
+
}
|
|
227
|
+
const sorted = res.data.sort((a, b) => a.id.localeCompare(b.id));
|
|
228
|
+
const autoSel = sorted.length < 10;
|
|
229
|
+
p.models = sorted.map(m => ({ id: m.id, selected: autoSel, status: 'pass' }));
|
|
230
|
+
this._renderModelList(idx);
|
|
231
|
+
} catch (e) {
|
|
232
|
+
area.innerHTML = `<div style="font-size:12px;color:#FF3B30;padding:8px;background:#FFF5F5;border-radius:6px;border:1px solid #FFD6D6">获取失败: ${e.message}</div>`;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
_onModelSearch(idx, val) {
|
|
237
|
+
const grid = document.querySelector(`.vibe-profile[data-index="${idx}"] .vibe-models-grid`);
|
|
238
|
+
const tags = grid.querySelectorAll('.vibe-tag');
|
|
239
|
+
const q = val.toLowerCase();
|
|
240
|
+
tags.forEach(t => t.classList.toggle('vibe-hidden', !t.dataset.id.toLowerCase().includes(q)));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
_batchSelect(idx, val) {
|
|
244
|
+
const p = this.config.profiles[idx];
|
|
245
|
+
const dom = document.querySelector(`.vibe-profile[data-index="${idx}"]`);
|
|
246
|
+
const searchInput = dom.querySelector('.vibe-search');
|
|
247
|
+
const q = searchInput ? searchInput.value.toLowerCase() : '';
|
|
248
|
+
|
|
249
|
+
// 仅干预当前可见(匹配搜索)的模型
|
|
250
|
+
p.models.forEach(m => {
|
|
251
|
+
if (m.id.toLowerCase().includes(q)) m.selected = val;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// 局部刷新列表但保留搜索框内容
|
|
255
|
+
this._renderModelList(idx);
|
|
256
|
+
const newSearchInput = dom.querySelector('.vibe-search');
|
|
257
|
+
if (newSearchInput) {
|
|
258
|
+
newSearchInput.value = q;
|
|
259
|
+
this._onModelSearch(idx, q); // 保持过滤状态
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_toggleModel(pIdx, mId, el) {
|
|
264
|
+
const m = this.config.profiles[pIdx].models.find(x => x.id === mId);
|
|
265
|
+
if (m) {
|
|
266
|
+
m.selected = !m.selected;
|
|
267
|
+
el.classList.toggle('active', m.selected); // 纯 DOM 切换,不重绘整个列表,不影响搜索焦点
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
_addProfile() {
|
|
272
|
+
this.config.profiles.push({ id: Math.random().toString(36).slice(2, 9), name: '新供应商', baseUrl: '', apiKey: '', models: [] });
|
|
273
|
+
this._renderProfiles();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
_removeProfile(idx) {
|
|
277
|
+
this.config.profiles.splice(idx, 1);
|
|
278
|
+
this._renderProfiles();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --- Crypto & Utils ---
|
|
282
|
+
|
|
283
|
+
async _getDecryptedKey(encStr) {
|
|
284
|
+
if (!encStr.startsWith('ENC:')) return encStr;
|
|
285
|
+
if (!this.sessionKey) {
|
|
286
|
+
const p = prompt("请输入主密码授权:");
|
|
287
|
+
if (!p) throw new Error("Need Password");
|
|
288
|
+
this.sessionKey = await this._deriveKey(p);
|
|
289
|
+
}
|
|
290
|
+
try { return await this._decrypt(encStr.replace('ENC:', '')); }
|
|
291
|
+
catch (e) { this.sessionKey = null; throw new Error("密码解密失败"); }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async _deriveKey(pwd) {
|
|
295
|
+
const enc = new TextEncoder();
|
|
296
|
+
const mat = await crypto.subtle.importKey("raw", enc.encode(pwd), "PBKDF2", false, ["deriveKey"]);
|
|
297
|
+
return crypto.subtle.deriveKey(
|
|
298
|
+
{ name: "PBKDF2", salt: enc.encode('vibe-v3'), iterations: 100000, hash: "SHA-256" },
|
|
299
|
+
mat, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async _encrypt(text) {
|
|
304
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
305
|
+
const enc = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, this.sessionKey, new TextEncoder().encode(text));
|
|
306
|
+
return btoa(JSON.stringify({ iv: Array.from(iv), data: Array.from(new Uint8Array(enc)) }));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async _decrypt(json) {
|
|
310
|
+
const { iv, data } = JSON.parse(atob(json));
|
|
311
|
+
const dec = await crypto.subtle.decrypt({ name: "AES-GCM", iv: new Uint8Array(iv) }, this.sessionKey, new Uint8Array(data));
|
|
312
|
+
return new TextDecoder().decode(dec);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async _toggleEncryption() {
|
|
316
|
+
if (!this.config.isEncrypted) {
|
|
317
|
+
const p = prompt("请设置主密码(此密码用于加密本地 Key,不会上传服务器):");
|
|
318
|
+
if (!p) return;
|
|
319
|
+
this.sessionKey = await this._deriveKey(p);
|
|
320
|
+
for (let x of this.config.profiles) if (x.apiKey && !x.apiKey.startsWith('ENC:')) x.apiKey = "ENC:" + await this._encrypt(x.apiKey);
|
|
321
|
+
this.config.isEncrypted = true;
|
|
322
|
+
} else {
|
|
323
|
+
if (confirm("关闭加密后 Key 将以明文存储,是否继续?")) {
|
|
324
|
+
for (let x of this.config.profiles) if (x.apiKey.startsWith('ENC:')) x.apiKey = await this._getDecryptedKey(x.apiKey);
|
|
325
|
+
this.config.isEncrypted = false;
|
|
326
|
+
this.sessionKey = null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
this.openSettings();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
_load() {
|
|
333
|
+
const r = localStorage.getItem(LOCAL_STORAGE_KEY_V3);
|
|
334
|
+
if (r) this.config = JSON.parse(r);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
_save() {
|
|
338
|
+
localStorage.setItem(LOCAL_STORAGE_KEY_V3, JSON.stringify(this.config));
|
|
339
|
+
this.boundSelects.forEach(id => this._renderSelect(document.getElementById(id)));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
_migrate() {
|
|
343
|
+
const v2 = localStorage.getItem(LOCAL_STORAGE_KEY_V2);
|
|
344
|
+
if (v2) {
|
|
345
|
+
try {
|
|
346
|
+
const old = JSON.parse(v2);
|
|
347
|
+
this.config.profiles = old.profiles.map(p => ({
|
|
348
|
+
id: Math.random().toString(36).slice(2, 9),
|
|
349
|
+
name: p.name, baseUrl: p.baseUrl, apiKey: p.apiKey,
|
|
350
|
+
models: p.models.map(m => ({ id: m, selected: true, status: 'pass' }))
|
|
351
|
+
}));
|
|
352
|
+
localStorage.removeItem(LOCAL_STORAGE_KEY_V2);
|
|
353
|
+
this._save();
|
|
354
|
+
} catch (e) {}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
_renderSelect(el) {
|
|
359
|
+
if (!el) return;
|
|
360
|
+
const cur = this.config.instanceStates[el.id] || "";
|
|
361
|
+
let h = `<option value="">选择模型...</option>`;
|
|
362
|
+
this.config.profiles.forEach(p => {
|
|
363
|
+
const sel = p.models.filter(m => m.selected);
|
|
364
|
+
if (sel.length) {
|
|
365
|
+
h += `<optgroup label="${p.name}">`;
|
|
366
|
+
sel.forEach(m => {
|
|
367
|
+
const val = `${p.id}|${m.id}`;
|
|
368
|
+
h += `<option value="${val}" ${val === cur ? 'selected' : ''}>${m.id}</option>`;
|
|
369
|
+
});
|
|
370
|
+
h += `</optgroup>`;
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
el.innerHTML = h;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
_closeModal() {
|
|
377
|
+
document.getElementById('vibe-modal').style.display = 'none';
|
|
201
378
|
this._save();
|
|
202
|
-
});
|
|
203
379
|
}
|
|
204
|
-
}
|
|
205
380
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
381
|
+
_initStyle() {
|
|
382
|
+
const s = document.createElement('style');
|
|
383
|
+
s.textContent = STYLE;
|
|
384
|
+
document.head.appendChild(s);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
_exportConfig() {
|
|
388
|
+
const a = document.createElement('a');
|
|
389
|
+
a.href = URL.createObjectURL(new Blob([JSON.stringify(this.config, null, 2)], { type: 'application/json' }));
|
|
390
|
+
a.download = `vibe-ai-config.json`;
|
|
391
|
+
a.click();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
_importConfig() {
|
|
395
|
+
const i = document.createElement('input');
|
|
396
|
+
i.type = 'file';
|
|
397
|
+
i.onchange = (e) => {
|
|
398
|
+
const r = new FileReader();
|
|
399
|
+
r.onload = (ev) => {
|
|
400
|
+
const d = JSON.parse(ev.target.result);
|
|
401
|
+
if (d.version) { this.config = d; this._save(); this.openSettings(); }
|
|
402
|
+
};
|
|
403
|
+
r.readAsText(e.target.files[0]);
|
|
404
|
+
};
|
|
405
|
+
i.click();
|
|
211
406
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
407
|
+
|
|
408
|
+
async* _handleStream(res) {
|
|
409
|
+
const r = res.body.getReader();
|
|
410
|
+
const dec = new TextDecoder();
|
|
411
|
+
let buf = '';
|
|
412
|
+
while (true) {
|
|
413
|
+
const { done, value } = await r.read();
|
|
414
|
+
if (done) break;
|
|
415
|
+
buf += dec.decode(value, { stream: true });
|
|
416
|
+
const lines = buf.split('\n');
|
|
417
|
+
buf = lines.pop();
|
|
418
|
+
for (const l of lines) {
|
|
419
|
+
const s = l.trim();
|
|
420
|
+
if (!s || s === 'data: [DONE]') continue;
|
|
421
|
+
if (s.startsWith('data: ')) {
|
|
422
|
+
try {
|
|
423
|
+
const c = JSON.parse(s.slice(6)).choices[0]?.delta?.content;
|
|
424
|
+
if (c) yield c;
|
|
425
|
+
} catch (e) {}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
229
429
|
}
|
|
230
|
-
this.modelSelectElement.innerHTML = profile.selectedModels.map(m =>
|
|
231
|
-
`<option value="${m.id}" ${m.id === this.data.activeModel ? 'selected' : ''}>
|
|
232
|
-
${m.status === 'pass' ? '✅' : m.status === 'fail' ? '❌' : '⏳'} ${m.id}
|
|
233
|
-
</option>`
|
|
234
|
-
).join('');
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
_renderProfiles() {
|
|
238
|
-
const list = this.modal.querySelector('#vaic-profile-list');
|
|
239
|
-
list.innerHTML = this.data.profiles.map(p => `
|
|
240
|
-
<div class="vaic-item ${p.id === this.data.activeProfileId ? 'active' : ''}">
|
|
241
|
-
<span onclick="vibeAI._switchProfile('${p.id}')" style="cursor:pointer; flex:1;">
|
|
242
|
-
<b>${p.name}</b> <small>(${p.selectedModels.length} models)</small>
|
|
243
|
-
</span>
|
|
244
|
-
<button onclick="vibeAI._deleteProfile('${p.id}')">删除</button>
|
|
245
|
-
</div>
|
|
246
|
-
`).join('');
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
openSettings() { this.modal.style.display = 'flex'; this._renderProfiles(); }
|
|
250
|
-
closeSettings() { this.modal.style.display = 'none'; }
|
|
251
|
-
|
|
252
|
-
_injectStyles() {
|
|
253
|
-
const style = document.createElement('style');
|
|
254
|
-
style.textContent = `
|
|
255
|
-
.vaic-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.3); backdrop-filter: blur(10px); display: none; align-items: center; justify-content: center; z-index: 10000; font-family: -apple-system, system-ui, sans-serif; }
|
|
256
|
-
.vaic-card { background: #fff; width: 420px; border-radius: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); overflow: hidden; display: flex; flex-direction: column; }
|
|
257
|
-
.vaic-header { padding: 15px 20px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #f0f0f0; }
|
|
258
|
-
.vaic-body { padding: 20px; overflow-y: auto; max-height: 70vh; }
|
|
259
|
-
.vaic-presets { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 15px; }
|
|
260
|
-
.vaic-badge { padding: 4px 10px; background: #f0f0f0; border-radius: 6px; font-size: 11px; cursor: pointer; border: 1px solid #eee; }
|
|
261
|
-
.vaic-badge:hover { background: #000; color: #fff; }
|
|
262
|
-
.vaic-form input { width: 100%; padding: 10px; margin-bottom: 8px; border: 1px solid #ddd; border-radius: 8px; box-sizing: border-box; font-size: 13px; }
|
|
263
|
-
.vaic-tip { font-size: 10px; color: #999; margin: -4px 0 12px 2px; }
|
|
264
|
-
.vaic-model-picker { display: flex; gap: 8px; }
|
|
265
|
-
.vaic-model-picker select { flex: 1; height: 100px; border-radius: 8px; border: 1px solid #ddd; padding: 5px; font-size: 12px; }
|
|
266
|
-
.vaic-model-picker button { width: 80px; border-radius: 8px; border: 1px solid #ddd; cursor: pointer; font-size: 11px; background: #f9f9f9; }
|
|
267
|
-
.vaic-item { display: flex; justify-content: space-between; padding: 10px; background: #f8f8f8; border-radius: 10px; margin-bottom: 8px; font-size: 13px; border: 1px solid transparent; }
|
|
268
|
-
.vaic-item.active { border-color: #000; background: #fff; box-shadow: 0 2px 5px rgba(0,0,0,0.05); }
|
|
269
|
-
.vaic-btn { width: 100%; padding: 14px; border-radius: 12px; border: none; cursor: pointer; font-weight: bold; background: #000; color: #fff; }
|
|
270
|
-
.vaic-item button { background: none; border: none; color: #ff4444; cursor: pointer; font-size: 11px; }
|
|
271
|
-
.vaic-close-x { background: none; border: none; font-size: 20px; cursor: pointer; color: #ccc; }
|
|
272
|
-
hr { border: 0; border-top: 1px solid #eee; margin: 15px 0; }
|
|
273
|
-
`;
|
|
274
|
-
document.head.appendChild(style);
|
|
275
|
-
}
|
|
276
430
|
}
|
|
277
431
|
|
|
278
432
|
export const vibeAI = new VibeAI();
|
|
279
|
-
window.vibeAI = vibeAI;
|
package/vibe-ai.min.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const LOCAL_STORAGE_KEY_V2='vibe_ai_v2_config';const LOCAL_STORAGE_KEY_V3='vibe_ai_v3_config';const PRESETS=[{name:'OpenAI',url:'https://api.openai.com/v1'},{name:'DeepSeek',url:'https://api.deepseek.com/v1'},{name:'Gemini',url:'https://generativelanguage.googleapis.com/v1beta/openai'},{name:'Groq',url:'https://api.groq.com/openai/v1'},{name:'Qwen',url:'https://dashscope.aliyuncs.com/compatible-mode/v1'}];const STYLE=`.vibe-modal*{box-sizing:border-box}.vibe-modal{position:fixed;inset:0;background:rgba(0,0,0,0.6);backdrop-filter:blur(8px);display:none;align-items:center;justify-content:center;z-index:9999;font-family:system-ui,sans-serif}.vibe-card{background:rgba(255,255,255,0.95);width:90%;max-width:600px;max-height:85vh;border-radius:20px;box-shadow:0 20px 50px rgba(0,0,0,0.3);display:flex;flex-direction:column;overflow:hidden;color:#333}.vibe-header{padding:20px;border-bottom:1px solid#eee;display:flex;justify-content:space-between;align-items:center;flex-shrink:0}.vibe-body{padding:20px;overflow-y:auto;flex:1}.vibe-footer{padding:15px;border-top:1px solid#eee;display:flex;justify-content:space-between;gap:10px;flex-shrink:0}.vibe-profile{border:1px solid#ddd;border-radius:12px;padding:15px;margin-bottom:15px;background:#fff;width:100%;overflow:hidden}.vibe-profile-head{display:flex;gap:10px;margin-bottom:10px}.vibe-input{border:1px solid#ccc;border-radius:6px;padding:8px 12px;font-size:14px;flex:1;min-width:0;outline:none;width:100%}.vibe-input:focus{border-color:#007AFF;box-shadow:0 0 0 2px rgba(0,122,255,0.1)}.vibe-btn{border:none;border-radius:8px;padding:8px 16px;cursor:pointer;font-weight:500;font-size:13px;transition:0.2s;white-space:nowrap}.vibe-btn-primary{background:#007AFF;color:white}.vibe-btn-danger{background:#FF3B30;color:white}.vibe-btn-ghost{background:#f0f0f0;color:#333}.vibe-badge{padding:2px 8px;background:#f5f5f5;border-radius:4px;font-size:11px;cursor:pointer;border:1px solid transparent}.vibe-badge:hover{border-color:#007AFF;color:#007AFF}.vibe-models-area{margin-top:12px;border-top:1px dashed#eee;padding-top:12px}.vibe-model-tools{display:flex;align-items:center;gap:8px;margin-bottom:10px}.vibe-models-grid{display:flex;flex-wrap:wrap;gap:6px;max-height:200px;overflow-y:auto;padding:4px}.vibe-tag{font-size:11px;padding:4px 10px;border-radius:14px;border:1px solid#ddd;cursor:pointer;background:#fff;white-space:nowrap;transition:0.1s}.vibe-tag.active{background:#E1F5FE;border-color:#007AFF;color:#007AFF}.vibe-tag.vibe-hidden{display:none}.vibe-lock-status{font-size:12px;color:#666;display:flex;align-items:center;gap:6px;cursor:pointer}.vibe-status-dot{width:8px;height:8px;border-radius:50%;display:inline-block}`;class VibeAI{constructor(){this.config={version:'3.3',isEncrypted:false,profiles:[],instanceStates:{}};this.sessionKey=null;this.boundSelects=new Set();this._initStyle()}async init({setupBtnId}){this._migrate();this._load();if(setupBtnId)document.getElementById(setupBtnId)?.addEventListener('click',()=>this.openSettings());window.vibeAI=this}bindModelSelect(id){const el=document.getElementById(id);if(!el)return;this.boundSelects.add(id);this._renderSelect(el);el.addEventListener('change',(e)=>{this.config.instanceStates[id]=e.target.value;this._save()})}async chat({instanceId,messages,stream=false,...rest}){const target=this.config.instanceStates[instanceId];if(!target)throw new Error("未选择模型");const[pId,mId]=target.split('|');const p=this.config.profiles.find(x=>x.id===pId);if(!p)throw new Error("供应商配置失效");let key=p.apiKey;if(this.config.isEncrypted)key=await this._getDecryptedKey(key);const response=await fetch(`${p.baseUrl}/chat/completions`,{method:'POST',headers:{'Content-Type':'application/json','Authorization':`Bearer ${key}`},body:JSON.stringify({model:mId,messages,stream,...rest})});if(!response.ok){const err=await response.json().catch(()=>({}));throw new Error(err.error?.message||`HTTP ${response.status}`)}return stream?this._handleStream(response):response.json()}openSettings(){this._renderModal();document.getElementById('vibe-modal').style.display='flex'}_renderModal(){let m=document.getElementById('vibe-modal');if(!m){m=document.createElement('div');m.id='vibe-modal';m.className='vibe-modal';document.body.appendChild(m)}const isEncrypted=this.config.isEncrypted;m.innerHTML=`<div class="vibe-card"><div class="vibe-header"><strong style="font-size:18px">VibeAI v3.3</strong><div class="vibe-lock-status"onclick="window.vibeAI._toggleEncryption()"><span class="vibe-status-dot"style="background:${isEncrypted ? '#34C759' : '#ccc'}"></span>${isEncrypted?'加密已开启':'明文存储 (点击加密)'}</div></div><div class="vibe-body"id="vibe-profiles-container"></div><div class="vibe-footer"><div><button class="vibe-btn vibe-btn-ghost"onclick="window.vibeAI._exportConfig()">导出</button><button class="vibe-btn vibe-btn-ghost"onclick="window.vibeAI._importConfig()">导入</button></div><button class="vibe-btn vibe-btn-primary"onclick="window.vibeAI._closeModal()">保存并关闭</button></div></div>`;this._renderProfiles()}_renderProfiles(){const container=document.getElementById('vibe-profiles-container');container.innerHTML=this.config.profiles.map((p,i)=>`<div class="vibe-profile"data-index="${i}"><div class="vibe-profile-head"><input type="text"class="vibe-input vibe-name"placeholder="名称"value="${p.name}"oninput="window.vibeAI._updateConfig(${i}, 'name', this.value)"><input type="password"class="vibe-input vibe-key"placeholder="${this.config.isEncrypted ? '已加密' : 'API Key'}"value="${this.config.isEncrypted ? '' : p.apiKey}"oninput="window.vibeAI._updateKey(${i}, this.value)"><button class="vibe-btn vibe-btn-danger"onclick="window.vibeAI._removeProfile(${i})">删除</button></div><input type="text"class="vibe-input vibe-url"style="margin-bottom:10px;"placeholder="Base URL"value="${p.baseUrl}"oninput="window.vibeAI._updateConfig(${i}, 'baseUrl', this.value)"><div style="display:flex; gap:5px; flex-wrap:wrap;">${PRESETS.map(pre=>`<span class="vibe-badge"onclick="window.vibeAI._applyPreset(${i},'${pre.name}','${pre.url}')">${pre.name}</span>`).join('')}</div><div class="vibe-models-area"id="vibe-models-area-${i}"></div><button class="vibe-btn vibe-btn-ghost"style="width:100%; margin-top:10px"onclick="window.vibeAI._fetchModels(${i})">获取并校验资产</button></div>`).join('')+`<button class="vibe-btn vibe-btn-ghost"style="width:100%; border:2px dashed #ccc; padding:12px"onclick="window.vibeAI._addProfile()">+添加供应商</button>`;this.config.profiles.forEach((p,i)=>this._renderModelList(i))}_renderModelList(idx){const area=document.getElementById(`vibe-models-area-${idx}`);const p=this.config.profiles[idx];if(!area||!p)return;area.innerHTML=`${p.models.length>=10?`<div class="vibe-model-tools"><input type="text"class="vibe-input vibe-search"placeholder="在 ${p.models.length} 个模型中搜索..."oninput="window.vibeAI._onModelSearch(${idx}, this.value)"><button class="vibe-btn vibe-btn-ghost"style="padding:4px 8px"onclick="window.vibeAI._batchSelect(${idx}, true)">全选</button><button class="vibe-btn vibe-btn-ghost"style="padding:4px 8px"onclick="window.vibeAI._batchSelect(${idx}, false)">清空</button></div>`:''}<div class="vibe-models-grid">${p.models.map(m=>`<div class="vibe-tag ${m.selected ? 'active' : ''}"data-id="${m.id}"onclick="window.vibeAI._toggleModel(${idx}, '${m.id}', this)">${m.id}${m.status==='pass'?'✅':''}</div>`).join('')||'<div style="font-size:12px;color:#999">未获取模型资产</div>'}</div>`}_updateConfig(i,key,val){this.config.profiles[i][key]=val}async _updateKey(i,val){if(this.config.isEncrypted&&val){if(!this.sessionKey)this.sessionKey=await this._deriveKey(prompt("设置主密码:")||"");val="ENC:"+await this._encrypt(val)}this.config.profiles[i].apiKey=val}_applyPreset(idx,name,url){const p=this.config.profiles[idx];const dom=document.querySelector(`.vibe-profile[data-index="${idx}"]`);p.baseUrl=url;dom.querySelector('.vibe-url').value=url;if(p.name==='新供应商'||!p.name){p.name=name;dom.querySelector('.vibe-name').value=name}}async _fetchModels(idx){const p=this.config.profiles[idx];const area=document.getElementById(`vibe-models-area-${idx}`);area.innerHTML=`<div style="font-size:12px;color:#007AFF">正在连接终端并获取资产清单...</div>`;const fetchFn=async(url)=>{let key=p.apiKey;if(this.config.isEncrypted)key=await this._getDecryptedKey(key);const r=await fetch(`${url}/models`,{headers:{'Authorization':`Bearer ${key}`}});if(!r.ok){const errBody=await r.json().catch(()=>({}));throw new Error(errBody.error?.message||`HTTP ${r.status}`)}return r.json()};try{let res,base=p.baseUrl.replace(/\/+$/,'');try{res=await fetchFn(base)}catch(e){if(!base.toLowerCase().endsWith('/v1')){const fixed=base+'/v1';res=await fetchFn(fixed);p.baseUrl=fixed;document.querySelector(`.vibe-profile[data-index="${idx}"].vibe-url`).value=fixed}else throw e;}const sorted=res.data.sort((a,b)=>a.id.localeCompare(b.id));const autoSel=sorted.length<10;p.models=sorted.map(m=>({id:m.id,selected:autoSel,status:'pass'}));this._renderModelList(idx)}catch(e){area.innerHTML=`<div style="font-size:12px;color:#FF3B30;padding:8px;background:#FFF5F5;border-radius:6px;border:1px solid #FFD6D6">获取失败:${e.message}</div>`}}_onModelSearch(idx,val){const grid=document.querySelector(`.vibe-profile[data-index="${idx}"].vibe-models-grid`);const tags=grid.querySelectorAll('.vibe-tag');const q=val.toLowerCase();tags.forEach(t=>t.classList.toggle('vibe-hidden',!t.dataset.id.toLowerCase().includes(q)))}_batchSelect(idx,val){const p=this.config.profiles[idx];const dom=document.querySelector(`.vibe-profile[data-index="${idx}"]`);const searchInput=dom.querySelector('.vibe-search');const q=searchInput?searchInput.value.toLowerCase():'';p.models.forEach(m=>{if(m.id.toLowerCase().includes(q))m.selected=val});this._renderModelList(idx);const newSearchInput=dom.querySelector('.vibe-search');if(newSearchInput){newSearchInput.value=q;this._onModelSearch(idx,q)}}_toggleModel(pIdx,mId,el){const m=this.config.profiles[pIdx].models.find(x=>x.id===mId);if(m){m.selected=!m.selected;el.classList.toggle('active',m.selected)}}_addProfile(){this.config.profiles.push({id:Math.random().toString(36).slice(2,9),name:'新供应商',baseUrl:'',apiKey:'',models:[]});this._renderProfiles()}_removeProfile(idx){this.config.profiles.splice(idx,1);this._renderProfiles()}async _getDecryptedKey(encStr){if(!encStr.startsWith('ENC:'))return encStr;if(!this.sessionKey){const p=prompt("请输入主密码授权:");if(!p)throw new Error("Need Password");this.sessionKey=await this._deriveKey(p)}try{return await this._decrypt(encStr.replace('ENC:',''))}catch(e){this.sessionKey=null;throw new Error("密码解密失败");}}async _deriveKey(pwd){const enc=new TextEncoder();const mat=await crypto.subtle.importKey("raw",enc.encode(pwd),"PBKDF2",false,["deriveKey"]);return crypto.subtle.deriveKey({name:"PBKDF2",salt:enc.encode('vibe-v3'),iterations:100000,hash:"SHA-256"},mat,{name:"AES-GCM",length:256},false,["encrypt","decrypt"])}async _encrypt(text){const iv=crypto.getRandomValues(new Uint8Array(12));const enc=await crypto.subtle.encrypt({name:"AES-GCM",iv},this.sessionKey,new TextEncoder().encode(text));return btoa(JSON.stringify({iv:Array.from(iv),data:Array.from(new Uint8Array(enc))}))}async _decrypt(json){const{iv,data}=JSON.parse(atob(json));const dec=await crypto.subtle.decrypt({name:"AES-GCM",iv:new Uint8Array(iv)},this.sessionKey,new Uint8Array(data));return new TextDecoder().decode(dec)}async _toggleEncryption(){if(!this.config.isEncrypted){const p=prompt("请设置主密码(此密码用于加密本地 Key,不会上传服务器):");if(!p)return;this.sessionKey=await this._deriveKey(p);for(let x of this.config.profiles)if(x.apiKey&&!x.apiKey.startsWith('ENC:'))x.apiKey="ENC:"+await this._encrypt(x.apiKey);this.config.isEncrypted=true}else{if(confirm("关闭加密后 Key 将以明文存储,是否继续?")){for(let x of this.config.profiles)if(x.apiKey.startsWith('ENC:'))x.apiKey=await this._getDecryptedKey(x.apiKey);this.config.isEncrypted=false;this.sessionKey=null}}this.openSettings()}_load(){const r=localStorage.getItem(LOCAL_STORAGE_KEY_V3);if(r)this.config=JSON.parse(r)}_save(){localStorage.setItem(LOCAL_STORAGE_KEY_V3,JSON.stringify(this.config));this.boundSelects.forEach(id=>this._renderSelect(document.getElementById(id)))}_migrate(){const v2=localStorage.getItem(LOCAL_STORAGE_KEY_V2);if(v2){try{const old=JSON.parse(v2);this.config.profiles=old.profiles.map(p=>({id:Math.random().toString(36).slice(2,9),name:p.name,baseUrl:p.baseUrl,apiKey:p.apiKey,models:p.models.map(m=>({id:m,selected:true,status:'pass'}))}));localStorage.removeItem(LOCAL_STORAGE_KEY_V2);this._save()}catch(e){}}}_renderSelect(el){if(!el)return;const cur=this.config.instanceStates[el.id]||"";let h=`<option value="">选择模型...</option>`;this.config.profiles.forEach(p=>{const sel=p.models.filter(m=>m.selected);if(sel.length){h+=`<optgroup label="${p.name}">`;sel.forEach(m=>{const val=`${p.id}|${m.id}`;h+=`<option value="${val}"${val===cur?'selected':''}>${m.id}</option>`;});h+=`</optgroup>`}});el.innerHTML=h}_closeModal(){document.getElementById('vibe-modal').style.display='none';this._save()}_initStyle(){const s=document.createElement('style');s.textContent=STYLE;document.head.appendChild(s)}_exportConfig(){const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify(this.config,null,2)],{type:'application/json'}));a.download=`vibe-ai-config.json`;a.click()}_importConfig(){const i=document.createElement('input');i.type='file';i.onchange=(e)=>{const r=new FileReader();r.onload=(ev)=>{const d=JSON.parse(ev.target.result);if(d.version){this.config=d;this._save();this.openSettings()}};r.readAsText(e.target.files[0])};i.click()}async*_handleStream(res){const r=res.body.getReader();const dec=new TextDecoder();let buf='';while(true){const{done,value}=await r.read();if(done)break;buf+=dec.decode(value,{stream:true});const lines=buf.split('\n');buf=lines.pop();for(const l of lines){const s=l.trim();if(!s||s==='data: [DONE]')continue;if(s.startsWith('data: ')){try{const c=JSON.parse(s.slice(6)).choices[0]?.delta?.content;if(c)yield c}catch(e){}}}}}}export const vibeAI=new VibeAI();
|
package/LLM.txt
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
# VibeAI
|
|
2
|
-
|
|
3
|
-
VibeAI 是一个极简的、面向浏览器的多供应商 AI SDK 与 UI 管理工具。它允许开发者通过几行代码集成 OpenAI 兼容的 API,并提供内置的供应商管理、模型资产池选择以及自动连通性校验功能。
|
|
4
|
-
|
|
5
|
-
## 核心特性
|
|
6
|
-
|
|
7
|
-
- **多供应商管理**:支持 OpenAI, DeepSeek, Gemini, Qwen 等所有兼容 OpenAI 协议的供应商。
|
|
8
|
-
- **模型资产池 (Model Assets)**:用户可从数百个模型中多选常用模型,构建专属资产池。
|
|
9
|
-
- **自动校验**:后台静默进行连通性检查(✅/❌ 状态实时反馈)。
|
|
10
|
-
- **零依赖 ESM**:纯 JavaScript 实现,无任何外部库依赖。
|
|
11
|
-
- **持久化**:所有配置自动存储于浏览器的 `localStorage` 中。
|
|
12
|
-
|
|
13
|
-
## 快速开始
|
|
14
|
-
|
|
15
|
-
### 1. 引入与初始化
|
|
16
|
-
```javascript
|
|
17
|
-
import { vibeAI } from './vibe-ai.js';
|
|
18
|
-
|
|
19
|
-
// 初始化并绑定 UI 元素
|
|
20
|
-
vibeAI.init({
|
|
21
|
-
setupBtnId: 'settings-btn', // 点击此按钮打开管理弹窗
|
|
22
|
-
modelSelectId: 'model-select' // 自动填充选中的模型资产池
|
|
23
|
-
});
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
### 2. 发起对话请求
|
|
27
|
-
```javascript
|
|
28
|
-
// 支持流式传输 (Streaming)
|
|
29
|
-
const stream = await vibeAI.chat({
|
|
30
|
-
messages: [{ role: 'user', content: 'Hello!' }],
|
|
31
|
-
stream: true
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
for await (const chunk of stream) {
|
|
35
|
-
console.log(chunk); // 逐字输出
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// 也支持普通请求
|
|
39
|
-
const response = await vibeAI.chat({
|
|
40
|
-
messages: [{ role: 'user', content: 'Hello!' }]
|
|
41
|
-
});
|
|
42
|
-
console.log(response.choices[0].message.content);
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
## API 参考
|
|
46
|
-
|
|
47
|
-
### `vibeAI.init(config)`
|
|
48
|
-
- `setupBtnId`: (可选) 触发设置界面的 DOM ID。
|
|
49
|
-
- `modelSelectId`: (可选) 用于切换当前激活模型的 `<select>` 元素 ID。
|
|
50
|
-
|
|
51
|
-
### `vibeAI.chat(payload)`
|
|
52
|
-
- `payload`: 符合 OpenAI Chat Completion 格式的对象。
|
|
53
|
-
- `payload.stream`: 布尔值,为 true 时返回 `AsyncGenerator`。
|
|
54
|
-
- **注意**:如果未配置 API Key,调用时会自动弹出设置窗口。
|
|
55
|
-
|
|
56
|
-
### `vibeAI.openSettings()`
|
|
57
|
-
- 手动打开供应商管理界面。
|
|
58
|
-
|
|
59
|
-
## 配置规范
|
|
60
|
-
|
|
61
|
-
- **Base URL**: 通常需要包含版本号,例如 `https://api.deepseek.com/v1`。
|
|
62
|
-
- **Presets**: 提供了常用的 Base URL 快速填充(Badges)。
|
|
63
|
-
- **连通性标识**:
|
|
64
|
-
- `⏳`: 正在测试连通性
|
|
65
|
-
- `✅`: 校验通过
|
|
66
|
-
- `❌`: 请求失败或 Key 无效
|
|
67
|
-
|
|
68
|
-
## 开发建议
|
|
69
|
-
|
|
70
|
-
- 建议在 HTML 中预留一个空的 `<select id="model-select"></select>` 以获得最佳体验。
|
|
71
|
-
- 所有的配置都通过 `vibe_ai_v2_config` 键名存储在 `localStorage` 中。
|