mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-04-29 08:12:41 +08:00
feat: 聊天对话支持使用技能
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { getActiveConfig, buildPiModel } from '../llm'
|
||||
import { getAllTools } from '../tools'
|
||||
import { getAllTools, createActivateSkillTool } from '../tools'
|
||||
import type { ToolContext, OwnerInfo } from '../tools/types'
|
||||
import { createSqlTools } from '../assistant/sqlToolRunner'
|
||||
import { getHistoryForAgent } from '../conversations'
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type Usage as PiUsage,
|
||||
} from '@mariozechner/pi-ai'
|
||||
|
||||
import type { AgentConfig, AgentStreamChunk, AgentResult, PromptConfig, TokenUsage } from './types'
|
||||
import type { AgentConfig, AgentStreamChunk, AgentResult, PromptConfig, TokenUsage, SkillContext } from './types'
|
||||
import type { AssistantConfig } from '../assistant/types'
|
||||
import { buildSystemPrompt } from './prompt-builder'
|
||||
import { extractThinkingContent, stripToolCallTags } from './content-parser'
|
||||
@@ -41,6 +41,7 @@ export class Agent {
|
||||
private chatType: 'group' | 'private' = 'group'
|
||||
private promptConfig?: PromptConfig
|
||||
private assistantConfig?: AssistantConfig
|
||||
private skillCtx?: SkillContext
|
||||
private locale: string = 'zh-CN'
|
||||
|
||||
constructor(
|
||||
@@ -51,7 +52,8 @@ export class Agent {
|
||||
chatType: 'group' | 'private' = 'group',
|
||||
promptConfig?: PromptConfig,
|
||||
locale: string = 'zh-CN',
|
||||
assistantConfig?: AssistantConfig
|
||||
assistantConfig?: AssistantConfig,
|
||||
skillCtx?: SkillContext
|
||||
) {
|
||||
this.context = context
|
||||
this.piModel = piModel
|
||||
@@ -60,6 +62,7 @@ export class Agent {
|
||||
this.chatType = chatType
|
||||
this.promptConfig = promptConfig
|
||||
this.assistantConfig = assistantConfig
|
||||
this.skillCtx = skillCtx
|
||||
this.locale = locale
|
||||
this.config = {
|
||||
maxToolRounds: config.maxToolRounds ?? 5,
|
||||
@@ -84,7 +87,7 @@ export class Agent {
|
||||
? { systemPrompt: this.assistantConfig.systemPrompt }
|
||||
: this.promptConfig
|
||||
|
||||
const systemPrompt = buildSystemPrompt(this.chatType, effectivePromptConfig, this.context.ownerInfo, this.locale)
|
||||
const systemPrompt = buildSystemPrompt(this.chatType, effectivePromptConfig, this.context.ownerInfo, this.locale, this.skillCtx)
|
||||
const answerWithoutToolsPrompt = i18nT('ai.agent.answerWithoutTools', { lng: this.locale })
|
||||
|
||||
const handler = new AgentEventHandler({
|
||||
@@ -156,6 +159,11 @@ export class Agent {
|
||||
piTools.push(...createSqlTools(this.assistantConfig.customSqlTools, toolContext))
|
||||
}
|
||||
|
||||
// AI 自选模式:注册 activate_skill 元工具(手动选择时不注册,避免冲突)
|
||||
if (this.skillCtx?.skillMenu && !this.skillCtx?.skillDef) {
|
||||
piTools.push(createActivateSkillTool(this.chatType, allowedTools, this.locale))
|
||||
}
|
||||
|
||||
coreAgent.setTools(maxToolRounds > 0 ? piTools : [])
|
||||
|
||||
const limit = this.config.contextHistoryLimit ?? 48
|
||||
@@ -299,7 +307,8 @@ export async function runAgent(
|
||||
chatType?: 'group' | 'private',
|
||||
promptConfig?: PromptConfig,
|
||||
locale?: string,
|
||||
assistantConfig?: AssistantConfig
|
||||
assistantConfig?: AssistantConfig,
|
||||
skillCtx?: SkillContext
|
||||
): Promise<AgentResult> {
|
||||
const activeConfig = getActiveConfig()
|
||||
if (!activeConfig) throw new Error('LLM service not configured')
|
||||
@@ -312,7 +321,8 @@ export async function runAgent(
|
||||
chatType,
|
||||
promptConfig,
|
||||
locale,
|
||||
assistantConfig
|
||||
assistantConfig,
|
||||
skillCtx
|
||||
)
|
||||
return agent.execute(userMessage)
|
||||
}
|
||||
@@ -328,7 +338,8 @@ export async function runAgentStream(
|
||||
chatType?: 'group' | 'private',
|
||||
promptConfig?: PromptConfig,
|
||||
locale?: string,
|
||||
assistantConfig?: AssistantConfig
|
||||
assistantConfig?: AssistantConfig,
|
||||
skillCtx?: SkillContext
|
||||
): Promise<AgentResult> {
|
||||
const activeConfig = getActiveConfig()
|
||||
if (!activeConfig) throw new Error('LLM service not configured')
|
||||
@@ -341,7 +352,8 @@ export async function runAgentStream(
|
||||
chatType,
|
||||
promptConfig,
|
||||
locale,
|
||||
assistantConfig
|
||||
assistantConfig,
|
||||
skillCtx
|
||||
)
|
||||
return agent.executeStream(userMessage, onChunk)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { t as i18nT } from '../../i18n'
|
||||
import type { OwnerInfo } from '../tools/types'
|
||||
import type { PromptConfig } from './types'
|
||||
import type { PromptConfig, SkillContext } from './types'
|
||||
|
||||
function agentT(key: string, locale: string, options?: Record<string, unknown>): string {
|
||||
return i18nT(key, { lng: locale, ...options })
|
||||
@@ -76,12 +76,23 @@ export function buildSystemPrompt(
|
||||
chatType: 'group' | 'private' = 'group',
|
||||
promptConfig?: PromptConfig,
|
||||
ownerInfo?: OwnerInfo,
|
||||
locale: string = 'zh-CN'
|
||||
locale: string = 'zh-CN',
|
||||
skillCtx?: SkillContext
|
||||
): string {
|
||||
const systemPrompt = promptConfig?.systemPrompt || getFallbackRoleDefinition(chatType, locale)
|
||||
const lockedSection = getLockedPromptSection(chatType, ownerInfo, locale)
|
||||
|
||||
return `${systemPrompt}
|
||||
let skillSection = ''
|
||||
if (skillCtx?.skillDef) {
|
||||
skillSection =
|
||||
`\n## ${agentT('ai.agent.currentTask', locale)}:${skillCtx.skillDef.name}\n` +
|
||||
`${agentT('ai.agent.skillPriorityNote', locale)}\n` +
|
||||
skillCtx.skillDef.prompt
|
||||
} else if (skillCtx?.skillMenu) {
|
||||
skillSection = `\n${skillCtx.skillMenu}`
|
||||
}
|
||||
|
||||
return `${systemPrompt}${skillSection}
|
||||
|
||||
${lockedSection}`
|
||||
}
|
||||
|
||||
@@ -67,3 +67,14 @@ export interface PromptConfig {
|
||||
/** 系统提示词(角色定义 + 回答要求,统一为单一字段) */
|
||||
systemPrompt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 技能上下文(传递给 prompt-builder)
|
||||
* 手动选择和 AI 自选两种模式互斥
|
||||
*/
|
||||
export interface SkillContext {
|
||||
/** 手动选择时传入完整 SkillDef,AI 自选时为 undefined */
|
||||
skillDef?: import('../skills/types').SkillDef
|
||||
/** AI 自选时传入技能菜单文本,手动选择时为 undefined */
|
||||
skillMenu?: string
|
||||
}
|
||||
|
||||
52
electron/main/ai/skills/builtin/member_profile.md
Normal file
52
electron/main/ai/skills/builtin/member_profile.md
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
id: member_profile
|
||||
name: 成员画像
|
||||
description: 为指定成员生成全面的行为画像分析,包含活跃度、作息、话题偏好和社交关系。快捷用语:「帮我分析张三的画像」「群里谁最活跃?做个画像」
|
||||
tags: 社群,成员,画像
|
||||
chatScope: all
|
||||
tools:
|
||||
- get_member_stats
|
||||
- search_messages
|
||||
- get_conversation_between
|
||||
---
|
||||
|
||||
## 目标
|
||||
|
||||
为指定成员生成全面的行为画像分析。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. **基础活跃度**:调用 get_member_stats 获取该成员的发言量、活跃天数等基础数据
|
||||
2. **典型发言**:调用 search_messages 搜索该成员的近期发言,了解其关注的话题和表达风格
|
||||
3. **互动关系**:调用 get_conversation_between 查看该成员与其他高频互动成员的对话
|
||||
|
||||
## 输出格式
|
||||
|
||||
### 👤 成员画像:{成员昵称}
|
||||
|
||||
#### 📈 活跃度概况
|
||||
|
||||
- 总发言量、平均日发言、最活跃时段
|
||||
- 活跃度趋势(上升/稳定/下降)
|
||||
|
||||
#### 🕐 作息规律
|
||||
|
||||
基于时段分布描述该成员的典型在线时间
|
||||
|
||||
#### 💬 话题偏好
|
||||
|
||||
基于发言内容归纳 3-5 个该成员最关注的话题领域
|
||||
|
||||
#### 🤝 社交关系
|
||||
|
||||
列出与该成员互动最频繁的 3-5 位成员,简要描述互动特点
|
||||
|
||||
#### 🎯 总结标签
|
||||
|
||||
用 3-5 个标签概括该成员特征(如 #夜猫子 #技术达人 #活跃分子)
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 所有结论必须基于工具返回的真实数据
|
||||
- 如果用户没有指定成员,先调用 get_member_stats 找出最活跃的成员进行分析
|
||||
- 如果某个维度数据不足,在画像中说明而非编造
|
||||
50
electron/main/ai/skills/builtin/relationship_map.md
Normal file
50
electron/main/ai/skills/builtin/relationship_map.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
id: relationship_map
|
||||
name: 关系图谱
|
||||
description: 分析群成员间的互动关系和社交网络,发现核心人物和亲密关系。快捷用语:「分析一下群里的关系网络」「谁和谁互动最多?」
|
||||
tags: 情感,关系,互动
|
||||
chatScope: group
|
||||
tools:
|
||||
- get_member_stats
|
||||
- get_conversation_between
|
||||
- search_messages
|
||||
---
|
||||
|
||||
## 目标
|
||||
|
||||
分析群成员间的互动关系和社交网络结构。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. **获取活跃成员**:调用 get_member_stats 获取成员活跃度排行,确定核心成员
|
||||
2. **分析互动关系**:对活跃度排名靠前的成员,调用 get_conversation_between 两两查看互动频率和内容
|
||||
3. **发现关系模式**:调用 search_messages 搜索 @ 互动、回复等关系线索,补充互动分析
|
||||
|
||||
## 输出格式
|
||||
|
||||
### 🕸️ 群关系图谱
|
||||
|
||||
#### 核心人物
|
||||
|
||||
列出群内最有影响力的 3-5 位成员,说明他们的角色(如话题发起者、氛围组、技术担当等)
|
||||
|
||||
#### 亲密关系 Top 5
|
||||
|
||||
| 关系对 | 互动频率 | 关系特征 |
|
||||
| ------ | -------- | -------- |
|
||||
|
||||
#### 关系模式
|
||||
|
||||
- **核心-边缘结构**:描述群内是否存在明显的核心圈子
|
||||
- **小团体**:是否存在经常一起互动的小群体
|
||||
- **桥梁人物**:连接不同小团体的关键成员
|
||||
|
||||
#### 💡 社交洞察
|
||||
|
||||
基于分析给出 2-3 条关于群内社交状态的观察
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 所有结论必须基于工具返回的真实数据
|
||||
- 避免对成员关系做过度解读或主观推测
|
||||
- 保护成员隐私,不过度暴露私密对话内容
|
||||
51
electron/main/ai/skills/builtin/topic_tracking.md
Normal file
51
electron/main/ai/skills/builtin/topic_tracking.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
id: topic_tracking
|
||||
name: 话题追踪
|
||||
description: 追踪群聊中的热门话题变化趋势,分析讨论热度和参与情况。快捷用语:「最近群里在讨论什么?」「追踪一下最近的热门话题」
|
||||
tags: 社群,话题,趋势
|
||||
chatScope: group
|
||||
tools:
|
||||
- search_messages
|
||||
- search_sessions
|
||||
- get_session_summaries
|
||||
---
|
||||
|
||||
## 目标
|
||||
|
||||
追踪群聊中的热门话题,分析讨论热度和参与情况。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. **获取会话摘要**:调用 get_session_summaries 获取近期的会话摘要列表,了解整体话题分布
|
||||
2. **搜索热门话题**:基于摘要中发现的关键主题,调用 search_sessions 搜索相关会话,统计各话题的讨论频次
|
||||
3. **深入分析**:对最热门的 3-5 个话题,调用 search_messages 获取代表性消息,分析讨论深度和参与者
|
||||
|
||||
## 输出格式
|
||||
|
||||
### 🔥 热门话题追踪
|
||||
|
||||
#### 话题概览
|
||||
|
||||
用表格概括发现的热门话题:
|
||||
|
||||
| 话题 | 相关会话数 | 参与者 | 热度 |
|
||||
| ---- | ---------- | ------ | ---- |
|
||||
|
||||
#### 话题详情
|
||||
|
||||
对每个热门话题(3-5 个),给出:
|
||||
- **话题摘要**:1-2 句话概括讨论内容
|
||||
- **核心观点**:列出 2-3 个代表性观点或结论
|
||||
- **活跃参与者**:主要参与讨论的成员
|
||||
|
||||
#### 📊 趋势观察
|
||||
|
||||
- 与过去相比,哪些话题是新兴的
|
||||
- 哪些话题持续火热
|
||||
- 是否有值得关注的趋势转变
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 所有结论必须基于工具返回的真实数据
|
||||
- 话题分类应基于实际讨论内容,不要预设类别
|
||||
- 如果数据不足以支撑趋势分析,如实说明
|
||||
57
electron/main/ai/skills/builtin/weekly_report.md
Normal file
57
electron/main/ai/skills/builtin/weekly_report.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
id: weekly_report
|
||||
name: 群聊周报
|
||||
description: 生成本周群聊活跃度周报,包含活跃排行、时段分析和热门话题。快捷用语:「生成本周周报」「这周群里都聊了什么?」
|
||||
tags: 社群,周报,活跃度
|
||||
chatScope: group
|
||||
tools:
|
||||
- get_member_stats
|
||||
- get_time_stats
|
||||
- search_messages
|
||||
- get_session_summaries
|
||||
---
|
||||
|
||||
## 目标
|
||||
|
||||
生成本周(最近 7 天)的群聊活跃度周报。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. **获取活跃度数据**:调用 get_member_stats,设置时间范围为最近 7 天,获取成员活跃度排行
|
||||
2. **获取时段分布**:调用 get_time_stats,设置时间范围为最近 7 天,分析群聊活跃时段规律
|
||||
3. **发现热门话题**:调用 search_messages,搜索最近 7 天的消息,从中提取高频讨论主题。如果发现明显的热门讨论,调用 get_session_summaries 获取相关会话摘要以补充细节
|
||||
4. **综合分析**:基于以上数据,总结本周群聊状态,给出运营洞察
|
||||
|
||||
## 输出格式
|
||||
|
||||
请严格按以下 Markdown 结构输出:
|
||||
|
||||
### 📊 本周概览
|
||||
|
||||
- 总消息数、活跃成员数、日均消息数
|
||||
- 与上周对比的变化趋势(如数据支持)
|
||||
|
||||
### 🏆 活跃排行 Top 5
|
||||
|
||||
使用表格呈现:
|
||||
|
||||
| 排名 | 成员 | 消息数 | 占比 |
|
||||
| ---- | ---- | ------ | ---- |
|
||||
|
||||
### ⏰ 活跃时段
|
||||
|
||||
描述最活跃的 2-3 个时间段,以及周末 vs 工作日的差异
|
||||
|
||||
### 🔥 本周热门话题
|
||||
|
||||
列出 3-5 个热门讨论话题,每个附 1-2 句简要描述
|
||||
|
||||
### 💡 运营建议
|
||||
|
||||
基于数据给出 2-3 条具体可执行的运营建议
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 所有结论必须基于工具返回的真实数据
|
||||
- 如果某个维度数据不足,在报告中说明而非编造
|
||||
- 保持报告紧凑,每个部分控制在合理篇幅内
|
||||
26
electron/main/ai/skills/index.ts
Normal file
26
electron/main/ai/skills/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 技能系统模块入口
|
||||
*/
|
||||
|
||||
export type {
|
||||
SkillDef,
|
||||
SkillSummary,
|
||||
BuiltinSkillInfo,
|
||||
SkillInitResult,
|
||||
SkillSaveResult,
|
||||
} from './types'
|
||||
|
||||
export {
|
||||
initSkillManager,
|
||||
getAllSkills,
|
||||
getSkillConfig,
|
||||
getBuiltinCatalog,
|
||||
importSkill,
|
||||
reimportSkill,
|
||||
updateSkill,
|
||||
createSkill,
|
||||
deleteSkill,
|
||||
getSkillMenu,
|
||||
} from './manager'
|
||||
|
||||
export { parseSkillFile } from './parser'
|
||||
365
electron/main/ai/skills/manager.ts
Normal file
365
electron/main/ai/skills/manager.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* 技能管理器
|
||||
* 负责技能配置的加载、CRUD、内置技能导入和 AI 自选菜单构建
|
||||
*
|
||||
* 存储策略(导入模型):
|
||||
* - 内置技能作为模板打包在 BUILTIN_SKILL_RAW 中(.md 原始字符串)
|
||||
* - 不自动导入任何技能(与助手系统不同)
|
||||
* - 用户通过"技能市场"主动导入内置技能
|
||||
* - 导入后完全属于用户,可自由编辑/删除
|
||||
* - 市场可查看内置技能是否有更新,用户可手动重新导入
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { createHash } from 'crypto'
|
||||
import { getAiDataDir, ensureDir } from '../../paths'
|
||||
import { aiLogger } from '../logger'
|
||||
import { parseSkillFile } from './parser'
|
||||
import type {
|
||||
SkillDef,
|
||||
SkillSummary,
|
||||
SkillInitResult,
|
||||
SkillSaveResult,
|
||||
BuiltinSkillInfo,
|
||||
} from './types'
|
||||
|
||||
// ==================== 内置技能模板(通过 ?raw import 在编译时内联) ====================
|
||||
// 与助手系统使用 JSON import 的策略一致,利用 Vite 构建能力将 .md 内容嵌入 bundle
|
||||
|
||||
import builtinWeeklyReport from './builtin/weekly_report.md?raw'
|
||||
import builtinMemberProfile from './builtin/member_profile.md?raw'
|
||||
import builtinTopicTracking from './builtin/topic_tracking.md?raw'
|
||||
import builtinRelationshipMap from './builtin/relationship_map.md?raw'
|
||||
|
||||
const BUILTIN_SKILL_RAW: { id: string; content: string }[] = [
|
||||
{ id: 'weekly_report', content: builtinWeeklyReport },
|
||||
{ id: 'member_profile', content: builtinMemberProfile },
|
||||
{ id: 'topic_tracking', content: builtinTopicTracking },
|
||||
{ id: 'relationship_map', content: builtinRelationshipMap },
|
||||
]
|
||||
|
||||
// Parsed cache of builtin templates
|
||||
const builtinSkillDefs: Map<string, SkillDef> = new Map()
|
||||
|
||||
function getBuiltinDefs(): Map<string, SkillDef> {
|
||||
if (builtinSkillDefs.size === 0) {
|
||||
for (const { id, content } of BUILTIN_SKILL_RAW) {
|
||||
const def = parseSkillFile(content, `${id}.md`)
|
||||
if (def) {
|
||||
builtinSkillDefs.set(id, def)
|
||||
}
|
||||
}
|
||||
}
|
||||
return builtinSkillDefs
|
||||
}
|
||||
|
||||
function getBuiltinRawContent(builtinId: string): string | undefined {
|
||||
return BUILTIN_SKILL_RAW.find((b) => b.id === builtinId)?.content
|
||||
}
|
||||
|
||||
// ==================== 用户技能缓存 ====================
|
||||
|
||||
const SKILLS_DIR_NAME = 'skills'
|
||||
|
||||
const cachedSkills: Map<string, SkillDef> = new Map()
|
||||
let initialized = false
|
||||
|
||||
function getSkillsDir(): string {
|
||||
return path.join(getAiDataDir(), SKILLS_DIR_NAME)
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
export function initSkillManager(): SkillInitResult {
|
||||
const skillsDir = getSkillsDir()
|
||||
ensureDir(skillsDir)
|
||||
|
||||
loadAllSkills()
|
||||
|
||||
initialized = true
|
||||
aiLogger.info('SkillManager', 'Initialized', { total: cachedSkills.size })
|
||||
|
||||
return { total: cachedSkills.size }
|
||||
}
|
||||
|
||||
function loadAllSkills(): void {
|
||||
cachedSkills.clear()
|
||||
|
||||
const skillsDir = getSkillsDir()
|
||||
if (!fs.existsSync(skillsDir)) return
|
||||
|
||||
const files = fs.readdirSync(skillsDir).filter((f) => f.endsWith('.md'))
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(skillsDir, file)
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
const def = parseSkillFile(content, filePath)
|
||||
if (def) {
|
||||
cachedSkills.set(def.id, def)
|
||||
} else {
|
||||
aiLogger.warn('SkillManager', `Failed to parse skill: ${file}`)
|
||||
}
|
||||
} catch (error) {
|
||||
aiLogger.warn('SkillManager', `Failed to load skill: ${file}`, { error: String(error) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 查询 API ====================
|
||||
|
||||
export function getAllSkills(): SkillSummary[] {
|
||||
ensureInitialized()
|
||||
return Array.from(cachedSkills.values()).map(toSummary)
|
||||
}
|
||||
|
||||
export function getSkillConfig(id: string): SkillDef | null {
|
||||
ensureInitialized()
|
||||
return cachedSkills.get(id) ?? null
|
||||
}
|
||||
|
||||
// ==================== 内置技能目录(市场) ====================
|
||||
|
||||
export function getBuiltinCatalog(): BuiltinSkillInfo[] {
|
||||
ensureInitialized()
|
||||
|
||||
const defs = getBuiltinDefs()
|
||||
return Array.from(defs.values()).map((builtin) => {
|
||||
const userSkill = findImportedByBuiltinId(builtin.id)
|
||||
const imported = !!userSkill
|
||||
const hasUpdate = imported ? hasBuiltinUpdate(builtin.id, userSkill!) : false
|
||||
|
||||
return {
|
||||
id: builtin.id,
|
||||
name: builtin.name,
|
||||
description: builtin.description,
|
||||
tags: builtin.tags,
|
||||
chatScope: builtin.chatScope,
|
||||
tools: builtin.tools,
|
||||
imported,
|
||||
hasUpdate,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function importSkill(builtinId: string): SkillSaveResult & { id?: string } {
|
||||
ensureInitialized()
|
||||
|
||||
const rawContent = getBuiltinRawContent(builtinId)
|
||||
if (!rawContent) {
|
||||
return { success: false, error: `Builtin skill not found: ${builtinId}` }
|
||||
}
|
||||
|
||||
const existing = findImportedByBuiltinId(builtinId)
|
||||
if (existing) {
|
||||
return { success: false, error: `Skill already imported: ${builtinId}` }
|
||||
}
|
||||
|
||||
const def = parseSkillFile(rawContent, `${builtinId}.md`)
|
||||
if (!def) {
|
||||
return { success: false, error: `Failed to parse builtin skill: ${builtinId}` }
|
||||
}
|
||||
|
||||
const contentWithBuiltinId = injectBuiltinId(rawContent, builtinId)
|
||||
return saveSkillToDisk(def.id, contentWithBuiltinId, def)
|
||||
}
|
||||
|
||||
export function reimportSkill(id: string): SkillSaveResult {
|
||||
ensureInitialized()
|
||||
|
||||
const existing = cachedSkills.get(id)
|
||||
if (!existing) {
|
||||
return { success: false, error: `Skill not found: ${id}` }
|
||||
}
|
||||
if (!existing.builtinId) {
|
||||
return { success: false, error: 'Only imported builtin skills can be reimported' }
|
||||
}
|
||||
|
||||
const rawContent = getBuiltinRawContent(existing.builtinId)
|
||||
if (!rawContent) {
|
||||
return { success: false, error: `Builtin template not found: ${existing.builtinId}` }
|
||||
}
|
||||
|
||||
const contentWithBuiltinId = injectBuiltinId(rawContent, existing.builtinId)
|
||||
const def = parseSkillFile(contentWithBuiltinId, `${id}.md`)
|
||||
if (!def) {
|
||||
return { success: false, error: `Failed to parse builtin skill: ${existing.builtinId}` }
|
||||
}
|
||||
def.builtinId = existing.builtinId
|
||||
|
||||
return saveSkillToDisk(id, contentWithBuiltinId, def)
|
||||
}
|
||||
|
||||
// ==================== 修改 API ====================
|
||||
|
||||
export function updateSkill(id: string, rawMd: string): SkillSaveResult {
|
||||
ensureInitialized()
|
||||
|
||||
const existing = cachedSkills.get(id)
|
||||
if (!existing) {
|
||||
return { success: false, error: `Skill not found: ${id}` }
|
||||
}
|
||||
|
||||
const def = parseSkillFile(rawMd, `${id}.md`)
|
||||
if (!def) {
|
||||
return { success: false, error: 'Failed to parse skill content' }
|
||||
}
|
||||
|
||||
def.id = id
|
||||
if (existing.builtinId) {
|
||||
def.builtinId = existing.builtinId
|
||||
}
|
||||
|
||||
return saveSkillToDisk(id, rawMd, def)
|
||||
}
|
||||
|
||||
export function createSkill(rawMd: string): SkillSaveResult & { id?: string } {
|
||||
ensureInitialized()
|
||||
|
||||
const def = parseSkillFile(rawMd, 'new_skill.md')
|
||||
if (!def) {
|
||||
return { success: false, error: 'Failed to parse skill content' }
|
||||
}
|
||||
|
||||
if (cachedSkills.has(def.id)) {
|
||||
const suffix = Date.now().toString(36)
|
||||
def.id = `${def.id}_${suffix}`
|
||||
}
|
||||
|
||||
const result = saveSkillToDisk(def.id, rawMd, def)
|
||||
return { ...result, id: result.success ? def.id : undefined }
|
||||
}
|
||||
|
||||
export function deleteSkill(id: string): SkillSaveResult {
|
||||
ensureInitialized()
|
||||
|
||||
const existing = cachedSkills.get(id)
|
||||
if (!existing) {
|
||||
return { success: false, error: `Skill not found: ${id}` }
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = path.join(getSkillsDir(), `${id}.md`)
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath)
|
||||
}
|
||||
cachedSkills.delete(id)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== AI 自选菜单 ====================
|
||||
|
||||
const MAX_SKILL_MENU_ITEMS = 15
|
||||
|
||||
/**
|
||||
* 构建 AI 自选技能菜单文本
|
||||
* 只包含与当前 chatType + 助手工具权限兼容的技能
|
||||
*/
|
||||
export function getSkillMenu(
|
||||
chatType: 'group' | 'private',
|
||||
allowedTools?: string[]
|
||||
): string | null {
|
||||
ensureInitialized()
|
||||
|
||||
const compatible = Array.from(cachedSkills.values()).filter((skill) => {
|
||||
if (skill.chatScope !== 'all' && skill.chatScope !== chatType) return false
|
||||
if (skill.tools.length > 0 && allowedTools && allowedTools.length > 0) {
|
||||
const allCovered = skill.tools.every((t) => allowedTools.includes(t))
|
||||
if (!allCovered) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (compatible.length === 0) return null
|
||||
|
||||
const items = compatible.slice(0, MAX_SKILL_MENU_ITEMS)
|
||||
const lines = items.map((s) => `- ${s.id}: ${s.name} — ${s.description}`)
|
||||
|
||||
return `## 可用技能
|
||||
以下是你可以使用的分析技能。当你判断用户的问题适合使用某个技能时,
|
||||
请调用 activate_skill 工具激活它,然后按照返回的指导完成任务。
|
||||
|
||||
${lines.join('\n')}
|
||||
|
||||
如果用户的问题不需要使用技能,直接回答即可。`
|
||||
}
|
||||
|
||||
// ==================== 内部工具函数 ====================
|
||||
|
||||
function ensureInitialized(): void {
|
||||
if (!initialized) {
|
||||
initSkillManager()
|
||||
}
|
||||
}
|
||||
|
||||
function findImportedByBuiltinId(builtinId: string): SkillDef | undefined {
|
||||
return Array.from(cachedSkills.values()).find((s) => s.builtinId === builtinId)
|
||||
}
|
||||
|
||||
function toSummary(def: SkillDef): SkillSummary {
|
||||
return {
|
||||
id: def.id,
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
tags: def.tags,
|
||||
chatScope: def.chatScope,
|
||||
tools: def.tools,
|
||||
builtinId: def.builtinId,
|
||||
}
|
||||
}
|
||||
|
||||
function saveSkillToDisk(id: string, rawMd: string, def: SkillDef): SkillSaveResult {
|
||||
try {
|
||||
const filePath = path.join(getSkillsDir(), `${id}.md`)
|
||||
fs.writeFileSync(filePath, rawMd, 'utf-8')
|
||||
cachedSkills.set(id, def)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
aiLogger.error('SkillManager', `Failed to save skill: ${id}`, { error: String(error) })
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 frontmatter 中注入 builtinId 标记
|
||||
* 使导入后的文件能追溯到内置模板来源
|
||||
*/
|
||||
function injectBuiltinId(rawMd: string, builtinId: string): string {
|
||||
const marker = `builtinId: ${builtinId}`
|
||||
if (rawMd.includes('builtinId:')) return rawMd
|
||||
|
||||
const endOfFrontmatter = rawMd.indexOf('\n---', 3)
|
||||
if (endOfFrontmatter === -1) return rawMd
|
||||
|
||||
return rawMd.slice(0, endOfFrontmatter) + `\n${marker}` + rawMd.slice(endOfFrontmatter)
|
||||
}
|
||||
|
||||
function contentHash(content: string): string {
|
||||
return createHash('md5').update(content).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查内置技能是否有更新(基于内容 hash 比对)
|
||||
*/
|
||||
function hasBuiltinUpdate(builtinId: string, userSkill: SkillDef): boolean {
|
||||
const rawContent = getBuiltinRawContent(builtinId)
|
||||
if (!rawContent) return false
|
||||
|
||||
const userFilePath = path.join(getSkillsDir(), `${userSkill.id}.md`)
|
||||
try {
|
||||
const userContent = fs.readFileSync(userFilePath, 'utf-8')
|
||||
const builtinPromptHash = contentHash(rawContent)
|
||||
const userPromptHash = contentHash(stripBuiltinId(userContent))
|
||||
return builtinPromptHash !== userPromptHash
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function stripBuiltinId(content: string): string {
|
||||
return content.replace(/\nbuiltinId:.*\n/g, '\n')
|
||||
}
|
||||
68
electron/main/ai/skills/parser.ts
Normal file
68
electron/main/ai/skills/parser.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 技能文件解析器
|
||||
* 将 Markdown + YAML Frontmatter 格式的 .md 文件解析为 SkillDef
|
||||
*/
|
||||
|
||||
import * as path from 'path'
|
||||
import matter from 'gray-matter'
|
||||
import type { SkillDef } from './types'
|
||||
|
||||
/**
|
||||
* 解析技能 Markdown 文件内容为 SkillDef
|
||||
*
|
||||
* 解析失败时返回 null(不抛异常),调用方负责处理。
|
||||
* 单文件解析失败不能影响整个 SkillManager 初始化。
|
||||
*/
|
||||
export function parseSkillFile(content: string, filePath: string): SkillDef | null {
|
||||
try {
|
||||
const { data: fm, content: prompt } = matter(content)
|
||||
|
||||
const id = fm.id ?? path.basename(filePath, '.md')
|
||||
const name = fm.name
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: fm.description ?? '',
|
||||
tags: parseTags(fm.tags),
|
||||
chatScope: validateChatScope(fm.chatScope),
|
||||
tools: Array.isArray(fm.tools) ? fm.tools : [],
|
||||
prompt: prompt.trim(),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将技能 Markdown 原始内容的 frontmatter 中提取 id(轻量级,不做完整解析)
|
||||
*/
|
||||
export function extractSkillId(content: string, filePath: string): string | null {
|
||||
try {
|
||||
const { data: fm } = matter(content)
|
||||
return fm.id ?? path.basename(filePath, '.md')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseTags(raw: unknown): string[] {
|
||||
if (typeof raw === 'string') {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map(String).filter(Boolean)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function validateChatScope(raw: unknown): 'all' | 'group' | 'private' {
|
||||
if (raw === 'group' || raw === 'private') return raw
|
||||
return 'all'
|
||||
}
|
||||
78
electron/main/ai/skills/types.ts
Normal file
78
electron/main/ai/skills/types.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 技能系统类型定义
|
||||
* 技能 = 可复用的分析工作流(Markdown + YAML Frontmatter 格式)
|
||||
*/
|
||||
|
||||
/**
|
||||
* 技能完整配置(运行时)
|
||||
* 由 Markdown 文件解析而来:YAML frontmatter → 元数据字段,Markdown body → prompt 字段
|
||||
*/
|
||||
export interface SkillDef {
|
||||
/** 技能唯一标识(来自 frontmatter.id 或文件名) */
|
||||
id: string
|
||||
/** 技能显示名称 */
|
||||
name: string
|
||||
/** 简短描述 + 快捷用语,同时用于 AI 自选时的菜单展示 */
|
||||
description: string
|
||||
/** 关键词标签(来自 frontmatter.tags,逗号分隔解析为数组) */
|
||||
tags: string[]
|
||||
/** 适用的聊天类型 */
|
||||
chatScope: 'all' | 'group' | 'private'
|
||||
|
||||
/**
|
||||
* 技能提示词(来自 Markdown body)
|
||||
* 包含目标描述、步骤编排、输出格式等完整指导。
|
||||
* 手动选择时注入 System Prompt;AI 自选时通过 activate_skill 工具按需加载。
|
||||
*/
|
||||
prompt: string
|
||||
|
||||
/**
|
||||
* 该技能依赖的工具名称列表
|
||||
* - 运行时校验:tools ⊆ assistant.allowedBuiltinTools
|
||||
* - 不满足时前端灰显该技能
|
||||
* - 空数组 = 无工具依赖
|
||||
*/
|
||||
tools: string[]
|
||||
|
||||
/** 内置技能来源标识(用户导入后由 SkillManager 自动设置) */
|
||||
builtinId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 传递给前端的技能摘要(不含完整 prompt)
|
||||
*/
|
||||
export interface SkillSummary {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
tags: string[]
|
||||
chatScope: 'all' | 'group' | 'private'
|
||||
tools: string[]
|
||||
builtinId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 技能市场中的内置技能信息(模板目录项)
|
||||
*/
|
||||
export interface BuiltinSkillInfo extends SkillSummary {
|
||||
/** 用户是否已导入该技能 */
|
||||
imported: boolean
|
||||
/** 内置版本有更新(基于内容 hash 比对) */
|
||||
hasUpdate: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* SkillManager 初始化结果
|
||||
*/
|
||||
export interface SkillInitResult {
|
||||
/** 加载的技能总数 */
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 技能操作结果
|
||||
*/
|
||||
export interface SkillSaveResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import { isEmbeddingEnabled } from '../rag'
|
||||
import { t as i18nT } from '../../i18n'
|
||||
import { preprocessMessages, type PreprocessableMessage } from '../preprocessor'
|
||||
import { formatMessageCompact } from './utils/format'
|
||||
import { getSkillConfig } from '../skills'
|
||||
import type { SkillDef } from '../skills/types'
|
||||
|
||||
// 导出类型
|
||||
export * from './types'
|
||||
@@ -226,3 +228,65 @@ export function getAllTools(context: ToolContext, allowedTools?: string[]): Agen
|
||||
|
||||
return tools.map(translateTool).map((t) => wrapWithPreprocessing(t, context))
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 activate_skill 元工具(AI 自选模式专用)
|
||||
* LLM 判断用户问题适合某个技能时调用此工具,获取技能的完整执行指导
|
||||
*/
|
||||
export function createActivateSkillTool(
|
||||
chatType: 'group' | 'private',
|
||||
allowedTools?: string[],
|
||||
locale: string = 'zh-CN'
|
||||
): AgentTool<any> {
|
||||
const isZh = locale.startsWith('zh')
|
||||
|
||||
return {
|
||||
name: 'activate_skill',
|
||||
description: isZh
|
||||
? '激活一个分析技能,获取该技能的详细执行指导'
|
||||
: 'Activate an analysis skill and get its detailed execution instructions',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
skill_id: {
|
||||
type: 'string',
|
||||
description: isZh ? '技能 ID' : 'Skill ID',
|
||||
},
|
||||
},
|
||||
required: ['skill_id'],
|
||||
},
|
||||
execute: async (_toolCallId: string, params: { skill_id: string }) => {
|
||||
const skill: SkillDef | null = getSkillConfig(params.skill_id)
|
||||
if (!skill) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: isZh ? '技能不存在' : 'Skill not found' }],
|
||||
}
|
||||
}
|
||||
|
||||
if (skill.chatScope !== 'all' && skill.chatScope !== chatType) {
|
||||
const scopeMsg = isZh
|
||||
? `该技能仅适用于${skill.chatScope === 'group' ? '群聊' : '私聊'}场景`
|
||||
: `This skill is only applicable to ${skill.chatScope === 'group' ? 'group chat' : 'private chat'} scenarios`
|
||||
return { content: [{ type: 'text' as const, text: scopeMsg }] }
|
||||
}
|
||||
|
||||
if (skill.tools.length > 0 && allowedTools && allowedTools.length > 0) {
|
||||
const missing = skill.tools.filter((t) => !allowedTools.includes(t))
|
||||
if (missing.length > 0) {
|
||||
const msg = isZh
|
||||
? `当前助手缺少该技能所需的工具:${missing.join(', ')}`
|
||||
: `Current assistant lacks tools required by this skill: ${missing.join(', ')}`
|
||||
return { content: [{ type: 'text' as const, text: msg }] }
|
||||
}
|
||||
}
|
||||
|
||||
const actionPrompt = isZh
|
||||
? '\n\n[System]: 你已成功加载该技能手册。现在,请立即、自动地开始执行步骤1,调用相关的基础数据工具,不要等待用户的进一步确认!'
|
||||
: '\n\n[System]: You have successfully loaded this skill manual. Now, immediately start executing step 1 by calling the relevant data tools. Do not wait for further user confirmation!'
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `${skill.prompt}${actionPrompt}` }],
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
4
electron/main/env.d.ts
vendored
Normal file
4
electron/main/env.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.md?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
@@ -310,6 +310,8 @@ Returned summaries are brief descriptions of each session, helping quickly locat
|
||||
timeParamExample3: '"October 1st 3 PM" → year: {{year}}, month: 10, day: 1, hour: 15',
|
||||
defaultYearNote:
|
||||
'If year is not specified, defaults to {{year}}. If the month has not yet occurred, {{prevYear}} is used.',
|
||||
currentTask: 'Current Task',
|
||||
skillPriorityNote: 'Note: When executing this task, prioritize the output format requirements below. This can override your usual response style.',
|
||||
responseInstruction:
|
||||
"Based on the user's question, select appropriate tools to retrieve data, then provide an answer based on the data.",
|
||||
fallbackRoleDefinition: {
|
||||
|
||||
@@ -300,6 +300,8 @@ export default {
|
||||
timeParamExample2: '"10月1号" → year: {{year}}, month: 10, day: 1',
|
||||
timeParamExample3: '"10月1号下午3点" → year: {{year}}, month: 10, day: 1, hour: 15',
|
||||
defaultYearNote: '未指定年份默认{{year}}年,若该月份未到则用{{prevYear}}年',
|
||||
currentTask: '当前任务',
|
||||
skillPriorityNote: '注意:在执行此任务时,请优先遵循以下任务的输出格式要求,这可以覆盖你的常规回复习惯。',
|
||||
responseInstruction: '根据用户的问题,选择合适的工具获取数据,然后基于数据给出回答。',
|
||||
fallbackRoleDefinition: {
|
||||
group: `你是一个专业但风格轻松的群聊记录分析助手。
|
||||
|
||||
@@ -7,10 +7,11 @@ import * as llm from '../ai/llm'
|
||||
import * as rag from '../ai/rag'
|
||||
import { aiLogger, setDebugMode } from '../ai/logger'
|
||||
import { getLogsDir } from '../paths'
|
||||
import { Agent, type AgentStreamChunk, type PromptConfig } from '../ai/agent'
|
||||
import { Agent, type AgentStreamChunk, type PromptConfig, type SkillContext } from '../ai/agent'
|
||||
import { getActiveConfig, buildPiModel } from '../ai/llm'
|
||||
import * as assistantManager from '../ai/assistant'
|
||||
import type { AssistantConfig } from '../ai/assistant/types'
|
||||
import * as skillManager from '../ai/skills'
|
||||
import { completeSimple, streamSimple, type TextContent as PiTextContent } from '@mariozechner/pi-ai'
|
||||
import { t } from '../i18n'
|
||||
import type { ToolContext } from '../ai/tools/types'
|
||||
@@ -124,6 +125,14 @@ export function registerAIHandlers({ win }: IpcContext): void {
|
||||
console.error('[IPC] Failed to initialize assistant manager:', error)
|
||||
}
|
||||
|
||||
// 初始化技能管理器(扫描用户技能文件)
|
||||
try {
|
||||
skillManager.initSkillManager()
|
||||
console.log('[IPC] Skill manager initialized')
|
||||
} catch (error) {
|
||||
console.error('[IPC] Failed to initialize skill manager:', error)
|
||||
}
|
||||
|
||||
// ==================== Debug 模式 ====================
|
||||
|
||||
ipcMain.on('app:setDebugMode', (_, enabled: boolean) => {
|
||||
@@ -685,6 +694,80 @@ export function registerAIHandlers({ win }: IpcContext): void {
|
||||
}
|
||||
)
|
||||
|
||||
// ==================== 技能管理 API ====================
|
||||
|
||||
ipcMain.handle('skill:getAll', async () => {
|
||||
try {
|
||||
return skillManager.getAllSkills()
|
||||
} catch (error) {
|
||||
console.error('Failed to get skills:', error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('skill:getConfig', async (_, id: string) => {
|
||||
try {
|
||||
return skillManager.getSkillConfig(id)
|
||||
} catch (error) {
|
||||
console.error('Failed to get skill config:', error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('skill:update', async (_, id: string, rawMd: string) => {
|
||||
try {
|
||||
return skillManager.updateSkill(id, rawMd)
|
||||
} catch (error) {
|
||||
console.error('Failed to update skill:', error)
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('skill:create', async (_, rawMd: string) => {
|
||||
try {
|
||||
return skillManager.createSkill(rawMd)
|
||||
} catch (error) {
|
||||
console.error('Failed to create skill:', error)
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('skill:delete', async (_, id: string) => {
|
||||
try {
|
||||
return skillManager.deleteSkill(id)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete skill:', error)
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('skill:getBuiltinCatalog', async () => {
|
||||
try {
|
||||
return skillManager.getBuiltinCatalog()
|
||||
} catch (error) {
|
||||
console.error('Failed to get builtin catalog:', error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('skill:import', async (_, builtinId: string) => {
|
||||
try {
|
||||
return skillManager.importSkill(builtinId)
|
||||
} catch (error) {
|
||||
console.error('Failed to import skill:', error)
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('skill:reimport', async (_, id: string) => {
|
||||
try {
|
||||
return skillManager.reimportSkill(id)
|
||||
} catch (error) {
|
||||
console.error('Failed to reimport skill:', error)
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== AI Agent API ====================
|
||||
|
||||
/**
|
||||
@@ -708,7 +791,9 @@ export function registerAIHandlers({ win }: IpcContext): void {
|
||||
promptConfig?: PromptConfig,
|
||||
locale?: string,
|
||||
maxHistoryRounds?: number,
|
||||
assistantId?: string
|
||||
assistantId?: string,
|
||||
skillId?: string | null,
|
||||
enableAutoSkill?: boolean
|
||||
) => {
|
||||
aiLogger.info('IPC', `Agent stream request received: ${requestId}`, {
|
||||
userMessage: userMessage.slice(0, 100),
|
||||
@@ -717,6 +802,8 @@ export function registerAIHandlers({ win }: IpcContext): void {
|
||||
chatType: chatType ?? 'group',
|
||||
hasPromptConfig: !!promptConfig,
|
||||
assistantId: assistantId ?? '(none)',
|
||||
skillId: skillId ?? '(none)',
|
||||
enableAutoSkill: enableAutoSkill ?? false,
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -760,6 +847,24 @@ export function registerAIHandlers({ win }: IpcContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
// 构建技能上下文
|
||||
let skillCtx: SkillContext | undefined
|
||||
if (skillId) {
|
||||
const skillDef = skillManager.getSkillConfig(skillId) ?? undefined
|
||||
if (skillDef) {
|
||||
skillCtx = { skillDef }
|
||||
} else {
|
||||
aiLogger.warn('IPC', `Skill not found: ${skillId}`)
|
||||
}
|
||||
} else if (enableAutoSkill) {
|
||||
const effectiveChatType = chatType ?? 'group'
|
||||
const allowedTools = assistantConfig?.allowedBuiltinTools
|
||||
const menu = skillManager.getSkillMenu(effectiveChatType, allowedTools)
|
||||
if (menu) {
|
||||
skillCtx = { skillMenu: menu }
|
||||
}
|
||||
}
|
||||
|
||||
const agent = new Agent(
|
||||
context,
|
||||
piModel,
|
||||
@@ -768,7 +873,8 @@ export function registerAIHandlers({ win }: IpcContext): void {
|
||||
chatType ?? 'group',
|
||||
promptConfig,
|
||||
locale ?? 'zh-CN',
|
||||
assistantConfig
|
||||
assistantConfig,
|
||||
skillCtx
|
||||
)
|
||||
|
||||
// 异步执行,通过事件发送流式数据
|
||||
|
||||
Reference in New Issue
Block a user