diff --git a/apps/electron/src/main/index.ts b/apps/electron/src/main/index.ts index d538e59a..33e0fa92 100644 --- a/apps/electron/src/main/index.ts +++ b/apps/electron/src/main/index.ts @@ -24,6 +24,7 @@ for (const key of Object.keys(process.env)) { import { createApplicationMenu } from './menu' import { registerIpcHandlers } from './ipc' +import { initAgentProfiles } from './lib/agent-profile-service' import { createTray, destroyTray } from './tray' import { initializeRuntime } from './lib/runtime-init' import { seedDefaultSkills } from './lib/config-paths' @@ -232,6 +233,9 @@ app.whenReady().then(async () => { const menu = createApplicationMenu() Menu.setApplicationMenu(menu) + // 初始化 Agent Profile(确保预置通用助手存在) + initAgentProfiles() + // Register IPC handlers registerIpcHandlers() diff --git a/apps/electron/src/main/ipc.ts b/apps/electron/src/main/ipc.ts index 51e5676b..edb7b078 100644 --- a/apps/electron/src/main/ipc.ts +++ b/apps/electron/src/main/ipc.ts @@ -80,6 +80,7 @@ import type { WeChatConfig, WeChatBridgeState, SDKMessage, + WorkspaceCapabilitiesSummary, } from '@proma/shared' import type { UserProfile, AppSettings } from '../types' import { getRuntimeStatus, getGitRepoStatus } from './lib/runtime-init' @@ -164,6 +165,14 @@ import { attachWorkspaceDirectory, detachWorkspaceDirectory, } from './lib/agent-workspace-manager' +import { + listAgentProfiles, + getAgentProfile, + createAgentProfile, + updateAgentProfile, + deleteAgentProfile, +} from './lib/agent-profile-service' +import type { AgentProfile, AgentProfileCreateInput, AgentProfileUpdateInput } from '@proma/shared' import { getMemoryConfig, setMemoryConfig } from './lib/memory-service' import { getAllToolInfos } from './lib/chat-tool-registry' import { updateToolState, updateToolCredentials, getToolCredentials, addCustomTool, deleteCustomTool } from './lib/chat-tool-config' @@ -996,6 +1005,58 @@ export function registerIpcHandlers(): void { } ) + // ===== Agent Profile 管理 ===== + + ipcMain.handle( + AGENT_IPC_CHANNELS.LIST_PROFILES, + async (): Promise => { + return listAgentProfiles() + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.GET_PROFILE, + async (_, id: string): Promise => { + return getAgentProfile(id) + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.CREATE_PROFILE, + async (_, input: AgentProfileCreateInput): Promise => { + return createAgentProfile(input) + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.UPDATE_PROFILE, + async (_, id: string, input: AgentProfileUpdateInput): Promise => { + return updateAgentProfile(id, input) + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.DELETE_PROFILE, + async (_, id: string): Promise => { + return deleteAgentProfile(id) + } + ) + + // ===== 获取所有工作区的能力汇总 ===== + + ipcMain.handle( + AGENT_IPC_CHANNELS.LIST_ALL_CAPABILITIES, + async (): Promise => { + const workspaces = listAgentWorkspaces() + return workspaces.map((ws) => ({ + workspaceId: ws.id, + workspaceName: ws.name, + workspaceSlug: ws.slug, + capabilities: getWorkspaceCapabilities(ws.slug), + })) + } + ) + // 发送 Agent 消息(触发 Agent SDK 流式响应) ipcMain.handle( AGENT_IPC_CHANNELS.SEND_MESSAGE, @@ -2325,6 +2386,7 @@ export function registerIpcHandlers(): void { mode: input.mode, text: input.text, files: input.files, + agentProfileId: input.agentProfileId, }) mainWin.show() mainWin.focus() diff --git a/apps/electron/src/main/lib/agent-orchestrator.ts b/apps/electron/src/main/lib/agent-orchestrator.ts index 7d48bd93..3d5e3b34 100644 --- a/apps/electron/src/main/lib/agent-orchestrator.ts +++ b/apps/electron/src/main/lib/agent-orchestrator.ts @@ -16,12 +16,12 @@ import { randomUUID } from 'node:crypto' import { homedir } from 'node:os' -import { join, dirname } from 'node:path' -import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync } from 'node:fs' +import { join, dirname, resolve, sep } from 'node:path' +import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, rmSync, readdirSync } from 'node:fs' import { execFileSync } from 'node:child_process' import { createRequire } from 'node:module' import { app } from 'electron' -import type { AgentSendInput, AgentMessage, AgentGenerateTitleInput, AgentProviderAdapter, TypedError, RetryAttempt, SDKMessage, SDKAssistantMessage, AgentStreamPayload, RewindSessionResult } from '@proma/shared' +import type { AgentSendInput, AgentMessage, AgentGenerateTitleInput, AgentProviderAdapter, TypedError, RetryAttempt, SDKMessage, SDKAssistantMessage, AgentStreamPayload, RewindSessionResult, AgentProfileMcpRef, AgentProfileSkillRef, ThinkingConfig, AgentEffort } from '@proma/shared' import { SAFE_TOOLS } from '@proma/shared' import type { PermissionRequest, PromaPermissionMode, AskUserRequest, ExitPlanModeRequest } from '@proma/shared' import type { ClaudeAgentQueryOptions } from './adapters/claude-agent-adapter' @@ -33,7 +33,7 @@ import { getFetchFn } from './proxy-fetch' import { getEffectiveProxyUrl } from './proxy-settings-service' import { appendSDKMessages, updateAgentSessionMeta, getAgentSessionMeta, getAgentSessionMessages, getAgentSessionSDKMessages, truncateSDKMessages, resolveUserUuidFromSDK, rewindFilesFromSnapshot } from './agent-session-manager' import { getAgentWorkspace, getWorkspaceMcpConfig, ensurePluginManifest, getWorkspacePermissionMode, setWorkspacePermissionMode } from './agent-workspace-manager' -import { getAgentWorkspacePath, getAgentSessionWorkspacePath, getSdkConfigDir, getWorkspaceFilesDir } from './config-paths' +import { getAgentWorkspacePath, getAgentSessionWorkspacePath, getSdkConfigDir, getWorkspaceFilesDir, getAgentProfilePluginDir } from './config-paths' import { getWorkspaceAttachedDirectories } from './agent-workspace-manager' import { getRuntimeStatus } from './runtime-init' import { getSettings } from './settings-service' @@ -42,6 +42,7 @@ import { permissionService } from './agent-permission-service' import type { PermissionResult, CanUseToolOptions } from './agent-permission-service' import { askUserService } from './agent-ask-user-service' import { exitPlanService, type ExitPlanPermissionResult } from './agent-exit-plan-service' +import { getAgentProfile } from './agent-profile-service' import { getMemoryConfig } from './memory-service' import { searchMemory, addMemory, formatSearchResult } from './memos-client' import { @@ -531,6 +532,137 @@ export class AgentOrchestrator { return mcpServers } + /** + * 根据 Agent Profile 的 enabledMcpServers 精确构建 MCP 服务器配置 + * 只加载 Profile 指定的服务器(可能来自不同工作区),不加载工作区全量。 + */ + private buildProfileMcpServers(refs: AgentProfileMcpRef[]): Record> { + const mcpServers: Record> = {} + for (const ref of refs) { + const sourceWs = getAgentWorkspace(ref.workspaceId) + if (!sourceWs) continue + const sourceMcpCfg = getWorkspaceMcpConfig(sourceWs.slug) + const entry = sourceMcpCfg.servers?.[ref.serverName] + if (!entry || !entry.enabled) continue + if (mcpServers[ref.serverName]) continue // 避免同名冲突 + if (entry.type === 'stdio' && entry.command) { + const mergedEnv: Record = { + ...(process.env.PATH && { PATH: process.env.PATH }), + ...entry.env, + } + mcpServers[ref.serverName] = { + type: 'stdio', + command: entry.command, + ...(entry.args?.length && { args: entry.args }), + ...(Object.keys(mergedEnv).length > 0 && { env: mergedEnv }), + required: false, + startup_timeout_sec: entry.timeout ?? 30, + } + } else if ((entry.type === 'http' || entry.type === 'sse') && entry.url) { + mcpServers[ref.serverName] = { + type: entry.type, + url: entry.url, + ...(entry.headers && Object.keys(entry.headers).length > 0 && { headers: entry.headers }), + required: false, + } + } + } + if (Object.keys(mcpServers).length > 0) { + console.log(`[Agent 编排] 已加载 Agent Profile 指定的 ${Object.keys(mcpServers).length} 个 MCP 服务器`) + } + return mcpServers + } + + /** 正在构建 plugin 目录的 profileId 集合,防止并发重建 */ + private pluginBuildLocks = new Set() + + /** + * 为 Agent Profile 构建临时 plugin 目录,只 symlink Profile 选中的 Skills。 + * 目录结构: + * ~/.proma/.cache/agent-plugins// + * .claude-plugin/plugin.json + * skills/ + * -> /skills/ + * + * @returns 临时 plugin 目录的绝对路径,可直接作为 SDK plugin path + */ + private buildProfilePluginDir( + profileId: string, + skillRefs: AgentProfileSkillRef[], + ): string { + const pluginDir = getAgentProfilePluginDir(profileId) + const skillsDir = join(pluginDir, 'skills') + const manifestDir = join(pluginDir, '.claude-plugin') + + // 快速一致性检查:已有目录且 skill 列表未变 → 直接复用 + if (existsSync(skillsDir)) { + const expected = new Set(skillRefs.map(r => r.skillName).sort()) + try { + const existing = new Set(readdirSync(skillsDir).filter(n => !n.startsWith('.')).sort()) + if (expected.size === existing.size && [...expected].every(s => existing.has(s))) { + return pluginDir + } + } catch { /* 读取失败则重建 */ } + } + + // 并发守卫:同一 profileId 不允许并发重建 + if (this.pluginBuildLocks.has(profileId)) { + // 另一个 session 正在构建,直接返回目录路径(即使目录可能不完整, + // SDK 在加载时会容错处理缺失的 skill) + console.warn(`[Agent 编排] Profile ${profileId} 的 plugin 目录正在被另一个会话构建,跳过重建`) + return pluginDir + } + + this.pluginBuildLocks.add(profileId) + try { + // 需要重建:清除旧目录 + if (existsSync(pluginDir)) { + rmSync(pluginDir, { recursive: true, force: true }) + } + + // 创建目录结构 + mkdirSync(skillsDir, { recursive: true }) + mkdirSync(manifestDir, { recursive: true }) + + // 写入 plugin.json + writeFileSync( + join(manifestDir, 'plugin.json'), + JSON.stringify({ name: `proma-agent-${profileId}`, version: '1.0.0' }), + ) + + // 为每个 skillRef 创建 symlink(校验路径防穿越) + for (const ref of skillRefs) { + const sourceWs = getAgentWorkspace(ref.workspaceId) + if (!sourceWs) continue + const sourceSkillDir = join(getAgentWorkspacePath(sourceWs.slug), 'skills', ref.skillName) + if (!existsSync(sourceSkillDir)) continue + const targetLink = resolve(skillsDir, ref.skillName) + // 防止路径穿越:确保 targetLink 在 skillsDir 内 + if (!targetLink.startsWith(skillsDir + sep)) continue + try { + symlinkSync(sourceSkillDir, targetLink, 'dir') + } catch (err) { + console.warn(`[Agent 编排] 创建 Skill symlink 失败: ${ref.skillName}`, err) + } + } + + console.log(`[Agent 编排] 已构建 Profile plugin 目录: ${pluginDir} (${skillRefs.length} skills)`) + } finally { + this.pluginBuildLocks.delete(profileId) + } + return pluginDir + } + + /** + * 清理 Agent Profile 的临时 plugin 目录 + */ + static cleanupProfilePluginDir(profileId: string): void { + const pluginDir = getAgentProfilePluginDir(profileId) + if (existsSync(pluginDir)) { + rmSync(pluginDir, { recursive: true, force: true }) + } + } + /** * 注入 SDK 内置记忆工具(全局,不依赖工作区) */ @@ -752,7 +884,47 @@ export class AgentOrchestrator { * 通过 EventBus 分发 AgentEvent,通过 callbacks 发送控制信号。 */ async sendMessage(input: AgentSendInput, callbacks: SessionCallbacks): Promise { - const { sessionId, userMessage, channelId, modelId, workspaceId, additionalDirectories, customMcpServers, permissionModeOverride, mentionedSkills, mentionedMcpServers } = input + const { sessionId, userMessage, channelId: inputChannelId, modelId: inputModelId, workspaceId: inputWorkspaceId, additionalDirectories, customMcpServers, permissionModeOverride, mentionedSkills, mentionedMcpServers, agentProfileId } = input + + // 读取 Agent Profile 配置(如果指定了) + let profileChannelId: string | undefined + let profileModelId: string | undefined + let profileWorkspaceId: string | undefined + let profileAdditionalPrompt: string | undefined + let profileMcpServerRefs: AgentProfileMcpRef[] | undefined + let profileSkillRefs: AgentProfileSkillRef[] | undefined + let profileThinking: ThinkingConfig | undefined + let profileEffort: AgentEffort | undefined + let profileMaxBudgetUsd: number | undefined + let profileMaxTurns: number | undefined + + if (agentProfileId) { + const profile = getAgentProfile(agentProfileId) + if (profile) { + console.log(`[Agent 编排] 使用 Agent Profile: ${profile.name} (${profile.id})`) + profileChannelId = profile.defaultChannelId + profileModelId = profile.defaultModelId + profileWorkspaceId = profile.defaultWorkspaceId + profileAdditionalPrompt = profile.additionalPrompt + profileMcpServerRefs = profile.enabledMcpServers + profileSkillRefs = profile.enabledSkills + profileThinking = profile.thinking + profileEffort = profile.effort + profileMaxBudgetUsd = profile.maxBudgetUsd + profileMaxTurns = profile.maxTurns + } + } + + // 配置优先级: + // - channelId/modelId: 会话输入(renderer per-session map)> Agent Profile > 全局默认 + // renderer 在选择 Agent 时已将 profile 的 channel/model 写入 session map, + // 用户手动切换模型时会覆盖 session map,因此 inputChannelId/inputModelId 始终是最终决策。 + // - workspaceId: 会话输入 > Agent Profile(会话已有工作区上下文时不应被 profile 覆盖) + // - additionalPrompt/MCP/Skills: Agent Profile > 会话输入 + const channelId = inputChannelId || profileChannelId || '' + const modelId = inputModelId || profileModelId + const workspaceId = inputWorkspaceId || profileWorkspaceId + const stderrChunks: string[] = [] // 0. 并发保护 @@ -948,7 +1120,11 @@ export class AgentOrchestrator { } // 10. 构建 MCP 服务器配置 + 记忆工具 + 生图工具 + 自定义工具 - const mcpServers = this.buildMcpServers(workspaceSlug) + // 当 Agent Profile 指定了 enabledMcpServers(含空数组,表示"不使用 MCP"), + // 只加载 Profile 指定的 MCP 服务器(精确过滤),否则加载工作区全部 MCP 服务器(默认行为)。 + const mcpServers = agentProfileId && profileMcpServerRefs !== undefined + ? this.buildProfileMcpServers(profileMcpServerRefs) + : this.buildMcpServers(workspaceSlug) await this.injectMemoryTools(sdk, mcpServers) await this.injectNanoBananaTools(sdk, mcpServers, sessionId, agentCwd) @@ -1198,9 +1374,8 @@ export class AgentOrchestrator { // 13. 构建 Adapter 查询选项 // 检测用户选用的模型是否为 Claude 系列,决定 SubAgent 是否使用独立模型分层 const claudeAvailable = (modelId || DEFAULT_MODEL_ID).toLowerCase().includes('claude') - const maxTurns = appSettings.agentMaxTurns && appSettings.agentMaxTurns > 0 - ? appSettings.agentMaxTurns - : undefined + const rawMaxTurns = profileMaxTurns ?? appSettings.agentMaxTurns + const maxTurns = rawMaxTurns && rawMaxTurns > 0 ? rawMaxTurns : undefined const queryOptions: ClaudeAgentQueryOptions = { sessionId, prompt: finalPrompt, @@ -1232,13 +1407,29 @@ export class AgentOrchestrator { permissionMode: initialPermissionMode, memoryEnabled: (() => { const mc = getMemoryConfig(); return mc.enabled && !!mc.apiKey })(), claudeAvailable, - }), + }) + (profileAdditionalPrompt + ? `\n\n## Agent 角色附加指令\n\n${profileAdditionalPrompt}` + : ''), }, resumeSessionId: existingSdkSessionId, // 回退后 resume:从指定消息处继续(SDK 在同一 JSONL 内创建分支) ...(rewindResumeAt && { resumeSessionAt: rewindResumeAt }), ...(Object.keys(mcpServers).length > 0 && { mcpServers }), - ...(workspaceSlug && { plugins: [{ type: 'local' as const, path: getAgentWorkspacePath(workspaceSlug) }] }), + // plugins: Agent Profile 指定 enabledSkills(含空数组表示"不使用 Skills")时精确过滤,否则加载当前工作区全量 + ...(() => { + const plugins: Array<{ type: 'local'; path: string }> = [] + if (agentProfileId && profileSkillRefs !== undefined) { + // Profile 精确模式:构建临时 plugin 目录,只 symlink 选中的 Skills(空数组 = 不加载任何 Skill) + if (profileSkillRefs.length > 0) { + const profilePluginDir = this.buildProfilePluginDir(agentProfileId, profileSkillRefs) + plugins.push({ type: 'local' as const, path: profilePluginDir }) + } + } else if (workspaceSlug) { + // 默认模式:加载当前工作区全部 Skills + plugins.push({ type: 'local' as const, path: getAgentWorkspacePath(workspaceSlug) }) + } + return plugins.length > 0 ? { plugins } : {} + })(), // 合并用户附加目录 + 工作区附加目录 + 工作区文件目录 ...(() => { const allDirs = [...(additionalDirectories || [])] @@ -1258,12 +1449,15 @@ export class AgentOrchestrator { })(), // 启用文件检查点,支持 rewindFiles 回退 enableFileCheckpointing: true, - // SDK 0.2.52+ 新增选项(从 settings 读取) - ...(appSettings.agentThinking && { thinking: appSettings.agentThinking }), - effort: appSettings.agentEffort ?? 'high', - ...(appSettings.agentMaxBudgetUsd != null && appSettings.agentMaxBudgetUsd > 0 && { - maxBudgetUsd: appSettings.agentMaxBudgetUsd, + // SDK 0.2.52+ 新增选项(Agent Profile > settings 全局默认) + ...((profileThinking ?? appSettings.agentThinking) && { + thinking: profileThinking ?? appSettings.agentThinking, }), + effort: profileEffort ?? appSettings.agentEffort ?? 'high', + ...(() => { + const budget = profileMaxBudgetUsd ?? appSettings.agentMaxBudgetUsd + return budget != null && budget > 0 ? { maxBudgetUsd: budget } : {} + })(), // 内置 SubAgent 定义(code-reviewer / explorer / researcher) // claudeAvailable=false 时 SubAgent 省略 model 字段,自动继承主 Agent 模型 agents: buildBuiltinAgents(claudeAvailable), diff --git a/apps/electron/src/main/lib/agent-profile-service.ts b/apps/electron/src/main/lib/agent-profile-service.ts new file mode 100644 index 00000000..6d553340 --- /dev/null +++ b/apps/electron/src/main/lib/agent-profile-service.ts @@ -0,0 +1,154 @@ +/** + * Agent Profile 服务 + * + * 管理全局 Agent Profile 的 CRUD 操作。 + * 存储在 ~/.proma/agents.json + */ + +import { readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs' +import { getAgentProfilesPath, getAgentProfilePluginDir } from './config-paths.ts' +import type { + AgentProfile, + AgentProfileCreateInput, + AgentProfileUpdateInput, +} from '@proma/shared' + +/** 预置通用助手 Agent 的固定 ID */ +const BUILTIN_AGENT_ID = 'builtin-general-assistant' + +/** 创建预置通用助手 Agent */ +function createBuiltinAgent(): AgentProfile { + const now = Date.now() + return { + id: BUILTIN_AGENT_ID, + name: '通用助手', + description: '通用 AI 助手,适用于各类任务', + icon: '🤖', + isBuiltin: true, + enabledMcpServers: [], + enabledSkills: [], + createdAt: now, + updatedAt: now, + } +} + +/** 读取所有 Agent Profile */ +function readProfiles(): AgentProfile[] { + const filePath = getAgentProfilesPath() + if (!existsSync(filePath)) return [] + try { + return JSON.parse(readFileSync(filePath, 'utf-8')) as AgentProfile[] + } catch { + console.error('[Agent Profile] 读取 agents.json 失败') + return [] + } +} + +/** 写入所有 Agent Profile */ +function writeProfiles(profiles: AgentProfile[]): void { + const filePath = getAgentProfilesPath() + writeFileSync(filePath, JSON.stringify(profiles, null, 2), 'utf-8') +} + +/** + * 初始化 Agent Profile 存储 + * 确保预置通用助手 Agent 存在 + */ +export function initAgentProfiles(): void { + const profiles = readProfiles() + const hasBuiltin = profiles.some((p) => p.isBuiltin) + if (!hasBuiltin) { + profiles.unshift(createBuiltinAgent()) + writeProfiles(profiles) + console.log('[Agent Profile] 已创建预置通用助手 Agent') + } +} + +/** 获取所有 Agent Profile */ +export function listAgentProfiles(): AgentProfile[] { + const profiles = readProfiles() + // 确保预置 Agent 始终排在最前 + return profiles.sort((a, b) => { + if (a.isBuiltin && !b.isBuiltin) return -1 + if (!a.isBuiltin && b.isBuiltin) return 1 + return 0 + }) +} + +/** 获取单个 Agent Profile */ +export function getAgentProfile(id: string): AgentProfile | null { + const profiles = readProfiles() + return profiles.find((p) => p.id === id) ?? null +} + +/** 创建 Agent Profile */ +export function createAgentProfile(input: AgentProfileCreateInput): AgentProfile { + const profiles = readProfiles() + const now = Date.now() + const profile: AgentProfile = { + id: crypto.randomUUID(), + name: input.name, + description: input.description, + icon: input.icon, + defaultChannelId: input.defaultChannelId, + defaultModelId: input.defaultModelId, + thinking: input.thinking, + effort: input.effort, + maxBudgetUsd: input.maxBudgetUsd, + maxTurns: input.maxTurns, + enabledMcpServers: input.enabledMcpServers ?? [], + enabledSkills: input.enabledSkills ?? [], + additionalPrompt: input.additionalPrompt, + defaultWorkspaceId: input.defaultWorkspaceId, + createdAt: now, + updatedAt: now, + } + profiles.push(profile) + writeProfiles(profiles) + console.log(`[Agent Profile] 已创建: ${profile.name} (${profile.id})`) + return profile +} + +/** 更新 Agent Profile */ +export function updateAgentProfile(id: string, input: AgentProfileUpdateInput): AgentProfile | null { + const profiles = readProfiles() + const index = profiles.findIndex((p) => p.id === id) + if (index === -1) return null + + const existing = profiles[index]! + const updated: AgentProfile = { + ...existing, + ...input, + id: existing.id, + name: input.name ?? existing.name, + isBuiltin: existing.isBuiltin, + createdAt: existing.createdAt, + enabledMcpServers: input.enabledMcpServers ?? existing.enabledMcpServers, + enabledSkills: input.enabledSkills ?? existing.enabledSkills, + updatedAt: Date.now(), + } + profiles[index] = updated + writeProfiles(profiles) + console.log(`[Agent Profile] 已更新: ${updated.name} (${updated.id})`) + return updated +} + +/** 删除 Agent Profile(预置 Agent 不可删除) */ +export function deleteAgentProfile(id: string): boolean { + const profiles = readProfiles() + const target = profiles.find((p) => p.id === id) + if (!target) return false + if (target.isBuiltin) { + console.warn('[Agent Profile] 预置 Agent 不可删除') + return false + } + const filtered = profiles.filter((p) => p.id !== id) + writeProfiles(filtered) + // 清理临时 plugin 目录 + const pluginDir = getAgentProfilePluginDir(id) + if (existsSync(pluginDir)) { + rmSync(pluginDir, { recursive: true, force: true }) + } + console.log(`[Agent Profile] 已删除: ${target.name} (${id})`) + return true +} diff --git a/apps/electron/src/main/lib/config-paths.ts b/apps/electron/src/main/lib/config-paths.ts index ee1111b4..9f4abd6e 100644 --- a/apps/electron/src/main/lib/config-paths.ts +++ b/apps/electron/src/main/lib/config-paths.ts @@ -211,6 +211,28 @@ export function getAgentSessionMessagesPath(id: string): string { return join(getAgentSessionsDir(), `${id}.jsonl`) } +/** + * 获取 Agent Profile 索引文件路径 + * + * @returns ~/.proma/agents.json + */ +export function getAgentProfilesPath(): string { + return join(getConfigDir(), 'agents.json') +} + +/** + * 获取 Agent Profile 临时 plugin 目录路径(用于 Skill 精确过滤) + * + * @returns ~/.proma/.cache/agent-plugins// + */ +export function getAgentProfilePluginDir(profileId: string): string { + // 校验 profileId 为 UUID 格式,防止路径穿越 + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(profileId)) { + throw new Error(`无效的 profileId: ${profileId}`) + } + return join(getConfigDir(), '.cache', 'agent-plugins', profileId) +} + /** * 获取 Agent 工作区索引文件路径 * diff --git a/apps/electron/src/preload/index.ts b/apps/electron/src/preload/index.ts index 5ef1fcfd..335f0407 100644 --- a/apps/electron/src/preload/index.ts +++ b/apps/electron/src/preload/index.ts @@ -51,6 +51,7 @@ import type { SkillMeta, OtherWorkspaceSkillsGroup, WorkspaceCapabilities, + WorkspaceCapabilitiesSummary, FileEntry, FileSearchResult, EnvironmentCheckResult, @@ -95,6 +96,9 @@ import type { WeChatBridgeState, AgentQueueMessageInput, PendingRequestsSnapshot, + AgentProfile, + AgentProfileCreateInput, + AgentProfileUpdateInput, } from '@proma/shared' import type { UserProfile, AppSettings, QuickTaskSubmitInput, QuickTaskOpenSessionData } from '../types' import { QUICK_TASK_IPC_CHANNELS } from '../types' @@ -354,6 +358,26 @@ export interface ElectronAPI { /** 停止任务 */ stopTask: (input: StopTaskInput) => Promise + // ===== Agent Profile 管理 ===== + + /** 获取 Agent Profile 列表 */ + listAgentProfiles: () => Promise + + /** 获取单个 Agent Profile */ + getAgentProfile: (id: string) => Promise + + /** 创建 Agent Profile */ + createAgentProfile: (input: AgentProfileCreateInput) => Promise + + /** 更新 Agent Profile */ + updateAgentProfile: (id: string, input: AgentProfileUpdateInput) => Promise + + /** 删除 Agent Profile */ + deleteAgentProfile: (id: string) => Promise + + /** 获取所有工作区的能力汇总(供 Agent Profile 编辑页) */ + listAllCapabilities: () => Promise + // ===== Agent 工作区管理相关 ===== /** 获取 Agent 工作区列表 */ @@ -1050,6 +1074,26 @@ const electronAPI: ElectronAPI = { return ipcRenderer.invoke(AGENT_IPC_CHANNELS.STOP_TASK, input) }, + // Agent Profile 管理 + listAgentProfiles: () => { + return ipcRenderer.invoke(AGENT_IPC_CHANNELS.LIST_PROFILES) + }, + getAgentProfile: (id: string) => { + return ipcRenderer.invoke(AGENT_IPC_CHANNELS.GET_PROFILE, id) + }, + createAgentProfile: (input: AgentProfileCreateInput) => { + return ipcRenderer.invoke(AGENT_IPC_CHANNELS.CREATE_PROFILE, input) + }, + updateAgentProfile: (id: string, input: AgentProfileUpdateInput) => { + return ipcRenderer.invoke(AGENT_IPC_CHANNELS.UPDATE_PROFILE, id, input) + }, + deleteAgentProfile: (id: string) => { + return ipcRenderer.invoke(AGENT_IPC_CHANNELS.DELETE_PROFILE, id) + }, + listAllCapabilities: () => { + return ipcRenderer.invoke(AGENT_IPC_CHANNELS.LIST_ALL_CAPABILITIES) + }, + // Agent 工作区管理 listAgentWorkspaces: () => { return ipcRenderer.invoke(AGENT_IPC_CHANNELS.LIST_WORKSPACES) diff --git a/apps/electron/src/renderer/atoms/agent-atoms.ts b/apps/electron/src/renderer/atoms/agent-atoms.ts index b2573ff0..38d6a043 100644 --- a/apps/electron/src/renderer/atoms/agent-atoms.ts +++ b/apps/electron/src/renderer/atoms/agent-atoms.ts @@ -7,7 +7,7 @@ import { atom } from 'jotai' import { atomFamily } from 'jotai/utils' -import type { AgentSessionMeta, AgentMessage, AgentEvent, AgentWorkspace, AgentPendingFile, RetryAttempt, PromaPermissionMode, PermissionRequest, AskUserRequest, ExitPlanModeRequest, ThinkingConfig, AgentEffort, TaskUsage, SDKMessage } from '@proma/shared' +import type { AgentSessionMeta, AgentMessage, AgentEvent, AgentWorkspace, AgentProfile, AgentPendingFile, RetryAttempt, PromaPermissionMode, PermissionRequest, AskUserRequest, ExitPlanModeRequest, ThinkingConfig, AgentEffort, TaskUsage, SDKMessage } from '@proma/shared' /** 活动状态 */ export type ActivityStatus = 'pending' | 'running' | 'completed' | 'error' | 'backgrounded' @@ -235,12 +235,19 @@ export function isActivityGroup(item: ActivityGroup | ToolActivity): item is Act export interface AgentPendingPrompt { sessionId: string message: string + /** 指定的 Agent Profile ID */ + agentProfileId?: string + /** Profile 指定的渠道(用于 UI 显示和发送) */ + profileChannelId?: string + /** Profile 指定的模型(用于 UI 显示和发送) */ + profileModelId?: string } // ===== Atoms ===== export const agentSessionsAtom = atom([]) export const agentWorkspacesAtom = atom([]) +export const agentProfilesAtom = atom([]) export const currentAgentWorkspaceIdAtom = atom(null) /** 全局默认渠道 ID(新会话继承用,从 settings.json 加载) */ export const agentChannelIdAtom = atom(null) @@ -253,6 +260,8 @@ export const agentChannelIdsAtom = atom([]) export const agentSessionChannelMapAtom = atom>(new Map()) /** Per-session 模型 ID Map — sessionId → modelId */ export const agentSessionModelMapAtom = atom>(new Map()) +/** Per-session Agent Profile ID Map — sessionId → profileId(键不存在 = 未选择 Profile,使用全局默认) */ +export const agentSessionProfileMapAtom = atom>(new Map()) export const currentAgentSessionIdAtom = atom(null) export const currentAgentMessagesAtom = atom([]) export const agentStreamingStatesAtom = atom>(new Map()) diff --git a/apps/electron/src/renderer/atoms/settings-tab.ts b/apps/electron/src/renderer/atoms/settings-tab.ts index 1efe7045..1210f46e 100644 --- a/apps/electron/src/renderer/atoms/settings-tab.ts +++ b/apps/electron/src/renderer/atoms/settings-tab.ts @@ -11,7 +11,7 @@ import { atom } from 'jotai' -export type SettingsTab = 'general' | 'channels' | 'proxy' | 'appearance' | 'about' | 'agent' | 'prompts' | 'tools' | 'bots' | 'tutorial' | 'shortcuts' +export type SettingsTab = 'general' | 'channels' | 'proxy' | 'appearance' | 'about' | 'agent' | 'agents' | 'prompts' | 'tools' | 'bots' | 'tutorial' | 'shortcuts' /** 当前设置标签页(不持久化,每次打开设置默认显示渠道) */ export const settingsTabAtom = atom('channels') diff --git a/apps/electron/src/renderer/components/agent/AgentSelector.tsx b/apps/electron/src/renderer/components/agent/AgentSelector.tsx new file mode 100644 index 00000000..b6f0c9b3 --- /dev/null +++ b/apps/electron/src/renderer/components/agent/AgentSelector.tsx @@ -0,0 +1,84 @@ +/** + * AgentSelector — Agent Profile 选择器 + * + * 集成在 AgentView 输入框底部工具栏,下拉选择当前会话的 Agent Profile。 + * 默认显示"默认"(工作区隐式配置),可选择自定义 Agent Profile。 + */ + +import * as React from 'react' +import { useAtomValue } from 'jotai' +import { ChevronDown, Check } from 'lucide-react' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { agentProfilesAtom } from '@/atoms/agent-atoms' +import type { AgentProfile } from '@proma/shared' + +interface AgentSelectorProps { + /** 当前选中的 Profile ID(null = 工作区隐式默认) */ + selectedProfileId: string | null + /** 选择回调(null = 切回隐式默认) */ + onSelect: (profile: AgentProfile | null) => void +} + +export function AgentSelector({ selectedProfileId, onSelect }: AgentSelectorProps): React.ReactElement { + const [open, setOpen] = React.useState(false) + const profiles = useAtomValue(agentProfilesAtom) + const selectedProfile = selectedProfileId ? profiles.find((p) => p.id === selectedProfileId) : null + + const handleSelect = (profile: AgentProfile | null): void => { + onSelect(profile) + setOpen(false) + } + + return ( + + + + + e.preventDefault()} + > + {/* 默认选项 */} + + + {/* Profile 列表 */} + {profiles.map((profile) => ( + + ))} + + + ) +} diff --git a/apps/electron/src/renderer/components/agent/AgentView.tsx b/apps/electron/src/renderer/components/agent/AgentView.tsx index b89b50e9..caf6e0a7 100644 --- a/apps/electron/src/renderer/components/agent/AgentView.tsx +++ b/apps/electron/src/renderer/components/agent/AgentView.tsx @@ -22,6 +22,7 @@ import { AgentHeader } from './AgentHeader' import { ContextUsageBadge } from './ContextUsageBadge' import { PermissionBanner } from './PermissionBanner' import { PermissionModeSelector } from './PermissionModeSelector' +import { AgentSelector } from './AgentSelector' import { AskUserBanner } from './AskUserBanner' import { ExitPlanModeBanner } from './ExitPlanModeBanner' import { PlanModeDashedBorder } from './PlanModeDashedBorder' @@ -70,6 +71,8 @@ import { agentPermissionModeMapAtom, agentDefaultPermissionModeAtom, agentSessionPathMapAtom, + agentSessionProfileMapAtom, + agentProfilesAtom, allPendingAskUserRequestsAtom, allPendingExitPlanRequestsAtom, finalizeStreamingActivities, @@ -81,7 +84,7 @@ import { useOpenSession } from '@/hooks/useOpenSession' import { AgentSessionProvider } from '@/contexts/session-context' import { draftSessionIdsAtom } from '@/atoms/draft-session-atoms' import { sendWithCmdEnterAtom } from '@/atoms/shortcut-atoms' -import type { AgentSendInput, AgentMessage, AgentPendingFile, ModelOption, SDKMessage } from '@proma/shared' +import type { AgentSendInput, AgentMessage, AgentPendingFile, ModelOption, SDKMessage, AgentProfile } from '@proma/shared' import { fileToBase64 } from '@/lib/file-utils' /** 稳定的空 SDKMessage 数组引用,避免 ?? [] 每次创建新引用 */ @@ -191,6 +194,12 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem const agentChannelId = sessionChannelMap.get(sessionId) ?? defaultChannelId const agentModelId = sessionModelMap.get(sessionId) ?? defaultModelId const agentChannelIds = useAtomValue(agentChannelIdsAtom) + // Per-session Agent Profile 选择 + const sessionProfileMap = useAtomValue(agentSessionProfileMapAtom) + const setSessionProfileMap = useSetAtom(agentSessionProfileMapAtom) + const profiles = useAtomValue(agentProfilesAtom) + const currentProfileId = sessionProfileMap.get(sessionId) ?? null + const currentProfile = currentProfileId ? profiles.find(p => p.id === currentProfileId) ?? null : null const [agentThinking, setAgentThinking] = useAtom(agentThinkingAtom) const setSettingsOpen = useSetAtom(settingsOpenAtom) const setDraftSessionIds = useSetAtom(draftSessionIdsAtom) @@ -473,12 +482,36 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem if (pendingPrompt.sessionId !== sessionId) return if (!agentChannelId || streaming) return - // 快照当前上下文 + // 同步 pendingPrompt 的 agentProfileId + channel/model 到 per-session maps + if (pendingPrompt.agentProfileId) { + setSessionProfileMap(prev => { + const map = new Map(prev) + map.set(sessionId, pendingPrompt.agentProfileId!) + return map + }) + } + if (pendingPrompt.profileChannelId) { + setSessionChannelMap(prev => { + const map = new Map(prev) + map.set(sessionId, pendingPrompt.profileChannelId!) + return map + }) + } + if (pendingPrompt.profileModelId) { + setSessionModelMap(prev => { + const map = new Map(prev) + map.set(sessionId, pendingPrompt.profileModelId!) + return map + }) + } + + // 快照当前上下文(Profile 配置优先于全局默认) const snapshot = { message: pendingPrompt.message, - channelId: agentChannelId, - modelId: agentModelId || undefined, + channelId: pendingPrompt.profileChannelId || agentChannelId, + modelId: pendingPrompt.profileModelId || agentModelId || undefined, workspaceId: currentWorkspaceId || undefined, + agentProfileId: pendingPrompt.agentProfileId, } setPendingPrompt(null) @@ -524,6 +557,7 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem channelId: snapshot.channelId, modelId: snapshot.modelId, workspaceId: snapshot.workspaceId, + agentProfileId: snapshot.agentProfileId, } window.electronAPI.sendAgentMessage(input).catch((error) => { console.error('[AgentView] 自动发送配置消息失败:', error) @@ -534,7 +568,7 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem }) }) }) - }, [messagesLoaded, pendingPrompt, sessionId, agentChannelId, agentModelId, currentWorkspaceId, streaming, setPendingPrompt, setStreamingStates]) + }, [messagesLoaded, pendingPrompt, sessionId, agentChannelId, agentModelId, currentWorkspaceId, streaming, setPendingPrompt, setStreamingStates, setSessionProfileMap, setSessionChannelMap, setSessionModelMap]) // ===== 附件处理 ===== @@ -753,6 +787,31 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem }).catch(console.error) }, [sessionId, setSessionChannelMap, setSessionModelMap, setDefaultChannelId, setDefaultModelId]) + /** Agent 选择回调 */ + const handleAgentSelect = React.useCallback((profile: AgentProfile | null): void => { + // 更新 per-session profile map + setSessionProfileMap(prev => { + const map = new Map(prev) + if (profile) map.set(sessionId, profile.id) + else map.delete(sessionId) + return map + }) + + // 如果 Profile 有默认渠道/模型,同步更新 per-session channel/model map + if (profile?.defaultChannelId) { + setSessionChannelMap(prev => { const m = new Map(prev); m.set(sessionId, profile.defaultChannelId!); return m }) + } + if (profile?.defaultModelId) { + setSessionModelMap(prev => { const m = new Map(prev); m.set(sessionId, profile.defaultModelId!); return m }) + } + // 更新思考 atom:有配置则应用,取消选择则重置 + if (profile?.thinking) { + setAgentThinking(profile.thinking) + } else if (!profile) { + setAgentThinking(undefined) + } + }, [sessionId, setSessionProfileMap, setSessionChannelMap, setSessionModelMap, setAgentThinking]) + /** 构建 externalSelectedModel 给 ModelSelector */ const externalSelectedModel = React.useMemo(() => { if (!agentChannelId || !agentModelId) return null @@ -950,6 +1009,7 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem channelId: agentChannelId, modelId: agentModelId || undefined, workspaceId: currentWorkspaceId || undefined, + agentProfileId: currentProfileId || undefined, ...(attachedDirs.length > 0 && { additionalDirectories: attachedDirs }), // 解析用户消息中的 Skill/MCP 引用,传递结构化元数据给后端 ...(() => { @@ -974,7 +1034,7 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem return map }) }) - }, [inputContent, pendingFiles, attachedDirs, sessionId, agentChannelId, agentModelId, currentWorkspaceId, workspaces, streaming, suggestion, hasAvailableModel, store, setStreamingStates, setPendingFiles, setAgentStreamErrors, setPromptSuggestions, setInputContent, setLiveMessagesMap]) + }, [inputContent, pendingFiles, attachedDirs, sessionId, agentChannelId, agentModelId, currentWorkspaceId, currentProfileId, workspaces, streaming, suggestion, hasAvailableModel, store, setStreamingStates, setPendingFiles, setAgentStreamErrors, setPromptSuggestions, setInputContent, setLiveMessagesMap]) /** 停止生成 */ const handleStop = React.useCallback((): void => { @@ -1354,6 +1414,10 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem {/* Footer 工具栏 */}
+ (null) const [isDragOver, setIsDragOver] = useState(false) + const [agentProfiles, setAgentProfiles] = useState([]) + const [selectedAgent, setSelectedAgent] = useState(null) + const [showAgentPicker, setShowAgentPicker] = useState(false) + const [agentFilterText, setAgentFilterText] = useState('') + const [pickerPos, setPickerPos] = useState({ left: 0, top: 0 }) + const [pickerIndex, setPickerIndex] = useState(0) + const mirrorRef = useRef(null) const textareaRef = useRef(null) const fileInputRef = useRef(null) + const pickerRef = useRef(null) // 设置透明背景 useEffect(() => { @@ -44,11 +53,7 @@ export function QuickTaskApp(): React.ReactElement { }, []) // 加载默认模型信息 - useEffect(() => { - loadModelInfo() - }, [mode]) - - async function loadModelInfo(): Promise { + const loadModelInfo = useCallback(async (): Promise => { try { const [settings, channels] = await Promise.all([ window.electronAPI.getSettings(), @@ -83,7 +88,18 @@ export function QuickTaskApp(): React.ReactElement { } catch { setModelInfo(null) } - } + }, [mode]) + + useEffect(() => { + loadModelInfo() + }, [loadModelInfo]) + + // Agent 模式下加载 Profile 列表 + useEffect(() => { + if (mode === 'agent') { + window.electronAPI.listAgentProfiles().then(setAgentProfiles).catch(console.error) + } + }, [mode]) // 聚焦输入框 const focusInput = useCallback(() => { @@ -97,11 +113,17 @@ export function QuickTaskApp(): React.ReactElement { const cleanup = window.electronAPI.onQuickTaskFocus(() => { setText('') setAttachments([]) + setSelectedAgent(null) + setShowAgentPicker(false) + setAgentFilterText('') + setPickerIndex(0) focusInput() loadModelInfo() + // 每次聚焦时重新加载 Agent Profile 列表(设置页可能已新增/修改) + window.electronAPI.listAgentProfiles().then(setAgentProfiles).catch(console.error) }) return cleanup - }, [focusInput]) + }, [focusInput, loadModelInfo]) // 初始聚焦 useEffect(() => { @@ -116,10 +138,57 @@ export function QuickTaskApp(): React.ReactElement { el.style.height = `${Math.min(el.scrollHeight, 160)}px` }, [text]) + // 计算 @ 字符在 textarea 中的像素坐标(用 mirror div 技术) + const measureCaretPosition = useCallback((textUpToCaret: string) => { + const textarea = textareaRef.current + const mirror = mirrorRef.current + if (!textarea || !mirror) return { left: 0, top: 0 } + + const style = getComputedStyle(textarea) + // 同步 mirror 样式 + mirror.style.font = style.font + mirror.style.fontSize = style.fontSize + mirror.style.letterSpacing = style.letterSpacing + mirror.style.lineHeight = style.lineHeight + mirror.style.padding = style.padding + mirror.style.width = `${textarea.clientWidth}px` + mirror.style.wordWrap = style.wordWrap + mirror.style.whiteSpace = 'pre-wrap' + mirror.style.overflowWrap = style.overflowWrap + + // 在 mirror 中放入 @ 前的文本 + 一个 marker span + mirror.textContent = '' + const textNode = document.createTextNode(textUpToCaret) + const marker = document.createElement('span') + marker.textContent = '@' + mirror.appendChild(textNode) + mirror.appendChild(marker) + + const markerRect = marker.getBoundingClientRect() + const mirrorRect = mirror.getBoundingClientRect() + + return { + left: markerRect.left - mirrorRect.left, + top: markerRect.top - mirrorRect.top + markerRect.height + 4, + } + }, []) + + // 过滤后的 agent 列表 + const filteredAgents = agentProfiles.filter((a) => + !agentFilterText || + a.name.toLowerCase().includes(agentFilterText.toLowerCase()) || + a.description?.toLowerCase().includes(agentFilterText.toLowerCase()) + ) + // 全局键盘事件 useEffect(() => { const handleKeyDown = (e: KeyboardEvent): void => { if (e.key === 'Escape') { + if (showAgentPicker) { + e.preventDefault() + setShowAgentPicker(false) + return + } e.preventDefault() window.electronAPI.hideQuickTask() return @@ -142,7 +211,7 @@ export function QuickTaskApp(): React.ReactElement { window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, []) + }, [showAgentPicker]) // 添加文件为附件 const addFiles = useCallback(async (files: File[]) => { @@ -213,6 +282,17 @@ export function QuickTaskApp(): React.ReactElement { e.target.value = '' // 允许重复选择同一文件 }, [addFiles]) + // 选择 Agent + const selectAgent = useCallback((agent: AgentProfile) => { + setSelectedAgent(agent) + setShowAgentPicker(false) + setPickerIndex(0) + // 清除 @xxx 文本 + const atIndex = text.lastIndexOf('@') + setText(atIndex !== -1 ? text.slice(0, atIndex) : '') + textareaRef.current?.focus() + }, [text]) + // 提交任务 const handleSubmit = useCallback(async () => { const trimmed = text.trim() @@ -226,6 +306,7 @@ export function QuickTaskApp(): React.ReactElement { files: attachments.map(({ filename, mediaType, base64, size }) => ({ filename, mediaType, base64, size, })), + agentProfileId: selectedAgent?.id, }) setText('') setAttachments([]) @@ -234,15 +315,49 @@ export function QuickTaskApp(): React.ReactElement { } finally { setIsSubmitting(false) } - }, [text, mode, attachments, isSubmitting]) + }, [text, mode, attachments, isSubmitting, selectedAgent]) - // Enter 提交,Shift+Enter 换行 + // 键盘事件:Enter 提交 / Shift+Enter 换行 / 弹窗导航 const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (showAgentPicker) { + if (e.key === 'ArrowDown') { + e.preventDefault() + setPickerIndex((i) => Math.min(i + 1, filteredAgents.length - 1)) + return + } + if (e.key === 'ArrowUp') { + e.preventDefault() + setPickerIndex((i) => Math.max(i - 1, 0)) + return + } + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + if (filteredAgents[pickerIndex]) { + selectAgent(filteredAgents[pickerIndex]) + } + return + } + if (e.key === 'Tab') { + e.preventDefault() + if (filteredAgents[pickerIndex]) { + selectAgent(filteredAgents[pickerIndex]) + } + return + } + } + if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault() handleSubmit() } - }, [handleSubmit]) + }, [handleSubmit, showAgentPicker, filteredAgents, pickerIndex, selectAgent]) + + // 滚动 picker 使高亮项可见 + useEffect(() => { + if (!showAgentPicker || !pickerRef.current) return + const items = pickerRef.current.querySelectorAll('[data-picker-item]') + items[pickerIndex]?.scrollIntoView({ block: 'nearest' }) + }, [pickerIndex, showAgentPicker]) const hasContent = text.trim().length > 0 || attachments.length > 0 @@ -299,20 +414,145 @@ export function QuickTaskApp(): React.ReactElement {
- {/* 输入区域 */} -
-