2ae7e7ad1b
* 修复: profile clone 时智能清理独占平台凭据,避免 gateway 健康检查超时 # 问题 `hermes profile create <name> --clone` 完整复制 .env + config.yaml(含独占型平台凭据 如 WEIXIN_TOKEN / TELEGRAM_BOT_TOKEN 等),导致多个 profile 共享同一身份 token。 hermes-agent 在 platform adapter 初始化或 scoped lock 获取阶段失败,gateway 健康检查 持续 15s 超时,前端报 'API Error 500: Gateway health check timed out'。 # 修复 在 web-ui 后端 clone 完成后自动: 1. 从 <profile>/.env 删除匹配独占平台的环境变量(写 .env.bak.* 备份) 2. 在 <profile>/config.yaml 中把 platforms.<exclusive>.enabled 置为 false 3. 清理节点直挂 + extra 子节点下的敏感字段(token / app_secret / account_id 等) 前端 toast 提示被剥离的凭据、被禁用的平台、被剥离的 config 字段,便于用户后续手动 重新填入新身份再启用。 # EXCLUSIVE_PLATFORMS 列表来源 精确对齐 hermes-agent gateway/platforms/*.py 中调用 _acquire_platform_lock 的 7 个 adapter: telegram, discord, slack, whatsapp, signal, weixin, feishu。 未来上游加新独占平台时用 `grep -l _acquire_platform_lock gateway/platforms/*.py` 验证。 # 测试 新增 tests/server/profile-credentials.test.ts(12 用例全过),覆盖: - isExclusivePlatformKey 命中/未命中边界 - env 文件剥离 + 备份 - config.yaml 平台禁用 + 节点凭据清理 - 已 disabled 平台仍清理残留凭据(防止后续 re-enable 复用旧身份) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(平台设置): 独占平台显示 token 隔离警告 在 PlatformSettings 中为使用 token 互斥锁的 6 个平台 (telegram, discord, slack, whatsapp, feishu, weixin) 添加视觉警告,提示用户每个 profile 必须使用不同的身份 token,避免与其他 profile 冲突。 # 背景 hermes-agent 的 acquire_scoped_lock 是 token-level(不是 platform-level),所以 设计上支持多 profile 各自配不同身份的同一平台(如 default 用个人微信、staging 用公司微信)。但用户从 UI 配置时容易误填同一 token,导致 gateway 启动失败。 # 实现 - PlatformCard 新增 exclusive 可选 prop,开启时 body 顶部用 NAlert (warning) 展示提示 - PlatformSettings 在 6 个独占平台数组项标记 exclusive: true 并传给 PlatformCard - 8 个 i18n locale 新增 platform.exclusiveTokenWarning 翻译 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
143 lines
3.7 KiB
TypeScript
143 lines
3.7 KiB
TypeScript
import { request, getBaseUrlValue, getApiKey } from '../client'
|
|
|
|
export interface HermesProfile {
|
|
name: string
|
|
active: boolean
|
|
model: string
|
|
gateway: string
|
|
alias: string
|
|
}
|
|
|
|
export interface HermesProfileDetail {
|
|
name: string
|
|
path: string
|
|
model: string
|
|
provider: string
|
|
gateway: string
|
|
skills: number
|
|
hasEnv: boolean
|
|
hasSoulMd: boolean
|
|
}
|
|
|
|
export async function fetchProfiles(): Promise<HermesProfile[]> {
|
|
const res = await request<{ profiles: HermesProfile[] }>('/api/hermes/profiles')
|
|
return res.profiles
|
|
}
|
|
|
|
export async function fetchProfileDetail(name: string): Promise<HermesProfileDetail> {
|
|
const res = await request<{ profile: HermesProfileDetail }>(`/api/hermes/profiles/${encodeURIComponent(name)}`)
|
|
return res.profile
|
|
}
|
|
|
|
export interface CreateProfileResult {
|
|
success: boolean
|
|
/** clone=true 时被清理的独占平台凭据 KEY 名 */
|
|
strippedCredentials?: string[]
|
|
/** clone=true 时被禁用的独占平台名 */
|
|
disabledPlatforms?: string[]
|
|
/** clone=true 时在 config.yaml 中被清理的内嵌凭据字段路径 */
|
|
strippedConfigCredentials?: string[]
|
|
}
|
|
|
|
export async function createProfile(name: string, clone?: boolean): Promise<CreateProfileResult> {
|
|
try {
|
|
const res = await request<{
|
|
success: boolean
|
|
strippedCredentials?: string[]
|
|
disabledPlatforms?: string[]
|
|
strippedConfigCredentials?: string[]
|
|
}>('/api/hermes/profiles', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, clone }),
|
|
})
|
|
return {
|
|
success: !!res.success,
|
|
strippedCredentials: res.strippedCredentials,
|
|
disabledPlatforms: res.disabledPlatforms,
|
|
strippedConfigCredentials: res.strippedConfigCredentials,
|
|
}
|
|
} catch {
|
|
return { success: false }
|
|
}
|
|
}
|
|
|
|
export async function deleteProfile(name: string): Promise<boolean> {
|
|
try {
|
|
await request(`/api/hermes/profiles/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export async function renameProfile(name: string, newName: string): Promise<boolean> {
|
|
try {
|
|
await request(`/api/hermes/profiles/${encodeURIComponent(name)}/rename`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ new_name: newName }),
|
|
})
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export async function switchProfile(name: string): Promise<boolean> {
|
|
try {
|
|
await request('/api/hermes/profiles/active', {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ name }),
|
|
})
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export async function exportProfile(name: string): Promise<boolean> {
|
|
try {
|
|
const baseUrl = getBaseUrlValue()
|
|
const token = getApiKey()
|
|
const headers: Record<string, string> = {}
|
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
|
|
|
const res = await fetch(`${baseUrl}/api/hermes/profiles/${encodeURIComponent(name)}/export`, {
|
|
method: 'POST',
|
|
headers,
|
|
})
|
|
if (!res.ok) throw new Error()
|
|
|
|
const blob = await res.blob()
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `hermes-profile-${name}.tar.gz`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export async function importProfile(file: File): Promise<boolean> {
|
|
try {
|
|
const baseUrl = getBaseUrlValue()
|
|
const token = getApiKey()
|
|
const headers: Record<string, string> = {}
|
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
|
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
|
|
const res = await fetch(`${baseUrl}/api/hermes/profiles/import`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
})
|
|
return res.ok
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|