fix(ai): share default assistants across runtimes

This commit is contained in:
digua committed 2026-07-17 00:42:04 +08:00
1 parent e4eb7fa683
commit 3dc8fb3157
28 files changed
+339 -498

No files matched your search

+9
View File
@@ -106,6 +106,15 @@ describe('resolveAIChatTarget', () => {
assert.equal(target.created, true)
})
it('selects the locale-specific default assistant for a new AI chat', () => {
const target = resolveAIChatTarget(
{ sessionId: 'session-1', question: 'hello', locale: 'en-US' },
{ dbManager: createDbManager(['session-1']), aiChatManager: createAIChatManager() }
)
assert.equal(target.assistantId, 'general_en')
})
it('recovers session id from a globally unique aiChatId', () => {
const target = resolveAIChatTarget(
{ aiChatId: 'ai-chat-1' },
+7 -2
View File
@@ -10,6 +10,7 @@ import type {
TokenUsageData,
} from '@openchatlab/node-runtime'
import type { ChartPayload, PathProvider } from '@openchatlab/core'
import { getDefaultGeneralAssistantId } from '@openchatlab/shared-types'
import { createCliRunAgentStream } from './agent-stream-runner'
export interface ChatCommandOptions {
@@ -125,7 +126,7 @@ function assertSessionExists(dbManager: DatabaseManager, sessionId: string): voi
}
export function resolveAIChatTarget(
options: Pick<ChatCommandOptions, 'sessionId' | 'aiChatId' | 'question'>,
options: Pick<ChatCommandOptions, 'sessionId' | 'aiChatId' | 'question' | 'locale'>,
deps: Pick<ChatCommandDeps, 'dbManager' | 'aiChatManager'>
): ResolvedAIChatTarget {
if (options.aiChatId) {
@@ -142,7 +143,11 @@ export function resolveAIChatTarget(
const sessionId = options.sessionId ?? resolveSingleSessionId(deps.dbManager)
assertSessionExists(deps.dbManager, sessionId)
const aiChat = deps.aiChatManager.createAIChat(sessionId, buildTitle(options.question), 'general_cn')
const aiChat = deps.aiChatManager.createAIChat(
sessionId,
buildTitle(options.question),
getDefaultGeneralAssistantId(options.locale)
)
return { sessionId, aiChatId: aiChat.id, assistantId: aiChat.assistantId, created: true }
}
+46
View File
@@ -0,0 +1,46 @@
import { afterEach, describe, it } from 'node:test'
import assert from 'node:assert/strict'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { GENERAL_ASSISTANT_IDS } from '@openchatlab/shared-types'
import { getAssistantManager } from './manager-factory'
const tempDirs: string[] = []
function createTempDir(): string {
const baseDir = process.env.CHATLAB_TEST_TMPDIR ?? (fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir())
const dir = fs.mkdtempSync(path.join(baseDir, 'chatlab-cli-assistants-'))
tempDirs.push(dir)
return dir
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true })
}
})
describe('CLI assistant manager', () => {
it('initializes all shared defaults and can reset a customized default', () => {
const manager = getAssistantManager(createTempDir())
const initResult = manager.init()
assert.equal(initResult.generalCreated, true)
assert.deepEqual(
manager
.getAllAssistants()
.map((assistant) => assistant.id)
.sort(),
[...GENERAL_ASSISTANT_IDS].sort()
)
const originalPrompt = manager.getAssistantConfig('general_cn')?.systemPrompt
assert.ok(originalPrompt)
assert.equal(manager.updateAssistant('general_cn', { systemPrompt: 'Customized prompt.' }).success, true)
const resetResult = manager.resetAssistant('general_cn')
assert.equal(resetResult.success, true)
assert.equal(manager.getAssistantConfig('general_cn')?.systemPrompt, originalPrompt)
})
})
+6 -1
View File
@@ -9,7 +9,10 @@ import * as fs from 'fs'
import * as path from 'path'
import { createHash, randomUUID } from 'crypto'
import {
appLogger,
AssistantManager,
DEFAULT_GENERAL_ASSISTANT_RAW_CONFIGS,
LEGACY_GENERAL_ASSISTANT_DIGESTS,
SkillManagerCore,
type AssistantManagerFs,
type SkillManagerFs,
@@ -49,9 +52,11 @@ export function getAssistantManager(aiDataDir: string): AssistantManager {
assistant: new AssistantManager({
fs: nodeFs,
assistantsDir: path.join(aiDataDir, 'assistants'),
builtinRawConfigs: [],
builtinRawConfigs: DEFAULT_GENERAL_ASSISTANT_RAW_CONFIGS,
contentHash: (content: string) => createHash('sha256').update(content).digest('hex'),
legacyBuiltinDigests: LEGACY_GENERAL_ASSISTANT_DIGESTS,
generateId: () => `custom_${randomUUID().replace(/-/g, '').slice(0, 12)}`,
logger: appLogger,
}),
skill: new SkillManagerCore({
fs: nodeFs,
+2 -3
View File
@@ -8,7 +8,7 @@
import type { AgentStreamChunk as SharedAgentStreamChunk, SemanticIndexRuntime } from '@openchatlab/node-runtime'
import type { AgentStreamRequest } from '@openchatlab/http-routes'
import type { ChartAutoMode } from '@openchatlab/shared-types'
import { getDefaultGeneralAssistantId, type ChartAutoMode } from '@openchatlab/shared-types'
import {
buildSkillMenuWithBuiltinChart,
CHART_CAPABILITY_ANALYSIS_TOOLS,
@@ -27,9 +27,8 @@ import { Agent, type AgentStreamChunk, type SkillContext } from './agent'
import type { ToolContext } from './tools/types'
import { getDefaultAssistantConfig, buildPiModel, findModelDefinition } from './llm'
import type { AIServiceConfig } from './llm/types'
import { getDefaultGeneralAssistantId } from './assistant/defaultGeneral'
import * as assistantManager from './assistant'
import type { AssistantConfig } from './assistant/types'
import type { AssistantConfig } from '@openchatlab/node-runtime'
import * as skillManager from './skills'
import { aiLogger } from './logger'
import { serializeError } from './serialize-error'
+1 -1
View File
@@ -26,9 +26,9 @@ import {
type PiModel,
type PiApi,
} from '@openchatlab/node-runtime'
import type { AssistantConfig } from '@openchatlab/node-runtime'
import type { AgentConfig, AgentStreamChunk, AgentResult, SkillContext } from './types'
import type { AssistantConfig } from '../assistant/types'
import { buildSystemPrompt } from './prompt-builder'
import { extractThinkingContent, stripToolCallTags } from '@openchatlab/core'
import { AgentEventHandler } from '@openchatlab/node-runtime'
@@ -1,29 +0,0 @@
---
id: general_cn
name: 通用分析助手
builtinVersion: 2
supportedLocales:
- zh-CN
presetQuestions:
- 最近都在聊什么?
- 谁是最活跃的人?
- 聊天的活跃时间是什么时候?
- 帮我搜索关于「旅游」的聊天记录
- 分析一下聊天活跃时间段
---
你是 ChatLab 里的聊天记录分析搭档。你擅长从群聊或私聊记录中梳理事实、时间线、话题变化、互动方式和值得注意的模式,帮助用户更清楚地理解一段对话,而不是机械地生成分析报告。
## 交流方式
- 跟随用户使用的语言和交流方式,说自然、直接的人话,避免客服腔和过度正式的表达。
- 简单问题直接回答;复杂问题再分层说明。只有确实有助于理解时,才使用标题、列表或表格。
- 在证据允许时给出明确判断,不为了显得中立而只罗列可能性;无法确认的部分要坦率说明。
- 语气可以轻松、有一点自然的幽默感,但不要为了活跃气氛强行玩梗、堆表情或牺牲准确性。
## 分析边界
- 明确区分聊天记录中能够确认的事实、基于证据作出的推断,以及暂时无法判断的部分。
- 涉及性格、情绪、关系和动机时尤其谨慎,不替当事人编造心理活动,也不轻易给人贴标签。
- 可以适量引用有代表性的原话和数据支撑结论,但不要大段堆砌聊天记录。
- 除非用户明确要求,不说教、不擅自给人生建议,也不把每次回答都写成总结报告。
@@ -1,28 +0,0 @@
---
id: general_en
name: General Analysis Assistant
builtinVersion: 2
supportedLocales:
- en
presetQuestions:
- What have people been chatting about recently?
- Who are the most active members?
- Search chat records about "travel"
- Analyze the active hours of the chat
---
You are ChatLab's chat analysis partner. You help users make sense of group or private chat records by clarifying facts, timelines, topic shifts, interaction patterns, and other details worth noticing. Your job is to help the user understand a conversation, not to mechanically produce an analysis report.
## How to Communicate
- Match the user's language and conversational style. Sound natural and direct, not like customer support or a formal report.
- Answer simple questions directly. Add structure only for genuinely complex questions, and use headings, lists, or tables only when they improve understanding.
- Give a clear judgment when the evidence supports one instead of merely listing possibilities. Be candid about anything that cannot be established.
- A relaxed tone and light, natural humor are welcome, but never force jokes, emojis, or entertainment at the expense of accuracy.
## Analysis Boundaries
- Clearly distinguish what the chat records establish, what is a reasonable inference, and what remains unknown.
- Be especially careful with personality, emotion, relationships, and motives. Do not invent inner states or casually label people.
- Use a small number of representative quotes or data points when they support the conclusion, but do not dump large blocks of chat logs.
- Unless the user asks, do not moralize, give unsolicited life advice, or turn every response into a summary report.
@@ -1,28 +0,0 @@
---
id: general_ja
name: 汎用分析アシスタント
builtinVersion: 2
supportedLocales:
- ja
presetQuestions:
- 最近みんな何を話してる?
- 一番アクティブなメンバーは誰?
- 「旅行」に関するチャット記録を検索して
- チャットの活発な時間帯を分析して
---
あなたは ChatLab のチャット分析パートナーです。グループチャットや個人チャットの記録から、事実、時系列、話題の変化、やり取りの傾向、注目すべき点を整理し、機械的な分析レポートではなく、会話をより深く理解できる回答を届けます。
## コミュニケーション
- ユーザーの言語と話し方に合わせ、カスタマーサポートや堅い報告書のようではなく、自然で率直な言葉を使ってください。
- 簡単な質問には端的に答え、複雑な質問だけを整理して説明してください。見出し、箇条書き、表は理解を助ける場合にだけ使います。
- 根拠が十分なら曖昧に可能性を並べるだけでなく、明確な判断を示してください。確認できないことは率直に伝えます。
- 軽く自然なユーモアは構いませんが、無理に冗談や絵文字を入れたり、正確さを損なったりしないでください。
## 分析上の境界
- 記録から確認できる事実、根拠に基づく推測、現時点では判断できないことを明確に区別してください。
- 性格、感情、人間関係、動機については特に慎重に扱い、本人の内面を作り上げたり、安易にレッテルを貼ったりしないでください。
- 結論を支える場合は代表的な発言やデータを少量引用できますが、長いチャットログをそのまま並べないでください。
- ユーザーが求めない限り、説教、頼まれていない人生相談、毎回のレポート化は避けてください。
@@ -1,29 +0,0 @@
---
id: general_tw
name: 通用分析助手
builtinVersion: 2
supportedLocales:
- zh-TW
presetQuestions:
- 最近都在聊什麼?
- 誰是最活躍的人?
- 聊天的活躍時間是什麼時候?
- 幫我搜尋關於「旅遊」的聊天記錄
- 分析一下聊天活躍時段
---
你是 ChatLab 裡的聊天記錄分析搭檔。你擅長從群聊或私聊記錄中梳理事實、時間線、話題變化、互動方式和值得注意的模式,幫助使用者更清楚地理解一段對話,而不是機械地產生分析報告。
## 交流方式
- 跟隨使用者使用的語言和交流方式,說自然、直接的人話,避免客服腔和過度正式的表達。
- 簡單問題直接回答;複雜問題再分層說明。只有確實有助於理解時,才使用標題、列表或表格。
- 在證據允許時給出明確判斷,不為了顯得中立而只羅列可能性;無法確認的部分要坦率說明。
- 語氣可以輕鬆、有一點自然的幽默感,但不要為了活躍氣氛強行玩梗、堆表情或犧牲準確性。
## 分析邊界
- 明確區分聊天記錄中能夠確認的事實、基於證據作出的推論,以及暫時無法判斷的部分。
- 涉及性格、情緒、關係和動機時尤其謹慎,不替當事人編造心理活動,也不輕易給人貼標籤。
- 可以適量引用有代表性的原話和資料支撐結論,但不要大段堆砌聊天記錄。
- 除非使用者明確要求,不說教、不擅自給人生建議,也不把每次回答都寫成總結報告。
@@ -1,12 +0,0 @@
/**
* 根据 locale 选择默认通用助手。
* 默认助手的选择应发生在知道用户语言的上层,而不是存储层。
*/
export function getDefaultGeneralAssistantId(
locale?: string
): 'general_cn' | 'general_tw' | 'general_en' | 'general_ja' {
if (locale?.startsWith('zh-TW')) return 'general_tw'
if (locale?.startsWith('en')) return 'general_en'
if (locale?.startsWith('ja')) return 'general_ja'
return 'general_cn'
}
+7 -1
View File
@@ -2,7 +2,13 @@
* 助手模块入口
*/
export * from './types'
export type {
AssistantConfig,
AssistantSummary,
AssistantInitResult,
AssistantSaveResult,
BuiltinAssistantInfo,
} from '@openchatlab/node-runtime'
export {
initAssistantManager,
getAllAssistants,
+13 -27
View File
@@ -8,28 +8,19 @@
import * as fs from 'fs'
import * as path from 'path'
import { createHash, randomUUID } from 'crypto'
import { AssistantManager as SharedAssistantManager, type AssistantManagerFs } from '@openchatlab/node-runtime'
import {
AssistantManager as SharedAssistantManager,
DEFAULT_GENERAL_ASSISTANT_RAW_CONFIGS,
LEGACY_GENERAL_ASSISTANT_DIGESTS,
type AssistantConfig,
type AssistantSummary,
type AssistantInitResult,
type AssistantSaveResult,
type BuiltinAssistantInfo,
type AssistantManagerFs,
} from '@openchatlab/node-runtime'
import { getPathProvider } from '../../paths/provider'
import { aiLogger } from '../logger'
import type {
AssistantConfig,
AssistantSummary,
AssistantInitResult,
AssistantSaveResult,
BuiltinAssistantInfo,
} from './types'
import builtinGeneralZhRaw from './builtins/general_cn.md?raw'
import builtinGeneralEnRaw from './builtins/general_en.md?raw'
import builtinGeneralJaRaw from './builtins/general_ja.md?raw'
import builtinGeneralTwRaw from './builtins/general_tw.md?raw'
// Version 0.31.2 and earlier lacked tracking metadata, so legacy digests identify untouched defaults.
const LEGACY_GENERAL_DIGESTS = {
general_cn: ['42989d512b8eca58839e838639f17d04413bf67c0253f7b5998f46b89ad0d330'],
general_en: ['3c6d223362dfc8bc863427768090c8794e0b1f8d1ec5f17068949ad728b3f9f6'],
general_ja: ['729a52d6ab4bd23f5ed5f84913b24c82e862246abc0ee70caec2244297a91d63'],
}
const nodeFs: AssistantManagerFs = {
ensureDir(dir: string) {
@@ -63,14 +54,9 @@ export function getManager(): SharedAssistantManager {
_manager = new SharedAssistantManager({
fs: nodeFs,
assistantsDir: path.join(getPathProvider().getAiDataDir(), 'assistants'),
builtinRawConfigs: [
{ id: 'general_cn', content: builtinGeneralZhRaw },
{ id: 'general_tw', content: builtinGeneralTwRaw },
{ id: 'general_en', content: builtinGeneralEnRaw },
{ id: 'general_ja', content: builtinGeneralJaRaw },
],
builtinRawConfigs: DEFAULT_GENERAL_ASSISTANT_RAW_CONFIGS,
contentHash: (content) => createHash('sha256').update(content).digest('hex'),
legacyBuiltinDigests: LEGACY_GENERAL_DIGESTS,
legacyBuiltinDigests: LEGACY_GENERAL_ASSISTANT_DIGESTS,
logger: aiLogger,
generateId: () => `custom_${randomUUID().replace(/-/g, '').slice(0, 12)}`,
})
-199
View File
@@ -1,199 +0,0 @@
/**
* 助手系统类型定义
* 定义助手配置(Markdown 格式)和声明式 SQL 工具等核心类型
*/
// ==================== 助手配置 ====================
/**
* 助手配置(Markdown 文件解析后的完整结构)
*
* 每个助手对应一个 .md 文件(YAML frontmatter + Markdown body)。
* - 内置助手模板打包在 electron/main/ai/assistant/builtins/*.md
* - 用户助手存储在 {userData}/data/ai/assistants/*.md
* - YAML frontmatter → 结构化元数据字段
* - Markdown body → systemPrompt
*/
export interface AssistantConfig {
/** 助手唯一标识 */
id: string
/** 助手显示名称 */
name: string
/** 系统提示词(角色定义 + 回答要求,来自 Markdown body) */
systemPrompt: string
/** 预设问题列表(前端展示,用户可点击直接发送) */
presetQuestions: string[]
/**
* 允许使用的内置工具名称白名单
* - undefined / 空数组 = 全部内置工具可用
* - 非空数组 = 仅列出的工具可用
*/
allowedBuiltinTools?: string[]
/**
* 内置助手来源标识
* 非空 = 该配置由某个内置助手导入而来(值为内置助手的 id)
*/
builtinId?: string
/** Builtin template version this config was based on */
builtinVersion?: number
/** Source builtin digest used to detect user customization */
builtinDigest?: string
/**
* 适用的聊天类型
* - undefined / [] = 通用(群聊+私聊均适用)
* - ['group'] = 仅群聊
* - ['private'] = 仅私聊
*/
applicableChatTypes?: ('group' | 'private')[]
/**
* 适用的语言/地区(前缀匹配,如 'zh' 匹配 'zh-CN'、'zh-TW')
* - undefined / [] = 全语言通用
* - ['zh'] = 仅中文用户
* - ['en'] = 仅英文用户
*/
supportedLocales?: string[]
}
/**
* 传递给前端的助手摘要信息
*/
export interface AssistantSummary {
id: string
name: string
systemPrompt: string
presetQuestions: string[]
builtinId?: string
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
}
/**
* 助手市场中的内置助手信息(模板目录项)
*/
export interface BuiltinAssistantInfo {
id: string
name: string
systemPrompt: string
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
/** 用户是否已导入该助手 */
imported: boolean
}
// ==================== 声明式 SQL 工具 ====================
/**
* 声明式 SQL 工具定义
*
* 每个定义在 LLM 眼中是一个 Function Calling 工具,
* 执行时通过参数化 SQL 查询数据库,将结果格式化为文本返回给 LLM。
*/
export interface CustomSqlToolDef {
/** 工具名称(作为 Function Calling 的 tool name) */
name: string
/** 工具描述(作为 Function Calling 的 tool description) */
description: string
/**
* 参数定义(标准 JSON Schema 格式)
*
* 示例:
* ```json
* {
* "type": "object",
* "properties": {
* "days": { "type": "number", "description": "查询天数" }
* },
* "required": ["days"]
* }
* ```
*
* 运行时会通过 jsonSchemaToTypeBox() 转换为 TypeBox 格式,
* 以满足 pi-agent-core AgentTool 的类型约束。
*/
parameters: JsonSchemaObject
/** 执行配置 */
execution: SqlToolExecution
}
/**
* JSON Schema 对象类型(简化版,覆盖技能参数定义的常见场景)
*/
export interface JsonSchemaObject {
type: 'object'
properties: Record<string, JsonSchemaProperty>
required?: string[]
}
/**
* JSON Schema 属性定义
*/
export interface JsonSchemaProperty {
type: 'string' | 'number' | 'integer' | 'boolean'
description?: string
default?: unknown
enum?: unknown[]
}
/**
* SQL 工具执行配置
*/
export interface SqlToolExecution {
/** 执行类型(目前仅支持 sqlite) */
type: 'sqlite'
/**
* 参数化 SQL 查询语句
* - 使用命名参数 @paramName(对应 parameters 中的属性名)
* - 必须是只读查询(better-sqlite3 的 stmt.readonly 会强制检查)
*
* 示例:
* ```sql
* SELECT sender_name, COUNT(*) as msg_count
* FROM message
* WHERE ts > unixepoch('now', '-' || @days || ' days')
* GROUP BY sender_name
* ORDER BY msg_count DESC
* LIMIT 10
* ```
*/
query: string
/**
* 行格式化模板,使用 {columnName} 占位符
* 示例:'用户【{sender_name}】共发言 {msg_count} 次'
*/
rowTemplate: string
/** 可选的汇总模板,在所有行之前输出(支持 {rowCount} 占位符) */
summaryTemplate?: string
/** 查询结果为空时返回的文本 */
fallback: string
}
// ==================== 助手管理器相关 ====================
/**
* AssistantManager 初始化结果
*/
export interface AssistantInitResult {
/** 加载的助手总数 */
total: number
/** general 助手是否为首次自动导入 */
generalCreated: boolean
/** Whether an unmodified general assistant was upgraded */
generalUpdated: boolean
}
/**
* 助手配置的保存/更新结果
*/
export interface AssistantSaveResult {
success: boolean
error?: string
}
@@ -6,6 +6,8 @@
import { parseAssistantFile, serializeAssistant } from './assistant-parser'
import type { AssistantConfig, AssistantSummary } from './types'
import { GENERAL_ASSISTANT_IDS } from '@openchatlab/shared-types'
import type { BuiltinAssistantInfo } from '@openchatlab/shared-types'
// ==================== Result types ====================
@@ -20,15 +22,6 @@ export interface AssistantSaveResult {
error?: string
}
export interface BuiltinAssistantInfo {
id: string
name: string
systemPrompt: string
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
imported: boolean
}
// ==================== Dependency abstraction ====================
export interface AssistantManagerFs {
@@ -44,11 +37,11 @@ export interface AssistantManagerFs {
export interface AssistantManagerDeps {
fs: AssistantManagerFs
assistantsDir: string
builtinRawConfigs?: Array<{ id: string; content: string }>
generalIds?: string[]
builtinRawConfigs?: ReadonlyArray<{ id: string; content: string }>
generalIds?: readonly string[]
contentHash: (content: string) => string
/** Legacy template digests used to identify untouched defaults created before tracking existed. */
legacyBuiltinDigests?: Record<string, string[]>
legacyBuiltinDigests?: Readonly<Record<string, readonly string[]>>
generateId?: () => string
logger?: {
info: (category: string, message: string, data?: unknown) => void
@@ -81,7 +74,7 @@ export class AssistantManager {
constructor(deps: AssistantManagerDeps) {
this.deps = deps
this.generalIds = deps.generalIds || ['general_cn', 'general_tw', 'general_en', 'general_ja']
this.generalIds = [...(deps.generalIds ?? GENERAL_ASSISTANT_IDS)]
this.initBuiltinCache()
}
@@ -332,7 +325,14 @@ export class AssistantManager {
const builtinConfig = this.getBuiltinConfig(existing.builtinId)
if (!builtinConfig) return { success: false, error: `Builtin config not found: ${existing.builtinId}` }
return this.saveToDisk(this.withBuiltinTracking(builtinConfig, existing.id))
const result = this.saveToDisk(this.withBuiltinTracking(builtinConfig, existing.id))
if (result.success) {
this.deps.logger?.info('AssistantManager', 'Reset builtin assistant to default template', {
id: existing.id,
builtinId: existing.builtinId,
})
}
return result
}
// ==================== Internal ====================
+2 -1
View File
@@ -9,9 +9,10 @@ import Database from 'better-sqlite3'
import * as fs from 'fs'
import * as path from 'path'
import type { ChartPayload, ChatEvidencePayload } from '@openchatlab/core'
import { DEFAULT_GENERAL_ASSISTANT_ID } from '@openchatlab/shared-types'
import type { PlanContentBlock, PlanDraftContentBlock } from './agent'
const DEFAULT_GENERAL_ID = 'general_cn'
const DEFAULT_GENERAL_ID = DEFAULT_GENERAL_ASSISTANT_ID
// ==================== 类型定义 ====================
@@ -0,0 +1,128 @@
import type { AssistantConfig } from '@openchatlab/shared-types'
import { serializeAssistant } from './assistant-parser'
/** Canonical built-in assistant templates shared by Desktop, CLI and CLI Web. */
export const DEFAULT_GENERAL_ASSISTANT_CONFIGS: readonly AssistantConfig[] = [
{
id: 'general_cn',
name: '通用分析助手',
builtinVersion: 2,
supportedLocales: ['zh-CN'],
presetQuestions: [
'最近都在聊什么?',
'谁是最活跃的人?',
'聊天的活跃时间是什么时候?',
'帮我搜索关于「旅游」的聊天记录',
'分析一下聊天活跃时间段',
],
systemPrompt: `你是 ChatLab 里的聊天记录分析搭档。你擅长从群聊或私聊记录中梳理事实、时间线、话题变化、互动方式和值得注意的模式,帮助用户更清楚地理解一段对话,而不是机械地生成分析报告。
## 交流方式
- 跟随用户使用的语言和交流方式,说自然、直接的人话,避免客服腔和过度正式的表达。
- 简单问题直接回答;复杂问题再分层说明。只有确实有助于理解时,才使用标题、列表或表格。
- 在证据允许时给出明确判断,不为了显得中立而只罗列可能性;无法确认的部分要坦率说明。
- 语气可以轻松、有一点自然的幽默感,但不要为了活跃气氛强行玩梗、堆表情或牺牲准确性。
## 分析边界
- 明确区分聊天记录中能够确认的事实、基于证据作出的推断,以及暂时无法判断的部分。
- 涉及性格、情绪、关系和动机时尤其谨慎,不替当事人编造心理活动,也不轻易给人贴标签。
- 可以适量引用有代表性的原话和数据支撑结论,但不要大段堆砌聊天记录。
- 除非用户明确要求,不说教、不擅自给人生建议,也不把每次回答都写成总结报告。`,
},
{
id: 'general_tw',
name: '通用分析助手',
builtinVersion: 2,
supportedLocales: ['zh-TW'],
presetQuestions: [
'最近都在聊什麼?',
'誰是最活躍的人?',
'聊天的活躍時間是什麼時候?',
'幫我搜尋關於「旅遊」的聊天記錄',
'分析一下聊天活躍時段',
],
systemPrompt: `你是 ChatLab 裡的聊天記錄分析搭檔。你擅長從群聊或私聊記錄中梳理事實、時間線、話題變化、互動方式和值得注意的模式,幫助使用者更清楚地理解一段對話,而不是機械地產生分析報告。
## 交流方式
- 跟隨使用者使用的語言和交流方式,說自然、直接的人話,避免客服腔和過度正式的表達。
- 簡單問題直接回答;複雜問題再分層說明。只有確實有助於理解時,才使用標題、列表或表格。
- 在證據允許時給出明確判斷,不為了顯得中立而只羅列可能性;無法確認的部分要坦率說明。
- 語氣可以輕鬆、有一點自然的幽默感,但不要為了活躍氣氛強行玩梗、堆表情或犧牲準確性。
## 分析邊界
- 明確區分聊天記錄中能夠確認的事實、基於證據作出的推論,以及暫時無法判斷的部分。
- 涉及性格、情緒、關係和動機時尤其謹慎,不替當事人編造心理活動,也不輕易給人貼標籤。
- 可以適量引用有代表性的原話和資料支撐結論,但不要大段堆砌聊天記錄。
- 除非使用者明確要求,不說教、不擅自給人生建議,也不把每次回答都寫成總結報告。`,
},
{
id: 'general_en',
name: 'General Analysis Assistant',
builtinVersion: 2,
supportedLocales: ['en'],
presetQuestions: [
'What have people been chatting about recently?',
'Who are the most active members?',
'Search chat records about "travel"',
'Analyze the active hours of the chat',
],
systemPrompt: `You are ChatLab's chat analysis partner. You help users make sense of group or private chat records by clarifying facts, timelines, topic shifts, interaction patterns, and other details worth noticing. Your job is to help the user understand a conversation, not to mechanically produce an analysis report.
## How to Communicate
- Match the user's language and conversational style. Sound natural and direct, not like customer support or a formal report.
- Answer simple questions directly. Add structure only for genuinely complex questions, and use headings, lists, or tables only when they improve understanding.
- Give a clear judgment when the evidence supports one instead of merely listing possibilities. Be candid about anything that cannot be established.
- A relaxed tone and light, natural humor are welcome, but never force jokes, emojis, or entertainment at the expense of accuracy.
## Analysis Boundaries
- Clearly distinguish what the chat records establish, what is a reasonable inference, and what remains unknown.
- Be especially careful with personality, emotion, relationships, and motives. Do not invent inner states or casually label people.
- Use a small number of representative quotes or data points when they support the conclusion, but do not dump large blocks of chat logs.
- Unless the user asks, do not moralize, give unsolicited life advice, or turn every response into a summary report.`,
},
{
id: 'general_ja',
name: '汎用分析アシスタント',
builtinVersion: 2,
supportedLocales: ['ja'],
presetQuestions: [
'最近みんな何を話してる?',
'一番アクティブなメンバーは誰?',
'「旅行」に関するチャット記録を検索して',
'チャットの活発な時間帯を分析して',
],
systemPrompt: `あなたは ChatLab のチャット分析パートナーです。グループチャットや個人チャットの記録から、事実、時系列、話題の変化、やり取りの傾向、注目すべき点を整理し、機械的な分析レポートではなく、会話をより深く理解できる回答を届けます。
## コミュニケーション
- ユーザーの言語と話し方に合わせ、カスタマーサポートや堅い報告書のようではなく、自然で率直な言葉を使ってください。
- 簡単な質問には端的に答え、複雑な質問だけを整理して説明してください。見出し、箇条書き、表は理解を助ける場合にだけ使います。
- 根拠が十分なら曖昧に可能性を並べるだけでなく、明確な判断を示してください。確認できないことは率直に伝えます。
- 軽く自然なユーモアは構いませんが、無理に冗談や絵文字を入れたり、正確さを損なったりしないでください。
## 分析上の境界
- 記録から確認できる事実、根拠に基づく推測、現時点では判断できないことを明確に区別してください。
- 性格、感情、人間関係、動機については特に慎重に扱い、本人の内面を作り上げたり、安易にレッテルを貼ったりしないでください。
- 結論を支える場合は代表的な発言やデータを少量引用できますが、長いチャットログをそのまま並べないでください。
- ユーザーが求めない限り、説教、頼まれていない人生相談、毎回のレポート化は避けてください。`,
},
]
export const DEFAULT_GENERAL_ASSISTANT_RAW_CONFIGS = DEFAULT_GENERAL_ASSISTANT_CONFIGS.map((config) => ({
id: config.id,
content: serializeAssistant(config),
}))
// Version 0.31.2 and earlier lacked tracking metadata; these digests identify untouched defaults.
export const LEGACY_GENERAL_ASSISTANT_DIGESTS: Readonly<Record<string, readonly string[]>> = {
general_cn: ['42989d512b8eca58839e838639f17d04413bf67c0253f7b5998f46b89ad0d330'],
general_en: ['3c6d223362dfc8bc863427768090c8794e0b1f8d1ec5f17068949ad728b3f9f6'],
general_ja: ['729a52d6ab4bd23f5ed5f84913b24c82e862246abc0ee70caec2244297a91d63'],
}
+7 -2
View File
@@ -11,15 +11,20 @@ export { AiLogger, extractErrorInfo, extractErrorStack } from './ai-logger'
export { formatAIError } from './error-formatter'
export type { FormatAIErrorOptions } from './error-formatter'
export type { AssistantConfig, AssistantSummary, SkillDef, SkillSummary } from './types'
export type { AssistantConfig, AssistantSummary, BuiltinAssistantInfo } from '@openchatlab/shared-types'
export type { SkillDef, SkillSummary } from './types'
export { parseAssistantFile, serializeAssistant } from './assistant-parser'
export {
DEFAULT_GENERAL_ASSISTANT_CONFIGS,
DEFAULT_GENERAL_ASSISTANT_RAW_CONFIGS,
LEGACY_GENERAL_ASSISTANT_DIGESTS,
} from './default-assistants'
// Assistant Manager
export { AssistantManager } from './assistant-manager'
export type {
AssistantInitResult,
AssistantSaveResult,
BuiltinAssistantInfo,
AssistantManagerFs,
AssistantManagerDeps,
} from './assistant-manager'
+2 -29
View File
@@ -1,33 +1,6 @@
/**
* AI 模块共享类型(助手、技能的解析结构)
*
* 与 Electron 端 types.ts 的同名接口结构一致(TypeScript 结构类型兼容)。
*/
/** AI module types that are specific to the Node runtime. */
export interface AssistantConfig {
id: string
name: string
systemPrompt: string
presetQuestions: string[]
allowedBuiltinTools?: string[]
builtinId?: string
/** Builtin template version this config was based on, used only for safe upgrades. */
builtinVersion?: number
/** Digest of the source builtin template; remains unchanged after user edits. */
builtinDigest?: string
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
}
export interface AssistantSummary {
id: string
name: string
systemPrompt: string
presetQuestions: string[]
builtinId?: string
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
}
export type { AssistantConfig, AssistantSummary } from '@openchatlab/shared-types'
export interface SkillDef {
id: string
+9 -1
View File
@@ -163,7 +163,15 @@ export {
// AI 助手/技能解析器 + 对话管理
export type { AssistantConfig, AssistantSummary, SkillDef, SkillSummary } from './ai'
export { parseAssistantFile, serializeAssistant, parseSkillFile, extractSkillId } from './ai'
export {
parseAssistantFile,
serializeAssistant,
parseSkillFile,
extractSkillId,
DEFAULT_GENERAL_ASSISTANT_CONFIGS,
DEFAULT_GENERAL_ASSISTANT_RAW_CONFIGS,
LEGACY_GENERAL_ASSISTANT_DIGESTS,
} from './ai'
export { AIChatManager } from './ai'
export { countTokens, countMessagesTokens, initTokenizer } from './ai'
export { createFileConfigStorage, createAuthProfileLlmConfigStore } from './ai'
+18
View File
@@ -0,0 +1,18 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { getDefaultGeneralAssistantId, isGeneralAssistantId } from './index'
describe('default general assistant rules', () => {
it('maps supported locales to their default assistants', () => {
assert.equal(getDefaultGeneralAssistantId('zh-CN'), 'general_cn')
assert.equal(getDefaultGeneralAssistantId('zh-TW'), 'general_tw')
assert.equal(getDefaultGeneralAssistantId('en-US'), 'general_en')
assert.equal(getDefaultGeneralAssistantId('ja-JP'), 'general_ja')
assert.equal(getDefaultGeneralAssistantId(), 'general_cn')
})
it('recognizes only built-in general assistant ids', () => {
assert.equal(isGeneralAssistantId('general_cn'), true)
assert.equal(isGeneralAssistantId('custom_assistant'), false)
})
})
+53
View File
@@ -78,6 +78,59 @@ export enum ChatType {
PRIVATE = 'private',
}
// ==================== AI Assistants ====================
export const GENERAL_ASSISTANT_IDS = ['general_cn', 'general_tw', 'general_en', 'general_ja'] as const
export type GeneralAssistantId = (typeof GENERAL_ASSISTANT_IDS)[number]
export const DEFAULT_GENERAL_ASSISTANT_ID: GeneralAssistantId = 'general_cn'
export function getDefaultGeneralAssistantId(locale?: string): GeneralAssistantId {
if (locale?.startsWith('zh-TW')) return 'general_tw'
if (locale?.startsWith('en')) return 'general_en'
if (locale?.startsWith('ja')) return 'general_ja'
return DEFAULT_GENERAL_ASSISTANT_ID
}
export function isGeneralAssistantId(id: string): id is GeneralAssistantId {
return GENERAL_ASSISTANT_IDS.some((generalId) => generalId === id)
}
export interface AssistantConfig {
id: string
name: string
systemPrompt: string
presetQuestions: string[]
allowedBuiltinTools?: string[]
builtinId?: string
/** Builtin template version this config was based on, used only for safe upgrades. */
builtinVersion?: number
/** Digest of the source builtin template; remains unchanged after user edits. */
builtinDigest?: string
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
}
export interface AssistantSummary {
id: string
name: string
systemPrompt: string
presetQuestions: string[]
builtinId?: string
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
}
export interface BuiltinAssistantInfo {
id: string
name: string
systemPrompt: string
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
imported: boolean
}
// ==================== 成员角色 ====================
export interface MemberRole {
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch, nextTick } from 'vue'
import { storeToRefs } from 'pinia'
import { getDefaultGeneralAssistantId } from '@openchatlab/shared-types'
import { useAssistantStore, type AssistantSummary } from '@/stores/assistant'
const props = defineProps<{
@@ -41,15 +42,8 @@ function handleDocumentMouseDown(event: MouseEvent) {
const assistantStore = useAssistantStore()
const { filteredAssistants, isLoaded } = storeToRefs(assistantStore)
function getLocaleGeneralId(locale: string): string {
if (locale.startsWith('zh-TW')) return 'general_tw'
if (locale.startsWith('ja')) return 'general_ja'
if (locale.startsWith('en')) return 'general_en'
return 'general_cn'
}
const sortedAssistants = computed<AssistantSummary[]>(() => {
const preferredGeneralId = getLocaleGeneralId(props.locale)
const preferredGeneralId = getDefaultGeneralAssistantId(props.locale)
return [...filteredAssistants.value].sort((a, b) => {
if (a.id === preferredGeneralId) return -1
if (b.id === preferredGeneralId) return 1
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { useAssistantStore, type CloudAssistantItem } from '@/stores/assistant'
import { useToast } from '@/composables/useToast'
import { isGeneralAssistantId } from '@openchatlab/shared-types'
const { t, locale } = useI18n()
const toast = useToast()
@@ -47,16 +48,12 @@ watch(
{ immediate: true }
)
function isGeneral(id: string): boolean {
return id === 'general_cn' || id === 'general_tw' || id === 'general_en' || id === 'general_ja'
}
function handleConfigure(id: string) {
emit('configure', id)
}
async function handleDelete(id: string) {
if (isGeneral(id)) return
if (isGeneralAssistantId(id)) return
await assistantStore.deleteAssistant(id)
}
@@ -192,7 +189,7 @@ function getChatTypeLabel(types?: ('group' | 'private')[]): string | null {
{{ getChatTypeLabel(assistant.applicableChatTypes) }}
</span>
<span
v-if="isGeneral(assistant.id)"
v-if="isGeneralAssistantId(assistant.id)"
class="inline-flex shrink-0 items-center rounded-full bg-green-100 px-2 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
>
{{ t('ai.assistant.market.default') }}
@@ -219,7 +216,7 @@ function getChatTypeLabel(types?: ('group' | 'private')[]): string | null {
<UIcon name="i-heroicons-document-duplicate" class="h-4 w-4" />
</button>
<button
v-if="!isGeneral(assistant.id)"
v-if="!isGeneralAssistantId(assistant.id)"
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(assistant.id)"
@@ -282,7 +279,7 @@ function getChatTypeLabel(types?: ('group' | 'private')[]): string | null {
{{ getChatTypeLabel(item.applicableChatTypes) }}
</span>
<span
v-if="isGeneral(item.id)"
v-if="isGeneralAssistantId(item.id)"
class="inline-flex shrink-0 items-center rounded-full bg-green-100 px-2 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
>
{{ t('ai.assistant.market.default') }}
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { getDefaultGeneralAssistantId } from '@openchatlab/shared-types'
import { useAssistantStore } from '@/stores/assistant'
import AssistantCard from './AssistantCard.vue'
@@ -22,15 +23,8 @@ const assistantStore = useAssistantStore()
const { filteredAssistants, isLoaded } = storeToRefs(assistantStore)
const rememberSelection = ref(true)
function getLocaleGeneralId(locale: string): string {
if (locale.startsWith('zh-TW')) return 'general_tw'
if (locale.startsWith('ja')) return 'general_ja'
if (locale.startsWith('en')) return 'general_en'
return 'general_cn'
}
const sortedVisibleAssistants = computed(() => {
const preferredGeneralId = getLocaleGeneralId(props.locale)
const preferredGeneralId = getDefaultGeneralAssistantId(props.locale)
return [...filteredAssistants.value].sort((a, b) => {
if (a.id === preferredGeneralId) return -1
if (b.id === preferredGeneralId) return 1
+2 -28
View File
@@ -1,32 +1,6 @@
export interface AssistantSummary {
id: string
name: string
systemPrompt: string
presetQuestions: string[]
builtinId?: string
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
}
import type { AssistantConfig, AssistantSummary, BuiltinAssistantInfo } from '@openchatlab/shared-types'
export interface AssistantConfig {
id: string
name: string
systemPrompt: string
presetQuestions: string[]
allowedBuiltinTools?: string[]
builtinId?: string
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
}
export interface BuiltinAssistantInfo {
id: string
name: string
systemPrompt: string
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
imported: boolean
}
export type { AssistantConfig, AssistantSummary, BuiltinAssistantInfo } from '@openchatlab/shared-types'
export interface AssistantServiceAdapter {
getAll(): Promise<AssistantSummary[]>
+1 -10
View File
@@ -20,6 +20,7 @@ import { useAgentStreamService } from '@/services/ai-stream/service'
import { buildSerializablePreprocessConfig, shouldEnsureDesensitizeRulesBeforeSerialize } from './aiPreprocessConfig'
import type { ChartPayload, ChatEvidencePayload } from '@openchatlab/core'
import { extractToolResultText, truncateToolResultText } from '@openchatlab/core'
import { getDefaultGeneralAssistantId } from '@openchatlab/shared-types'
import {
createRenderOnlyToolPendingBlock,
extractChartPayloads,
@@ -197,16 +198,6 @@ export interface SendMessageResult {
const DRAFT_AI_CHAT_KEY = '__draft__'
/**
* 创建对话时前端已经知道 locale,因此默认助手在这里选择即可。
*/
function getDefaultGeneralAssistantId(locale: string): 'general_cn' | 'general_tw' | 'general_en' | 'general_ja' {
if (locale.startsWith('zh-TW')) return 'general_tw'
if (locale.startsWith('en')) return 'general_en'
if (locale.startsWith('ja')) return 'general_ja'
return 'general_cn'
}
function buildTimeFilterKey(timeFilter?: { startTs: number; endTs: number }): string {
if (!timeFilter) return 'all'
return `${timeFilter.startTs}_${timeFilter.endTs}`
+3 -29
View File
@@ -7,40 +7,14 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { i18n } from '@/i18n'
import { usePlatformService, useAssistantService } from '@/services'
import type { AssistantConfig, AssistantSummary, BuiltinAssistantInfo } from '@openchatlab/shared-types'
import { CHATLAB_SITE_BASE } from '@/utils/chatlabSiteLocale'
const CLOUD_MARKET_BASE_URL = CHATLAB_SITE_BASE
const LOCALE_PATH_MAP: Record<string, string> = { 'zh-CN': 'cn', 'zh-TW': 'cn', 'en-US': 'en', 'ja-JP': 'ja' }
export interface AssistantSummary {
id: string
name: string
systemPrompt: string
presetQuestions: string[]
builtinId?: string
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
}
export interface AssistantConfigFull {
id: string
name: string
systemPrompt: string
presetQuestions: string[]
allowedBuiltinTools?: string[]
builtinId?: string
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
}
export interface BuiltinAssistantInfo {
id: string
name: string
systemPrompt: string
applicableChatTypes?: ('group' | 'private')[]
supportedLocales?: string[]
imported: boolean
}
export type { AssistantSummary, BuiltinAssistantInfo } from '@openchatlab/shared-types'
export type AssistantConfigFull = AssistantConfig
export interface CloudAssistantItem {
id: string