diff --git a/README.md b/README.md
index 0667374..02e17ad 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
支持流式对话、多模态图片输入、文本/代码文件上传分析、Think 深度推理、RAG 本地知识库、历史记录管理,可安装为 PWA 离线使用。
-
+


@@ -28,6 +28,7 @@
| 连接检测 | 实时状态指示,CORS 问题自动提示 |
| 显存管理 | 一键卸载模型释放显存 |
| RAG 知识库 | 本地向量存储,文档上传自动分块,语义检索增强问答 |
+| Agent 预设 | 一键切换角色/工作模式(翻译官、代码审查、写作助手等),自动应用系统提示词+温度+Think |
| 使用帮助 | 内置帮助弹框,快速了解功能与快捷键 |
---
@@ -152,6 +153,71 @@ metona-ollama-vectors (IndexedDB)
---
+## 🤖 Agent 预设系统
+
+用户可以创建、保存多个"角色预设",每个预设包含系统提示词、温度、上下文长度、Think 开关等参数。**一键切换预设 = 切换人格/工作模式**,无需每次手动修改设置。
+
+### 工作流程
+
+```
+选择预设 → applyPresetToState() → 同步到全局 state + 设置面板 UI
+ ├── systemPrompt (系统提示词)
+ ├── temperature (温度 0~2)
+ ├── numCtx (上下文长度)
+ └── think (Think 推理开关)
+ ↓
+ 用户直接发消息 → 使用新预设配置调用 /api/chat
+```
+
+### 内置预设
+
+| 预设 | 图标 | 温度 | 上下文 | Think | 说明 |
+|------|------|------|--------|-------|------|
+| 默认 | 💬 | 0.7 | 24576 | ❌ | 通用对话,无系统提示词 |
+| 翻译官 | 🌐 | 0.3 | 16384 | ❌ | 专业翻译,自动识别语言,保持格式语气 |
+| 代码审查 | 🔍 | 0.2 | 32768 | ✅ | Bug/安全/性能/可读性五维审查,按严重程度排列 |
+| 写作助手 | ✍️ | 0.9 | 24576 | ❌ | 多文体创作、润色、风格调整,温度较高鼓励创意 |
+| 数据分析师 | 📊 | 0.4 | 32768 | ✅ | 统计分析、可视化建议、可执行洞察 |
+| 学习导师 | 🎓 | 0.6 | 24576 | ✅ | 苏格拉底式引导教学,由浅入深 |
+
+### 使用方式
+
+1. 模型选择栏下方显示 **预设栏**,点击胶囊按钮即可切换
+2. 点击右侧 **+** 按钮打开管理面板
+3. 创建自定义预设:填写名称、图标、系统提示词、温度、上下文长度、Think 开关
+4. 右键自定义预设胶囊可编辑/删除
+
+### 数据存储
+
+预设存储在 IndexedDB `settings` Store 中:
+
+```
+metona-ollama (IndexedDB)
+├── sessions # 聊天会话
+└── settings
+ ├── agentPresets # 用户自定义预设数组
+ ├── activePresetId # 当前激活预设 ID
+ ├── systemPrompt # 当前系统提示词
+ ├── systemPromptEnabled # 系统提示词开关
+ ├── numCtx # 上下文长度
+ └── temperature # 温度参数
+```
+
+### 核心模块
+
+| 模块 | 文件 | 职责 |
+|------|------|------|
+| 预设管理 | `js/preset-manager.js` | 预设 CRUD、内置预设定义、切换时应用配置 |
+| 预设栏 | `js/components/preset-bar.js` | 预设胶囊按钮 UI、管理模态框(新建/编辑/删除) |
+
+### 与设置面板的关系
+
+- 预设系统和设置面板 **双向同步**:切换预设自动更新设置面板;设置面板修改也会反映在当前预设状态
+- 预设是设置的 **快捷方式**,本质操作的是同一组参数(systemPrompt / numCtx / temperature / think)
+- 内置预设不可修改/删除,自定义预设完全可编辑
+
+---
+
## 🔌 Ollama API 调用清单
Metona 通过 `js/ollama-api.js` 封装了 Ollama REST API,以下是所有调用的接口:
@@ -266,6 +332,7 @@ metona-ollama/
├── rag.js # RAG 检索增强生成管线(嵌入、检索、上下文构造)
├── vector-store.js # 向量存储与余弦相似度检索(IndexedDB 持久化)
├── document-processor.js # 文档分块处理(段落/句子级,中英文兼容)
+ ├── preset-manager.js # Agent 预设管理(CRUD + 内置预设 + 切换应用)
├── lib/
│ └── marked.esm.js # marked.js v15 (Markdown 解析器)
└── components/
@@ -276,6 +343,7 @@ metona-ollama/
├── kb-modal.js # 知识库管理面板(集合创建、文档上传、进度展示)
├── header.js # 顶部导航、连接状态检测
├── model-bar.js # 模型选择栏、Think/Vision 能力检测
+ ├── preset-bar.js # 预设栏胶囊按钮、预设管理模态框
├── toast.js # Toast 通知组件
└── lightbox.js # 图片预览灯箱
```
@@ -326,6 +394,29 @@ metona-ollama/
## 📋 更新日志
+### v4.0.2
+
+- **修复切换预设时 Think 按钮显示异常** — 当模型不支持 Think 时,即使预设开启了 Think,按钮也不应显示激活态(蓝色渐变),保持禁用样式
+- 版本号升级至 4.0.2
+
+### v4.0.1
+
+- **设置面板新增温度滑块** — 在上下文长度下方新增 Temperature 滑块控制 (0~2, 步长0.1),实时显示当前值,保存到 IndexedDB 持久化
+- 切换预设时自动同步温度滑块 UI
+- 应用启动时从 DB 恢复温度设置
+- 版本号升级至 4.0.1
+
+### v4.0.0
+
+- **Agent 预设系统** — 用户可创建、保存多个角色预设,每个预设包含系统提示词、温度、上下文长度、Think 开关。一键切换预设 = 切换人格/工作模式
+ - 内置 6 个预设:💬 默认、🌐 翻译官、🔍 代码审查、✍️ 写作助手、📊 数据分析师、🎓 学习导师
+ - 预设栏胶囊按钮,点击即切换,自动应用全部配置(系统提示词 + temperature + num_ctx + think)
+ - 支持创建/编辑/删除自定义预设,IndexedDB 持久化
+ - 切换预设时同步设置面板 UI,无需手动修改
+ - 温度参数集成到 `/api/chat` 调用的 `options.temperature`
+- **新模块** — `js/preset-manager.js`(预设 CRUD + 6 个内置预设)、`js/components/preset-bar.js`(预设栏 UI + 管理模态框)
+- 版本号升级至 4.0.0
+
### v3.2.0
- **State 管理优化** — 新增 `shallowEqual` 结构化浅比较、`update()` 函数式更新、`setIn()` 路径式嵌套更新、`batch()` 批量更新 API,修复对象/数组变更无法触发监听器的问题
diff --git a/css/style.css b/css/style.css
index d3c3352..9790192 100644
--- a/css/style.css
+++ b/css/style.css
@@ -372,6 +372,166 @@ body::before {
border: 1px solid rgba(0, 245, 212, 0.2);
}
+/* ── 预设栏 ── */
+.preset-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 16px;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-glass);
+ flex-shrink: 0;
+ overflow: hidden;
+}
+
+.preset-list {
+ display: flex;
+ gap: 6px;
+ flex: 1;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ padding: 2px 0;
+}
+
+.preset-list::-webkit-scrollbar { display: none; }
+
+.preset-chip {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 5px 12px;
+ border-radius: 20px;
+ border: 1px solid var(--border-glass);
+ background: var(--bg-glass-light);
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-weight: 500;
+ font-family: var(--font-sans);
+ white-space: nowrap;
+ cursor: pointer;
+ transition: var(--transition);
+ flex-shrink: 0;
+ user-select: none;
+}
+
+.preset-chip:hover {
+ background: var(--bg-glass-hover);
+ border-color: rgba(0, 245, 212, 0.3);
+ color: var(--text-primary);
+ transform: translateY(-1px);
+}
+
+.preset-chip.active {
+ background: var(--gradient-main);
+ border-color: transparent;
+ color: #fff;
+ font-weight: 600;
+ box-shadow: 0 2px 10px rgba(0, 245, 212, 0.25);
+}
+
+.preset-chip.active:hover {
+ box-shadow: 0 4px 16px rgba(0, 245, 212, 0.35);
+ transform: translateY(-1px);
+}
+
+.preset-chip-icon {
+ font-size: 14px;
+ line-height: 1;
+}
+
+.preset-chip-name {
+ line-height: 1;
+}
+
+.preset-add-btn {
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ border: 1px dashed var(--border-glass);
+ background: transparent;
+ color: var(--text-muted);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: var(--transition);
+ flex-shrink: 0;
+}
+
+.preset-add-btn:hover {
+ border-color: var(--accent-cyan);
+ color: var(--accent-cyan);
+ background: rgba(0, 245, 212, 0.06);
+ transform: scale(1.1);
+}
+
+.preset-add-btn svg {
+ width: 16px;
+ height: 16px;
+}
+
+/* ── 预设模态框 ── */
+.preset-name-row {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.preset-icon-input {
+ width: 56px !important;
+ text-align: center;
+ font-size: 18px;
+ flex-shrink: 0;
+ padding: 8px 4px !important;
+}
+
+.preset-temp-slider {
+ width: 100%;
+ -webkit-appearance: none;
+ appearance: none;
+ height: 6px;
+ border-radius: 3px;
+ background: var(--bg-glass-light);
+ outline: none;
+ margin: 8px 0;
+}
+
+.preset-temp-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: var(--gradient-main);
+ cursor: pointer;
+ box-shadow: 0 2px 8px rgba(0, 245, 212, 0.3);
+ transition: transform 0.15s ease;
+}
+
+.preset-temp-slider::-webkit-slider-thumb:hover {
+ transform: scale(1.2);
+}
+
+.preset-temp-slider::-moz-range-thumb {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: var(--gradient-main);
+ cursor: pointer;
+ border: none;
+ box-shadow: 0 2px 8px rgba(0, 245, 212, 0.3);
+}
+
+.preset-modal-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ margin-top: 16px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border-glass);
+}
+
/* ── Think 按钮(输入栏内) ── */
.think-toggle {
display: flex;
diff --git a/index.html b/index.html
index a100612..87dd453 100644
--- a/index.html
+++ b/index.html
@@ -20,7 +20,7 @@
diff --git a/js/app.js b/js/app.js
index b1b5d1f..89d97b9 100644
--- a/js/app.js
+++ b/js/app.js
@@ -18,6 +18,8 @@ import { initInputArea } from './components/input-area.js';
import { initSettingsModal, closeSettingsModal } from './components/settings-modal.js';
import { initHistoryModal, closeHistoryModal } from './components/history-modal.js';
import { initKBModal } from './components/kb-modal.js';
+import { initPresetBar, initPresetModal } from './components/preset-bar.js';
+import { initPresetManager } from './preset-manager.js';
import { initVectorStore } from './rag.js';
function initHelpModal() {
@@ -33,7 +35,7 @@ function initHelpModal() {
});
}
-console.log('[Metona] 模块化版本 v3.2.0 ' + new Date().toISOString());
+console.log('[Metona] 模块化版本 v4.0.2 ' + new Date().toISOString());
// ── 会话管理 ──
@@ -115,6 +117,8 @@ async function init() {
initSettingsModal();
initHistoryModal();
initKBModal();
+ initPresetBar();
+ initPresetModal();
initHelpModal();
// 5. 绑定全局事件
@@ -127,6 +131,9 @@ async function init() {
// 6.1 初始化向量存储
await initVectorStore();
+ // 6.2 初始化预设管理器(会从 DB 加载并应用上次激活的预设)
+ await initPresetManager();
+
// 7. 恢复上次选择的模型
const savedModel = await db.getSetting('selectedModel', '');
if (savedModel) {
@@ -135,19 +142,23 @@ async function init() {
await db.saveSetting('selectedModel', savedModel);
}
- // 8. 恢复系统提示词和上下文长度
+ // 8. 恢复系统提示词、上下文长度、温度
const systemPromptEnabled = await db.getSetting('systemPromptEnabled', false);
const systemPrompt = await db.getSetting('systemPrompt', '');
const numCtx = await db.getSetting('numCtx', 24576);
+ const temperature = await db.getSetting('temperature', 0.7);
state.set(KEYS.SYSTEM_PROMPT_ENABLED, systemPromptEnabled);
state.set(KEYS.SYSTEM_PROMPT, systemPrompt);
state.set(KEYS.NUM_CTX, numCtx);
+ state.set('temperature', temperature);
document.querySelector('#toggleSystemPrompt').checked = systemPromptEnabled;
document.querySelector('#inputSystemPrompt').disabled = !systemPromptEnabled;
document.querySelector('#inputSystemPrompt').value = systemPrompt;
document.querySelector('#inputNumCtx').value = numCtx;
+ document.querySelector('#inputTemperature').value = temperature;
+ document.querySelector('#tempValue').textContent = parseFloat(temperature).toFixed(1);
// 9. 聚焦输入框
document.querySelector('#chatInput')?.focus();
@@ -173,6 +184,9 @@ async function init() {
initInputArea();
initSettingsModal();
initHistoryModal();
+ initPresetBar();
+ initPresetModal();
+ initPresetManager();
checkConnection().then(loadModels);
}
}
diff --git a/js/components/input-area.js b/js/components/input-area.js
index 65bb46a..857b573 100644
--- a/js/components/input-area.js
+++ b/js/components/input-area.js
@@ -402,12 +402,14 @@ export async function sendMessage() {
try {
const apiMessages = buildApiMessages(freshSession.messages);
+ const temperature = state.get('temperature', 0.7);
+
const chatParams = {
model,
messages: apiMessages,
think: thinkMode,
...(systemPromptEnabled && systemPrompt && { system: systemPrompt }),
- ...(numCtx && { options: { num_ctx: numCtx } })
+ ...(numCtx && { options: { num_ctx: numCtx, temperature } })
};
// RAG 检索增强
diff --git a/js/components/preset-bar.js b/js/components/preset-bar.js
new file mode 100644
index 0000000..132c415
--- /dev/null
+++ b/js/components/preset-bar.js
@@ -0,0 +1,212 @@
+/**
+ * PresetBar - 预设栏组件
+ *
+ * 在模型栏下方显示预设胶囊按钮列表,点击即切换
+ * 右侧"+"按钮打开管理面板
+ */
+
+import { state, KEYS } from '../state.js';
+import {
+ getPresets, getActivePresetId, activatePreset,
+ createPreset, updatePreset, deletePreset
+} from '../preset-manager.js';
+import { showToast } from './toast.js';
+
+let presetBarEl;
+let presetListEl;
+let editingPresetId = null;
+
+export function initPresetBar() {
+ // 创建预设栏 DOM
+ const modelBar = document.querySelector('.model-bar');
+ if (!modelBar) return;
+
+ const bar = document.createElement('div');
+ bar.className = 'preset-bar';
+ bar.innerHTML = `
+
+
+ `;
+ modelBar.after(bar);
+
+ presetBarEl = bar;
+ presetListEl = bar.querySelector('#presetList');
+
+ // 点击"+"打开管理面板
+ bar.querySelector('#btnAddPreset').addEventListener('click', openPresetModal);
+
+ // 监听预设变化,刷新 UI
+ state.on('activePresetId', renderPresetBar);
+ state.on('presets', renderPresetBar);
+
+ // 初始渲染
+ renderPresetBar();
+}
+
+/**
+ * 渲染预设胶囊列表
+ */
+function renderPresetBar() {
+ if (!presetListEl) return;
+
+ const presets = getPresets();
+ const activeId = getActivePresetId();
+
+ presetListEl.innerHTML = '';
+
+ for (const preset of presets) {
+ const chip = document.createElement('button');
+ chip.className = 'preset-chip' + (preset.id === activeId ? ' active' : '');
+ chip.title = preset.systemPrompt ? preset.systemPrompt.slice(0, 100) : '无系统提示词';
+ chip.innerHTML = `
${preset.icon}${escapeHtml(preset.name)}`;
+
+ chip.addEventListener('click', () => handlePresetClick(preset.id));
+ // 右键编辑(仅自定义)
+ chip.addEventListener('contextmenu', (e) => {
+ if (!preset.builtIn) {
+ e.preventDefault();
+ editingPresetId = preset.id;
+ openPresetModal(preset);
+ }
+ });
+
+ presetListEl.appendChild(chip);
+ }
+}
+
+async function handlePresetClick(presetId) {
+ const currentId = getActivePresetId();
+ if (presetId === currentId) return; // 已激活,不重复切换
+
+ await activatePreset(presetId);
+
+ const preset = getPresets().find(p => p.id === presetId);
+ if (preset) {
+ showToast(`已切换到 ${preset.icon} ${preset.name}`, 'success', 2000);
+ }
+}
+
+// ═══════════════════════════════════════
+// 预设管理模态框
+// ═══════════════════════════════════════
+
+function openPresetModal(editPreset) {
+ const modal = document.querySelector('#presetModal');
+ const titleEl = modal.querySelector('#presetModalTitle');
+ const nameInput = modal.querySelector('#inputPresetName');
+ const iconInput = modal.querySelector('#inputPresetIcon');
+ const spInput = modal.querySelector('#inputPresetSystemPrompt');
+ const tempInput = modal.querySelector('#inputPresetTemp');
+ const tempVal = modal.querySelector('#presetTempValue');
+ const ctxInput = modal.querySelector('#inputPresetNumCtx');
+ const thinkCheck = modal.querySelector('#togglePresetThink');
+ const btnSave = modal.querySelector('#btnSavePreset');
+ const btnDelete = modal.querySelector('#btnDeletePreset');
+
+ if (editPreset && editPreset.id) {
+ // 编辑模式
+ editingPresetId = editPreset.id;
+ titleEl.textContent = '✏️ 编辑预设';
+ nameInput.value = editPreset.name;
+ iconInput.value = editPreset.icon;
+ spInput.value = editPreset.systemPrompt;
+ tempInput.value = editPreset.temperature ?? 0.7;
+ tempVal.textContent = (editPreset.temperature ?? 0.7).toFixed(1);
+ ctxInput.value = editPreset.numCtx || 24576;
+ thinkCheck.checked = editPreset.think ?? false;
+ btnDelete.style.display = editPreset.builtIn ? 'none' : '';
+ btnSave.textContent = '保存';
+ } else {
+ // 新建模式
+ editingPresetId = null;
+ titleEl.textContent = '✨ 新建预设';
+ nameInput.value = '';
+ iconInput.value = '🤖';
+ spInput.value = '';
+ tempInput.value = 0.7;
+ tempVal.textContent = '0.7';
+ ctxInput.value = 24576;
+ thinkCheck.checked = false;
+ btnDelete.style.display = 'none';
+ btnSave.textContent = '创建';
+ }
+
+ modal.style.display = '';
+ nameInput.focus();
+
+ // 温度滑块实时更新
+ tempInput.oninput = () => {
+ tempVal.textContent = parseFloat(tempInput.value).toFixed(1);
+ };
+}
+
+export function initPresetModal() {
+ const modal = document.querySelector('#presetModal');
+ if (!modal) return;
+
+ // 关闭
+ modal.querySelector('#btnClosePresetModal').addEventListener('click', () => {
+ modal.style.display = 'none';
+ });
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) modal.style.display = 'none';
+ });
+
+ // 保存
+ modal.querySelector('#btnSavePreset').addEventListener('click', async () => {
+ const name = modal.querySelector('#inputPresetName').value.trim();
+ if (!name) {
+ showToast('请输入预设名称', 'warning');
+ return;
+ }
+
+ const data = {
+ name,
+ icon: modal.querySelector('#inputPresetIcon').value.trim() || '🤖',
+ systemPrompt: modal.querySelector('#inputPresetSystemPrompt').value.trim(),
+ temperature: parseFloat(modal.querySelector('#inputPresetTemp').value) || 0.7,
+ numCtx: parseInt(modal.querySelector('#inputPresetNumCtx').value) || 24576,
+ think: modal.querySelector('#togglePresetThink').checked
+ };
+
+ if (editingPresetId) {
+ await updatePreset(editingPresetId, data);
+ showToast('预设已更新', 'success', 2000);
+ // 如果更新的是当前激活预设,重新应用
+ if (editingPresetId === getActivePresetId()) {
+ await activatePreset(editingPresetId);
+ }
+ } else {
+ const created = await createPreset(data);
+ // 自动激活新建的预设
+ await activatePreset(created.id);
+ showToast(`已创建并激活 ${data.icon} ${data.name}`, 'success', 2000);
+ }
+
+ modal.style.display = 'none';
+ editingPresetId = null;
+ });
+
+ // 删除
+ modal.querySelector('#btnDeletePreset').addEventListener('click', async () => {
+ if (!editingPresetId) return;
+ const preset = getPresets().find(p => p.id === editingPresetId);
+ if (!preset) return;
+
+ if (confirm(`确定删除预设 "${preset.icon} ${preset.name}" ?`)) {
+ await deletePreset(editingPresetId);
+ showToast('预设已删除', 'info', 2000);
+ modal.style.display = 'none';
+ editingPresetId = null;
+ }
+ });
+}
+
+function escapeHtml(str) {
+ const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+ return String(str).replace(/[&<>"']/g, c => map[c]);
+}
diff --git a/js/components/settings-modal.js b/js/components/settings-modal.js
index 2850296..d1a9a37 100644
--- a/js/components/settings-modal.js
+++ b/js/components/settings-modal.js
@@ -62,6 +62,21 @@ export function initSettingsModal() {
}, 500);
document.querySelector('#inputNumCtx').addEventListener('input', saveNumCtx);
+ // 温度滑块
+ const tempSlider = document.querySelector('#inputTemperature');
+ const tempDisplay = document.querySelector('#tempValue');
+ tempSlider.addEventListener('input', () => {
+ const val = parseFloat(tempSlider.value);
+ tempDisplay.textContent = val.toFixed(1);
+ });
+ const saveTemperature = debounce(async () => {
+ const db = state.get(KEYS.DB);
+ const val = parseFloat(tempSlider.value);
+ state.set('temperature', val);
+ if (db) await db.saveSetting('temperature', val);
+ }, 300);
+ tempSlider.addEventListener('change', saveTemperature);
+
// 释放显存
document.querySelector('#btnReleaseVRAM').addEventListener('click', async () => {
const api = state.get(KEYS.API);
diff --git a/js/preset-manager.js b/js/preset-manager.js
new file mode 100644
index 0000000..7b63762
--- /dev/null
+++ b/js/preset-manager.js
@@ -0,0 +1,319 @@
+/**
+ * PresetManager - Agent 预设管理
+ *
+ * 预设数据结构:
+ * {
+ * id: string,
+ * name: string, // 预设名称,如 "翻译官"
+ * icon: string, // emoji 图标
+ * systemPrompt: string, // 系统提示词
+ * temperature: number, // 温度 (0~2)
+ * numCtx: number, // 上下文长度
+ * think: boolean, // Think 开关
+ * createdAt: number,
+ * updatedAt: number,
+ * }
+ */
+
+import { state, KEYS } from './state.js';
+import { generateId } from './utils.js';
+
+const PRESETS_KEY = 'agentPresets';
+const ACTIVE_PRESET_KEY = 'activePresetId';
+
+// ── 内置预设 ──
+const BUILT_IN_PRESETS = [
+ {
+ id: '_default',
+ name: '默认',
+ icon: '💬',
+ systemPrompt: '',
+ temperature: 0.7,
+ numCtx: 24576,
+ think: false,
+ builtIn: true,
+ createdAt: 0,
+ updatedAt: 0
+ },
+ {
+ id: '_translator',
+ name: '翻译官',
+ icon: '🌐',
+ systemPrompt: '你是一位专业翻译官。你的任务是将用户输入的内容准确翻译为目标语言。\n\n规则:\n1. 自动识别源语言,翻译为用户指定的目标语言(如未指定则翻译为中文)\n2. 保持原文的格式、语气和风格\n3. 专有名词保留原文并给出通用译名\n4. 对于有多种含义的词语,结合上下文选择最合适的翻译\n5. 直接输出翻译结果,不需要额外解释(除非用户要求)',
+ temperature: 0.3,
+ numCtx: 16384,
+ think: false,
+ builtIn: true,
+ createdAt: 0,
+ updatedAt: 0
+ },
+ {
+ id: '_code_review',
+ name: '代码审查',
+ icon: '🔍',
+ systemPrompt: '你是一位资深代码审查专家。对用户提交的代码进行全面审查。\n\n审查维度:\n1. **Bug 与逻辑错误** — 潜在的运行时错误、边界条件、空指针等\n2. **安全性** — SQL注入、XSS、敏感信息泄露等安全隐患\n3. **性能** — 时间/空间复杂度、不必要的计算、内存泄漏\n4. **可读性** — 命名规范、代码结构、注释质量\n5. **最佳实践** — 设计模式、语言惯用法、框架约定\n\n输出格式:\n- 按严重程度排列问题(严重 → 轻微)\n- 每个问题给出:位置、问题描述、修复建议、改进后的代码\n- 最后给出整体评价和改进建议',
+ temperature: 0.2,
+ numCtx: 32768,
+ think: true,
+ builtIn: true,
+ createdAt: 0,
+ updatedAt: 0
+ },
+ {
+ id: '_writer',
+ name: '写作助手',
+ icon: '✍️',
+ systemPrompt: '你是一位才华横溢的写作助手。擅长多种文体和写作风格。\n\n能力范围:\n1. 文章撰写 — 议论文、说明文、散文、故事\n2. 文案创作 — 广告文案、产品描述、社交媒体文案\n3. 内容润色 — 修改语法、优化措辞、提升表达力\n4. 风格调整 — 正式/口语/文艺/简洁等多种风格\n5. 创意写作 — 小说、诗歌、剧本、对话\n\n工作方式:\n- 仔细理解用户的写作需求(读者、用途、风格偏好)\n- 如需信息不全,主动询问确认\n- 提供多种版本供选择\n- 解释重要的写作决策',
+ temperature: 0.9,
+ numCtx: 24576,
+ think: false,
+ builtIn: true,
+ createdAt: 0,
+ updatedAt: 0
+ },
+ {
+ id: '_analyst',
+ name: '数据分析师',
+ icon: '📊',
+ systemPrompt: '你是一位专业的数据分析师。擅长从数据中提取洞察并给出可执行的建议。\n\n分析方法:\n1. 数据概览 — 数据量、字段类型、缺失值、异常值\n2. 统计分析 — 描述性统计、相关性分析、趋势分析\n3. 可视化建议 — 推荐合适的图表类型和展示方式\n4. 洞察提取 — 关键发现、模式识别、因果推断\n5. 行动建议 — 基于数据的具体可执行建议\n\n输出格式:\n- 先给出核心结论(Executive Summary)\n- 再展开详细分析过程\n- 使用表格和列表组织数据\n- 给出代码(Python/SQL)时附带注释',
+ temperature: 0.4,
+ numCtx: 32768,
+ think: true,
+ builtIn: true,
+ createdAt: 0,
+ updatedAt: 0
+ },
+ {
+ id: '_tutor',
+ name: '学习导师',
+ icon: '🎓',
+ systemPrompt: '你是一位耐心且善于启发的学习导师。用苏格拉底式教学法帮助用户深入理解知识。\n\n教学原则:\n1. **先问后答** — 通过引导性问题帮助用户自己找到答案\n2. **由浅入深** — 从基础概念开始,逐步构建知识体系\n3. **类比教学** — 用生活中的类比解释抽象概念\n4. **实践导向** — 每个知识点配一个动手练习\n5. **鼓励探索** — 提供延伸阅读方向和思考题\n\n当用户提问时:\n- 先评估用户当前理解水平\n- 用引导性问题帮助思考\n- 如果用户确实卡住,再给出答案并解释思路\n- 每个概念学完后给出一个小练习',
+ temperature: 0.6,
+ numCtx: 24576,
+ think: true,
+ builtIn: true,
+ createdAt: 0,
+ updatedAt: 0
+ }
+];
+
+let presets = [];
+let activePresetId = null;
+
+/**
+ * 初始化预设管理器
+ */
+export async function initPresetManager() {
+ const db = state.get(KEYS.DB);
+
+ // 从 DB 加载用户自定义预设
+ let userPresets = [];
+ if (db) {
+ userPresets = await db.getSetting(PRESETS_KEY, []);
+ }
+
+ // 合并:内置 + 用户自定义
+ presets = [...BUILT_IN_PRESETS, ...userPresets];
+
+ // 加载上次激活的预设
+ if (db) {
+ activePresetId = await db.getSetting(ACTIVE_PRESET_KEY, '_default');
+ } else {
+ activePresetId = '_default';
+ }
+
+ // 写入 state
+ state.set('presets', presets);
+ state.set('activePresetId', activePresetId);
+
+ // 应用当前预设(恢复设置)
+ const activePreset = presets.find(p => p.id === activePresetId);
+ if (activePreset && activePreset.id !== '_default') {
+ applyPresetToState(activePreset, false); // 不再 DB,已在 init 阶段加载
+ }
+}
+
+/**
+ * 获取所有预设
+ */
+export function getPresets() {
+ return presets;
+}
+
+/**
+ * 获取当前激活预设
+ */
+export function getActivePreset() {
+ return presets.find(p => p.id === activePresetId) || presets[0];
+}
+
+/**
+ * 获取当前激活预设 ID
+ */
+export function getActivePresetId() {
+ return activePresetId;
+}
+
+/**
+ * 激活预设
+ */
+export async function activatePreset(presetId) {
+ const preset = presets.find(p => p.id === presetId);
+ if (!preset) return;
+
+ activePresetId = presetId;
+ state.set('activePresetId', presetId);
+
+ applyPresetToState(preset, true);
+
+ const db = state.get(KEYS.DB);
+ if (db) {
+ await db.saveSetting(ACTIVE_PRESET_KEY, presetId);
+ }
+}
+
+/**
+ * 将预设配置应用到全局 state
+ */
+function applyPresetToState(preset, saveToDb) {
+ const db = state.get(KEYS.DB);
+
+ // 系统提示词
+ if (preset.systemPrompt) {
+ state.set(KEYS.SYSTEM_PROMPT_ENABLED, true);
+ state.set(KEYS.SYSTEM_PROMPT, preset.systemPrompt);
+ } else {
+ state.set(KEYS.SYSTEM_PROMPT_ENABLED, false);
+ state.set(KEYS.SYSTEM_PROMPT, '');
+ }
+
+ // 上下文长度
+ if (preset.numCtx) {
+ state.set(KEYS.NUM_CTX, preset.numCtx);
+ }
+
+ // 温度(存储到 state,聊天时使用)
+ state.set('temperature', preset.temperature ?? 0.7);
+
+ // Think 开关
+ state.set('thinkEnabled', preset.think ?? false);
+
+ // 同步到 UI
+ syncPresetToUI(preset);
+
+ // 持久化
+ if (saveToDb && db) {
+ db.saveSetting('systemPromptEnabled', preset.systemPrompt ? true : false);
+ db.saveSetting('systemPrompt', preset.systemPrompt || '');
+ db.saveSetting('numCtx', preset.numCtx || 24576);
+ db.saveSetting('temperature', preset.temperature ?? 0.7);
+ }
+}
+
+/**
+ * 将预设配置同步到设置面板 UI
+ */
+function syncPresetToUI(preset) {
+ // 系统提示词
+ const toggleSP = document.querySelector('#toggleSystemPrompt');
+ const inputSP = document.querySelector('#inputSystemPrompt');
+ if (toggleSP) toggleSP.checked = !!preset.systemPrompt;
+ if (inputSP) {
+ inputSP.disabled = !preset.systemPrompt;
+ inputSP.value = preset.systemPrompt || '';
+ }
+
+ // numCtx
+ const inputCtx = document.querySelector('#inputNumCtx');
+ if (inputCtx) inputCtx.value = preset.numCtx || 24576;
+
+ // Temperature
+ const tempSlider = document.querySelector('#inputTemperature');
+ const tempDisplay = document.querySelector('#tempValue');
+ if (tempSlider) tempSlider.value = preset.temperature ?? 0.7;
+ if (tempDisplay) tempDisplay.textContent = (preset.temperature ?? 0.7).toFixed(1);
+
+ // Think toggle — 仅在模型支持时才设为 checked,否则保持 unchecked
+ const thinkCheckbox = document.querySelector('#toggleThink');
+ if (thinkCheckbox) {
+ const modelSupportsThink = !thinkCheckbox.disabled;
+ thinkCheckbox.checked = modelSupportsThink && (preset.think ?? false);
+ }
+}
+
+/**
+ * 创建预设
+ */
+export async function createPreset({ name, icon, systemPrompt, temperature, numCtx, think }) {
+ const preset = {
+ id: `preset_${generateId()}`,
+ name: name || '新预设',
+ icon: icon || '🤖',
+ systemPrompt: systemPrompt || '',
+ temperature: temperature ?? 0.7,
+ numCtx: numCtx ?? 24576,
+ think: think ?? false,
+ createdAt: Date.now(),
+ updatedAt: Date.now()
+ };
+
+ presets.push(preset);
+ state.set('presets', [...presets]);
+
+ await saveUserPresets();
+ return preset;
+}
+
+/**
+ * 更新预设
+ */
+export async function updatePreset(presetId, updates) {
+ const idx = presets.findIndex(p => p.id === presetId);
+ if (idx === -1) return;
+
+ // 内置预设不允许修改
+ if (presets[idx].builtIn) return;
+
+ presets[idx] = { ...presets[idx], ...updates, updatedAt: Date.now() };
+ state.set('presets', [...presets]);
+
+ // 如果更新的是当前激活预设,重新应用
+ if (activePresetId === presetId) {
+ applyPresetToState(presets[idx], true);
+ }
+
+ await saveUserPresets();
+}
+
+/**
+ * 删除预设
+ */
+export async function deletePreset(presetId) {
+ const idx = presets.findIndex(p => p.id === presetId);
+ if (idx === -1) return;
+ if (presets[idx].builtIn) return; // 内置不可删
+
+ presets.splice(idx, 1);
+ state.set('presets', [...presets]);
+
+ // 如果删除的是当前激活预设,切回默认
+ if (activePresetId === presetId) {
+ await activatePreset('_default');
+ }
+
+ await saveUserPresets();
+}
+
+/**
+ * 保存用户自定义预设到 IndexedDB
+ */
+async function saveUserPresets() {
+ const db = state.get(KEYS.DB);
+ if (!db) return;
+
+ const userPresets = presets
+ .filter(p => !p.builtIn)
+ .map(({ builtIn, ...rest }) => rest); // 去掉 builtIn 标记
+
+ await db.saveSetting(PRESETS_KEY, userPresets);
+}