From 1290ae6569f26d4d3224f244c10e484cddbd2b74 Mon Sep 17 00:00:00 2001 From: digua Date: Tue, 9 Jun 2026 20:43:57 +0800 Subject: [PATCH] feat(ai): add chart auto mode preference --- apps/cli/src/ai/agent-stream-runner.ts | 20 +++-- apps/cli/src/ai/agent.ts | 15 +++- apps/cli/src/ai/chat-command.ts | 1 + apps/desktop/main/ai/agent-stream-runner.ts | 25 +++--- apps/desktop/main/ai/agent/index.ts | 8 +- apps/desktop/main/ai/agent/types.ts | 3 + packages/http-routes/src/context.ts | 2 + .../src/ai/__tests__/chart-runtime.test.ts | 42 +++++++++- packages/node-runtime/src/ai/chart-runtime.ts | 17 ++++- packages/node-runtime/src/preferences.test.ts | 1 + packages/node-runtime/src/preferences.ts | 1 + packages/shared-types/index.ts | 3 + .../common/Settings/AI/AIPromptConfigTab.vue | 76 ++++++++++++++----- src/i18n/locales/en-US/settings.json | 14 +++- src/i18n/locales/ja-JP/settings.json | 14 +++- src/i18n/locales/zh-CN/settings.json | 14 +++- src/i18n/locales/zh-TW/settings.json | 14 +++- src/services/ai-stream/types.ts | 2 + src/stores/aiChat.ts | 7 +- src/stores/prompt.ts | 3 + 20 files changed, 225 insertions(+), 57 deletions(-) diff --git a/apps/cli/src/ai/agent-stream-runner.ts b/apps/cli/src/ai/agent-stream-runner.ts index 1e70f9ff..7f2e3c72 100644 --- a/apps/cli/src/ai/agent-stream-runner.ts +++ b/apps/cli/src/ai/agent-stream-runner.ts @@ -16,6 +16,7 @@ import { resolveChartRuntimeForRequest, } from '@openchatlab/node-runtime' import { getChatOverview, normalizeBuiltinToolNames } from '@openchatlab/core' +import type { ChartAutoMode } from '@openchatlab/shared-types' import type { DataSnapshot } from '@openchatlab/node-runtime' import type { AgentStreamRequest } from '@openchatlab/http-routes' import { AGENT_TOOL_REGISTRY } from '@openchatlab/tools' @@ -75,6 +76,7 @@ export function createCliRunAgentStream( assistantId, skillId, enableAutoSkill, + chartAutoMode, compressionConfig, ownerInfo, mentionedMembers, @@ -99,17 +101,18 @@ export function createCliRunAgentStream( const maxToolResultTokens = Math.floor(contextWindow * (maxToolResultPercent / 100)) const db = (dbManager as any).open?.(sessionId) + const resolvedChartAutoMode: ChartAutoMode = chartAutoMode ?? 'suggest' const chartRuntime = resolveChartRuntimeForRequest({ skillId, userMessage, locale, assistantAllowedTools, enableAutoDetection: enableAutoSkill === true, - enableAnalyticalAutoDetection: enableAutoSkill === true, + chartAutoMode: resolvedChartAutoMode, }) const isChartCapability = chartRuntime.isChartCapability const autoSkillAllowedTools = - !skillId && enableAutoSkill + !skillId && enableAutoSkill && resolvedChartAutoMode !== 'explicit' ? getAllowedBuiltinToolsForChartAutoSkill(assistantAllowedTools) : assistantAllowedTools const allowedToolSet = getAllowedToolSet( @@ -135,11 +138,9 @@ export function createCliRunAgentStream( const def = skillMgr.getSkillConfig(skillId) if (def) resolvedSkillDef = { name: def.name, prompt: def.prompt } } else if (enableAutoSkill) { - const menu = buildSkillMenuWithBuiltinChart( - skillMgr.getSkillMenu(chatType ?? 'group', toolNames), - locale, - toolNames - ) + const baseMenu = skillMgr.getSkillMenu(chatType ?? 'group', toolNames) + const menu = + resolvedChartAutoMode === 'aggressive' ? buildSkillMenuWithBuiltinChart(baseMenu, locale, toolNames) : baseMenu if (menu) resolvedSkillMenu = menu } @@ -150,7 +151,9 @@ export function createCliRunAgentStream( coreToolNames: new Set(CHART_CAPABILITY_CORE_TOOLS), locale, getSkillConfig: (id) => - getSkillConfigWithBuiltinChart(id, locale, (skillConfigId) => skillMgr.getSkillConfig(skillConfigId)), + resolvedChartAutoMode === 'aggressive' + ? getSkillConfigWithBuiltinChart(id, locale, (skillConfigId) => skillMgr.getSkillConfig(skillConfigId)) + : skillMgr.getSkillConfig(id), }) agentTools.push(activateSkillTool as any) } @@ -192,6 +195,7 @@ export function createCliRunAgentStream( mentionedMembers, dataSnapshot, thinkingLevel, + chartAutoMode: resolvedChartAutoMode, }) } } diff --git a/apps/cli/src/ai/agent.ts b/apps/cli/src/ai/agent.ts index 3429cd2c..de421c06 100644 --- a/apps/cli/src/ai/agent.ts +++ b/apps/cli/src/ai/agent.ts @@ -19,6 +19,7 @@ import { createCompressionLlmAdapter, AgentEventHandler, formatAIError, + shouldUseChartCapabilityForMessage, getChartPlannerCapabilityForMessage, initTokenizer, type AgentStreamChunk, @@ -31,6 +32,7 @@ import { type OwnerInfo, type MentionedMember, } from '@openchatlab/node-runtime' +import type { ChartAutoMode } from '@openchatlab/shared-types' import { getDefaultAssistantConfig, buildPiModel } from './llm-config' import { getServerAiLogger } from './logger' @@ -56,6 +58,7 @@ export interface RunAgentOptions { mentionedMembers?: MentionedMember[] dataSnapshot?: DataSnapshot thinkingLevel?: string + chartAutoMode?: ChartAutoMode } export async function runServerAgent(options: RunAgentOptions): Promise { @@ -78,6 +81,7 @@ export async function runServerAgent(options: RunAgentOptions): Promise { mentionedMembers, dataSnapshot, thinkingLevel, + chartAutoMode = 'suggest', } = options const aiLogger = getServerAiLogger() @@ -168,6 +172,10 @@ export async function runServerAgent(options: RunAgentOptions): Promise { const steerMessage = t('ai.agent.answerWithoutTools') let cachedMessages: PiMessage[] = [] + const effectiveTools = + chartAutoMode === 'explicit' && !shouldUseChartCapabilityForMessage(userMessage) + ? tools.filter((tool) => tool.name !== 'render_chart') + : tools try { const routeInput = { @@ -175,12 +183,13 @@ export async function runServerAgent(options: RunAgentOptions): Promise { chatType, locale, dataSnapshot, - availableTools: tools.map((tool) => tool.name), + availableTools: effectiveTools.map((tool) => tool.name), availableCapabilities: [ getChartPlannerCapabilityForMessage({ userMessage, locale, - availableTools: tools.map((tool) => tool.name), + availableTools: effectiveTools.map((tool) => tool.name), + chartAutoMode, }), ].filter((capability) => capability !== null), skillSummary: skillDef?.name ?? (skillMenu ? 'auto_skill_menu' : undefined), @@ -239,7 +248,7 @@ export async function runServerAgent(options: RunAgentOptions): Promise { piModel, apiKey: llmConfig.apiKey, systemPrompt: effectiveSystemPrompt, - tools, + tools: effectiveTools, history, userMessage, maxToolRounds: DEFAULT_MAX_TOOL_ROUNDS, diff --git a/apps/cli/src/ai/chat-command.ts b/apps/cli/src/ai/chat-command.ts index 70fe39ae..0575b6bb 100644 --- a/apps/cli/src/ai/chat-command.ts +++ b/apps/cli/src/ai/chat-command.ts @@ -256,6 +256,7 @@ export async function runChatTurn( chatType: 'group', locale: options.locale ?? 'zh-CN', enableAutoSkill: true, + chartAutoMode: 'suggest', }, (chunk: AgentStreamChunk) => { if (options.includeEvents) { diff --git a/apps/desktop/main/ai/agent-stream-runner.ts b/apps/desktop/main/ai/agent-stream-runner.ts index 69996b80..9dc39cbd 100644 --- a/apps/desktop/main/ai/agent-stream-runner.ts +++ b/apps/desktop/main/ai/agent-stream-runner.ts @@ -8,6 +8,7 @@ import type { AgentStreamChunk as SharedAgentStreamChunk } from '@openchatlab/node-runtime' import type { AgentStreamRequest } from '@openchatlab/http-routes' +import type { ChartAutoMode } from '@openchatlab/shared-types' import { buildSkillMenuWithBuiltinChart, CHART_CAPABILITY_ANALYSIS_TOOLS, @@ -76,6 +77,7 @@ export function createElectronRunAgentStream(): ( assistantId, skillId, enableAutoSkill, + chartAutoMode, compressionConfig, ownerInfo, mentionedMembers, @@ -158,13 +160,14 @@ export function createElectronRunAgentStream(): ( } let skillCtx: SkillContext | undefined + const resolvedChartAutoMode: ChartAutoMode = chartAutoMode ?? 'suggest' const chartRuntime = resolveChartRuntimeForRequest({ skillId, userMessage, locale, assistantAllowedTools: assistantConfig?.allowedBuiltinTools, enableAutoDetection: enableAutoSkill === true, - enableAnalyticalAutoDetection: enableAutoSkill === true, + chartAutoMode: resolvedChartAutoMode, }) if (chartRuntime.isChartCapability) { const chartSkill = chartRuntime.skillDef ?? getChartCapabilitySkill(locale ?? 'zh-CN') @@ -185,9 +188,12 @@ export function createElectronRunAgentStream(): ( } } else if (enableAutoSkill) { const effectiveChatType = chatType ?? 'group' - const autoSkillAllowedTools = getAllowedBuiltinToolsForChartAutoSkill(assistantConfig?.allowedBuiltinTools) ?? [ - ...CHART_CAPABILITY_ANALYSIS_TOOLS, - ] + const autoSkillAllowedTools = + resolvedChartAutoMode === 'explicit' + ? assistantConfig?.allowedBuiltinTools + : (getAllowedBuiltinToolsForChartAutoSkill(assistantConfig?.allowedBuiltinTools) ?? [ + ...CHART_CAPABILITY_ANALYSIS_TOOLS, + ]) assistantConfig = { ...(assistantConfig ?? { id: resolvedAssistantId, @@ -198,11 +204,11 @@ export function createElectronRunAgentStream(): ( allowedBuiltinTools: autoSkillAllowedTools, } const allowedTools = autoSkillAllowedTools - const menu = buildSkillMenuWithBuiltinChart( - skillManager.getSkillMenu(effectiveChatType, allowedTools), - locale, - allowedTools - ) + const baseMenu = skillManager.getSkillMenu(effectiveChatType, allowedTools) + const menu = + resolvedChartAutoMode === 'aggressive' + ? buildSkillMenuWithBuiltinChart(baseMenu, locale, allowedTools) + : baseMenu if (menu) { skillCtx = { skillMenu: menu } } @@ -240,6 +246,7 @@ export function createElectronRunAgentStream(): ( { abortSignal, thinkingLevel: thinkingLevel as import('@openchatlab/core').ThinkingLevel | undefined, + chartAutoMode: resolvedChartAutoMode, }, chatType ?? 'group', locale ?? 'zh-CN', diff --git a/apps/desktop/main/ai/agent/index.ts b/apps/desktop/main/ai/agent/index.ts index 85b0e157..403013ae 100644 --- a/apps/desktop/main/ai/agent/index.ts +++ b/apps/desktop/main/ai/agent/index.ts @@ -17,6 +17,7 @@ import { createPlanContentBlock, decideRequestRoute, getChartPlannerCapabilityForMessage, + shouldUseChartCapabilityForMessage, runAgentCore, streamSimple, type PiMessage, @@ -72,6 +73,7 @@ export class Agent { this.config = { maxToolRounds: config.maxToolRounds ?? DEFAULT_MAX_TOOL_ROUNDS, thinkingLevel: config.thinkingLevel, + chartAutoMode: config.chartAutoMode ?? 'suggest', } } @@ -127,7 +129,10 @@ export class Agent { const allowedTools = this.assistantConfig?.allowedBuiltinTools const toolContext = { ...this.context, locale: this.locale } - const piTools = getAllTools(toolContext, allowedTools) + let piTools = getAllTools(toolContext, allowedTools) + if (this.config.chartAutoMode === 'explicit' && !shouldUseChartCapabilityForMessage(userMessage)) { + piTools = piTools.filter((tool) => tool.name !== 'render_chart') + } if (this.skillCtx?.skillMenu && !this.skillCtx?.skillDef) { piTools.push(createActivateSkillTool(this.chatType, allowedTools, this.locale)) @@ -152,6 +157,7 @@ export class Agent { userMessage, locale: this.locale, availableTools: availableToolNames, + chartAutoMode: this.config.chartAutoMode, }), ].filter((capability) => capability !== null), assistantSummary: this.assistantConfig?.name, diff --git a/apps/desktop/main/ai/agent/types.ts b/apps/desktop/main/ai/agent/types.ts index f2e6fc96..1ea8e8c4 100644 --- a/apps/desktop/main/ai/agent/types.ts +++ b/apps/desktop/main/ai/agent/types.ts @@ -5,6 +5,7 @@ import type { TokenUsage } from '@openchatlab/node-runtime' import type { SerializedErrorInfo } from '../../../shared/types' import type { ThinkingLevel } from '@openchatlab/core' +import type { ChartAutoMode } from '@openchatlab/shared-types' export type { TokenUsage, AgentRuntimeStatus } from '@openchatlab/node-runtime' export type { SerializedErrorInfo } @@ -19,6 +20,8 @@ export interface AgentConfig { abortSignal?: AbortSignal /** Override thinking level for this request; clamped to what the model supports. */ thinkingLevel?: ThinkingLevel + /** Controls how aggressively chart capability is exposed for automatic use. */ + chartAutoMode?: ChartAutoMode } /** diff --git a/packages/http-routes/src/context.ts b/packages/http-routes/src/context.ts index 163b2a90..f03d8f8e 100644 --- a/packages/http-routes/src/context.ts +++ b/packages/http-routes/src/context.ts @@ -7,6 +7,7 @@ */ import type { PathProvider } from '@openchatlab/core' +import type { ChartAutoMode } from '@openchatlab/shared-types' import type { DatabaseManager, DataDirSwitchResult, @@ -80,6 +81,7 @@ export interface AgentStreamRequest { assistantId?: string skillId?: string | null enableAutoSkill?: boolean + chartAutoMode?: ChartAutoMode compressionConfig?: { enabled: boolean tokenThresholdPercent?: number diff --git a/packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts b/packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts index e06adfa3..bb2f9516 100644 --- a/packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts +++ b/packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts @@ -39,13 +39,25 @@ describe('chart runtime policy', () => { ) }) - it('can auto-enable chart runtime for analytical trend questions', () => { + it('keeps analytical trend questions in normal runtime for the default chart auto mode', () => { const runtime = resolveChartRuntimeForRequest({ skillId: null, userMessage: '分析过去一年群里每季度消息量变化趋势,指出峰值和低谷。', locale: 'zh-CN', enableAutoDetection: true, - enableAnalyticalAutoDetection: true, + chartAutoMode: 'suggest', + }) + + assert.equal(runtime.isChartCapability, false) + }) + + it('can auto-enable chart runtime aggressively for analytical trend questions', () => { + const runtime = resolveChartRuntimeForRequest({ + skillId: null, + userMessage: '分析过去一年群里每季度消息量变化趋势,指出峰值和低谷。', + locale: 'zh-CN', + enableAutoDetection: true, + chartAutoMode: 'aggressive', }) assert.equal(runtime.isChartCapability, true) @@ -53,6 +65,18 @@ describe('chart runtime policy', () => { assert.deepEqual(runtime.allowedBuiltinTools, ['render_chart']) }) + it('still auto-enables chart runtime for explicit chart requests', () => { + const runtime = resolveChartRuntimeForRequest({ + skillId: null, + userMessage: '画一个最近一年的消息量趋势图。', + locale: 'zh-CN', + enableAutoDetection: true, + chartAutoMode: 'explicit', + }) + + assert.equal(runtime.isChartCapability, true) + }) + it('keeps chart tool allowlists free of raw SQL', () => { assert.deepEqual(getChartCapabilityAllowedBuiltinTools(), ['render_chart']) assert.deepEqual(getChartCapabilityAllowedBuiltinTools(['keyword_frequency', 'execute_sql']), [ @@ -78,17 +102,29 @@ describe('chart runtime policy', () => { ]) }) - it('offers chart planner capability for analytical trend questions without explicit chart wording', () => { + it('offers chart planner capability for analytical trend questions in suggest mode', () => { const capability = getChartPlannerCapabilityForMessage({ userMessage: '分析过去一年群里话题的变化趋势,按季度总结主要变化。', locale: 'zh-CN', availableTools: ['get_schema', 'search_messages', 'render_chart'], + chartAutoMode: 'suggest', }) assert.equal(capability?.id, 'chart_generation') assert.deepEqual(capability?.tools, ['get_schema', 'render_chart']) }) + it('does not offer chart planner capability for analytical wording in explicit mode', () => { + const capability = getChartPlannerCapabilityForMessage({ + userMessage: '分析过去一年群里话题的变化趋势,按季度总结主要变化。', + locale: 'zh-CN', + availableTools: ['get_schema', 'search_messages', 'render_chart'], + chartAutoMode: 'explicit', + }) + + assert.equal(capability, null) + }) + it('does not offer chart planner capability when render_chart is unavailable', () => { const capability = getChartPlannerCapabilityForMessage({ userMessage: '分析过去一年群里话题的变化趋势。', diff --git a/packages/node-runtime/src/ai/chart-runtime.ts b/packages/node-runtime/src/ai/chart-runtime.ts index e771dc50..beaced91 100644 --- a/packages/node-runtime/src/ai/chart-runtime.ts +++ b/packages/node-runtime/src/ai/chart-runtime.ts @@ -2,6 +2,7 @@ import { CHART_CAPABILITY_SKILL_ID } from '@openchatlab/core' import { appendSkillMenuLines, formatSkillMenuLine } from './skill-menu' import type { SkillDef } from './types' import type { PlannerCapabilitySummary } from './agent/planning-types' +import type { ChartAutoMode } from '@openchatlab/shared-types' export { CHART_CAPABILITY_SKILL_ID } @@ -17,6 +18,7 @@ export interface ResolveChartRuntimeOptions { assistantAllowedTools?: readonly string[] | null enableAutoDetection?: boolean enableAnalyticalAutoDetection?: boolean + chartAutoMode?: ChartAutoMode } export interface ResolvedChartRuntime { @@ -59,12 +61,18 @@ export function getChartPlannerCapabilityForMessage(options: { userMessage: string locale?: string availableTools: readonly string[] + chartAutoMode?: ChartAutoMode }): PlannerCapabilitySummary | null { const availableToolSet = new Set(options.availableTools) if (!availableToolSet.has('get_schema') || !availableToolSet.has('render_chart')) { return null } - if (!shouldOfferChartCapabilityForAnalyticalMessage(options.userMessage)) { + const chartAutoMode = options.chartAutoMode ?? 'suggest' + const shouldOffer = + chartAutoMode === 'explicit' + ? shouldUseChartCapabilityForMessage(options.userMessage) + : shouldOfferChartCapabilityForAnalyticalMessage(options.userMessage) + if (!shouldOffer) { return null } @@ -82,10 +90,13 @@ export function getChartPlannerCapabilityForMessage(options: { export function resolveChartRuntimeForRequest(options: ResolveChartRuntimeOptions): ResolvedChartRuntime { const locale = options.locale ?? 'zh-CN' const isExplicitChartSkill = options.skillId === CHART_CAPABILITY_SKILL_ID + const allowAnalyticalAutoDetection = + options.chartAutoMode !== undefined + ? options.chartAutoMode === 'aggressive' + : options.enableAnalyticalAutoDetection === true const shouldAutoDetectChart = shouldUseChartCapabilityForMessage(options.userMessage ?? '') || - (options.enableAnalyticalAutoDetection === true && - shouldOfferChartCapabilityForAnalyticalMessage(options.userMessage ?? '')) + (allowAnalyticalAutoDetection && shouldOfferChartCapabilityForAnalyticalMessage(options.userMessage ?? '')) const isAutoChartSkill = options.enableAutoDetection === true && !options.skillId && shouldAutoDetectChart if (!isExplicitChartSkill && !isAutoChartSkill) { diff --git a/packages/node-runtime/src/preferences.test.ts b/packages/node-runtime/src/preferences.test.ts index 447dff95..dc93cec1 100644 --- a/packages/node-runtime/src/preferences.test.ts +++ b/packages/node-runtime/src/preferences.test.ts @@ -183,6 +183,7 @@ test('loads thinkingLevels as empty object when field is absent in legacy prefer assert.deepEqual(loaded.thinkingLevels, {}) // Existing fields must survive the migration assert.equal(loaded.aiGlobalSettings.maxMessagesPerRequest, 500) + assert.equal(loaded.aiGlobalSettings.chartAutoMode, 'suggest') } finally { rmSync(systemDir, { recursive: true, force: true }) } diff --git a/packages/node-runtime/src/preferences.ts b/packages/node-runtime/src/preferences.ts index 5365b3a9..c9605bf0 100644 --- a/packages/node-runtime/src/preferences.ts +++ b/packages/node-runtime/src/preferences.ts @@ -49,6 +49,7 @@ const DEFAULTS: Preferences = { exportFormat: 'markdown', sqlExportFormat: 'csv', enableAutoSkill: true, + chartAutoMode: 'suggest', searchContextBefore: 2, searchContextAfter: 2, contextCompression: { diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index 1ff90e0d..31cdf138 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -126,11 +126,14 @@ export interface ContextCompressionSettings { maxToolResultPercent: number } +export type ChartAutoMode = 'explicit' | 'suggest' | 'aggressive' + export interface AIGlobalSettings { maxMessagesPerRequest: number exportFormat: 'markdown' | 'txt' sqlExportFormat: 'csv' | 'json' enableAutoSkill: boolean + chartAutoMode: ChartAutoMode searchContextBefore: number searchContextAfter: number contextCompression: ContextCompressionSettings diff --git a/src/components/common/Settings/AI/AIPromptConfigTab.vue b/src/components/common/Settings/AI/AIPromptConfigTab.vue index 4f5f5c42..83c4a6bc 100644 --- a/src/components/common/Settings/AI/AIPromptConfigTab.vue +++ b/src/components/common/Settings/AI/AIPromptConfigTab.vue @@ -3,6 +3,7 @@ import { computed } from 'vue' import { storeToRefs } from 'pinia' import { useI18n } from 'vue-i18n' import { usePromptStore } from '@/stores/prompt' +import type { ChartAutoMode } from '@openchatlab/shared-types' const { t } = useI18n() @@ -40,6 +41,22 @@ const enableAutoSkill = computed({ }, }) +const chartAutoModeOptions = computed(() => [ + { label: t('settings.aiPrompt.skillSettings.chartAutoMode.explicit'), value: 'explicit' }, + { label: t('settings.aiPrompt.skillSettings.chartAutoMode.suggest'), value: 'suggest' }, + { label: t('settings.aiPrompt.skillSettings.chartAutoMode.aggressive'), value: 'aggressive' }, +]) + +const chartAutoMode = computed({ + get: () => aiGlobalSettings.value.chartAutoMode ?? 'suggest', + set: (val: ChartAutoMode) => { + promptStore.updateAIGlobalSettings({ chartAutoMode: val }) + emit('config-changed') + }, +}) + +const chartAutoModeHint = computed(() => t(`settings.aiPrompt.skillSettings.chartAutoMode.${chartAutoMode.value}Hint`)) + const searchContextBefore = computed({ get: () => aiGlobalSettings.value.searchContextBefore ?? 3, set: (val: number) => { @@ -105,6 +122,44 @@ const maxToolResultPercent = computed({