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
+20 -8
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)
}
+14 -3
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}`
}
+11
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
}
@@ -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 找出最活跃的成员进行分析
- 如果某个维度数据不足,在画像中说明而非编造
@@ -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 条关于群内社交状态的观察
## 注意事项
- 所有结论必须基于工具返回的真实数据
- 避免对成员关系做过度解读或主观推测
- 保护成员隐私,不过度暴露私密对话内容
@@ -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 个代表性观点或结论
- **活跃参与者**:主要参与讨论的成员
#### 📊 趋势观察
- 与过去相比,哪些话题是新兴的
- 哪些话题持续火热
- 是否有值得关注的趋势转变
## 注意事项
- 所有结论必须基于工具返回的真实数据
- 话题分类应基于实际讨论内容,不要预设类别
- 如果数据不足以支撑趋势分析,如实说明
@@ -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
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'
+365
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')
}
+68
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'
}
+78
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
}
+64
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
View File
@@ -0,0 +1,4 @@
declare module '*.md?raw' {
const content: string
export default content
}
+2
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: {
+2
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: `你是一个专业但风格轻松的群聊记录分析助手。
+109 -3
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
)
// 异步执行,通过事件发送流式数据
+69 -2
View File
@@ -39,6 +39,7 @@ export type ContentBlock =
params?: Record<string, unknown>
}
}
| { type: 'skill'; skillId: string; skillName: string }
export interface AIMessage {
id: string
@@ -761,6 +762,68 @@ export const assistantApi = {
},
}
// ==================== Skill API ====================
export interface SkillSummary {
id: string
name: string
description: string
tags: string[]
chatScope: 'all' | 'group' | 'private'
tools: string[]
builtinId?: string
}
export interface SkillConfigFull {
id: string
name: string
description: string
tags: string[]
chatScope: 'all' | 'group' | 'private'
prompt: string
tools: string[]
builtinId?: string
}
export interface BuiltinSkillInfo extends SkillSummary {
imported: boolean
hasUpdate: boolean
}
export const skillApi = {
getAll: (): Promise<SkillSummary[]> => {
return ipcRenderer.invoke('skill:getAll')
},
getConfig: (id: string): Promise<SkillConfigFull | null> => {
return ipcRenderer.invoke('skill:getConfig', id)
},
update: (id: string, rawMd: string): Promise<{ success: boolean; error?: string }> => {
return ipcRenderer.invoke('skill:update', id, rawMd)
},
create: (rawMd: string): Promise<{ success: boolean; id?: string; error?: string }> => {
return ipcRenderer.invoke('skill:create', rawMd)
},
delete: (id: string): Promise<{ success: boolean; error?: string }> => {
return ipcRenderer.invoke('skill:delete', id)
},
getBuiltinCatalog: (): Promise<BuiltinSkillInfo[]> => {
return ipcRenderer.invoke('skill:getBuiltinCatalog')
},
importSkill: (builtinId: string): Promise<{ success: boolean; id?: string; error?: string }> => {
return ipcRenderer.invoke('skill:import', builtinId)
},
reimportSkill: (id: string): Promise<{ success: boolean; error?: string }> => {
return ipcRenderer.invoke('skill:reimport', id)
},
}
// ==================== Agent API ====================
export const agentApi = {
@@ -781,7 +844,9 @@ export const agentApi = {
promptConfig?: PromptConfig,
locale?: string,
maxHistoryRounds?: number,
assistantId?: string
assistantId?: string,
skillId?: string | null,
enableAutoSkill?: boolean
): { requestId: string; promise: Promise<{ success: boolean; result?: AgentResult; error?: string }> } => {
// 防御性处理:确保传给 IPC 的 context 是“可结构化克隆”的纯对象
// 避免调用方误传入响应式 Proxy(例如 Pinia/Vue state)导致 invoke 失败
@@ -861,7 +926,9 @@ export const agentApi = {
promptConfig,
locale,
maxHistoryRounds,
assistantId
assistantId,
skillId,
enableAutoSkill
)
.then((result) => {
console.log('[preload] Agent invoke 返回:', result)
+48 -1
View File
@@ -298,6 +298,7 @@ type AIContentBlock =
params?: Record<string, unknown>
}
}
| { type: 'skill'; skillId: string; skillName: string }
interface AIMessage {
id: string
@@ -671,7 +672,9 @@ interface AgentApi {
promptConfig?: PromptConfig,
locale?: string,
maxHistoryRounds?: number,
assistantId?: string
assistantId?: string,
skillId?: string | null,
enableAutoSkill?: boolean
) => { requestId: string; promise: Promise<{ success: boolean; result?: AgentResult; error?: string }> }
abort: (requestId: string) => Promise<{ success: boolean; error?: string }>
}
@@ -741,6 +744,45 @@ interface AssistantApi {
}) => Promise<{ success: boolean; filePath?: string; error?: string }>
}
// ==================== 技能管理 ====================
interface SkillSummary {
id: string
name: string
description: string
tags: string[]
chatScope: 'all' | 'group' | 'private'
tools: string[]
builtinId?: string
}
interface SkillConfigFull {
id: string
name: string
description: string
tags: string[]
chatScope: 'all' | 'group' | 'private'
prompt: string
tools: string[]
builtinId?: string
}
interface BuiltinSkillInfo extends SkillSummary {
imported: boolean
hasUpdate: boolean
}
interface SkillApi {
getAll: () => Promise<SkillSummary[]>
getConfig: (id: string) => Promise<SkillConfigFull | null>
update: (id: string, rawMd: string) => Promise<{ success: boolean; error?: string }>
create: (rawMd: string) => Promise<{ success: boolean; id?: string; error?: string }>
delete: (id: string) => Promise<{ success: boolean; error?: string }>
getBuiltinCatalog: () => Promise<BuiltinSkillInfo[]>
importSkill: (builtinId: string) => Promise<{ success: boolean; id?: string; error?: string }>
reimportSkill: (id: string) => Promise<{ success: boolean; error?: string }>
}
// Cache API 类型
interface CacheDirectoryInfo {
id: string
@@ -934,6 +976,7 @@ declare global {
embeddingApi: EmbeddingApi
agentApi: AgentApi
assistantApi: AssistantApi
skillApi: SkillApi
cacheApi: CacheApi
networkApi: NetworkApi
sessionApi: SessionApi
@@ -956,6 +999,10 @@ export {
AssistantConfigFull,
BuiltinAssistantInfo,
BuiltinSqlToolInfo,
SkillApi,
SkillSummary,
SkillConfigFull,
BuiltinSkillInfo,
CacheApi,
NetworkApi,
NlpApi,
+4 -1
View File
@@ -8,7 +8,7 @@ import { electronAPI } from '@electron-toolkit/preload'
// 从拆分的模块导入 API
import { extendedApi } from './apis/core'
import { chatApi, mergeApi } from './apis/chat'
import { aiApi, llmApi, agentApi, embeddingApi, assistantApi } from './apis/ai'
import { aiApi, llmApi, agentApi, embeddingApi, assistantApi, skillApi } from './apis/ai'
import { nlpApi, networkApi, cacheApi, sessionApi } from './apis/utils'
// Use `contextBridge` APIs to expose Electron APIs to
@@ -25,6 +25,7 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld('agentApi', agentApi)
contextBridge.exposeInMainWorld('embeddingApi', embeddingApi)
contextBridge.exposeInMainWorld('assistantApi', assistantApi)
contextBridge.exposeInMainWorld('skillApi', skillApi)
contextBridge.exposeInMainWorld('cacheApi', cacheApi)
contextBridge.exposeInMainWorld('networkApi', networkApi)
contextBridge.exposeInMainWorld('sessionApi', sessionApi)
@@ -52,6 +53,8 @@ if (process.contextIsolated) {
// @ts-ignore (define in dts)
window.assistantApi = assistantApi
// @ts-ignore (define in dts)
window.skillApi = skillApi
// @ts-ignore (define in dts)
window.cacheApi = cacheApi
// @ts-ignore (define in dts)
window.networkApi = networkApi
+1
View File
@@ -43,6 +43,7 @@
"echarts": "^6.0.0",
"echarts-wordcloud": "^2.1.0",
"electron-updater": "^6.6.2",
"gray-matter": "^4.0.3",
"i18next": "^25.8.5",
"markdown-it": "^14.1.0",
"node-machine-id": "^1.1.12",
+5973 -7144
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,385 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import SlashCommandMenu from './SlashCommandMenu.vue'
import { useSkillStore, type SkillSummary } from '@/stores/skill'
const { t } = useI18n()
const props = defineProps<{
disabled?: boolean
status?: 'ready' | 'submitted' | 'streaming' | 'error'
chatType: 'group' | 'private'
}>()
const emit = defineEmits<{
send: [content: string]
stop: []
manageSkills: []
skillActivated: [skill: SkillSummary]
}>()
const skillStore = useSkillStore()
const { compatibleSkills, activeSkill, activeSkillId, isLoaded } = storeToRefs(skillStore)
const rootRef = ref<HTMLElement | null>(null)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const inputValue = ref('')
const showSlashMenu = ref(false)
const slashFilter = ref('')
const highlightIndex = ref(0)
const isComposing = ref(false)
const dismissedSlashValue = ref<string | null>(null)
const canSubmit = computed(() => inputValue.value.trim().length > 0 && !props.disabled)
const inputPlaceholder = computed(() => {
if (activeSkill.value && inputValue.value.trim().length === 0) {
return t('ai.chat.input.placeholderWithActiveSkill', { name: activeSkill.value.name })
}
return t('ai.chat.input.placeholderWithSlash')
})
const sendButtonTitle = computed(() => {
if (props.status === 'streaming') {
return ''
}
if (canSubmit.value) {
return t('ai.chat.input.send')
}
if (activeSkill.value) {
return t('ai.chat.input.needMoreThanSkill')
}
return t('ai.chat.input.needQuestion')
})
const filteredSkills = computed(() => {
const keyword = slashFilter.value.trim().toLocaleLowerCase()
if (!keyword) return compatibleSkills.value
return compatibleSkills.value.filter((skill) => {
const haystack = [skill.name, skill.description, skill.tags.join(' ')].join(' ').toLocaleLowerCase()
return haystack.includes(keyword)
})
})
function syncTextareaHeight() {
if (!textareaRef.value) return
const textarea = textareaRef.value
textarea.style.height = 'auto'
// 默认展示两行,最多扩展到约 8 行,避免输入框过高挤压消息区。
const maxHeight = 192
const nextHeight = Math.min(textarea.scrollHeight, maxHeight)
textarea.style.height = `${nextHeight}px`
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden'
}
function focusTextarea() {
textareaRef.value?.focus()
}
function resetSlashState() {
showSlashMenu.value = false
slashFilter.value = ''
highlightIndex.value = 0
}
function dismissSlashMenu() {
if (/^\s*\/([^\n]*)$/.test(inputValue.value)) {
dismissedSlashValue.value = inputValue.value
}
resetSlashState()
}
function updateSlashState(value: string) {
if (props.disabled) {
resetSlashState()
return
}
if (dismissedSlashValue.value && dismissedSlashValue.value !== value) {
dismissedSlashValue.value = null
}
// 只在输入开头检测 slash,避免普通文本中的 / 误触发技能菜单。
const match = value.match(/^\s*\/([^\n]*)$/)
if (!match) {
resetSlashState()
return
}
slashFilter.value = match[1]
if (dismissedSlashValue.value === value) {
showSlashMenu.value = false
return
}
showSlashMenu.value = true
highlightIndex.value = 0
}
function clearActiveSkill() {
skillStore.activateSkill(null)
nextTick(focusTextarea)
}
function openSkillSelector() {
if (props.disabled) return
// 从外部快捷入口进入技能选择时,统一回到 slash 模式并清掉旧技能上下文。
if (activeSkillId.value) {
skillStore.activateSkill(null)
}
inputValue.value = '/'
dismissedSlashValue.value = null
updateSlashState(inputValue.value)
nextTick(() => {
syncTextareaHeight()
focusTextarea()
textareaRef.value?.setSelectionRange(1, 1)
})
}
function fillInput(content: string) {
if (props.disabled) return
// 预设问题只回填到输入框,保留用户二次编辑的机会。
inputValue.value = content
dismissedSlashValue.value = null
nextTick(() => {
syncTextareaHeight()
focusTextarea()
const cursor = inputValue.value.length
textareaRef.value?.setSelectionRange(cursor, cursor)
})
}
function handleSubmit() {
if (!canSubmit.value) return
emit('send', inputValue.value.trim())
inputValue.value = ''
dismissedSlashValue.value = null
// 技能为单次消息生效:发送后立即清空,下一次提问需重新选择。
if (activeSkillId.value) {
skillStore.activateSkill(null)
}
}
function handleSelectSkill(skill: SkillSummary) {
if (props.disabled) return
skillStore.activateSkill(skill.id)
emit('skillActivated', skill)
// slash 选择技能只改变当前上下文,不应替用户生成一条消息。
inputValue.value = ''
dismissedSlashValue.value = null
resetSlashState()
nextTick(() => {
focusTextarea()
})
}
function handleManageSkills() {
dismissSlashMenu()
emit('manageSkills')
}
function moveHighlight(step: 1 | -1) {
if (!filteredSkills.value.length) return
const total = filteredSkills.value.length
highlightIndex.value = (highlightIndex.value + step + total) % total
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Backspace' && inputValue.value.length === 0 && activeSkillId.value) {
event.preventDefault()
clearActiveSkill()
return
}
if (showSlashMenu.value) {
if (event.key === 'ArrowDown') {
event.preventDefault()
moveHighlight(1)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
moveHighlight(-1)
return
}
if (event.key === 'Escape') {
event.preventDefault()
dismissSlashMenu()
return
}
if (event.key === 'Enter' && !event.shiftKey && !isComposing.value) {
event.preventDefault()
const skill = filteredSkills.value[highlightIndex.value]
if (skill) {
handleSelectSkill(skill)
}
return
}
}
if (event.key === 'Enter' && !event.shiftKey && !isComposing.value) {
event.preventDefault()
handleSubmit()
}
}
function handleDocumentMouseDown(event: MouseEvent) {
if (!showSlashMenu.value || !rootRef.value) return
const target = event.target
if (target instanceof Node && !rootRef.value.contains(target)) {
dismissSlashMenu()
}
}
watch(
() => props.chatType,
(chatType) => {
skillStore.setFilterContext(chatType)
},
{ immediate: true }
)
watch(inputValue, async (value) => {
updateSlashState(value)
await nextTick()
syncTextareaHeight()
})
watch(
filteredSkills,
(skills) => {
if (skills.length === 0) {
highlightIndex.value = 0
return
}
if (highlightIndex.value >= skills.length) {
highlightIndex.value = skills.length - 1
}
},
{ immediate: true }
)
watch(
() => props.disabled,
(disabled) => {
if (disabled) {
dismissSlashMenu()
}
}
)
onMounted(async () => {
if (!isLoaded.value) {
await skillStore.loadSkills()
}
await nextTick()
syncTextareaHeight()
document.addEventListener('mousedown', handleDocumentMouseDown)
})
onBeforeUnmount(() => {
document.removeEventListener('mousedown', handleDocumentMouseDown)
})
defineExpose({
fillInput,
openSkillSelector,
})
</script>
<template>
<div class="shrink-0 pt-2 pb-2">
<div ref="rootRef" class="relative w-full max-w-4xl mx-auto">
<SlashCommandMenu
:visible="showSlashMenu"
:skills="filteredSkills"
:highlight-index="highlightIndex"
:active-skill-id="activeSkillId"
@select="handleSelectSkill"
@close="dismissSlashMenu"
@manage="handleManageSkills"
@highlight="highlightIndex = $event"
/>
<div
class="flex flex-col overflow-hidden rounded-2xl bg-white shadow-[0_2px_14px_rgba(0,0,0,0.04)] ring-1 ring-gray-200 transition-all dark:bg-gray-900 dark:ring-gray-800"
:class="
props.disabled
? 'bg-gray-50/50 dark:bg-gray-900/50'
: 'focus-within:ring-primary-500/50 focus-within:shadow-[0_4px_20px_rgba(0,0,0,0.08)] dark:focus-within:ring-primary-500/50'
"
>
<div class="relative px-4 pt-2.5 pb-2.5">
<!-- 技能标签与输入框同排显示形成视觉内联 slash command 效果 -->
<div class="flex items-start gap-2 pr-10">
<div
v-if="activeSkill"
class="inline-flex max-w-[180px] shrink-0 items-center rounded-md bg-primary-50 px-2 text-sm leading-6 font-medium text-primary-700 dark:bg-primary-500/10 dark:text-primary-400"
>
<span class="truncate">/{{ activeSkill.name }}</span>
</div>
<textarea
ref="textareaRef"
v-model="inputValue"
rows="2"
class="min-h-[48px] min-w-0 flex-1 resize-none border-0 bg-transparent px-0 py-0 text-sm leading-6 text-gray-900 outline-none placeholder:text-gray-400 disabled:cursor-not-allowed disabled:text-gray-400 dark:text-gray-100 dark:placeholder:text-gray-500 dark:disabled:text-gray-500"
:disabled="props.disabled"
:placeholder="inputPlaceholder"
@keydown="handleKeydown"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
/>
</div>
<button
v-if="props.status === 'streaming'"
type="button"
class="absolute right-3 bottom-2 flex h-7 w-7 items-center justify-center rounded-full bg-primary-500 text-white shadow-sm transition-colors hover:bg-primary-600"
@click="emit('stop')"
>
<UIcon name="i-heroicons-stop-16-solid" class="h-3.5 w-3.5" />
</button>
<button
v-else
type="button"
class="absolute right-3 bottom-2 flex h-7 w-7 items-center justify-center rounded-full transition-all duration-200"
:class="
canSubmit
? 'bg-primary-500 text-white hover:bg-primary-600 shadow-sm'
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500'
"
:disabled="!canSubmit"
:title="sendButtonTitle"
@click="handleSubmit"
>
<UIcon name="i-heroicons-arrow-up-20-solid" class="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
</template>
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import ConversationList from './ConversationList.vue'
import DataSourcePanel from './DataSourcePanel.vue'
import ChatMessage from './ChatMessage.vue'
import ChatInput from './ChatInput.vue'
import AIChatInput from './AIChatInput.vue'
import AIThinkingIndicator from './AIThinkingIndicator.vue'
import ChatStatusBar from './ChatStatusBar.vue'
import { useAIChat } from '@/composables/useAIChat'
@@ -12,14 +12,18 @@ import CaptureButton from '@/components/common/CaptureButton.vue'
import AssistantSelector from './AssistantSelector.vue'
import AssistantConfigModal from './AssistantConfigModal.vue'
import AssistantMarketModal from './AssistantMarketModal.vue'
import SkillMarketModal from './SkillMarketModal.vue'
import SkillConfigModal from './SkillConfigModal.vue'
import PresetQuestions from './PresetQuestions.vue'
import { usePromptStore } from '@/stores/prompt'
import { useSettingsStore } from '@/stores/settings'
import { useAssistantStore } from '@/stores/assistant'
import { useSkillStore } from '@/stores/skill'
const { t } = useI18n()
const settingsStore = useSettingsStore()
const assistantStore = useAssistantStore()
const skillStore = useSkillStore()
// Props
const props = defineProps<{
@@ -59,6 +63,11 @@ const configModalAssistantId = ref<string | null>(null)
const configModalReadonly = ref(false)
const marketModalVisible = ref(false)
// 技能相关状态
const skillMarketModalVisible = ref(false)
const skillConfigModalVisible = ref(false)
const skillConfigModalSkillId = ref<string | null>(null)
// 当前选中助手的预设问题
const currentPresetQuestions = computed(() => {
return assistantStore.selectedAssistant?.presetQuestions ?? []
@@ -73,6 +82,10 @@ const hasLLMConfig = ref(false)
const isCheckingConfig = ref(true)
const messagesContainer = ref<HTMLElement | null>(null)
const conversationListRef = ref<InstanceType<typeof ConversationList> | null>(null)
const chatInputRef = ref<{
fillInput: (content: string) => void
openSkillSelector: () => void
} | null>(null)
// 智能滚动状态
const isStickToBottom = ref(true) // 是否粘在底部(自动滚动)
@@ -203,17 +216,50 @@ async function handleAssistantCreated(_id: string) {
// 返回助手选择
function handleBackToSelector() {
assistantStore.clearSelection()
skillStore.activateSkill(null)
showAssistantSelector.value = true
}
function handleOpenSkillMarket() {
skillMarketModalVisible.value = true
}
function handleSkillMarketConfigure(id: string) {
skillConfigModalSkillId.value = id
skillConfigModalVisible.value = true
}
function handleCreateSkill() {
skillConfigModalSkillId.value = null
skillConfigModalVisible.value = true
}
async function handleSkillConfigSaved() {
await skillStore.loadSkills()
}
async function handleSkillCreated(_id: string) {
await skillStore.loadSkills()
await skillStore.loadBuiltinCatalog()
}
// 助手配置保存后刷新列表
async function handleAssistantConfigSaved() {
await assistantStore.loadAssistants()
}
// 发送消息(包括从预设问题点击发送
// 预设问题只回填到输入框,方便用户继续编辑后再发送
function handlePresetQuestion(question: string) {
handleSend(question)
chatInputRef.value?.fillInput(question)
}
function handleUseSkillEntry() {
chatInputRef.value?.openSkillSelector()
}
// slash 技能选择在输入框内完成,这里保留事件钩子便于后续扩展联动。
function handleSkillActivated() {
scrollToBottom(true)
}
// 发送消息
@@ -400,6 +446,7 @@ watch(
<span>{{ assistantStore.selectedAssistant?.name || t('ai.assistant.fallbackName') }}</span>
</button>
</div>
<!-- 消息列表 -->
<div ref="messagesContainer" class="min-h-0 flex-1 overflow-y-auto p-4">
<div ref="conversationContentRef" class="mx-auto max-w-3xl space-y-4">
@@ -484,18 +531,27 @@ watch(
<!-- 预设问题气泡(仅在对话为空时显示) -->
<div v-if="messages.length === 0 && !isAIThinking" class="px-4 pb-2">
<div class="mx-auto max-w-3xl">
<PresetQuestions :questions="currentPresetQuestions" @select="handlePresetQuestion" />
<PresetQuestions
:questions="currentPresetQuestions"
:leading-action-label="t('ai.chat.input.useSkill')"
@select="handlePresetQuestion"
@leading-action="handleUseSkillEntry"
/>
</div>
</div>
<!-- 输入框区域 -->
<div class="px-4 pb-2">
<div class="mx-auto max-w-3xl">
<ChatInput
<AIChatInput
ref="chatInputRef"
:disabled="isAIThinking"
:status="isAIThinking ? 'streaming' : 'ready'"
:chat-type="currentChatType"
@send="handleSend"
@stop="handleStop"
@manage-skills="handleOpenSkillMarket"
@skill-activated="handleSkillActivated"
/>
<!-- 底部状态栏 -->
@@ -552,6 +608,23 @@ watch(
@view-config="handleMarketViewConfig"
@create="handleCreateAssistant"
/>
<!-- 技能管理弹窗 -->
<SkillMarketModal
:open="skillMarketModalVisible"
@update:open="skillMarketModalVisible = $event"
@configure="handleSkillMarketConfigure"
@create="handleCreateSkill"
/>
<!-- 技能配置弹窗 -->
<SkillConfigModal
:open="skillConfigModalVisible"
:skill-id="skillConfigModalSkillId"
@update:open="skillConfigModalVisible = $event"
@saved="handleSkillConfigSaved"
@created="handleSkillCreated"
/>
</div>
</template>
@@ -288,6 +288,15 @@ function formatToolParams(tool: ToolBlockContent): string {
</div>
</details>
<!-- 技能块 -->
<div
v-else-if="block.type === 'skill'"
class="inline-flex items-center gap-1.5 rounded-full border border-green-200 bg-green-50 px-3 py-1 text-xs font-medium text-green-700 dark:border-green-800/50 dark:bg-green-900/20 dark:text-green-400"
>
<UIcon name="i-heroicons-bolt" class="h-3.5 w-3.5" />
<span>{{ t('ai.skill.active.label', { name: block.skillName }) }}</span>
</div>
<!-- 工具块 -->
<div
v-else-if="block.type === 'tool'"
@@ -250,7 +250,7 @@ async function openAiLogFile() {
</script>
<template>
<div class="flex items-center justify-between px-1">
<div class="flex items-center justify-between">
<!-- 左侧预设选择器 + 模型切换器 -->
<div class="flex items-center gap-1">
<UPopover v-model:open="isPresetPopoverOpen" :ui="{ content: 'p-0' }">
@@ -1,24 +1,225 @@
<script setup lang="ts">
defineProps<{
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
questions: string[]
disabled?: boolean
leadingActionLabel?: string
}>()
const emit = defineEmits<{
select: [question: string]
leadingAction: []
}>()
type PresetItem =
| {
key: string
label: string
type: 'leadingAction'
}
| {
key: string
label: string
type: 'question'
question: string
}
const hasItems = computed(() => props.questions.length > 0 || Boolean(props.leadingActionLabel))
const containerRef = ref<HTMLElement | null>(null)
const measureRef = ref<HTMLElement | null>(null)
const showMoreMenu = ref(false)
const visibleCount = ref(0)
let resizeObserver: ResizeObserver | null = null
const chipClass =
'rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] leading-4 text-gray-600 transition-all hover:border-primary-300 hover:bg-primary-50 hover:text-primary-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:border-primary-600 dark:hover:bg-primary-950/30 dark:hover:text-primary-400'
const items = computed<PresetItem[]>(() => {
const result: PresetItem[] = []
if (props.leadingActionLabel) {
result.push({
key: 'leading-action',
label: props.leadingActionLabel,
type: 'leadingAction',
})
}
props.questions.forEach((question, index) => {
result.push({
key: `question-${index}`,
label: question,
type: 'question',
question,
})
})
return result
})
const hiddenCount = computed(() => Math.max(items.value.length - visibleCount.value, 0))
const visibleItems = computed(() => items.value.slice(0, visibleCount.value))
const hiddenItems = computed(() => items.value.slice(visibleCount.value))
const measureMoreLabel = computed(() => `${t('ai.chat.input.presetMore')}...`)
const toggleLabel = computed(() => `${t('ai.chat.input.presetMore')}...`)
function handleItemClick(item: PresetItem) {
showMoreMenu.value = false
if (item.type === 'leadingAction') {
emit('leadingAction')
return
}
emit('select', item.question)
}
function toggleMoreMenu() {
if (props.disabled || hiddenCount.value === 0) return
showMoreMenu.value = !showMoreMenu.value
}
function handleDocumentMouseDown(event: MouseEvent) {
if (!showMoreMenu.value || !containerRef.value) return
const target = event.target
if (target instanceof Node && !containerRef.value.contains(target)) {
showMoreMenu.value = false
}
}
function measureVisibleItems() {
if (!containerRef.value) return
const chips = Array.from(measureRef.value?.querySelectorAll<HTMLElement>('[data-measure-chip]') ?? [])
if (chips.length === 0) {
visibleCount.value = 0
return
}
const availableWidth = containerRef.value.clientWidth
if (!availableWidth) {
visibleCount.value = chips.length
return
}
const moreButton = measureRef.value?.querySelector<HTMLElement>('[data-measure-more]')
const chipWidths = chips.map((chip) => chip.offsetWidth)
const moreWidth = moreButton?.offsetWidth ?? 0
const gap = 8
let usedWidth = 0
let nextVisibleCount = 0
for (let index = 0; index < chipWidths.length; index += 1) {
const width = chipWidths[index]
const nextWidth = nextVisibleCount === 0 ? width : usedWidth + gap + width
const hasRemaining = index < chipWidths.length - 1
const reserveWidth = hasRemaining ? gap + moreWidth : 0
if (nextWidth + reserveWidth > availableWidth) {
break
}
usedWidth = nextWidth
nextVisibleCount += 1
}
// 至少展示一个标签,避免窄窗口下直接只剩“更多”。
visibleCount.value = Math.max(1, nextVisibleCount)
}
async function syncCollapsedLayout() {
if (!hasItems.value) return
await nextTick()
measureVisibleItems()
}
watch(
items,
async () => {
showMoreMenu.value = false
await syncCollapsedLayout()
},
{ deep: true, immediate: true }
)
watch(hiddenCount, (count) => {
if (count === 0) {
showMoreMenu.value = false
}
})
onMounted(async () => {
await syncCollapsedLayout()
document.addEventListener('mousedown', handleDocumentMouseDown)
if (typeof ResizeObserver === 'undefined' || !containerRef.value) return
resizeObserver = new ResizeObserver(() => {
showMoreMenu.value = false
measureVisibleItems()
})
resizeObserver.observe(containerRef.value)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
document.removeEventListener('mousedown', handleDocumentMouseDown)
})
</script>
<template>
<div v-if="questions.length > 0" class="flex flex-wrap gap-2">
<button
v-for="(question, index) in questions"
:key="index"
class="rounded-full border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 transition-all hover:border-primary-300 hover:bg-primary-50 hover:text-primary-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:border-primary-600 dark:hover:bg-primary-950/30 dark:hover:text-primary-400"
:disabled="disabled"
@click="emit('select', question)"
>
{{ question }}
</button>
<div v-if="hasItems" ref="containerRef" class="relative">
<div class="pointer-events-none absolute left-0 top-0 -z-10 h-0 overflow-hidden opacity-0" aria-hidden="true">
<div ref="measureRef" class="flex whitespace-nowrap gap-2">
<span v-for="item in items" :key="item.key" :class="chipClass" data-measure-chip>
{{ item.label }}
</span>
<span :class="chipClass" data-measure-more>
{{ measureMoreLabel }}
</span>
</div>
</div>
<!-- 主行保持单行展示但不裁掉更多的上浮面板 -->
<div class="relative z-10 flex flex-nowrap gap-2">
<button
v-for="item in visibleItems"
:key="item.key"
:class="chipClass"
:disabled="props.disabled"
@click="handleItemClick(item)"
>
{{ item.label }}
</button>
<div v-if="hiddenCount > 0" class="relative shrink-0">
<button :class="chipClass" :disabled="props.disabled" @click="toggleMoreMenu">
{{ toggleLabel }}
</button>
<!-- 隐藏标签直接向上展开并与主行保持同一套胶囊样式 -->
<div
v-if="showMoreMenu"
class="absolute right-0 bottom-full z-20 mb-2 flex w-[320px] max-w-[calc(100vw-3rem)] flex-wrap justify-end gap-2"
>
<button
v-for="item in hiddenItems"
:key="item.key"
:class="chipClass"
:disabled="props.disabled"
@click="handleItemClick(item)"
>
{{ item.label }}
</button>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { SkillSummary } from '@/stores/skill'
defineProps<{
skill: SkillSummary
selected?: boolean
disabled?: boolean
}>()
const emit = defineEmits<{
select: [id: string]
}>()
function getChatScopeIcon(scope: string): string {
if (scope === 'group') return 'i-heroicons-user-group'
if (scope === 'private') return 'i-heroicons-user'
return 'i-heroicons-globe-alt'
}
</script>
<template>
<div
class="group relative cursor-pointer rounded-lg border px-3 py-2 transition-all duration-150"
:class="[
disabled
? 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50 dark:border-gray-700 dark:bg-gray-800/50'
: selected
? 'border-primary-500 bg-primary-50 shadow-sm dark:border-primary-400 dark:bg-primary-950/30'
: 'border-gray-200 bg-white hover:border-primary-300 hover:shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:hover:border-primary-600',
]"
@click="!disabled && emit('select', skill.id)"
>
<div class="flex items-start gap-2">
<UIcon :name="getChatScopeIcon(skill.chatScope)" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-gray-400" />
<div class="min-w-0 flex-1">
<h4 class="truncate text-xs font-medium text-gray-900 dark:text-gray-100">
{{ skill.name }}
</h4>
<p class="mt-0.5 line-clamp-1 text-[11px] leading-relaxed text-gray-500 dark:text-gray-400">
{{ skill.description }}
</p>
</div>
</div>
<!-- Tags -->
<div v-if="skill.tags.length" class="mt-1.5 flex flex-wrap gap-1">
<span
v-for="tag in skill.tags.slice(0, 3)"
:key="tag"
class="rounded-full bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500 dark:bg-gray-700 dark:text-gray-400"
>
{{ tag }}
</span>
</div>
</div>
</template>
@@ -0,0 +1,152 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSkillStore } from '@/stores/skill'
const { t } = useI18n()
const props = defineProps<{
open: boolean
skillId: string | null
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
saved: []
created: [id: string]
}>()
const skillStore = useSkillStore()
const rawMd = ref('')
const isLoading = ref(false)
const isSaving = ref(false)
const isCreateMode = ref(false)
const defaultTemplate = `---
name: ""
description: ""
tags: []
chatScope: all
tools: []
---
`
watch(
() => [props.open, props.skillId] as const,
async ([open, id]) => {
if (!open) return
if (!id) {
isCreateMode.value = true
rawMd.value = defaultTemplate
return
}
isCreateMode.value = false
isLoading.value = true
try {
const config = await skillStore.getSkillConfig(id)
if (config) {
const frontmatter = [
'---',
`name: "${config.name}"`,
`description: "${config.description}"`,
`tags: [${config.tags.map((t) => `"${t}"`).join(', ')}]`,
`chatScope: ${config.chatScope}`,
config.tools.length ? `tools: [${config.tools.map((t) => `"${t}"`).join(', ')}]` : 'tools: []',
config.builtinId ? `builtinId: ${config.builtinId}` : '',
'---',
'',
config.prompt,
]
.filter(Boolean)
.join('\n')
rawMd.value = frontmatter
}
} finally {
isLoading.value = false
}
},
{ immediate: true }
)
async function handleSave() {
if (!rawMd.value.trim()) return
isSaving.value = true
try {
if (isCreateMode.value) {
const result = await skillStore.createSkill(rawMd.value)
if (result.success && result.id) {
emit('created', result.id)
emit('update:open', false)
}
} else if (props.skillId) {
const result = await skillStore.updateSkill(props.skillId, rawMd.value)
if (result.success) {
emit('saved')
emit('update:open', false)
}
}
} finally {
isSaving.value = false
}
}
</script>
<template>
<UModal :open="open" :ui="{ content: 'sm:max-w-2xl z-50' }" @update:open="emit('update:open', $event)">
<template #content>
<div class="p-6">
<!-- 标题 -->
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{ isCreateMode ? t('ai.skill.config.createTitle') : t('ai.skill.config.editTitle') }}
</h2>
<button
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
@click="emit('update:open', false)"
>
<UIcon name="i-heroicons-x-mark" class="h-5 w-5" />
</button>
</div>
<!-- 提示 -->
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
{{ t('ai.skill.config.rawEditorHint') }}
</p>
<!-- Markdown 编辑器 -->
<div v-if="isLoading" class="flex h-64 items-center justify-center text-gray-400">
<UIcon name="i-heroicons-arrow-path" class="mr-2 h-5 w-5 animate-spin" />
</div>
<textarea
v-else
v-model="rawMd"
class="h-[400px] w-full resize-none rounded-lg border border-gray-300 bg-gray-50 p-3 font-mono text-xs text-gray-900 focus:border-primary-500 focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
spellcheck="false"
/>
<!-- 底部按钮 -->
<div class="mt-4 flex justify-end gap-2">
<button
class="rounded-lg px-4 py-2 text-sm text-gray-500 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
@click="emit('update:open', false)"
>
{{ t('common.cancel') }}
</button>
<button
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-600 disabled:opacity-50"
:disabled="isSaving || !rawMd.trim()"
@click="handleSave"
>
{{ isCreateMode ? t('ai.skill.config.create') : t('ai.skill.config.save') }}
</button>
</div>
</div>
</template>
</UModal>
</template>
@@ -0,0 +1,261 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { useSkillStore, type BuiltinSkillInfo } from '@/stores/skill'
const { t } = useI18n()
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
configure: [id: string]
create: []
}>()
const skillStore = useSkillStore()
const { skills, builtinCatalog } = storeToRefs(skillStore)
const activeTab = ref<'local' | 'market'>('local')
const importing = new Set<string>()
onMounted(() => {
skillStore.loadBuiltinCatalog()
})
const sortedSkills = computed(() => {
return [...skills.value].sort((a, b) => a.name.localeCompare(b.name))
})
const sortedCatalog = computed(() => {
return [...builtinCatalog.value].sort((a, b) => a.name.localeCompare(b.name))
})
function getChatScopeLabel(scope: string): string {
if (scope === 'group') return t('ai.skill.config.chatScopeGroup')
if (scope === 'private') return t('ai.skill.config.chatScopePrivate')
return t('ai.skill.config.chatScopeAll')
}
function handleConfigure(id: string) {
emit('configure', id)
}
async function handleDelete(id: string) {
await skillStore.deleteSkill(id)
}
function handleCreate() {
emit('create')
}
async function handleImport(builtinId: string) {
if (importing.has(builtinId)) return
importing.add(builtinId)
try {
await skillStore.importSkill(builtinId)
} finally {
importing.delete(builtinId)
}
}
async function handleReimport(item: BuiltinSkillInfo) {
const userSkill = skills.value.find((s) => s.builtinId === item.id)
if (!userSkill) return
await skillStore.reimportSkill(userSkill.id)
}
</script>
<template>
<UModal :open="open" :ui="{ content: 'sm:max-w-xl z-50' }" @update:open="emit('update:open', $event)">
<template #content>
<div class="p-6">
<!-- 标题 -->
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-bold text-gray-900 dark:text-gray-100">{{ t('ai.skill.market.title') }}</h2>
<button
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
@click="emit('update:open', false)"
>
<UIcon name="i-heroicons-x-mark" class="h-5 w-5" />
</button>
</div>
<!-- Tab 切换 -->
<div class="mb-4 flex gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-800">
<button
class="flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
:class="
activeTab === 'local'
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400'
"
@click="activeTab = 'local'"
>
{{ t('ai.skill.market.tabs.local') }}
<span
v-if="skills.length"
class="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary-100 px-1 text-[10px] text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
>
{{ skills.length }}
</span>
</button>
<button
class="flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
:class="
activeTab === 'market'
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400'
"
@click="activeTab = 'market'"
>
{{ t('ai.skill.market.tabs.market') }}
</button>
</div>
<!-- 本地技能 Tab -->
<div v-show="activeTab === 'local'">
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">{{ t('ai.skill.market.localHint') }}</p>
<div class="max-h-[400px] space-y-3 overflow-y-auto pr-1">
<div
v-for="skill in sortedSkills"
:key="skill.id"
class="flex items-center gap-4 rounded-xl border border-gray-200 bg-white p-4 transition-colors dark:border-gray-700 dark:bg-gray-800"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ skill.name }}
</h3>
<span
class="inline-flex shrink-0 items-center rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{{ getChatScopeLabel(skill.chatScope) }}
</span>
</div>
<p class="mt-1 line-clamp-1 text-xs text-gray-500 dark:text-gray-400">
{{ skill.description }}
</p>
<div v-if="skill.tags.length" class="mt-1.5 flex flex-wrap gap-1">
<span
v-for="tag in skill.tags"
:key="tag"
class="rounded-full bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500 dark:bg-gray-700 dark:text-gray-400"
>
{{ tag }}
</span>
</div>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<button
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
:title="t('common.edit')"
@click.stop="handleConfigure(skill.id)"
>
<UIcon name="i-heroicons-pencil-square" class="h-4 w-4" />
</button>
<button
class="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title="t('common.delete')"
@click.stop="handleDelete(skill.id)"
>
<UIcon name="i-heroicons-trash" class="h-4 w-4" />
</button>
</div>
</div>
</div>
<!-- 新增技能按钮 -->
<button
type="button"
class="mt-3 flex w-full items-center justify-center gap-1.5 rounded-xl border-2 border-dashed border-gray-300 py-4 text-sm text-gray-500 transition-colors hover:border-primary-400 hover:text-primary-600 dark:border-gray-600 dark:text-gray-400 dark:hover:border-primary-500 dark:hover:text-primary-400"
@click="handleCreate"
>
<UIcon name="i-heroicons-plus" class="h-4 w-4" />
{{ t('ai.skill.market.addSkill') }}
</button>
<div v-if="sortedSkills.length === 0" class="py-12 text-center text-sm text-gray-400">
{{ t('ai.skill.market.noLocal') }}
</div>
</div>
<!-- 技能市场 Tab -->
<div v-show="activeTab === 'market'">
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">{{ t('ai.skill.market.marketHint') }}</p>
<div class="max-h-[400px] space-y-3 overflow-y-auto pr-1">
<div
v-for="item in sortedCatalog"
:key="item.id"
class="flex items-center gap-4 rounded-xl border border-gray-200 bg-white p-4 transition-colors dark:border-gray-700 dark:bg-gray-800"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ item.name }}
</h3>
<span
class="inline-flex shrink-0 items-center rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{{ getChatScopeLabel(item.chatScope) }}
</span>
<span
v-if="item.imported && item.hasUpdate"
class="inline-flex shrink-0 items-center rounded-full bg-orange-100 px-2 py-0.5 text-[10px] font-medium text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
>
{{ t('ai.skill.market.updateAvailable') }}
</span>
</div>
<p class="mt-1 line-clamp-1 text-xs text-gray-500 dark:text-gray-400">
{{ item.description }}
</p>
<div v-if="item.tags.length" class="mt-1.5 flex flex-wrap gap-1">
<span
v-for="tag in item.tags"
:key="tag"
class="rounded-full bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500 dark:bg-gray-700 dark:text-gray-400"
>
{{ tag }}
</span>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
v-if="item.imported && item.hasUpdate"
class="rounded-lg bg-orange-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-orange-600"
@click="handleReimport(item)"
>
{{ t('ai.skill.market.reimport') }}
</button>
<span
v-else-if="item.imported"
class="px-3 py-1.5 text-xs font-medium text-gray-400 dark:text-gray-500"
>
{{ t('ai.skill.market.imported') }}
</span>
<button
v-else
class="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-primary-600"
@click="handleImport(item.id)"
>
{{ t('ai.skill.market.import') }}
</button>
</div>
</div>
</div>
<div v-if="sortedCatalog.length === 0" class="py-12 text-center text-sm text-gray-400">
{{ t('ai.skill.market.noCatalog') }}
</div>
</div>
</div>
</template>
</UModal>
</template>
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { useSkillStore } from '@/stores/skill'
import SkillCard from './SkillCard.vue'
const { t } = useI18n()
const props = defineProps<{
chatType: 'group' | 'private'
}>()
const emit = defineEmits<{
manage: []
}>()
const skillStore = useSkillStore()
const { compatibleSkills, activeSkillId, isLoaded } = storeToRefs(skillStore)
watch(
() => props.chatType,
(chatType) => {
skillStore.setFilterContext(chatType)
},
{ immediate: true }
)
onMounted(async () => {
if (!isLoaded.value) {
await skillStore.loadSkills()
}
})
function handleSelectSkill(id: string) {
if (activeSkillId.value === id) {
skillStore.activateSkill(null)
} else {
skillStore.activateSkill(id)
}
}
function handleFreeChat() {
skillStore.activateSkill(null)
}
</script>
<template>
<div class="space-y-3 rounded-xl border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800">
<!-- 标题 + 管理入口 -->
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('ai.skill.selector.title') }}</span>
<button
class="text-[11px] text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
@click="emit('manage')"
>
{{ t('ai.skill.selector.manage') }}
</button>
</div>
<!-- 自由对话选项 -->
<div
class="cursor-pointer rounded-lg border px-3 py-2 transition-all duration-150"
:class="[
!activeSkillId
? 'border-primary-500 bg-primary-50 dark:border-primary-400 dark:bg-primary-950/30'
: 'border-gray-200 hover:border-primary-300 dark:border-gray-700 dark:hover:border-primary-600',
]"
@click="handleFreeChat"
>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-chat-bubble-left-right" class="h-3.5 w-3.5 text-gray-500" />
<div>
<span class="text-xs font-medium text-gray-900 dark:text-gray-100">
{{ t('ai.skill.selector.freeChat') }}
</span>
<p class="text-[11px] text-gray-400 dark:text-gray-500">
{{ t('ai.skill.selector.freeChatDesc') }}
</p>
</div>
</div>
</div>
<!-- 技能列表 -->
<div v-if="compatibleSkills.length > 0" class="max-h-48 space-y-1.5 overflow-y-auto">
<SkillCard
v-for="skill in compatibleSkills"
:key="skill.id"
:skill="skill"
:selected="activeSkillId === skill.id"
@select="handleSelectSkill"
/>
</div>
<!-- 无可用技能 -->
<div v-else-if="isLoaded" class="py-3 text-center">
<p class="text-xs text-gray-400 dark:text-gray-500">{{ t('ai.skill.selector.noSkills') }}</p>
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">{{ t('ai.skill.selector.noSkillsHint') }}</p>
</div>
</div>
</template>
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import MarkdownIt from 'markdown-it'
import { useSkillStore, type SkillSummary } from '@/stores/skill'
const { t, te } = useI18n()
const skillStore = useSkillStore()
const props = defineProps<{
visible: boolean
skills: SkillSummary[]
highlightIndex: number
activeSkillId?: string | null
}>()
const emit = defineEmits<{
select: [skill: SkillSummary]
close: []
manage: []
highlight: [index: number]
}>()
const previewPromptCache = ref<Record<string, string>>({})
const previewPrompt = ref('')
const previewLoading = ref(false)
// 与消息渲染保持一致,使用 markdown-it 展示技能 prompt 的结构化内容。
const md = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
typographer: true,
})
const previewSkill = computed(() => {
if (props.skills.length === 0) return null
const index = Math.min(props.highlightIndex, props.skills.length - 1)
return props.skills[index] ?? null
})
const renderedPreviewPrompt = computed(() => {
if (!previewPrompt.value) return ''
return md.render(previewPrompt.value)
})
function getToolLabel(toolName: string): string {
const key = `ai.assistant.builtinToolDesc.${toolName}`
return te(key) ? t(key) : toolName
}
const previewTagsText = computed(() => {
return previewSkill.value?.tags.join(' / ') || ''
})
const previewToolsText = computed(() => {
return previewSkill.value?.tools.map((tool) => getToolLabel(tool)).join(' / ') || ''
})
watch(
() => [props.visible, previewSkill.value?.id] as const,
async ([visible, skillId]) => {
if (!visible || !skillId) {
previewPrompt.value = ''
previewLoading.value = false
return
}
if (previewPromptCache.value[skillId]) {
previewPrompt.value = previewPromptCache.value[skillId]
previewLoading.value = false
return
}
previewLoading.value = true
const config = await skillStore.getSkillConfig(skillId)
const prompt = config?.prompt?.trim() || ''
previewPromptCache.value = {
...previewPromptCache.value,
[skillId]: prompt,
}
// 仅在当前高亮项未变化时更新预览,避免异步请求回写旧数据。
if (previewSkill.value?.id === skillId) {
previewPrompt.value = prompt
previewLoading.value = false
}
},
{ immediate: true }
)
</script>
<template>
<Transition name="slash-menu">
<div v-if="visible" class="absolute bottom-full left-0 z-20 mb-1.5 w-[240px] max-w-[calc(100vw-2rem)]">
<div
class="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl shadow-gray-900/10 dark:border-gray-700 dark:bg-gray-900"
>
<div class="flex items-center justify-between border-b border-gray-100 px-2.5 py-1.5 dark:border-gray-800">
<div class="flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400">
<span>{{ t('ai.chat.input.slashHint') }}</span>
</div>
<button
type="button"
class="rounded-md p-0.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
:title="t('common.close')"
@click="emit('close')"
>
<UIcon name="i-heroicons-x-mark" class="h-3.5 w-3.5" />
</button>
</div>
<div v-if="props.skills.length > 0" class="max-h-72 overflow-y-auto p-1.5">
<button
v-for="(skill, index) in props.skills"
:key="skill.id"
type="button"
class="flex w-full items-start justify-between gap-1.5 rounded-lg px-2.5 py-1.5 text-left transition-colors"
:class="
index === props.highlightIndex
? 'bg-primary-50 text-gray-900 dark:bg-primary-950/30 dark:text-gray-100'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800/80'
"
@mouseenter="emit('highlight', index)"
@click="emit('select', skill)"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="truncate text-xs font-medium">
{{ skill.name }}
</span>
<UIcon
v-if="props.activeSkillId === skill.id"
name="i-heroicons-check-circle-20-solid"
class="h-3.5 w-3.5 shrink-0 text-primary-500"
/>
</div>
<p class="mt-0.5 line-clamp-1 text-xs leading-4 text-gray-500 dark:text-gray-400">
{{ skill.description }}
</p>
</div>
</button>
</div>
<div v-else class="px-3 py-4 text-center">
<p class="text-sm font-medium text-gray-700 dark:text-gray-200">
{{ t('ai.chat.input.slashEmpty') }}
</p>
<p class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">
{{ t('ai.chat.input.slashEmptyHint') }}
</p>
</div>
<button
type="button"
class="flex w-full items-center justify-between border-t border-gray-100 px-2.5 py-1.5 text-xs text-gray-600 transition-colors hover:bg-gray-50 hover:text-gray-900 dark:border-gray-800 dark:text-gray-300 dark:hover:bg-gray-800/80 dark:hover:text-gray-100"
@click="emit('manage')"
>
<span>{{ t('ai.chat.input.manageSkills') }}</span>
<UIcon name="i-heroicons-arrow-top-right-on-square" class="h-3.5 w-3.5" />
</button>
</div>
<!-- 让详情预览相对左侧菜单在 Y 轴居中避免视觉上只偏向顶部或底部 -->
<div
v-if="props.skills.length > 0 && previewSkill"
class="absolute top-1/2 left-full ml-1 flex max-h-[min(348px,calc(100vh-10rem))] w-[300px] -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl shadow-gray-900/10 dark:border-gray-700 dark:bg-gray-900"
>
<div class="border-b border-gray-100 px-3 py-2 dark:border-gray-800">
<p class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{{ previewSkill.name }}
</p>
<p class="mt-0.5 text-xs leading-5 text-gray-500 dark:text-gray-400">
{{ previewSkill.description }}
</p>
<div v-if="previewTagsText" class="mt-1 flex items-start gap-1.5 text-[11px] leading-4">
<span class="shrink-0 font-medium text-gray-600 dark:text-gray-300">
{{ t('ai.chat.input.previewTagsLabel') }}
</span>
<span class="line-clamp-1 min-w-0 flex-1 text-gray-500 dark:text-gray-400">
{{ previewTagsText }}
</span>
</div>
<div v-if="previewToolsText" class="mt-0.5 flex items-start gap-1.5 text-[11px] leading-4">
<span class="shrink-0 font-medium text-gray-600 dark:text-gray-300">
{{ t('ai.chat.input.previewToolsLabel') }}
</span>
<span class="line-clamp-1 min-w-0 flex-1 text-gray-500 dark:text-gray-400">
{{ previewToolsText }}
</span>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-3 py-2">
<div v-if="previewLoading" class="text-xs text-gray-400 dark:text-gray-500">
{{ t('common.loading') }}
</div>
<div
v-else-if="renderedPreviewPrompt"
class="prose prose-sm max-w-none text-xs leading-5 dark:prose-invert prose-headings:mb-1.5 prose-headings:mt-2 prose-p:my-1.5 prose-ul:my-1.5 prose-ol:my-1.5"
v-html="renderedPreviewPrompt"
/>
<div v-else class="text-xs leading-relaxed text-gray-400 dark:text-gray-500">
{{ t('ai.chat.input.previewUnavailable') }}
</div>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.slash-menu-enter-active,
.slash-menu-leave-active {
transition:
opacity 0.16s ease,
transform 0.16s ease;
}
.slash-menu-enter-from,
.slash-menu-leave-to {
transform: translateY(8px);
opacity: 0;
}
</style>
+20 -3
View File
@@ -9,6 +9,7 @@ import { usePromptStore } from '@/stores/prompt'
import { useSessionStore } from '@/stores/session'
import { useSettingsStore } from '@/stores/settings'
import { useAssistantStore } from '@/stores/assistant'
import { useSkillStore } from '@/stores/skill'
import type { TokenUsage, AgentRuntimeStatus } from '@electron/shared/types'
// 工具调用记录
@@ -31,11 +32,12 @@ export interface ToolBlockContent {
// 内容块类型(用于 AI 消息的流式混合渲染)
export type ContentBlock =
| { type: 'text'; text: string }
| { type: 'think'; tag: string; text: string; durationMs?: number } // 思考内容块
| { type: 'think'; tag: string; text: string; durationMs?: number }
| {
type: 'tool'
tool: ToolBlockContent
}
| { type: 'skill'; skillId: string; skillName: string }
// 消息类型
export interface ChatMessage {
@@ -95,6 +97,7 @@ export function useAIChat(
const sessionStore = useSessionStore()
const settingsStore = useSettingsStore()
const assistantStore = useAssistantStore()
const skillStore = useSkillStore()
const { activePreset, aiGlobalSettings } = storeToRefs(promptStore)
const currentPromptConfig = computed(() => {
@@ -204,6 +207,11 @@ export function useAIChat(
return
}
// 在任何异步操作前固定本轮技能上下文,避免发送后 UI 立刻清空技能导致本轮请求丢失技能。
const currentSkillId = skillStore.activeSkillId
const currentSkillName = skillStore.activeSkill?.name
const autoSkillEnabled = aiGlobalSettings.value.enableAutoSkill ?? true
// 检查是否已配置 LLM
console.log('[AI] 检查 LLM 配置...')
const hasConfig = await window.llmApi.hasConfig()
@@ -253,6 +261,14 @@ export function useAIChat(
isStreaming: true,
contentBlocks: [], // 初始化内容块数组
}
if (currentSkillId && currentSkillName) {
aiMessage.contentBlocks!.push({
type: 'skill',
skillId: currentSkillId,
skillName: currentSkillName,
})
}
messages.value.push(aiMessage)
const aiMessageIndex = messages.value.length - 1
let hasStreamError = false
@@ -360,7 +376,6 @@ export function useAIChat(
}
try {
// 当前选中的助手 ID(如果有)
const currentAssistantId = assistantStore.selectedAssistantId ?? undefined
// 确保对话 ID 存在(数据流倒置:Agent 从 SQLite 读取历史,需要有效的 conversationId
@@ -537,7 +552,9 @@ export function useAIChat(
currentAssistantId ? undefined : { systemPrompt: currentPromptConfig.value.systemPrompt },
locale,
maxHistoryRounds,
currentAssistantId
currentAssistantId,
currentSkillId,
!currentSkillId ? autoSkillEnabled : undefined
)
// 存储 Agent 请求 ID(用于中止)
+78 -1
View File
@@ -14,7 +14,24 @@
"capture": "Capture Chat",
"scrollToBottom": "Back to Bottom",
"input": {
"placeholder": "Enter your question..."
"placeholder": "Enter your question...",
"placeholderWithSlash": "Enter your question, or type / to pick a skill...",
"placeholderWithActiveSkill": "/{name} selected. Enter your specific question...",
"removeSkill": "Remove active skill",
"slashHint": "Type / to pick a skill",
"useSkill": "Choose Skill",
"slashEmpty": "No matching skills",
"slashEmptyHint": "Open Skill Manager to import built-in skills or create a custom one",
"manageSkills": "Manage Skills",
"previewTitle": "Skill Prompt",
"previewTagsLabel": "Tags",
"previewToolsLabel": "Tools",
"previewUnavailable": "No skill prompt available for preview",
"presetMore": "More",
"presetCollapse": "Collapse",
"send": "Send",
"needMoreThanSkill": "Selecting a skill alone is not enough. Enter a specific question.",
"needQuestion": "Enter a question before sending"
},
"message": {
"userAvatar": "User Avatar",
@@ -297,5 +314,65 @@
"get_session_summaries": "Get session summaries",
"semantic_search_messages": "Semantic search messages"
}
},
"skill": {
"selector": {
"title": "Select Skill",
"freeChat": "Free Chat",
"freeChatDesc": "No preset skill, AI responds freely",
"manage": "Manage Skills",
"incompatible": "Incompatible with current assistant",
"noSkills": "No skills available",
"noSkillsHint": "Import built-in skills or create custom skills via Skill Manager",
"autoSkill": "AI auto-select skill",
"autoSkillHint": "When enabled, AI can automatically choose the best skill for your question"
},
"config": {
"createTitle": "New Skill",
"editTitle": "Skill Config",
"name": "Name",
"description": "Description",
"tags": "Tags",
"chatScope": "Scope",
"chatScopeAll": "All",
"chatScopeGroup": "Group",
"chatScopePrivate": "Private",
"prompt": "Skill Prompt",
"tools": "Required Tools",
"rawEditor": "Markdown Editor",
"rawEditorHint": "Edit the full skill Markdown file (with YAML frontmatter)",
"create": "Create",
"save": "Save"
},
"market": {
"title": "Skill Manager",
"tabs": {
"local": "Local Skills",
"market": "Skill Market"
},
"localHint": "Manage imported skills, configure or delete",
"marketHint": "Browse built-in skill templates, import to use and customize",
"updateAvailable": "Update available",
"reimport": "Re-import",
"imported": "Imported",
"import": "Import",
"noLocal": "No imported skills",
"noCatalog": "No skill templates available",
"addSkill": "Create custom skill"
},
"block": {
"label": "Skill: {name}"
},
"active": {
"label": "Manually Invoked Skill: {name}"
},
"toast": {
"createSuccess": "Skill created successfully",
"createFailed": "Failed to create skill",
"saveSuccess": "Skill saved successfully",
"saveFailed": "Failed to save skill",
"deleteSuccess": "Skill deleted",
"deleteFailed": "Failed to delete"
}
}
}
+5
View File
@@ -186,6 +186,11 @@
"exportSettings": {
"title": "Export Settings"
},
"skillSettings": {
"title": "Skill Settings",
"enableAutoSkill": "AI Auto-Select Skills",
"enableAutoSkillDesc": "When enabled, AI will automatically match and activate suitable analysis skills based on user queries; when disabled, skills only take effect when manually selected"
},
"maxMessages": {
"title": "Max Messages per Request",
"description": "Max number of messages sent to AI per request. Higher values consume more tokens but provide more accurate context (recommended: 2000)."
+26 -1
View File
@@ -14,7 +14,24 @@
"capture": "会話を画像保存",
"scrollToBottom": "下へ移動",
"input": {
"placeholder": "質問を入力..."
"placeholder": "質問を入力...",
"placeholderWithSlash": "質問を入力、または / でスキルを選択...",
"placeholderWithActiveSkill": "/{name} を選択済みです。具体的な質問を入力してください...",
"removeSkill": "現在のスキルを解除",
"slashHint": "/ でスキルを選択",
"useSkill": "スキルを選ぶ",
"slashEmpty": "一致するスキルがありません",
"slashEmptyHint": "スキル管理を開いて内蔵スキルを読み込むか、カスタムスキルを作成してください",
"manageSkills": "スキルを管理",
"previewTitle": "スキルプロンプト",
"previewTagsLabel": "タグ",
"previewToolsLabel": "ツール",
"previewUnavailable": "プレビューできるスキルプロンプトがありません",
"presetMore": "もっと見る",
"presetCollapse": "折りたたむ",
"send": "送信",
"needMoreThanSkill": "スキルを選んだだけでは送信できません。具体的な質問を入力してください。",
"needQuestion": "質問を入力してから送信してください"
},
"message": {
"userAvatar": "ユーザーアバター",
@@ -297,5 +314,13 @@
"get_session_summaries": "セッション要約を取得",
"semantic_search_messages": "セマンティック検索"
}
},
"skill": {
"block": {
"label": "スキル:{name}"
},
"active": {
"label": "手動呼び出しスキル:{name}"
}
}
}
+5
View File
@@ -186,6 +186,11 @@
"exportSettings": {
"title": "エクスポート設定"
},
"skillSettings": {
"title": "スキル設定",
"enableAutoSkill": "AI 自動スキル選択",
"enableAutoSkillDesc": "有効にすると、AI がユーザーの質問に基づいて適切な分析スキルを自動的にマッチして有効化します。無効にすると、手動で選択した場合のみスキルが適用されます"
},
"maxMessages": {
"title": "送信メッセージ数の上限",
"description": "1 回のリクエストで AI に送る最大メッセージ数です。増やすほど Token 消費は増えますが、分析精度も上がります(初めてなら 2000 推奨)"
+78 -1
View File
@@ -14,7 +14,24 @@
"capture": "截屏对话",
"scrollToBottom": "返回底部",
"input": {
"placeholder": "输入你的问题..."
"placeholder": "输入你的问题...",
"placeholderWithSlash": "输入你的问题,或输入 / 选择技能...",
"placeholderWithActiveSkill": "已选择 /{name},请输入具体问题...",
"removeSkill": "移除当前技能",
"slashHint": "输入 / 选择技能",
"useSkill": "选择技能",
"slashEmpty": "没有匹配的技能",
"slashEmptyHint": "打开技能管理导入内置技能,或创建一个自定义技能",
"manageSkills": "管理技能",
"previewTitle": "技能提示词",
"previewTagsLabel": "标签",
"previewToolsLabel": "工具",
"previewUnavailable": "暂无可预览的技能提示词",
"presetMore": "更多",
"presetCollapse": "收起",
"send": "发送",
"needMoreThanSkill": "仅选择技能还不能发送,请继续输入具体问题",
"needQuestion": "请输入问题后再发送"
},
"message": {
"userAvatar": "用户头像",
@@ -297,5 +314,65 @@
"get_session_summaries": "获取会话摘要",
"semantic_search_messages": "语义搜索消息"
}
},
"skill": {
"selector": {
"title": "选择技能",
"freeChat": "自由对话",
"freeChatDesc": "不使用预设技能,由 AI 自主回答",
"manage": "技能管理",
"incompatible": "与当前助手不兼容",
"noSkills": "暂无可用技能",
"noSkillsHint": "通过技能管理导入内置技能或创建自定义技能",
"autoSkill": "AI 自主选择技能",
"autoSkillHint": "开启后 AI 可根据问题自动选择合适的技能"
},
"config": {
"createTitle": "新建技能",
"editTitle": "技能配置",
"name": "名称",
"description": "描述",
"tags": "标签",
"chatScope": "适用范围",
"chatScopeAll": "全部",
"chatScopeGroup": "群聊",
"chatScopePrivate": "私聊",
"prompt": "技能提示词",
"tools": "所需工具",
"rawEditor": "Markdown 编辑器",
"rawEditorHint": "直接编辑完整的技能 Markdown 文件(含 YAML frontmatter",
"create": "创建",
"save": "保存"
},
"market": {
"title": "技能管理",
"tabs": {
"local": "本地技能",
"market": "技能市场"
},
"localHint": "管理已导入的技能,可配置或删除",
"marketHint": "浏览内置技能模板,导入后即可使用和自由编辑",
"updateAvailable": "更新可用",
"reimport": "重新导入",
"imported": "已导入",
"import": "导入",
"noLocal": "暂无已导入的技能",
"noCatalog": "暂无可用技能模板",
"addSkill": "新建自定义技能"
},
"block": {
"label": "技能:{name}"
},
"active": {
"label": "主动调用技能:{name}"
},
"toast": {
"createSuccess": "技能创建成功",
"createFailed": "技能创建失败",
"saveSuccess": "技能保存成功",
"saveFailed": "技能保存失败",
"deleteSuccess": "技能已删除",
"deleteFailed": "删除失败"
}
}
}
+5
View File
@@ -186,6 +186,11 @@
"exportSettings": {
"title": "导出设置"
},
"skillSettings": {
"title": "技能设置",
"enableAutoSkill": "AI 自主选择技能",
"enableAutoSkillDesc": "启用后,AI 会根据用户的问题自动匹配并激活合适的分析技能;关闭后仅在手动选择技能时生效"
},
"maxMessages": {
"title": "发送条数限制",
"description": "每次提交给 AI 的最大消息数,数值越大 Token 消耗越多,分析也更准确(新手建议2000)"
+26 -1
View File
@@ -14,7 +14,24 @@
"capture": "截圖對話",
"scrollToBottom": "回到底部",
"input": {
"placeholder": "輸入你的問題..."
"placeholder": "輸入你的問題...",
"placeholderWithSlash": "輸入你的問題,或輸入 / 選擇技能...",
"placeholderWithActiveSkill": "已選擇 /{name},請輸入具體問題...",
"removeSkill": "移除目前技能",
"slashHint": "輸入 / 選擇技能",
"useSkill": "選擇技能",
"slashEmpty": "沒有符合的技能",
"slashEmptyHint": "開啟技能管理匯入內建技能,或建立自訂技能",
"manageSkills": "管理技能",
"previewTitle": "技能提示詞",
"previewTagsLabel": "標籤",
"previewToolsLabel": "工具",
"previewUnavailable": "暫無可預覽的技能提示詞",
"presetMore": "更多",
"presetCollapse": "收起",
"send": "送出",
"needMoreThanSkill": "只選擇技能還不能送出,請繼續輸入具體問題",
"needQuestion": "請輸入問題後再送出"
},
"message": {
"userAvatar": "使用者頭像",
@@ -297,5 +314,13 @@
"get_session_summaries": "取得會話摘要",
"semantic_search_messages": "語意搜尋訊息"
}
},
"skill": {
"block": {
"label": "技能:{name}"
},
"active": {
"label": "主動呼叫技能:{name}"
}
}
}
+5
View File
@@ -186,6 +186,11 @@
"exportSettings": {
"title": "匯出設定"
},
"skillSettings": {
"title": "技能設定",
"enableAutoSkill": "AI 自主選擇技能",
"enableAutoSkillDesc": "啟用後,AI 會根據使用者的問題自動匹配並啟用合適的分析技能;關閉後僅在手動選擇技能時生效"
},
"maxMessages": {
"title": "傳送條數限制",
"description": "每次提交給 AI 的最大訊息數,數值越大 Token 消耗越多,分析也更準確(新手建議2000)"
@@ -6,7 +6,6 @@ import { usePromptStore } from '@/stores/prompt'
const { t } = useI18n()
// Store
const promptStore = usePromptStore()
const { aiGlobalSettings } = storeToRefs(promptStore)
@@ -64,6 +63,14 @@ const sqlExportFormat = computed({
emit('config-changed')
},
})
const enableAutoSkill = computed({
get: () => aiGlobalSettings.value.enableAutoSkill ?? true,
set: (val: boolean) => {
promptStore.updateAIGlobalSettings({ enableAutoSkill: val })
emit('config-changed')
},
})
</script>
<template>
@@ -103,6 +110,27 @@ const sqlExportFormat = computed({
</div>
</div>
<!-- 技能设置 -->
<div>
<h4 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-bolt" class="h-4 w-4 text-amber-500" />
{{ t('settings.aiPrompt.skillSettings.title') }}
</h4>
<div class="space-y-4 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<div class="flex items-center justify-between">
<div class="flex-1 pr-4">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('settings.aiPrompt.skillSettings.enableAutoSkill') }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('settings.aiPrompt.skillSettings.enableAutoSkillDesc') }}
</p>
</div>
<USwitch v-model="enableAutoSkill" />
</div>
</div>
</div>
<!-- 导出设置 -->
<div>
<h4 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
+5 -3
View File
@@ -42,9 +42,10 @@ export const usePromptStore = defineStore(
const aiConfigVersion = ref(0)
const aiGlobalSettings = ref({
maxMessagesPerRequest: 1000,
maxHistoryRounds: 5, // AI上下文会话轮数限制
exportFormat: 'markdown' as 'markdown' | 'txt', // 对话导出格式
sqlExportFormat: 'csv' as 'csv' | 'json', // SQL Lab 导出格式
maxHistoryRounds: 5,
exportFormat: 'markdown' as 'markdown' | 'txt',
sqlExportFormat: 'csv' as 'csv' | 'json',
enableAutoSkill: true,
})
const customKeywordTemplates = ref<KeywordTemplate[]>([])
const deletedPresetTemplateIds = ref<string[]>([])
@@ -103,6 +104,7 @@ export const usePromptStore = defineStore(
maxHistoryRounds: number
exportFormat: 'markdown' | 'txt'
sqlExportFormat: 'csv' | 'json'
enableAutoSkill: boolean
}>
) {
aiGlobalSettings.value = { ...aiGlobalSettings.value, ...settings }
+201
View File
@@ -0,0 +1,201 @@
/**
* 技能管理 Store
* 管理技能列表缓存、当前激活技能、配置 CRUD、内置技能目录
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useAssistantStore } from './assistant'
export interface SkillSummary {
id: string
name: string
description: string
tags: string[]
chatScope: 'all' | 'group' | 'private'
tools: string[]
builtinId?: string
}
export interface SkillConfigFull {
id: string
name: string
description: string
tags: string[]
chatScope: 'all' | 'group' | 'private'
prompt: string
tools: string[]
builtinId?: string
}
export interface BuiltinSkillInfo extends SkillSummary {
imported: boolean
hasUpdate: boolean
}
export const useSkillStore = defineStore('skill', () => {
const skills = ref<SkillSummary[]>([])
const activeSkillId = ref<string | null>(null)
const builtinCatalog = ref<BuiltinSkillInfo[]>([])
const isLoaded = ref(false)
const currentChatType = ref<'group' | 'private'>('group')
const activeSkill = computed(() => {
if (!activeSkillId.value) return null
return skills.value.find((s) => s.id === activeSkillId.value) ?? null
})
/** 按 chatScope 过滤:'all' 或匹配当前 chatType */
const scopedSkills = computed(() => {
return skills.value.filter((s) => s.chatScope === 'all' || s.chatScope === currentChatType.value)
})
/** 在 scopedSkills 基础上按助手工具权限过滤 */
const compatibleSkills = computed(() => {
const assistantStore = useAssistantStore()
const config = assistantStore.selectedAssistant
if (!config) return scopedSkills.value
return scopedSkills.value.filter((s) => {
if (!s.tools.length) return true
// 需要对当前助手的 allowedBuiltinTools 做兼容检查
// 但 AssistantSummary 不含 allowedBuiltinTools,所以这里不做严格过滤
// 严格兼容检查在后端 getSkillMenu 中完成
return true
})
})
/** 按 tags 分组 */
const groupedSkills = computed(() => {
const groups: Record<string, SkillSummary[]> = {}
for (const skill of compatibleSkills.value) {
const tag = skill.tags[0] || 'other'
if (!groups[tag]) groups[tag] = []
groups[tag].push(skill)
}
return groups
})
function setFilterContext(chatType: 'group' | 'private'): void {
currentChatType.value = chatType
}
async function loadSkills(): Promise<void> {
try {
skills.value = await window.skillApi.getAll()
isLoaded.value = true
} catch (error) {
console.error('[SkillStore] Failed to load skills:', error)
}
}
async function loadBuiltinCatalog(): Promise<void> {
try {
builtinCatalog.value = await window.skillApi.getBuiltinCatalog()
} catch (error) {
console.error('[SkillStore] Failed to load builtin catalog:', error)
}
}
function activateSkill(id: string | null): void {
activeSkillId.value = id
}
async function getSkillConfig(id: string): Promise<SkillConfigFull | null> {
try {
return await window.skillApi.getConfig(id)
} catch (error) {
console.error('[SkillStore] Failed to get skill config:', error)
return null
}
}
async function updateSkill(id: string, rawMd: string): Promise<{ success: boolean; error?: string }> {
try {
const result = await window.skillApi.update(id, rawMd)
if (result.success) {
await loadSkills()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function createSkill(rawMd: string): Promise<{ success: boolean; id?: string; error?: string }> {
try {
const result = await window.skillApi.create(rawMd)
if (result.success) {
await loadSkills()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function deleteSkill(id: string): Promise<{ success: boolean; error?: string }> {
try {
const result = await window.skillApi.delete(id)
if (result.success) {
if (activeSkillId.value === id) {
activeSkillId.value = null
}
await loadSkills()
await loadBuiltinCatalog()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function importSkill(builtinId: string): Promise<{ success: boolean; id?: string; error?: string }> {
try {
const result = await window.skillApi.importSkill(builtinId)
if (result.success) {
await loadSkills()
await loadBuiltinCatalog()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function reimportSkill(id: string): Promise<{ success: boolean; error?: string }> {
try {
const result = await window.skillApi.reimportSkill(id)
if (result.success) {
await loadSkills()
await loadBuiltinCatalog()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
return {
skills,
activeSkillId,
builtinCatalog,
isLoaded,
currentChatType,
activeSkill,
scopedSkills,
compatibleSkills,
groupedSkills,
setFilterContext,
loadSkills,
loadBuiltinCatalog,
activateSkill,
getSkillConfig,
updateSkill,
createSkill,
deleteSkill,
importSkill,
reimportSkill,
}
})