feat: 聊天对话支持使用技能

This commit is contained in:
digua
2026-03-15 14:01:43 +08:00
committed by digua
parent 95fc70ae4b
commit 7a1a9fc2b1
43 changed files with 9033 additions and 7190 deletions

View File

@@ -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)
}

View File

@@ -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}`
}

View File

@@ -67,3 +67,14 @@ export interface PromptConfig {
/** 系统提示词(角色定义 + 回答要求,统一为单一字段) */
systemPrompt: string
}
/**
* 技能上下文(传递给 prompt-builder
* 手动选择和 AI 自选两种模式互斥
*/
export interface SkillContext {
/** 手动选择时传入完整 SkillDefAI 自选时为 undefined */
skillDef?: import('../skills/types').SkillDef
/** AI 自选时传入技能菜单文本,手动选择时为 undefined */
skillMenu?: string
}

View 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 找出最活跃的成员进行分析
- 如果某个维度数据不足,在画像中说明而非编造

View 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 条关于群内社交状态的观察
## 注意事项
- 所有结论必须基于工具返回的真实数据
- 避免对成员关系做过度解读或主观推测
- 保护成员隐私,不过度暴露私密对话内容

View 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 个代表性观点或结论
- **活跃参与者**:主要参与讨论的成员
#### 📊 趋势观察
- 与过去相比,哪些话题是新兴的
- 哪些话题持续火热
- 是否有值得关注的趋势转变
## 注意事项
- 所有结论必须基于工具返回的真实数据
- 话题分类应基于实际讨论内容,不要预设类别
- 如果数据不足以支撑趋势分析,如实说明

View 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 条具体可执行的运营建议
## 注意事项
- 所有结论必须基于工具返回的真实数据
- 如果某个维度数据不足,在报告中说明而非编造
- 保持报告紧凑,每个部分控制在合理篇幅内

View 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'

View 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')
}

View 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'
}

View 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 PromptAI 自选时通过 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
}

View File

@@ -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
View File

@@ -0,0 +1,4 @@
declare module '*.md?raw' {
const content: string
export default content
}

View File

@@ -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: {

View File

@@ -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: `你是一个专业但风格轻松的群聊记录分析助手。

View File

@@ -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
)
// 异步执行,通过事件发送流式数据