feat: v4.2 智能进化 — 技能自动生成 + 用户画像 + 人格模板
新增功能: 1. 技能自动生成系统 (Skill Loop) - 新增 skills 表:id/name/description/trigger_keywords/tool_chain/success_count/fail_count - skill-manager.ts: 从成功工具调用链自动提取可复用技能 - 关键词匹配 + 成功率加权的技能检索 - 匹配到的技能自动注入 system prompt(含工具链步骤和成功率) - Agent Loop 结束后自动提取,开始前自动匹配注入 - DB/IPC/Preload 全链路支持 2. 用户画像模型 - 自动从对话中推断技术栈(14 种技术关键词检测) - 画像存储在 settings.user_profile - 自动注入 system prompt(技术栈/角色/风格) - 随使用不断积累完善 3. 人格模板切换(6 种预设) - 🤖 通用助手(默认,温度跟随设置) - 💻 编程助手(温度 0.3,注重代码质量) - ✍️ 写作助手(温度 0.8,注重文采流畅) - 📊 数据分析(温度 0.2,注重精确逻辑) - 🌐 翻译助手(温度 0.4,注重信达雅) - 🔍 代码审查(温度 0.3,注重安全性能) - 设置面板 3×2 网格按钮,点击切换 + 自动调整温度 修改文件:9 个已有 + 1 个新建(skill-manager.ts)
This commit is contained in:
@@ -203,6 +203,22 @@ export async function initDatabase(): Promise<SQL.Database> {
|
|||||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_traces_session ON traces(session_id, created_at);
|
CREATE INDEX IF NOT EXISTS idx_traces_session ON traces(session_id, created_at);
|
||||||
|
|
||||||
|
-- v4.2 技能表(自动从成功工具调用链提取)
|
||||||
|
CREATE TABLE IF NOT EXISTS skills (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
trigger_keywords TEXT,
|
||||||
|
tool_chain TEXT NOT NULL,
|
||||||
|
success_count INTEGER DEFAULT 0,
|
||||||
|
fail_count INTEGER DEFAULT 0,
|
||||||
|
avg_duration_ms INTEGER DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
last_used_at INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_skills_updated ON skills(updated_at DESC);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// 尝试创建 FTS5 全文搜索(可选,sql.js 默认 WASM 可能不包含 FTS5)
|
// 尝试创建 FTS5 全文搜索(可选,sql.js 默认 WASM 可能不包含 FTS5)
|
||||||
@@ -286,6 +302,21 @@ export interface ToolCallRow {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v4.2
|
||||||
|
export interface SkillRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
trigger_keywords: string | null;
|
||||||
|
tool_chain: string;
|
||||||
|
success_count: number;
|
||||||
|
fail_count: number;
|
||||||
|
avg_duration_ms: number;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
last_used_at: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TraceRow {
|
export interface TraceRow {
|
||||||
id: string;
|
id: string;
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -487,6 +518,65 @@ export function getTracesBySession(sessionId: string): TraceRow[] {
|
|||||||
return queryAll(getDb(), 'SELECT * FROM traces WHERE session_id = ? ORDER BY step_index ASC', [sessionId]) as unknown as TraceRow[];
|
return queryAll(getDb(), 'SELECT * FROM traces WHERE session_id = ? ORDER BY step_index ASC', [sessionId]) as unknown as TraceRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Skills CRUD (v4.2) ───
|
||||||
|
|
||||||
|
export function saveSkill(skill: SkillRow): string {
|
||||||
|
runExec(getDb(), `INSERT OR REPLACE INTO skills (id, name, description, trigger_keywords, tool_chain, success_count, fail_count, avg_duration_ms, created_at, updated_at, last_used_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[skill.id, skill.name, skill.description, skill.trigger_keywords, skill.tool_chain,
|
||||||
|
skill.success_count, skill.fail_count, skill.avg_duration_ms,
|
||||||
|
skill.created_at, skill.updated_at, skill.last_used_at]
|
||||||
|
);
|
||||||
|
persist();
|
||||||
|
return skill.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSkill(id: string): SkillRow | null {
|
||||||
|
return queryOne(getDb(), 'SELECT * FROM skills WHERE id = ?', [id]) as unknown as SkillRow | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllSkills(): SkillRow[] {
|
||||||
|
return queryAll(getDb(), 'SELECT * FROM skills ORDER BY success_count DESC, updated_at DESC') as unknown as SkillRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSkill(id: string): void {
|
||||||
|
runExec(getDb(), 'DELETE FROM skills WHERE id = ?', [id]);
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllSkills(): void {
|
||||||
|
getDb().run('DELETE FROM skills');
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchSkills(keywords: string, limit = 5): SkillRow[] {
|
||||||
|
const likeQ = `%${keywords.replace(/[^\w\u4e00-\u9fff]/g, '%')}%`;
|
||||||
|
return queryAll(getDb(), `
|
||||||
|
SELECT * FROM skills
|
||||||
|
WHERE trigger_keywords LIKE ? OR name LIKE ? OR description LIKE ?
|
||||||
|
ORDER BY success_count DESC, updated_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`, [likeQ, likeQ, likeQ, limit]) as unknown as SkillRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function incrementSkillUsage(id: string, success: boolean, durationMs: number): void {
|
||||||
|
const d = getDb();
|
||||||
|
const skill = queryOne(d, 'SELECT success_count, fail_count, avg_duration_ms FROM skills WHERE id = ?', [id]) as any;
|
||||||
|
if (!skill) return;
|
||||||
|
|
||||||
|
const newSuccess = success ? skill.success_count + 1 : skill.success_count;
|
||||||
|
const newFail = success ? skill.fail_count : skill.fail_count + 1;
|
||||||
|
const totalUses = newSuccess + newFail;
|
||||||
|
// 指数移动平均
|
||||||
|
const alpha = 0.3;
|
||||||
|
const newAvg = Math.round(alpha * durationMs + (1 - alpha) * (skill.avg_duration_ms || durationMs));
|
||||||
|
|
||||||
|
runExec(d, `UPDATE skills SET success_count = ?, fail_count = ?, avg_duration_ms = ?, updated_at = ?, last_used_at = ? WHERE id = ?`,
|
||||||
|
[newSuccess, newFail, newAvg, Date.now(), Date.now(), id]
|
||||||
|
);
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Export/Import ───
|
// ─── Export/Import ───
|
||||||
|
|
||||||
export interface ExportData {
|
export interface ExportData {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
saveSetting, getSetting,
|
saveSetting, getSetting,
|
||||||
saveToolCall, getToolCallsBySession,
|
saveToolCall, getToolCallsBySession,
|
||||||
saveTrace, getTracesBySession,
|
saveTrace, getTracesBySession,
|
||||||
|
saveSkill, getAllSkills, deleteSkill, clearAllSkills, searchSkills, incrementSkillUsage,
|
||||||
exportAllSessions, importSessions
|
exportAllSessions, importSessions
|
||||||
} from './db/sqlite.js';
|
} from './db/sqlite.js';
|
||||||
import type { ExportData } from './db/sqlite.js';
|
import type { ExportData } from './db/sqlite.js';
|
||||||
@@ -371,4 +372,30 @@ export async function setupIPC(): Promise<void> {
|
|||||||
try { return importSessions(data); }
|
try { return importSessions(data); }
|
||||||
catch (err) { sendLog('error', '导入失败', (err as Error).message); return { imported: 0, skipped: 0 }; }
|
catch (err) { sendLog('error', '导入失败', (err as Error).message); return { imported: 0, skipped: 0 }; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Skills (v4.2) ──
|
||||||
|
ipcMain.handle('db:saveSkill', (_, skill) => {
|
||||||
|
try { return { success: true, id: saveSkill(skill) }; }
|
||||||
|
catch (err) { return { success: false, error: (err as Error).message }; }
|
||||||
|
});
|
||||||
|
ipcMain.handle('db:getAllSkills', () => {
|
||||||
|
try { return getAllSkills(); }
|
||||||
|
catch { return []; }
|
||||||
|
});
|
||||||
|
ipcMain.handle('db:deleteSkill', (_, id) => {
|
||||||
|
try { deleteSkill(id); return { success: true }; }
|
||||||
|
catch (err) { return { success: false, error: (err as Error).message }; }
|
||||||
|
});
|
||||||
|
ipcMain.handle('db:clearAllSkills', () => {
|
||||||
|
try { clearAllSkills(); return { success: true }; }
|
||||||
|
catch (err) { return { success: false, error: (err as Error).message }; }
|
||||||
|
});
|
||||||
|
ipcMain.handle('db:searchSkills', (_, query: string, limit?: number) => {
|
||||||
|
try { return searchSkills(query, limit ?? 5); }
|
||||||
|
catch { return []; }
|
||||||
|
});
|
||||||
|
ipcMain.handle('db:incrementSkillUsage', (_, id: string, success: boolean, durationMs: number) => {
|
||||||
|
try { incrementSkillUsage(id, success, durationMs); return { success: true }; }
|
||||||
|
catch (err) { return { success: false, error: (err as Error).message }; }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ contextBridge.exposeInMainWorld('metonaDesktop', {
|
|||||||
getTraces: (sessionId: string) => ipcRenderer.invoke('db:getTraces', sessionId),
|
getTraces: (sessionId: string) => ipcRenderer.invoke('db:getTraces', sessionId),
|
||||||
exportSessions: () => ipcRenderer.invoke('db:exportSessions'),
|
exportSessions: () => ipcRenderer.invoke('db:exportSessions'),
|
||||||
importSessions: (data: unknown) => ipcRenderer.invoke('db:importSessions', data),
|
importSessions: (data: unknown) => ipcRenderer.invoke('db:importSessions', data),
|
||||||
|
// v4.2 Skills
|
||||||
|
saveSkill: (skill: unknown) => ipcRenderer.invoke('db:saveSkill', skill),
|
||||||
|
getAllSkills: () => ipcRenderer.invoke('db:getAllSkills'),
|
||||||
|
deleteSkill: (id: string) => ipcRenderer.invoke('db:deleteSkill', id),
|
||||||
|
clearAllSkills: () => ipcRenderer.invoke('db:clearAllSkills'),
|
||||||
|
searchSkills: (query: string, limit?: number) => ipcRenderer.invoke('db:searchSkills', query, limit),
|
||||||
|
incrementSkillUsage: (id: string, success: boolean, durationMs: number) => ipcRenderer.invoke('db:incrementSkillUsage', id, success, durationMs),
|
||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
getDir: () => ipcRenderer.invoke('workspace:getDir'),
|
getDir: () => ipcRenderer.invoke('workspace:getDir'),
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ import { OllamaAPI } from '../api/ollama.js';
|
|||||||
import { ChatDB } from '../db/chat-db.js';
|
import { ChatDB } from '../db/chat-db.js';
|
||||||
import type { ChatSession } from '../types.js';
|
import type { ChatSession } from '../types.js';
|
||||||
|
|
||||||
|
/** v4.2 人格模式 system prompt 映射 */
|
||||||
|
export const PERSONALITY_SYSTEM_PROMPTS: Record<string, string> = {
|
||||||
|
default: '',
|
||||||
|
coder: '你是一个专业的编程助手。你注重代码质量、可读性和最佳实践。回答时优先给出可运行的代码,其次才是解释。遇到问题先分析根因再给方案。使用 type hints、合理的命名和注释。代码风格偏向简洁紧凑。',
|
||||||
|
writer: '你是一个专业的写作助手。你的文字流畅、有文采,注重结构和节奏。擅长技术文档、文章、文案、邮件等各种文体。回答时先理解写作目的和受众,再给出合适的风格和内容。',
|
||||||
|
analyst: '你是一个精确的数据分析助手。你注重数据的准确性和逻辑的严密性。回答时先明确数据来源和假设,再给出分析过程和结论。偏好用数字和事实说话,避免模糊表述。',
|
||||||
|
translator: '你是一个专业的翻译助手。你精通多语言互译,注重信达雅。翻译时保留原文的语气和风格,专业术语保持一致。遇到歧义时给出多种翻译并标注区别。',
|
||||||
|
reviewer: '你是一个严格的代码审查助手。你关注:正确性(逻辑错误、边界情况)、安全性(注入、泄露)、性能(时间/空间复杂度)、可维护性(命名、结构、注释)。审查时先给出总体评价,再逐项列出问题并给出修复建议。',
|
||||||
|
};
|
||||||
|
|
||||||
let settingsModalEl: HTMLElement;
|
let settingsModalEl: HTMLElement;
|
||||||
|
|
||||||
export function initSettingsModal(): void {
|
export function initSettingsModal(): void {
|
||||||
@@ -63,6 +73,50 @@ export function initSettingsModal(): void {
|
|||||||
}, 300);
|
}, 300);
|
||||||
tempSlider.addEventListener('change', saveTemperature);
|
tempSlider.addEventListener('change', saveTemperature);
|
||||||
|
|
||||||
|
// ── v4.2 人格模式切换 ──
|
||||||
|
const PERSONALITY_TEMPERATURES: Record<string, number> = {
|
||||||
|
default: -1, // 使用用户设置
|
||||||
|
coder: 0.3,
|
||||||
|
writer: 0.8,
|
||||||
|
analyst: 0.2,
|
||||||
|
translator: 0.4,
|
||||||
|
reviewer: 0.3,
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll('.personality-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const personality = (btn as HTMLElement).dataset.personality || 'default';
|
||||||
|
state.set('personality', personality);
|
||||||
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
|
if (db) await db.saveSetting('personality', personality);
|
||||||
|
|
||||||
|
// 更新按钮样式
|
||||||
|
document.querySelectorAll('.personality-btn').forEach(b => {
|
||||||
|
(b as HTMLElement).style.border = '2px solid var(--border-subtle)';
|
||||||
|
b.classList.remove('active');
|
||||||
|
});
|
||||||
|
(btn as HTMLElement).style.border = '2px solid var(--primary)';
|
||||||
|
btn.classList.add('active');
|
||||||
|
|
||||||
|
// 如果人格有推荐温度,自动调整
|
||||||
|
const recommendedTemp = PERSONALITY_TEMPERATURES[personality];
|
||||||
|
if (recommendedTemp >= 0) {
|
||||||
|
const tempSlider = document.querySelector('#inputTemperature') as HTMLInputElement;
|
||||||
|
tempSlider.value = String(recommendedTemp);
|
||||||
|
document.querySelector('#tempValue')!.textContent = recommendedTemp.toFixed(1);
|
||||||
|
state.set('temperature', recommendedTemp);
|
||||||
|
if (db) await db.saveSetting('temperature', recommendedTemp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
default: '通用助手', coder: '编程助手', writer: '写作助手',
|
||||||
|
analyst: '数据分析', translator: '翻译助手', reviewer: '代码审查'
|
||||||
|
};
|
||||||
|
showToast(`已切换到 ${names[personality] || personality}`, 'success');
|
||||||
|
logSetting('人格模式', names[personality] || personality);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelector('#btnReleaseVRAM')!.addEventListener('click', async () => {
|
document.querySelector('#btnReleaseVRAM')!.addEventListener('click', async () => {
|
||||||
const api = state.get<OllamaAPI>(KEYS.API);
|
const api = state.get<OllamaAPI>(KEYS.API);
|
||||||
const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value;
|
const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value;
|
||||||
|
|||||||
@@ -260,6 +260,18 @@
|
|||||||
<input type="range" id="inputTemperature" class="temp-slider" min="0" max="2" step="0.1" value="0.7">
|
<input type="range" id="inputTemperature" class="temp-slider" min="0" max="2" step="0.1" value="0.7">
|
||||||
<p class="text-muted" style="margin-top:4px;font-size:11px;">低值更精确稳定,高值更有创意多样(0 = 确定性,2 = 最大随机)</p>
|
<p class="text-muted" style="margin-top:4px;font-size:11px;">低值更精确稳定,高值更有创意多样(0 = 确定性,2 = 最大随机)</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-group">
|
||||||
|
<label class="setting-label">🎭 人格模式</label>
|
||||||
|
<div class="personality-grid" id="personalityGrid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:4px;">
|
||||||
|
<button class="btn btn-sm personality-btn active" data-personality="default" style="padding:6px 8px;font-size:12px;border:2px solid var(--primary);border-radius:var(--radius-control);background:var(--bg-layer);cursor:pointer;">🤖 通用助手</button>
|
||||||
|
<button class="btn btn-sm personality-btn" data-personality="coder" style="padding:6px 8px;font-size:12px;border:2px solid var(--border-subtle);border-radius:var(--radius-control);background:var(--bg-layer);cursor:pointer;">💻 编程助手</button>
|
||||||
|
<button class="btn btn-sm personality-btn" data-personality="writer" style="padding:6px 8px;font-size:12px;border:2px solid var(--border-subtle);border-radius:var(--radius-control);background:var(--bg-layer);cursor:pointer;">✍️ 写作助手</button>
|
||||||
|
<button class="btn btn-sm personality-btn" data-personality="analyst" style="padding:6px 8px;font-size:12px;border:2px solid var(--border-subtle);border-radius:var(--radius-control);background:var(--bg-layer);cursor:pointer;">📊 数据分析</button>
|
||||||
|
<button class="btn btn-sm personality-btn" data-personality="translator" style="padding:6px 8px;font-size:12px;border:2px solid var(--border-subtle);border-radius:var(--radius-control);background:var(--bg-layer);cursor:pointer;">🌐 翻译助手</button>
|
||||||
|
<button class="btn btn-sm personality-btn" data-personality="reviewer" style="padding:6px 8px;font-size:12px;border:2px solid var(--border-subtle);border-radius:var(--radius-control);background:var(--bg-layer);cursor:pointer;">🔍 代码审查</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted" style="margin-top:4px;font-size:11px;">不同人格有不同的行为风格和温度偏好。通用助手不做额外约束。</p>
|
||||||
|
</div>
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<label class="setting-label">运行中的模型</label>
|
<label class="setting-label">运行中的模型</label>
|
||||||
<div class="running-models" id="runningModels">
|
<div class="running-models" id="runningModels">
|
||||||
|
|||||||
@@ -363,6 +363,21 @@ async function init(): Promise<void> {
|
|||||||
const memoryEnabled = await db.getSetting('memoryEnabled', true);
|
const memoryEnabled = await db.getSetting('memoryEnabled', true);
|
||||||
state.set('memoryEnabled', memoryEnabled);
|
state.set('memoryEnabled', memoryEnabled);
|
||||||
|
|
||||||
|
// ── v4.2 人格模式 ──
|
||||||
|
const personality = await db.getSetting('personality', 'default');
|
||||||
|
state.set('personality', personality);
|
||||||
|
// 激活对应的人格按钮
|
||||||
|
document.querySelectorAll('.personality-btn').forEach(b => {
|
||||||
|
const p = (b as HTMLElement).dataset.personality;
|
||||||
|
if (p === personality) {
|
||||||
|
(b as HTMLElement).style.border = '2px solid var(--primary)';
|
||||||
|
b.classList.add('active');
|
||||||
|
} else {
|
||||||
|
(b as HTMLElement).style.border = '2px solid var(--border-subtle)';
|
||||||
|
b.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
(document.querySelector('#chatInput') as HTMLElement)?.focus();
|
(document.querySelector('#chatInput') as HTMLElement)?.focus();
|
||||||
updateTotalTokens();
|
updateTotalTokens();
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
needsConfirmation
|
needsConfirmation
|
||||||
} from './tool-registry.js';
|
} from './tool-registry.js';
|
||||||
import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js';
|
import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js';
|
||||||
|
import { extractSkillsFromToolRecords, matchSkills, buildSkillContext } from './skill-manager.js';
|
||||||
|
import { PERSONALITY_SYSTEM_PROMPTS } from '../components/settings-modal.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
import { logInfo, logWarn, logError, logToolStart, logToolResult, logThink, logAgentLoop, logModelResponse } from './log-service.js';
|
import { logInfo, logWarn, logError, logToolStart, logToolResult, logThink, logAgentLoop, logModelResponse } from './log-service.js';
|
||||||
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
|
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
|
||||||
@@ -136,7 +138,53 @@ function parseToolCallsFromText(content: string): ToolCall[] {
|
|||||||
return calls;
|
return calls;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 工具调用缓存:key = toolName + JSON(args),用于避免重复调用 */
|
/** v4.2: 从对话中自动推断用户画像 */
|
||||||
|
async function inferUserProfile(userMsg: string, assistantMsg: string): Promise<void> {
|
||||||
|
const bridge = (window as any).metonaDesktop;
|
||||||
|
if (!bridge?.db?.getSetting) return;
|
||||||
|
|
||||||
|
const profile: Record<string, unknown> = await bridge.db.getSetting('user_profile', {}) || {};
|
||||||
|
|
||||||
|
// 技术栈检测
|
||||||
|
const techPatterns: Record<string, RegExp> = {
|
||||||
|
'Python': /\b(python|pip|py|django|flask|fastapi|pandas|numpy)\b/i,
|
||||||
|
'TypeScript': /\b(typescript|ts|tsx|deno)\b/i,
|
||||||
|
'JavaScript': /\b(javascript|js|jsx|node\.?js|npm|yarn|pnpm)\b/i,
|
||||||
|
'Go': /\b(golang|go\s+(run|build|mod)|\.go\b)/i,
|
||||||
|
'Rust': /\b(rust|cargo|rustc|\.rs\b)/i,
|
||||||
|
'Java': /\b(java|maven|gradle|spring|\.java\b)/i,
|
||||||
|
'React': /\b(react|next\.?js|jsx|tsx|vite)\b/i,
|
||||||
|
'Vue': /\b(vue|nuxt|vuex|pinia)\b/i,
|
||||||
|
'Docker': /\b(docker|dockerfile|docker-compose|container)\b/i,
|
||||||
|
'Kubernetes': /\b(k8s|kubernetes|kubectl|helm)\b/i,
|
||||||
|
'Linux': /\b(linux|ubuntu|debian|centos|bash|shell|terminal)\b/i,
|
||||||
|
'Git': /\b(git|github|gitee|gitlab|commit|push|pull|branch|merge)\b/i,
|
||||||
|
'SQL': /\b(sql|sqlite|mysql|postgres|database|查询)\b/i,
|
||||||
|
'AI/ML': /\b(machine learning|深度学习|神经网络|模型训练|ollama|llm|transformer)\b/i,
|
||||||
|
};
|
||||||
|
|
||||||
|
const combinedText = userMsg + ' ' + assistantMsg;
|
||||||
|
const currentStack: string[] = (profile.tech_stack as string[]) || [];
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const [tech, pattern] of Object.entries(techPatterns)) {
|
||||||
|
if (pattern.test(combinedText) && !currentStack.includes(tech)) {
|
||||||
|
currentStack.push(tech);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制技术栈数量
|
||||||
|
if (currentStack.length > 15) {
|
||||||
|
currentStack.splice(0, currentStack.length - 15);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
profile.tech_stack = currentStack;
|
||||||
|
await bridge.db.saveSetting('user_profile', profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
const toolResultCache = new Map<string, ToolResult>();
|
const toolResultCache = new Map<string, ToolResult>();
|
||||||
|
|
||||||
/** 生成工具调用缓存 key */
|
/** 生成工具调用缓存 key */
|
||||||
@@ -342,10 +390,45 @@ export async function runAgentLoop(
|
|||||||
文件操作工具(read_file、write_file 等)的相对路径基于此目录解析。`);
|
文件操作工具(read_file、write_file 等)的相对路径基于此目录解析。`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v4.2 注入匹配的技能上下文
|
||||||
|
if (useTools && userContent) {
|
||||||
|
const matchedSkills = await matchSkills(userContent, 3);
|
||||||
|
if (matchedSkills.length > 0) {
|
||||||
|
const skillContext = buildSkillContext(matchedSkills);
|
||||||
|
if (skillContext) {
|
||||||
|
systemPromptParts.push(skillContext);
|
||||||
|
logInfo(`技能匹配: ${matchedSkills.length} 个技能已注入`, matchedSkills.map(s => s.name).join(', '));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v4.2 注入用户画像
|
||||||
|
const db = state.get<any>(KEYS.DB);
|
||||||
|
if (db) {
|
||||||
|
try {
|
||||||
|
const userProfile = await db.getSetting('user_profile', null);
|
||||||
|
if (userProfile && typeof userProfile === 'object') {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (userProfile.tech_stack?.length) parts.push(`技术栈: ${userProfile.tech_stack.join(', ')}`);
|
||||||
|
if (userProfile.role) parts.push(`角色: ${userProfile.role}`);
|
||||||
|
if (userProfile.style) parts.push(`代码风格: ${userProfile.style}`);
|
||||||
|
if (parts.length > 0) {
|
||||||
|
systemPromptParts.push(`【用户画像】\n${parts.join('\n')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* 不阻塞 */ }
|
||||||
|
}
|
||||||
|
|
||||||
// 组合 system prompt
|
// 组合 system prompt
|
||||||
const fullSystemPrompt = systemPromptParts.length > 0
|
const personality = state.get<string>('personality', 'default');
|
||||||
? systemPromptParts.join('\n\n') + '\n\n' + AGENT_SYSTEM_PROMPT + '\n\n' + TOOL_USAGE_GUIDE
|
const personalityPrompt = PERSONALITY_SYSTEM_PROMPTS[personality] || '';
|
||||||
: AGENT_SYSTEM_PROMPT + '\n\n' + TOOL_USAGE_GUIDE;
|
|
||||||
|
const fullSystemPrompt = [
|
||||||
|
...systemPromptParts,
|
||||||
|
AGENT_SYSTEM_PROMPT,
|
||||||
|
...(personalityPrompt ? [personalityPrompt] : []),
|
||||||
|
TOOL_USAGE_GUIDE
|
||||||
|
].join('\n\n');
|
||||||
|
|
||||||
messages.push({ role: 'system', content: fullSystemPrompt });
|
messages.push({ role: 'system', content: fullSystemPrompt });
|
||||||
|
|
||||||
@@ -538,6 +621,18 @@ export async function runAgentLoop(
|
|||||||
} catch { /* 不阻塞 */ }
|
} catch { /* 不阻塞 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v4.2 自动提取技能
|
||||||
|
if (allToolRecords.length >= 2) {
|
||||||
|
try {
|
||||||
|
await extractSkillsFromToolRecords(allToolRecords, userContent, currentSession?.title || '');
|
||||||
|
} catch { /* 不阻塞 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// v4.2 自动推断用户画像
|
||||||
|
try {
|
||||||
|
await inferUserProfile(userContent, content);
|
||||||
|
} catch { /* 不阻塞 */ }
|
||||||
|
|
||||||
callbacks.onDone(content || '(模型未返回内容)', allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
|
callbacks.onDone(content || '(模型未返回内容)', allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ export function logConnection(status: string, detail?: string): void { addLog(st
|
|||||||
export function logModel(name: string, action: string): void { addLog('info', `模型: ${action}`, name); }
|
export function logModel(name: string, action: string): void { addLog('info', `模型: ${action}`, name); }
|
||||||
export function logSession(action: string, detail?: string): void { addLog('info', `会话: ${action}`, detail); }
|
export function logSession(action: string, detail?: string): void { addLog('info', `会话: ${action}`, detail); }
|
||||||
export function logMemory(action: string, detail?: string): void { addLog('debug', `记忆: ${action}`, detail); }
|
export function logMemory(action: string, detail?: string): void { addLog('debug', `记忆: ${action}`, detail); }
|
||||||
|
export function logSkill(action: string, detail?: string): void { addLog('debug', `技能: ${action}`, detail); }
|
||||||
export function logRAG(action: string, detail?: string): void { addLog('info', `向量: ${action}`, detail); }
|
export function logRAG(action: string, detail?: string): void { addLog('info', `向量: ${action}`, detail); }
|
||||||
export function logSetting(key: string, value: string): void { addLog('debug', `设置: ${key}`, value); }
|
export function logSetting(key: string, value: string): void { addLog('debug', `设置: ${key}`, value); }
|
||||||
export function logInit(msg: string): void { addLog('info', `初始化: ${msg}`); }
|
export function logInit(msg: string): void { addLog('info', `初始化: ${msg}`); }
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
/**
|
||||||
|
* SkillManager - 技能自动生成系统 (v4.2)
|
||||||
|
* 从成功的工具调用链中提取可复用技能,匹配用户意图后注入上下文
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { state, KEYS } from '../state/state.js';
|
||||||
|
import { generateId } from '../utils/utils.js';
|
||||||
|
import { logInfo, logWarn, logSkill } from './log-service.js';
|
||||||
|
import type { ToolCallRecord, ChatSession } from '../types.js';
|
||||||
|
|
||||||
|
/** 技能数据结构 */
|
||||||
|
export interface Skill {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
trigger_keywords: string | null;
|
||||||
|
tool_chain: string; // JSON: ToolChainStep[]
|
||||||
|
success_count: number;
|
||||||
|
fail_count: number;
|
||||||
|
avg_duration_ms: number;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
last_used_at: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工具链步骤 */
|
||||||
|
interface ToolChainStep {
|
||||||
|
name: string;
|
||||||
|
description: string; // 该步骤的作用描述
|
||||||
|
args_hint: string; // 参数提示(哪些参数是关键的)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最小工具链长度,太短的不算技能 */
|
||||||
|
const MIN_CHAIN_LENGTH = 2;
|
||||||
|
|
||||||
|
/** 技能匹配最低阈值 */
|
||||||
|
const MATCH_THRESHOLD = 0.3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Agent Loop 完成后的工具调用记录中提取技能
|
||||||
|
* 只提取成功的、链长 >= 2 的工具调用序列
|
||||||
|
*/
|
||||||
|
export async function extractSkillsFromToolRecords(
|
||||||
|
toolRecords: ToolCallRecord[],
|
||||||
|
userMessage: string,
|
||||||
|
sessionTitle: string
|
||||||
|
): Promise<number> {
|
||||||
|
const bridge = (window as any).metonaDesktop;
|
||||||
|
if (!bridge?.db?.saveSkill) return 0;
|
||||||
|
|
||||||
|
// 只看成功的工具调用
|
||||||
|
const successfulTools = toolRecords.filter(t => t.status === 'success');
|
||||||
|
if (successfulTools.length < MIN_CHAIN_LENGTH) return 0;
|
||||||
|
|
||||||
|
// 提取工具链
|
||||||
|
const chain: ToolChainStep[] = successfulTools.map(t => ({
|
||||||
|
name: t.name,
|
||||||
|
description: describeToolStep(t),
|
||||||
|
args_hint: extractKeyArgs(t)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const chainNames = chain.map(s => s.name).join(' → ');
|
||||||
|
|
||||||
|
// 检查是否已有完全相同的技能链
|
||||||
|
const existingSkills: Skill[] = await bridge.db.getAllSkills();
|
||||||
|
const duplicate = existingSkills.find(s => {
|
||||||
|
try {
|
||||||
|
const existingChain: ToolChainStep[] = JSON.parse(s.tool_chain);
|
||||||
|
return existingChain.map((c: ToolChainStep) => c.name).join(' → ') === chainNames;
|
||||||
|
} catch { return false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (duplicate) {
|
||||||
|
// 已有相同技能,不重复创建
|
||||||
|
logInfo(`技能已存在,跳过提取: ${duplicate.name}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从用户消息中提取触发关键词
|
||||||
|
const keywords = extractKeywords(userMessage);
|
||||||
|
|
||||||
|
// 生成技能名称和描述
|
||||||
|
const name = generateSkillName(chain, userMessage);
|
||||||
|
const description = generateSkillDescription(chain, sessionTitle);
|
||||||
|
|
||||||
|
const skill: Skill = {
|
||||||
|
id: `skill_${generateId()}`,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
trigger_keywords: keywords,
|
||||||
|
tool_chain: JSON.stringify(chain),
|
||||||
|
success_count: 1,
|
||||||
|
fail_count: 0,
|
||||||
|
avg_duration_ms: toolRecords.reduce((sum, t) => sum + (t.timestamp - (toolRecords[0]?.timestamp || t.timestamp)), 0),
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
last_used_at: null
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await bridge.db.saveSkill(skill);
|
||||||
|
logSkill(`新技能提取: ${name}`, `链: ${chainNames}`);
|
||||||
|
return 1;
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('技能保存失败', (err as Error).message);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 匹配用户消息与已有技能
|
||||||
|
* 返回匹配度最高的技能列表
|
||||||
|
*/
|
||||||
|
export async function matchSkills(userMessage: string, limit = 3): Promise<Skill[]> {
|
||||||
|
const bridge = (window as any).metonaDesktop;
|
||||||
|
if (!bridge?.db?.getAllSkills) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allSkills: Skill[] = await bridge.db.getAllSkills();
|
||||||
|
if (allSkills.length === 0) return [];
|
||||||
|
|
||||||
|
const messageLower = userMessage.toLowerCase();
|
||||||
|
const messageWords = new Set(messageLower.split(/[\s,,。!?、;:""''()\[\]【】]+/).filter(w => w.length > 1));
|
||||||
|
|
||||||
|
const scored = allSkills.map(skill => {
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// 关键词匹配
|
||||||
|
if (skill.trigger_keywords) {
|
||||||
|
const kwSet = new Set(skill.trigger_keywords.toLowerCase().split(',').map(k => k.trim()));
|
||||||
|
for (const kw of kwSet) {
|
||||||
|
if (kw && messageLower.includes(kw)) score += 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 名称和描述匹配
|
||||||
|
const nameWords = skill.name.toLowerCase().split(/[\s→\-]+/);
|
||||||
|
for (const w of nameWords) {
|
||||||
|
if (w.length > 1 && messageWords.has(w)) score += 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成功率加成
|
||||||
|
const total = skill.success_count + skill.fail_count;
|
||||||
|
if (total > 0) score *= (0.5 + 0.5 * (skill.success_count / total));
|
||||||
|
|
||||||
|
return { skill, score };
|
||||||
|
});
|
||||||
|
|
||||||
|
return scored
|
||||||
|
.filter(s => s.score >= MATCH_THRESHOLD)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(s => s.skill);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从匹配的技能构建 system prompt 上下文
|
||||||
|
*/
|
||||||
|
export function buildSkillContext(skills: Skill[]): string {
|
||||||
|
if (skills.length === 0) return '';
|
||||||
|
|
||||||
|
const parts = skills.map(skill => {
|
||||||
|
let chain: ToolChainStep[] = [];
|
||||||
|
try { chain = JSON.parse(skill.tool_chain); } catch { return ''; }
|
||||||
|
|
||||||
|
const steps = chain.map((s, i) =>
|
||||||
|
` ${i + 1}. ${s.name}(${s.description})${s.args_hint ? ` — 参数: ${s.args_hint}` : ''}`
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
const total = skill.success_count + skill.fail_count;
|
||||||
|
const rate = total > 0 ? Math.round(skill.success_count / total * 100) : 100;
|
||||||
|
|
||||||
|
return `【技能】${skill.name}\n${skill.description}\n成功执行 ${skill.success_count} 次(成功率 ${rate}%)\n工具链:\n${steps}`;
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
|
if (parts.length === 0) return '';
|
||||||
|
|
||||||
|
return `【可用技能提示 — 以下技能曾成功完成过类似任务,可作为参考】\n\n${parts.join('\n\n')}\n\n提示:如果当前任务与某个技能匹配,可以参考其工具链步骤,但根据实际情况调整参数。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有技能(用于 UI 展示)
|
||||||
|
*/
|
||||||
|
export async function getAllSkillsForUI(): Promise<Skill[]> {
|
||||||
|
const bridge = (window as any).metonaDesktop;
|
||||||
|
if (!bridge?.db?.getAllSkills) return [];
|
||||||
|
try {
|
||||||
|
return await bridge.db.getAllSkills();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除技能
|
||||||
|
*/
|
||||||
|
export async function deleteSkillById(id: string): Promise<boolean> {
|
||||||
|
const bridge = (window as any).metonaDesktop;
|
||||||
|
if (!bridge?.db?.deleteSkill) return false;
|
||||||
|
try {
|
||||||
|
await bridge.db.deleteSkill(id);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有技能
|
||||||
|
*/
|
||||||
|
export async function clearAllSkills(): Promise<boolean> {
|
||||||
|
const bridge = (window as any).metonaDesktop;
|
||||||
|
if (!bridge?.db?.clearAllSkills) return false;
|
||||||
|
try {
|
||||||
|
await bridge.db.clearAllSkills();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 辅助函数 ──
|
||||||
|
|
||||||
|
/** 生成工具步骤描述 */
|
||||||
|
function describeToolStep(t: ToolCallRecord): string {
|
||||||
|
const args = t.arguments || {};
|
||||||
|
switch (t.name) {
|
||||||
|
case 'read_file': return `读取文件 ${args.path || ''}`;
|
||||||
|
case 'write_file': return `写入文件 ${args.path || ''}`;
|
||||||
|
case 'list_directory': return `列出目录 ${args.path || ''}`;
|
||||||
|
case 'search_files': return `搜索文件 "${args.query || ''}"`;
|
||||||
|
case 'web_search': return `联网搜索 "${args.query || ''}"`;
|
||||||
|
case 'web_fetch': return `抓取网页 ${args.url || ''}`;
|
||||||
|
case 'run_command': return `执行命令 ${String(args.command || '').slice(0, 60)}`;
|
||||||
|
case 'git': return `Git ${args.action || ''}`;
|
||||||
|
case 'edit_file': return `编辑文件 ${args.path || ''}`;
|
||||||
|
case 'delete_file': return `删除 ${args.path || ''}`;
|
||||||
|
case 'create_directory': return `创建目录 ${args.path || ''}`;
|
||||||
|
case 'memory_search': return `搜索记忆 "${args.query || ''}"`;
|
||||||
|
case 'memory_add': return `添加记忆`;
|
||||||
|
default: return t.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取关键参数名 */
|
||||||
|
function extractKeyArgs(t: ToolCallRecord): string {
|
||||||
|
const args = t.arguments || {};
|
||||||
|
const keys: string[] = [];
|
||||||
|
if (args.path) keys.push('path');
|
||||||
|
if (args.query) keys.push('query');
|
||||||
|
if (args.url) keys.push('url');
|
||||||
|
if (args.command) keys.push('command');
|
||||||
|
if (args.action) keys.push('action');
|
||||||
|
if (args.branch) keys.push('branch');
|
||||||
|
return keys.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从用户消息提取关键词(简单分词) */
|
||||||
|
function extractKeywords(text: string): string {
|
||||||
|
// 移除常见停用词
|
||||||
|
const stopWords = new Set(['我', '你', '的', '了', '是', '在', '有', '和', '就', '不', '人', '都', '一', '一个', '上', '也', '很', '到', '说', '要', '去', '你', '会', '着', '没有', '看', '好', '自己', '这', '请', '帮', '帮我', '可以', '怎么', '什么', '如何', 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'to', 'of', 'and', 'in', 'that', 'have', 'for', 'it', 'with', 'as', 'this', 'from', 'by', 'on', 'not', 'but', 'what', 'all', 'can', 'had', 'how', 'do']);
|
||||||
|
|
||||||
|
const words = text.toLowerCase()
|
||||||
|
.split(/[\s,,。!?、;:""''()\[\]【】<>《》]+/)
|
||||||
|
.filter(w => w.length > 1 && !stopWords.has(w));
|
||||||
|
|
||||||
|
// 取前 8 个关键词
|
||||||
|
return [...new Set(words)].slice(0, 8).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成技能名称 */
|
||||||
|
function generateSkillName(chain: ToolChainStep[], userMessage: string): string {
|
||||||
|
const firstTool = chain[0].name;
|
||||||
|
const lastTool = chain[chain.length - 1].name;
|
||||||
|
|
||||||
|
// 基于工具链模式命名
|
||||||
|
const patterns: Record<string, string> = {
|
||||||
|
'web_search → web_fetch': '联网搜索并抓取详情',
|
||||||
|
'list_directory → read_file': '浏览目录并读取文件',
|
||||||
|
'search_files → read_file': '搜索并读取文件',
|
||||||
|
'search_files → edit_file': '搜索并编辑文件',
|
||||||
|
'search_files → write_file': '搜索并写入文件',
|
||||||
|
'run_command → read_file': '执行命令并读取输出',
|
||||||
|
'web_search → web_fetch → write_file': '搜索、抓取并保存',
|
||||||
|
};
|
||||||
|
|
||||||
|
const chainKey = chain.map(s => s.name).join(' → ');
|
||||||
|
if (patterns[chainKey]) return patterns[chainKey];
|
||||||
|
|
||||||
|
// 基于用户消息前 20 字
|
||||||
|
const msgPreview = userMessage.slice(0, 20).replace(/[\r\n]/g, ' ');
|
||||||
|
return `${firstTool} → ... → ${lastTool}(${msgPreview})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成技能描述 */
|
||||||
|
function generateSkillDescription(chain: ToolChainStep[], sessionTitle: string): string {
|
||||||
|
const steps = chain.map((s, i) => `${i + 1}. ${s.description}`).join(';');
|
||||||
|
return `从会话「${sessionTitle}」中提取。步骤:${steps}`;
|
||||||
|
}
|
||||||
@@ -1332,6 +1332,22 @@ html, body {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v4.2 人格按钮 */
|
||||||
|
.personality-btn {
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.personality-btn:hover {
|
||||||
|
background: var(--bg-hover) !important;
|
||||||
|
border-color: var(--primary) !important;
|
||||||
|
}
|
||||||
|
.personality-btn.active {
|
||||||
|
background: var(--primary-bg) !important;
|
||||||
|
border-color: var(--primary) !important;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.setting-label {
|
.setting-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user