Merge branch 'dev' into main — v0.3.2
- fix: use deep merge for config updates to prevent nested field loss - fix: save config inputs on blur instead of every keystroke - fix: job edit schedule format error (#25) - chore: add engines requirement (node >= 20) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hermes-web-ui",
|
"name": "hermes-web-ui",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"description": "Web dashboard for Hermes Agent — multi-platform AI chat, session management, scheduled jobs, usage analytics & channel configuration (Telegram, Discord, Slack, WhatsApp)",
|
"description": "Web dashboard for Hermes Agent — multi-platform AI chat, session management, scheduled jobs, usage analytics & channel configuration (Telegram, Discord, Slack, WhatsApp)",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -8,6 +8,9 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/EKKOLearnAI/hermes-web-ui",
|
"homepage": "https://github.com/EKKOLearnAI/hermes-web-ui",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"hermes",
|
"hermes",
|
||||||
"ai-agent",
|
"ai-agent",
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export interface CreateJobRequest {
|
|||||||
|
|
||||||
export interface UpdateJobRequest {
|
export interface UpdateJobRequest {
|
||||||
name?: string
|
name?: string
|
||||||
schedule?: string
|
schedule?: string | { kind: string; expr: string; display: string }
|
||||||
prompt?: string
|
prompt?: string
|
||||||
deliver?: string
|
deliver?: string
|
||||||
skills?: string[]
|
skills?: string[]
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ const targetOptions = computed(() => [
|
|||||||
{ label: t('jobs.local'), value: 'local' },
|
{ label: t('jobs.local'), value: 'local' },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const originalSchedule = ref<{ kind: string; expr: string; display: string } | null>(null)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (props.jobId) {
|
if (props.jobId) {
|
||||||
try {
|
try {
|
||||||
@@ -60,6 +62,9 @@ onMounted(async () => {
|
|||||||
deliver: job.deliver || 'origin',
|
deliver: job.deliver || 'origin',
|
||||||
repeat_times: typeof job.repeat === 'number' ? job.repeat : (typeof job.repeat === 'object' ? job.repeat.times : null),
|
repeat_times: typeof job.repeat === 'number' ? job.repeat : (typeof job.repeat === 'object' ? job.repeat.times : null),
|
||||||
}
|
}
|
||||||
|
if (typeof job.schedule === 'object' && job.schedule) {
|
||||||
|
originalSchedule.value = job.schedule
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(t('jobs.loadFailed') + ': ' + e.message)
|
message.error(t('jobs.loadFailed') + ': ' + e.message)
|
||||||
}
|
}
|
||||||
@@ -86,6 +91,14 @@ async function handleSave() {
|
|||||||
repeat: formData.value.repeat_times ?? undefined,
|
repeat: formData.value.repeat_times ?? undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isEdit.value && originalSchedule.value) {
|
||||||
|
(payload as any).schedule = {
|
||||||
|
kind: originalSchedule.value.kind,
|
||||||
|
expr: formData.value.schedule,
|
||||||
|
display: formData.value.schedule,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
await jobsStore.updateJob(props.jobId!, payload)
|
await jobsStore.updateJob(props.jobId!, payload)
|
||||||
message.success(t('jobs.jobUpdated'))
|
message.success(t('jobs.jobUpdated'))
|
||||||
|
|||||||
@@ -22,25 +22,6 @@ function isSaving(platform: string, field: string) {
|
|||||||
return !!saving[savingKey(platform, field)]
|
return !!saving[savingKey(platform, field)]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debounce timers
|
|
||||||
const debounceTimers: Record<string, ReturnType<typeof setTimeout>> = {}
|
|
||||||
|
|
||||||
function debounceSave(platform: string, field: string, saveFn: () => Promise<void>, delay = 600) {
|
|
||||||
const key = savingKey(platform, field)
|
|
||||||
if (debounceTimers[key]) clearTimeout(debounceTimers[key])
|
|
||||||
debounceTimers[key] = setTimeout(async () => {
|
|
||||||
saving[key] = true
|
|
||||||
try {
|
|
||||||
await saveFn()
|
|
||||||
message.success(t('settings.saved'))
|
|
||||||
} catch (err: any) {
|
|
||||||
message.error(t('settings.saveFailed'))
|
|
||||||
} finally {
|
|
||||||
saving[key] = false
|
|
||||||
}
|
|
||||||
}, delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Immediate save for switches
|
// Immediate save for switches
|
||||||
async function immediateSave(platform: string, field: string, saveFn: () => Promise<void>) {
|
async function immediateSave(platform: string, field: string, saveFn: () => Promise<void>) {
|
||||||
const key = savingKey(platform, field)
|
const key = savingKey(platform, field)
|
||||||
@@ -59,10 +40,6 @@ async function saveChannel(platform: string, field: string, values: Record<strin
|
|||||||
immediateSave(platform, field, () => settingsStore.saveSection(platform, values))
|
immediateSave(platform, field, () => settingsStore.saveSection(platform, values))
|
||||||
}
|
}
|
||||||
|
|
||||||
function debouncedSaveChannel(platform: string, field: string, values: Record<string, any>) {
|
|
||||||
debounceSave(platform, field, () => settingsStore.saveSection(platform, values))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save credentials to .env (matching hermes gateway setup behavior)
|
// Save credentials to .env (matching hermes gateway setup behavior)
|
||||||
async function saveCredentials(platform: string, field: string, values: Record<string, any>) {
|
async function saveCredentials(platform: string, field: string, values: Record<string, any>) {
|
||||||
immediateSave(platform, field, async () => {
|
immediateSave(platform, field, async () => {
|
||||||
@@ -71,13 +48,6 @@ async function saveCredentials(platform: string, field: string, values: Record<s
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function debouncedSaveCredentials(platform: string, field: string, values: Record<string, any>) {
|
|
||||||
debounceSave(platform, field, async () => {
|
|
||||||
await saveCredsApi(platform, values)
|
|
||||||
await settingsStore.fetchSettings()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCreds(key: string) {
|
function getCreds(key: string) {
|
||||||
return (settingsStore.platforms[key] || {}) as Record<string, any>
|
return (settingsStore.platforms[key] || {}) as Record<string, any>
|
||||||
}
|
}
|
||||||
@@ -121,7 +91,6 @@ function pollWeixinStatus() {
|
|||||||
wxQrStatus.value = 'expired'
|
wxQrStatus.value = 'expired'
|
||||||
} else if (data.status === 'confirmed') {
|
} else if (data.status === 'confirmed') {
|
||||||
wxQrStatus.value = 'confirmed'
|
wxQrStatus.value = 'confirmed'
|
||||||
// Save credentials to .env
|
|
||||||
await saveWeixinCredentials({
|
await saveWeixinCredentials({
|
||||||
account_id: data.account_id!,
|
account_id: data.account_id!,
|
||||||
token: data.token!,
|
token: data.token!,
|
||||||
@@ -131,7 +100,6 @@ function pollWeixinStatus() {
|
|||||||
message.success(t('settings.saved'))
|
message.success(t('settings.saved'))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Retry poll on network error
|
|
||||||
pollWeixinStatus()
|
pollWeixinStatus()
|
||||||
}
|
}
|
||||||
}, 3000)
|
}, 3000)
|
||||||
@@ -146,7 +114,6 @@ function stopWeixinPoll() {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopWeixinPoll()
|
stopWeixinPoll()
|
||||||
Object.values(debounceTimers).forEach(t => clearTimeout(t))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const platforms = [
|
const platforms = [
|
||||||
@@ -180,11 +147,6 @@ const platforms = [
|
|||||||
name: 'Feishu',
|
name: 'Feishu',
|
||||||
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.59 3.41a2.25 2.25 0 0 1 3.182 0L13.5 7.14l-3.182 3.182L6.59 7.59a2.25 2.25 0 0 1 0-3.182zm5.303 5.303L15.075 5.53a2.25 2.25 0 0 1 3.182 3.182L15.075 11.894 11.893 8.713zM3.41 6.59a2.25 2.25 0 0 1 3.182 0l3.182 3.182-3.182 3.182a2.25 2.25 0 0 1-3.182-3.182L3.41 6.59zm5.303 5.303L11.894 15.075a2.25 2.25 0 0 1-3.182 3.182L5.53 15.075 8.713 11.893zm5.303-5.303L17.478 9.778a2.25 2.25 0 0 1-3.182 3.182L10.53 10.075l3.182-3.182 0 .023z"/></svg>',
|
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.59 3.41a2.25 2.25 0 0 1 3.182 0L13.5 7.14l-3.182 3.182L6.59 7.59a2.25 2.25 0 0 1 0-3.182zm5.303 5.303L15.075 5.53a2.25 2.25 0 0 1 3.182 3.182L15.075 11.894 11.893 8.713zM3.41 6.59a2.25 2.25 0 0 1 3.182 0l3.182 3.182-3.182 3.182a2.25 2.25 0 0 1-3.182-3.182L3.41 6.59zm5.303 5.303L11.894 15.075a2.25 2.25 0 0 1-3.182 3.182L5.53 15.075 8.713 11.893zm5.303-5.303L17.478 9.778a2.25 2.25 0 0 1-3.182 3.182L10.53 10.075l3.182-3.182 0 .023z"/></svg>',
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// key: 'dingtalk',
|
|
||||||
// name: 'DingTalk',
|
|
||||||
// icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221l-1.897 6.376a.5.5 0 0 1-.957-.016l-1.238-3.81a.5.5 0 0 0-.477-.354l-3.81-.324a.5.5 0 0 1-.074-.993l6.376-1.897a.5.5 0 0 1 .577.718z"/></svg>',
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
key: 'weixin',
|
key: 'weixin',
|
||||||
name: 'Weixin',
|
name: 'Weixin',
|
||||||
@@ -211,7 +173,7 @@ const platforms = [
|
|||||||
<!-- Telegram -->
|
<!-- Telegram -->
|
||||||
<template v-if="p.key === 'telegram'">
|
<template v-if="p.key === 'telegram'">
|
||||||
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
|
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
|
||||||
<NInput :value="getCreds('telegram').token || ''" :loading="isSaving('telegram', 'token')" clearable size="small" class="input-lg" placeholder="123456:ABC-DEF..." @update:value="v => debouncedSaveCredentials('telegram', 'token', { token: v })" />
|
<NInput :default-value="getCreds('telegram').token || ''" :loading="isSaving('telegram', 'token')" clearable size="small" class="input-lg" placeholder="123456:ABC-DEF..." @change="v => saveCredentials('telegram', 'token', { token: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
|
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
|
||||||
<NSwitch :value="settingsStore.telegram.require_mention" :loading="isSaving('telegram', 'require_mention')" @update:value="v => saveChannel('telegram', 'require_mention', { require_mention: v })" />
|
<NSwitch :value="settingsStore.telegram.require_mention" :loading="isSaving('telegram', 'require_mention')" @update:value="v => saveChannel('telegram', 'require_mention', { require_mention: v })" />
|
||||||
@@ -220,17 +182,17 @@ const platforms = [
|
|||||||
<NSwitch :value="settingsStore.telegram.reactions" :loading="isSaving('telegram', 'reactions')" @update:value="v => saveChannel('telegram', 'reactions', { reactions: v })" />
|
<NSwitch :value="settingsStore.telegram.reactions" :loading="isSaving('telegram', 'reactions')" @update:value="v => saveChannel('telegram', 'reactions', { reactions: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
||||||
<NInput :value="settingsStore.telegram.free_response_chats || ''" :loading="isSaving('telegram', 'free_response_chats')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => debouncedSaveChannel('telegram', 'free_response_chats', { free_response_chats: v })" />
|
<NInput :default-value="settingsStore.telegram.free_response_chats || ''" :loading="isSaving('telegram', 'free_response_chats')" size="small" placeholder="chat_id1,chat_id2" @change="v => saveChannel('telegram', 'free_response_chats', { free_response_chats: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
|
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
|
||||||
<NInput :value="(settingsStore.telegram.mention_patterns || []).join(', ')" :loading="isSaving('telegram', 'mention_patterns')" size="small" placeholder="pattern1, pattern2" @update:value="v => debouncedSaveChannel('telegram', 'mention_patterns', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
|
<NInput :default-value="(settingsStore.telegram.mention_patterns || []).join(', ')" :loading="isSaving('telegram', 'mention_patterns')" size="small" placeholder="pattern1, pattern2" @change="v => saveChannel('telegram', 'mention_patterns', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Discord -->
|
<!-- Discord -->
|
||||||
<template v-if="p.key === 'discord'">
|
<template v-if="p.key === 'discord'">
|
||||||
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
|
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
|
||||||
<NInput :value="getCreds('discord').token || ''" :loading="isSaving('discord', 'token')" clearable size="small" class="input-lg" placeholder="Bot token..." @update:value="v => debouncedSaveCredentials('discord', 'token', { token: v })" />
|
<NInput :default-value="getCreds('discord').token || ''" :loading="isSaving('discord', 'token')" clearable size="small" class="input-lg" placeholder="Bot token..." @change="v => saveCredentials('discord', 'token', { token: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
|
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
|
||||||
<NSwitch :value="settingsStore.discord.require_mention" :loading="isSaving('discord', 'require_mention')" @update:value="v => saveChannel('discord', 'require_mention', { require_mention: v })" />
|
<NSwitch :value="settingsStore.discord.require_mention" :loading="isSaving('discord', 'require_mention')" @update:value="v => saveChannel('discord', 'require_mention', { require_mention: v })" />
|
||||||
@@ -242,23 +204,23 @@ const platforms = [
|
|||||||
<NSwitch :value="settingsStore.discord.reactions" :loading="isSaving('discord', 'reactions')" @update:value="v => saveChannel('discord', 'reactions', { reactions: v })" />
|
<NSwitch :value="settingsStore.discord.reactions" :loading="isSaving('discord', 'reactions')" @update:value="v => saveChannel('discord', 'reactions', { reactions: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
|
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
|
||||||
<NInput :value="settingsStore.discord.free_response_channels || ''" :loading="isSaving('discord', 'free_response_channels')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => debouncedSaveChannel('discord', 'free_response_channels', { free_response_channels: v })" />
|
<NInput :default-value="settingsStore.discord.free_response_channels || ''" :loading="isSaving('discord', 'free_response_channels')" size="small" placeholder="channel_id1,channel_id2" @change="v => saveChannel('discord', 'free_response_channels', { free_response_channels: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.allowedChannels')" :hint="t('platform.allowedChannelsHint')">
|
<SettingRow :label="t('platform.allowedChannels')" :hint="t('platform.allowedChannelsHint')">
|
||||||
<NInput :value="settingsStore.discord.allowed_channels || ''" :loading="isSaving('discord', 'allowed_channels')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => debouncedSaveChannel('discord', 'allowed_channels', { allowed_channels: v })" />
|
<NInput :default-value="settingsStore.discord.allowed_channels || ''" :loading="isSaving('discord', 'allowed_channels')" size="small" placeholder="channel_id1,channel_id2" @change="v => saveChannel('discord', 'allowed_channels', { allowed_channels: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.ignoredChannels')" :hint="t('platform.ignoredChannelsHint')">
|
<SettingRow :label="t('platform.ignoredChannels')" :hint="t('platform.ignoredChannelsHint')">
|
||||||
<NInput :value="settingsStore.discord.ignored_channels || ''" :loading="isSaving('discord', 'ignored_channels')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => debouncedSaveChannel('discord', 'ignored_channels', { ignored_channels: v })" />
|
<NInput :default-value="settingsStore.discord.ignored_channels || ''" :loading="isSaving('discord', 'ignored_channels')" size="small" placeholder="channel_id1,channel_id2" @change="v => saveChannel('discord', 'ignored_channels', { ignored_channels: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.noThreadChannels')" :hint="t('platform.noThreadChannelsHint')">
|
<SettingRow :label="t('platform.noThreadChannels')" :hint="t('platform.noThreadChannelsHint')">
|
||||||
<NInput :value="settingsStore.discord.no_thread_channels || ''" :loading="isSaving('discord', 'no_thread_channels')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => debouncedSaveChannel('discord', 'no_thread_channels', { no_thread_channels: v })" />
|
<NInput :default-value="settingsStore.discord.no_thread_channels || ''" :loading="isSaving('discord', 'no_thread_channels')" size="small" placeholder="channel_id1,channel_id2" @change="v => saveChannel('discord', 'no_thread_channels', { no_thread_channels: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Slack -->
|
<!-- Slack -->
|
||||||
<template v-if="p.key === 'slack'">
|
<template v-if="p.key === 'slack'">
|
||||||
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
|
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
|
||||||
<NInput :value="getCreds('slack').token || ''" :loading="isSaving('slack', 'token')" clearable size="small" class="input-lg" placeholder="xoxb-..." @update:value="v => debouncedSaveCredentials('slack', 'token', { token: v })" />
|
<NInput :default-value="getCreds('slack').token || ''" :loading="isSaving('slack', 'token')" clearable size="small" class="input-lg" placeholder="xoxb-..." @change="v => saveCredentials('slack', 'token', { token: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
|
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
|
||||||
<NSwitch :value="settingsStore.slack.require_mention" :loading="isSaving('slack', 'require_mention')" @update:value="v => saveChannel('slack', 'require_mention', { require_mention: v })" />
|
<NSwitch :value="settingsStore.slack.require_mention" :loading="isSaving('slack', 'require_mention')" @update:value="v => saveChannel('slack', 'require_mention', { require_mention: v })" />
|
||||||
@@ -267,7 +229,7 @@ const platforms = [
|
|||||||
<NSwitch :value="settingsStore.slack.allow_bots" :loading="isSaving('slack', 'allow_bots')" @update:value="v => saveChannel('slack', 'allow_bots', { allow_bots: v })" />
|
<NSwitch :value="settingsStore.slack.allow_bots" :loading="isSaving('slack', 'allow_bots')" @update:value="v => saveChannel('slack', 'allow_bots', { allow_bots: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
|
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
|
||||||
<NInput :value="settingsStore.slack.free_response_channels || ''" :loading="isSaving('slack', 'free_response_channels')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => debouncedSaveChannel('slack', 'free_response_channels', { free_response_channels: v })" />
|
<NInput :default-value="settingsStore.slack.free_response_channels || ''" :loading="isSaving('slack', 'free_response_channels')" size="small" placeholder="channel_id1,channel_id2" @change="v => saveChannel('slack', 'free_response_channels', { free_response_channels: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -280,20 +242,20 @@ const platforms = [
|
|||||||
<NSwitch :value="settingsStore.whatsapp.require_mention" :loading="isSaving('whatsapp', 'require_mention')" @update:value="v => saveChannel('whatsapp', 'require_mention', { require_mention: v })" />
|
<NSwitch :value="settingsStore.whatsapp.require_mention" :loading="isSaving('whatsapp', 'require_mention')" @update:value="v => saveChannel('whatsapp', 'require_mention', { require_mention: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
||||||
<NInput :value="settingsStore.whatsapp.free_response_chats || ''" :loading="isSaving('whatsapp', 'free_response_chats')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => debouncedSaveChannel('whatsapp', 'free_response_chats', { free_response_chats: v })" />
|
<NInput :default-value="settingsStore.whatsapp.free_response_chats || ''" :loading="isSaving('whatsapp', 'free_response_chats')" size="small" placeholder="chat_id1,chat_id2" @change="v => saveChannel('whatsapp', 'free_response_chats', { free_response_chats: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
|
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
|
||||||
<NInput :value="(settingsStore.whatsapp.mention_patterns || []).join(', ')" :loading="isSaving('whatsapp', 'mention_patterns')" size="small" placeholder="pattern1, pattern2" @update:value="v => debouncedSaveChannel('whatsapp', 'mention_patterns', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
|
<NInput :default-value="(settingsStore.whatsapp.mention_patterns || []).join(', ')" :loading="isSaving('whatsapp', 'mention_patterns')" size="small" placeholder="pattern1, pattern2" @change="v => saveChannel('whatsapp', 'mention_patterns', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Matrix -->
|
<!-- Matrix -->
|
||||||
<template v-if="p.key === 'matrix'">
|
<template v-if="p.key === 'matrix'">
|
||||||
<SettingRow :label="t('platform.accessToken')" :hint="t('platform.accessTokenHint')">
|
<SettingRow :label="t('platform.accessToken')" :hint="t('platform.accessTokenHint')">
|
||||||
<NInput :value="getCreds('matrix').token || ''" :loading="isSaving('matrix', 'token')" clearable size="small" class="input-lg" placeholder="syt_..." @update:value="v => debouncedSaveCredentials('matrix', 'token', { token: v })" />
|
<NInput :default-value="getCreds('matrix').token || ''" :loading="isSaving('matrix', 'token')" clearable size="small" class="input-lg" placeholder="syt_..." @change="v => saveCredentials('matrix', 'token', { token: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.homeserver')" :hint="t('platform.homeserverHint')">
|
<SettingRow :label="t('platform.homeserver')" :hint="t('platform.homeserverHint')">
|
||||||
<NInput :value="getCreds('matrix').extra?.homeserver || ''" :loading="isSaving('matrix', 'homeserver')" clearable size="small" class="input-lg" placeholder="https://matrix.org" @update:value="v => debouncedSaveCredentials('matrix', 'homeserver', { extra: { ...getCreds('matrix').extra, homeserver: v } })" />
|
<NInput :default-value="getCreds('matrix').extra?.homeserver || ''" :loading="isSaving('matrix', 'homeserver')" clearable size="small" class="input-lg" placeholder="https://matrix.org" @change="v => saveCredentials('matrix', 'homeserver', { extra: { ...getCreds('matrix').extra, homeserver: v } })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionRoom')">
|
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionRoom')">
|
||||||
<NSwitch :value="settingsStore.matrix.require_mention" :loading="isSaving('matrix', 'require_mention')" @update:value="v => saveChannel('matrix', 'require_mention', { require_mention: v })" />
|
<NSwitch :value="settingsStore.matrix.require_mention" :loading="isSaving('matrix', 'require_mention')" @update:value="v => saveChannel('matrix', 'require_mention', { require_mention: v })" />
|
||||||
@@ -305,39 +267,39 @@ const platforms = [
|
|||||||
<NSwitch :value="settingsStore.matrix.dm_mention_threads" :loading="isSaving('matrix', 'dm_mention_threads')" @update:value="v => saveChannel('matrix', 'dm_mention_threads', { dm_mention_threads: v })" />
|
<NSwitch :value="settingsStore.matrix.dm_mention_threads" :loading="isSaving('matrix', 'dm_mention_threads')" @update:value="v => saveChannel('matrix', 'dm_mention_threads', { dm_mention_threads: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.freeResponseRooms')" :hint="t('platform.freeResponseRoomsHint')">
|
<SettingRow :label="t('platform.freeResponseRooms')" :hint="t('platform.freeResponseRoomsHint')">
|
||||||
<NInput :value="settingsStore.matrix.free_response_rooms || ''" :loading="isSaving('matrix', 'free_response_rooms')" size="small" placeholder="room_id1,room_id2" @update:value="v => debouncedSaveChannel('matrix', 'free_response_rooms', { free_response_rooms: v })" />
|
<NInput :default-value="settingsStore.matrix.free_response_rooms || ''" :loading="isSaving('matrix', 'free_response_rooms')" size="small" placeholder="room_id1,room_id2" @change="v => saveChannel('matrix', 'free_response_rooms', { free_response_rooms: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Feishu -->
|
<!-- Feishu -->
|
||||||
<template v-if="p.key === 'feishu'">
|
<template v-if="p.key === 'feishu'">
|
||||||
<SettingRow :label="t('platform.appId')" :hint="t('platform.appIdHint')">
|
<SettingRow :label="t('platform.appId')" :hint="t('platform.appIdHint')">
|
||||||
<NInput :value="getCreds('feishu').extra?.app_id || ''" :loading="isSaving('feishu', 'app_id')" clearable size="small" class="input-lg" placeholder="cli_..." @update:value="v => debouncedSaveCredentials('feishu', 'app_id', { extra: { ...getCreds('feishu').extra, app_id: v } })" />
|
<NInput :default-value="getCreds('feishu').extra?.app_id || ''" :loading="isSaving('feishu', 'app_id')" clearable size="small" class="input-lg" placeholder="cli_..." @change="v => saveCredentials('feishu', 'app_id', { extra: { ...getCreds('feishu').extra, app_id: v } })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.appSecretHint')">
|
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.appSecretHint')">
|
||||||
<NInput :value="getCreds('feishu').extra?.app_secret || ''" :loading="isSaving('feishu', 'app_secret')" clearable size="small" class="input-lg" placeholder="App Secret" @update:value="v => debouncedSaveCredentials('feishu', 'app_secret', { extra: { ...getCreds('feishu').extra, app_secret: v } })" />
|
<NInput :default-value="getCreds('feishu').extra?.app_secret || ''" :loading="isSaving('feishu', 'app_secret')" clearable size="small" class="input-lg" placeholder="App Secret" @change="v => saveCredentials('feishu', 'app_secret', { extra: { ...getCreds('feishu').extra, app_secret: v } })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
|
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
|
||||||
<NSwitch :value="settingsStore.feishu.require_mention" :loading="isSaving('feishu', 'require_mention')" @update:value="v => saveChannel('feishu', 'require_mention', { require_mention: v })" />
|
<NSwitch :value="settingsStore.feishu.require_mention" :loading="isSaving('feishu', 'require_mention')" @update:value="v => saveChannel('feishu', 'require_mention', { require_mention: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
||||||
<NInput :value="settingsStore.feishu.free_response_chats || ''" :loading="isSaving('feishu', 'free_response_chats')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => debouncedSaveChannel('feishu', 'free_response_chats', { free_response_chats: v })" />
|
<NInput :default-value="settingsStore.feishu.free_response_chats || ''" :loading="isSaving('feishu', 'free_response_chats')" size="small" placeholder="chat_id1,chat_id2" @change="v => saveChannel('feishu', 'free_response_chats', { free_response_chats: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- DingTalk -->
|
<!-- DingTalk -->
|
||||||
<template v-if="p.key === 'dingtalk'">
|
<template v-if="p.key === 'dingtalk'">
|
||||||
<SettingRow :label="t('platform.clientId')" :hint="t('platform.clientIdHint')">
|
<SettingRow :label="t('platform.clientId')" :hint="t('platform.clientIdHint')">
|
||||||
<NInput :value="getCreds('dingtalk').extra?.client_id || ''" :loading="isSaving('dingtalk', 'client_id')" clearable size="small" class="input-lg" placeholder="Client ID" @update:value="v => debouncedSaveCredentials('dingtalk', 'client_id', { extra: { ...getCreds('dingtalk').extra, client_id: v } })" />
|
<NInput :default-value="getCreds('dingtalk').extra?.client_id || ''" :loading="isSaving('dingtalk', 'client_id')" clearable size="small" class="input-lg" placeholder="Client ID" @change="v => saveCredentials('dingtalk', 'client_id', { extra: { ...getCreds('dingtalk').extra, client_id: v } })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.clientSecret')" :hint="t('platform.clientSecretHint')">
|
<SettingRow :label="t('platform.clientSecret')" :hint="t('platform.clientSecretHint')">
|
||||||
<NInput :value="getCreds('dingtalk').extra?.client_secret || ''" :loading="isSaving('dingtalk', 'client_secret')" clearable size="small" class="input-lg" placeholder="Client Secret" @update:value="v => debouncedSaveCredentials('dingtalk', 'client_secret', { extra: { ...getCreds('dingtalk').extra, client_secret: v } })" />
|
<NInput :default-value="getCreds('dingtalk').extra?.client_secret || ''" :loading="isSaving('dingtalk', 'client_secret')" clearable size="small" class="input-lg" placeholder="Client Secret" @change="v => saveCredentials('dingtalk', 'client_secret', { extra: { ...getCreds('dingtalk').extra, client_secret: v } })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
|
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
|
||||||
<NSwitch :value="settingsStore.dingtalk.require_mention" :loading="isSaving('dingtalk', 'require_mention')" @update:value="v => saveChannel('dingtalk', 'require_mention', { require_mention: v })" />
|
<NSwitch :value="settingsStore.dingtalk.require_mention" :loading="isSaving('dingtalk', 'require_mention')" @update:value="v => saveChannel('dingtalk', 'require_mention', { require_mention: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
||||||
<NInput :value="settingsStore.dingtalk.free_response_chats || ''" :loading="isSaving('dingtalk', 'free_response_chats')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => debouncedSaveChannel('dingtalk', 'free_response_chats', { free_response_chats: v })" />
|
<NInput :default-value="settingsStore.dingtalk.free_response_chats || ''" :loading="isSaving('dingtalk', 'free_response_chats')" size="small" placeholder="chat_id1,chat_id2" @change="v => saveChannel('dingtalk', 'free_response_chats', { free_response_chats: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -361,20 +323,20 @@ const platforms = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SettingRow :label="t('platform.weixinToken')" :hint="t('platform.weixinTokenHint')">
|
<SettingRow :label="t('platform.weixinToken')" :hint="t('platform.weixinTokenHint')">
|
||||||
<NInput :value="getCreds('weixin').token || ''" :loading="isSaving('weixin', 'token')" clearable size="small" class="input-lg" placeholder="Token" @update:value="v => debouncedSaveCredentials('weixin', 'token', { token: v })" />
|
<NInput :default-value="getCreds('weixin').token || ''" :loading="isSaving('weixin', 'token')" clearable size="small" class="input-lg" placeholder="Token" @change="v => saveCredentials('weixin', 'token', { token: v })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.accountId')" :hint="t('platform.accountIdHint')">
|
<SettingRow :label="t('platform.accountId')" :hint="t('platform.accountIdHint')">
|
||||||
<NInput :value="getCreds('weixin').extra?.account_id || ''" :loading="isSaving('weixin', 'account_id')" clearable size="small" class="input-lg" placeholder="Account ID" @update:value="v => debouncedSaveCredentials('weixin', 'account_id', { extra: { ...getCreds('weixin').extra, account_id: v } })" />
|
<NInput :default-value="getCreds('weixin').extra?.account_id || ''" :loading="isSaving('weixin', 'account_id')" clearable size="small" class="input-lg" placeholder="Account ID" @change="v => saveCredentials('weixin', 'account_id', { extra: { ...getCreds('weixin').extra, account_id: v } })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- WeCom -->
|
<!-- WeCom -->
|
||||||
<template v-if="p.key === 'wecom'">
|
<template v-if="p.key === 'wecom'">
|
||||||
<SettingRow :label="t('platform.botId')" :hint="t('platform.botIdHint')">
|
<SettingRow :label="t('platform.botId')" :hint="t('platform.botIdHint')">
|
||||||
<NInput :value="getCreds('wecom').extra?.bot_id || ''" :loading="isSaving('wecom', 'bot_id')" clearable size="small" class="input-lg" placeholder="Bot ID" @update:value="v => debouncedSaveCredentials('wecom', 'bot_id', { extra: { ...getCreds('wecom').extra, bot_id: v } })" />
|
<NInput :default-value="getCreds('wecom').extra?.bot_id || ''" :loading="isSaving('wecom', 'bot_id')" clearable size="small" class="input-lg" placeholder="Bot ID" @change="v => saveCredentials('wecom', 'bot_id', { extra: { ...getCreds('wecom').extra, bot_id: v } })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.wecomSecretHint')">
|
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.wecomSecretHint')">
|
||||||
<NInput :value="getCreds('wecom').extra?.secret || ''" :loading="isSaving('wecom', 'secret')" clearable size="small" class="input-lg" placeholder="Secret" @update:value="v => debouncedSaveCredentials('wecom', 'secret', { extra: { ...getCreds('wecom').extra, secret: v } })" />
|
<NInput :default-value="getCreds('wecom').extra?.secret || ''" :loading="isSaving('wecom', 'secret')" clearable size="small" class="input-lg" placeholder="Secret" @change="v => saveCredentials('wecom', 'secret', { extra: { ...getCreds('wecom').extra, secret: v } })" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</template>
|
</template>
|
||||||
</PlatformCard>
|
</PlatformCard>
|
||||||
|
|||||||
@@ -86,13 +86,13 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|||||||
label: 'MiniMax',
|
label: 'MiniMax',
|
||||||
value: 'minimax',
|
value: 'minimax',
|
||||||
base_url: 'https://api.minimax.io/anthropic/v1',
|
base_url: 'https://api.minimax.io/anthropic/v1',
|
||||||
models: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1', 'MiniMax-M2'],
|
models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2', 'MiniMax-M2-highspeed'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'MiniMax (China)',
|
label: 'MiniMax (China)',
|
||||||
value: 'minimax-cn',
|
value: 'minimax-cn',
|
||||||
base_url: 'https://api.minimaxi.com/v1',
|
base_url: 'https://api.minimaxi.com/v1',
|
||||||
models: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1', 'MiniMax-M2'],
|
models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2', 'MiniMax-M2-highspeed'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Alibaba Cloud',
|
label: 'Alibaba Cloud',
|
||||||
|
|||||||
@@ -1,29 +1,36 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { onMounted } from "vue";
|
||||||
import { NTabs, NTabPane, NSpin, NSwitch, NInput, NInputNumber, useMessage } from 'naive-ui'
|
import {
|
||||||
import { useI18n } from 'vue-i18n'
|
NTabs,
|
||||||
import { useSettingsStore } from '@/stores/hermes/settings'
|
NTabPane,
|
||||||
import DisplaySettings from '@/components/hermes/settings/DisplaySettings.vue'
|
NSpin,
|
||||||
import AgentSettings from '@/components/hermes/settings/AgentSettings.vue'
|
NSwitch,
|
||||||
import MemorySettings from '@/components/hermes/settings/MemorySettings.vue'
|
NInput,
|
||||||
import SessionSettings from '@/components/hermes/settings/SessionSettings.vue'
|
NInputNumber,
|
||||||
import PrivacySettings from '@/components/hermes/settings/PrivacySettings.vue'
|
useMessage,
|
||||||
import SettingRow from '@/components/hermes/settings/SettingRow.vue'
|
} from "naive-ui";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useSettingsStore } from "@/stores/hermes/settings";
|
||||||
|
import DisplaySettings from "@/components/hermes/settings/DisplaySettings.vue";
|
||||||
|
import AgentSettings from "@/components/hermes/settings/AgentSettings.vue";
|
||||||
|
import MemorySettings from "@/components/hermes/settings/MemorySettings.vue";
|
||||||
|
import SessionSettings from "@/components/hermes/settings/SessionSettings.vue";
|
||||||
|
import PrivacySettings from "@/components/hermes/settings/PrivacySettings.vue";
|
||||||
|
import SettingRow from "@/components/hermes/settings/SettingRow.vue";
|
||||||
|
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore();
|
||||||
const message = useMessage()
|
const message = useMessage();
|
||||||
const { t } = useI18n()
|
const { t } = useI18n();
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
settingsStore.fetchSettings()
|
settingsStore.fetchSettings();
|
||||||
})
|
});
|
||||||
|
|
||||||
async function saveApiServer(values: Record<string, any>) {
|
async function saveApiServer(values: Record<string, any>) {
|
||||||
try {
|
try {
|
||||||
await settingsStore.saveSection('platforms', { api_server: values })
|
await settingsStore.saveSection("platforms", { api_server: values });
|
||||||
message.success(t('settings.saved'))
|
message.success(t("settings.saved"));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error(t('settings.saveFailed'))
|
message.error(t("settings.saveFailed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -31,11 +38,15 @@ async function saveApiServer(values: Record<string, any>) {
|
|||||||
<template>
|
<template>
|
||||||
<div class="settings-view">
|
<div class="settings-view">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h2 class="header-title">{{ t('settings.title') }}</h2>
|
<h2 class="header-title">{{ t("settings.title") }}</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="settings-content">
|
<div class="settings-content">
|
||||||
<NSpin :show="settingsStore.loading || settingsStore.saving" size="large" :description="t('common.loading')">
|
<NSpin
|
||||||
|
:show="settingsStore.loading || settingsStore.saving"
|
||||||
|
size="large"
|
||||||
|
:description="t('common.loading')"
|
||||||
|
>
|
||||||
<NTabs type="line" animated>
|
<NTabs type="line" animated>
|
||||||
<NTabPane name="display" :tab="t('settings.tabs.display')">
|
<NTabPane name="display" :tab="t('settings.tabs.display')">
|
||||||
<DisplaySettings />
|
<DisplaySettings />
|
||||||
@@ -54,40 +65,66 @@ async function saveApiServer(values: Record<string, any>) {
|
|||||||
</NTabPane>
|
</NTabPane>
|
||||||
<NTabPane name="api_server" :tab="t('settings.tabs.apiServer')">
|
<NTabPane name="api_server" :tab="t('settings.tabs.apiServer')">
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<SettingRow :label="t('settings.apiServer.enable')" :hint="t('settings.apiServer.enableHint')">
|
<SettingRow
|
||||||
|
:label="t('settings.apiServer.enable')"
|
||||||
|
:hint="t('settings.apiServer.enableHint')"
|
||||||
|
>
|
||||||
<NSwitch
|
<NSwitch
|
||||||
:value="settingsStore.platforms?.api_server?.enabled"
|
:value="settingsStore.platforms?.api_server?.enabled"
|
||||||
@update:value="v => saveApiServer({ enabled: v })"
|
@update:value="(v) => saveApiServer({ enabled: v })"
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('settings.apiServer.host')" :hint="t('settings.apiServer.hostHint')">
|
<SettingRow
|
||||||
|
:label="t('settings.apiServer.host')"
|
||||||
|
:hint="t('settings.apiServer.hostHint')"
|
||||||
|
>
|
||||||
<NInput
|
<NInput
|
||||||
:value="settingsStore.platforms?.api_server?.host || ''"
|
:default-value="settingsStore.platforms?.api_server?.host || ''"
|
||||||
size="small" class="input-md"
|
size="small"
|
||||||
@update:value="v => saveApiServer({ host: v })"
|
class="input-md"
|
||||||
|
@change="(v: string) => saveApiServer({ host: v })"
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('settings.apiServer.port')" :hint="t('settings.apiServer.portHint')">
|
<SettingRow
|
||||||
|
:label="t('settings.apiServer.port')"
|
||||||
|
:hint="t('settings.apiServer.portHint')"
|
||||||
|
>
|
||||||
<NInputNumber
|
<NInputNumber
|
||||||
:value="settingsStore.platforms?.api_server?.port"
|
:default-value="settingsStore.platforms?.api_server?.port"
|
||||||
:min="1024" :max="65535"
|
:min="1024"
|
||||||
size="small" class="input-sm"
|
:max="65535"
|
||||||
@update:value="v => v != null && saveApiServer({ port: v })"
|
size="small"
|
||||||
|
class="input-sm"
|
||||||
|
@blur="(e: FocusEvent) => {
|
||||||
|
const val = (e.target as HTMLInputElement).value
|
||||||
|
if (val) saveApiServer({ port: Number(val) })
|
||||||
|
}"
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('settings.apiServer.key')" :hint="t('settings.apiServer.keyHint')">
|
<SettingRow
|
||||||
|
:label="t('settings.apiServer.key')"
|
||||||
|
:hint="t('settings.apiServer.keyHint')"
|
||||||
|
>
|
||||||
<NInput
|
<NInput
|
||||||
:value="settingsStore.platforms?.api_server?.key || ''"
|
:default-value="settingsStore.platforms?.api_server?.key || ''"
|
||||||
type="password" show-password-on="click"
|
type="password"
|
||||||
size="small" class="input-md"
|
show-password-on="click"
|
||||||
@update:value="v => saveApiServer({ key: v })"
|
size="small"
|
||||||
|
class="input-md"
|
||||||
|
@change="(v: string) => saveApiServer({ key: v })"
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('settings.apiServer.cors')" :hint="t('settings.apiServer.corsHint')">
|
<SettingRow
|
||||||
|
:label="t('settings.apiServer.cors')"
|
||||||
|
:hint="t('settings.apiServer.corsHint')"
|
||||||
|
>
|
||||||
<NInput
|
<NInput
|
||||||
:value="settingsStore.platforms?.api_server?.cors_origins || ''"
|
:default-value="
|
||||||
size="small" class="input-md"
|
settingsStore.platforms?.api_server?.cors_origins || ''
|
||||||
@update:value="v => saveApiServer({ cors_origins: v })"
|
"
|
||||||
|
size="small"
|
||||||
|
class="input-md"
|
||||||
|
@change="(v: string) => saveApiServer({ cors_origins: v })"
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</section>
|
</section>
|
||||||
@@ -99,7 +136,7 @@ async function saveApiServer(values: Record<string, any>) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@use '@/styles/variables' as *;
|
@use "@/styles/variables" as *;
|
||||||
|
|
||||||
.settings-view {
|
.settings-view {
|
||||||
height: calc(100 * var(--vh));
|
height: calc(100 * var(--vh));
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { config } from './config'
|
|||||||
import { hermesRoutes, setupTerminalWebSocket, proxyMiddleware } from './routes/hermes'
|
import { hermesRoutes, setupTerminalWebSocket, proxyMiddleware } from './routes/hermes'
|
||||||
import { uploadRoutes } from './routes/upload'
|
import { uploadRoutes } from './routes/upload'
|
||||||
import { webhookRoutes } from './routes/webhook'
|
import { webhookRoutes } from './routes/webhook'
|
||||||
import * as hermesCli from './services/hermes-cli'
|
import * as hermesCli from './services/hermes/hermes-cli'
|
||||||
import { getToken, authMiddleware } from './services/auth'
|
import { getToken, authMiddleware } from './services/auth'
|
||||||
|
|
||||||
function getLocalVersion(): string {
|
function getLocalVersion(): string {
|
||||||
@@ -232,7 +232,7 @@ function bindShutdown() {
|
|||||||
async function ensureApiServerConfig() {
|
async function ensureApiServerConfig() {
|
||||||
const { readFileSync, writeFileSync, existsSync, copyFileSync } = await import('fs')
|
const { readFileSync, writeFileSync, existsSync, copyFileSync } = await import('fs')
|
||||||
const yaml = (await import('js-yaml')).default
|
const yaml = (await import('js-yaml')).default
|
||||||
const { getActiveConfigPath } = await import('./services/hermes-profile')
|
const { getActiveConfigPath } = await import('./services/hermes/hermes-profile')
|
||||||
const configPath = getActiveConfigPath()
|
const configPath = getActiveConfigPath()
|
||||||
|
|
||||||
const defaults: Record<string, any> = {
|
const defaults: Record<string, any> = {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { readFile, writeFile, copyFile } from 'fs/promises'
|
|||||||
import { chmod } from 'fs/promises'
|
import { chmod } from 'fs/promises'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import YAML from 'js-yaml'
|
import YAML from 'js-yaml'
|
||||||
import { restartGateway } from '../../services/hermes-cli'
|
import { restartGateway } from '../../services/hermes/hermes-cli'
|
||||||
import { getActiveConfigPath, getActiveEnvPath, getActiveProfileDir } from '../../services/hermes-profile'
|
import { getActiveConfigPath, getActiveEnvPath, getActiveProfileDir } from '../../services/hermes/hermes-profile'
|
||||||
|
|
||||||
// Platform sections that require gateway restart after config change
|
// Platform sections that require gateway restart after config change
|
||||||
const PLATFORM_SECTIONS = new Set([
|
const PLATFORM_SECTIONS = new Set([
|
||||||
@@ -79,6 +79,20 @@ function getNested(obj: Record<string, any>, path: string): any {
|
|||||||
return cur
|
return cur
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deepMerge(target: Record<string, any>, source: Record<string, any>): Record<string, any> {
|
||||||
|
for (const key of Object.keys(source)) {
|
||||||
|
if (
|
||||||
|
source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) &&
|
||||||
|
target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])
|
||||||
|
) {
|
||||||
|
target[key] = deepMerge(target[key], source[key])
|
||||||
|
} else {
|
||||||
|
target[key] = source[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
async function readEnvPlatforms(): Promise<Record<string, any>> {
|
async function readEnvPlatforms(): Promise<Record<string, any>> {
|
||||||
try {
|
try {
|
||||||
const raw = await readFile(envPath(), 'utf-8')
|
const raw = await readFile(envPath(), 'utf-8')
|
||||||
@@ -217,7 +231,7 @@ configRoutes.put('/api/hermes/config', async (ctx) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await readConfig()
|
const config = await readConfig()
|
||||||
config[section] = { ...(config[section] || {}), ...values }
|
config[section] = deepMerge(config[section] || {}, values)
|
||||||
await writeConfig(config)
|
await writeConfig(config)
|
||||||
// Restart gateway for platform/channel config changes
|
// Restart gateway for platform/channel config changes
|
||||||
if (PLATFORM_SECTIONS.has(section)) {
|
if (PLATFORM_SECTIONS.has(section)) {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import Router from '@koa/router'
|
|||||||
import { readdir, readFile, stat, writeFile, mkdir, copyFile } from 'fs/promises'
|
import { readdir, readFile, stat, writeFile, mkdir, copyFile } from 'fs/promises'
|
||||||
import { join, resolve } from 'path'
|
import { join, resolve } from 'path'
|
||||||
import YAML from 'js-yaml'
|
import YAML from 'js-yaml'
|
||||||
import { getActiveProfileDir, getActiveConfigPath, getActiveAuthPath, getActiveEnvPath } from '../../services/hermes-profile'
|
import { getActiveProfileDir, getActiveConfigPath, getActiveAuthPath, getActiveEnvPath } from '../../services/hermes/hermes-profile'
|
||||||
import * as hermesCli from '../../services/hermes-cli'
|
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||||
|
|
||||||
// --- Provider env var mapping (from hermes providers.py HERMES_OVERLAYS + config.py) ---
|
// --- Provider env var mapping (from hermes providers.py HERMES_OVERLAYS + config.py) ---
|
||||||
// Maps provider key → { api_key_envs: all env var aliases for API key, base_url_env: env var for base URL }
|
// Maps provider key → { api_key_envs: all env var aliases for API key, base_url_env: env var for base URL }
|
||||||
@@ -513,21 +513,40 @@ fsRoutes.get('/api/hermes/available-models', async (ctx) => {
|
|||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.status === 'fulfilled' && result.value.models.length > 0) {
|
if (result.status === 'fulfilled' && result.value.models.length > 0) {
|
||||||
const { key, label, base_url, models } = result.value
|
const { key, label, base_url, models } = result.value
|
||||||
groups.push({ provider: key, label, base_url, models })
|
groups.push({ provider: key, label, base_url, models: Array.from(new Set(models)) })
|
||||||
} else if (result.status === 'rejected') {
|
} else if (result.status === 'rejected') {
|
||||||
console.error(`[available-models] Failed: ${result.reason?.message || result.reason}`)
|
console.error(`[available-models] Failed: ${result.reason?.message || result.reason}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deduplicate models within each group and merge groups with the same provider key
|
||||||
|
const dedupedGroups: typeof groups = []
|
||||||
|
const seenProviders = new Map<string, number>()
|
||||||
|
for (const g of groups) {
|
||||||
|
g.models = Array.from(new Set(g.models))
|
||||||
|
const existingIdx = seenProviders.get(g.provider)
|
||||||
|
if (existingIdx !== undefined) {
|
||||||
|
// Merge models into existing group
|
||||||
|
const existing = dedupedGroups[existingIdx]
|
||||||
|
const existingSet = new Set(existing.models)
|
||||||
|
for (const m of g.models) {
|
||||||
|
if (!existingSet.has(m)) existing.models.push(m)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
seenProviders.set(g.provider, dedupedGroups.length)
|
||||||
|
dedupedGroups.push(g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback: if no providers returned models, fall back to config.yaml parsing
|
// Fallback: if no providers returned models, fall back to config.yaml parsing
|
||||||
if (groups.length === 0) {
|
if (dedupedGroups.length === 0) {
|
||||||
const fallback = buildModelGroups(config)
|
const fallback = buildModelGroups(config)
|
||||||
ctx.body = fallback
|
ctx.body = fallback
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.body = { default: currentDefault, groups }
|
ctx.body = { default: currentDefault, groups: dedupedGroups }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ctx.status = 500
|
ctx.status = 500
|
||||||
ctx.body = { error: err.message }
|
ctx.body = { error: err.message }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
import * as hermesCli from '../../services/hermes-cli'
|
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||||
|
|
||||||
export const logRoutes = new Router()
|
export const logRoutes = new Router()
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { mkdir, writeFile } from 'fs/promises'
|
|||||||
import { basename, join } from 'path'
|
import { basename, join } from 'path'
|
||||||
import { tmpdir, homedir } from 'os'
|
import { tmpdir, homedir } from 'os'
|
||||||
import YAML from 'js-yaml'
|
import YAML from 'js-yaml'
|
||||||
import * as hermesCli from '../../services/hermes-cli'
|
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||||
|
|
||||||
const apiServerDefaults = {
|
const apiServerDefaults = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
import * as hermesCli from '../../services/hermes-cli'
|
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||||
|
|
||||||
export const sessionRoutes = new Router()
|
export const sessionRoutes = new Router()
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import axios from 'axios'
|
|||||||
import { readFile, writeFile } from 'fs/promises'
|
import { readFile, writeFile } from 'fs/promises'
|
||||||
import { chmod } from 'fs/promises'
|
import { chmod } from 'fs/promises'
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
import { restartGateway } from '../../services/hermes-cli'
|
import { restartGateway } from '../../services/hermes/hermes-cli'
|
||||||
import { getActiveEnvPath } from '../../services/hermes-profile'
|
import { getActiveEnvPath } from '../../services/hermes/hermes-profile'
|
||||||
|
|
||||||
const envPath = () => getActiveEnvPath()
|
const envPath = () => getActiveEnvPath()
|
||||||
const ILINK_BASE = 'https://ilinkai.weixin.qq.com'
|
const ILINK_BASE = 'https://ilinkai.weixin.qq.com'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
import { emitWebhook } from '../services/hermes'
|
import { emitWebhook } from '../services/hermes/hermes'
|
||||||
|
|
||||||
export const webhookRoutes = new Router()
|
export const webhookRoutes = new Router()
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { config } from '../config'
|
import { config } from '../../config'
|
||||||
|
|
||||||
const UPSTREAM = config.upstream.replace(/\/$/, '')
|
const UPSTREAM = config.upstream.replace(/\/$/, '')
|
||||||
|
|
||||||
@@ -86,13 +86,13 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|||||||
label: 'MiniMax',
|
label: 'MiniMax',
|
||||||
value: 'minimax',
|
value: 'minimax',
|
||||||
base_url: 'https://api.minimax.io/anthropic/v1',
|
base_url: 'https://api.minimax.io/anthropic/v1',
|
||||||
models: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1', 'MiniMax-M2'],
|
models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2', 'MiniMax-M2-highspeed'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'MiniMax (China)',
|
label: 'MiniMax (China)',
|
||||||
value: 'minimax-cn',
|
value: 'minimax-cn',
|
||||||
base_url: 'https://api.minimaxi.com/v1',
|
base_url: 'https://api.minimaxi.com/v1',
|
||||||
models: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1', 'MiniMax-M2'],
|
models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2', 'MiniMax-M2-highspeed'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Alibaba Cloud',
|
label: 'Alibaba Cloud',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
// Mock hermes-cli
|
// Mock hermes-cli
|
||||||
vi.mock('../../packages/server/src/services/hermes-cli', () => ({
|
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||||
listProfiles: vi.fn(),
|
listProfiles: vi.fn(),
|
||||||
getProfile: vi.fn(),
|
getProfile: vi.fn(),
|
||||||
createProfile: vi.fn(),
|
createProfile: vi.fn(),
|
||||||
@@ -16,7 +16,7 @@ vi.mock('../../packages/server/src/services/hermes-cli', () => ({
|
|||||||
importProfile: vi.fn(),
|
importProfile: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import * as hermesCli from '../../packages/server/src/services/hermes-cli'
|
import * as hermesCli from '../../packages/server/src/services/hermes/hermes-cli'
|
||||||
|
|
||||||
describe('Profile Routes', () => {
|
describe('Profile Routes', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -120,7 +120,15 @@ describe('Proxy Handler', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('returns 502 on connection failure', async () => {
|
it('returns 502 on connection failure', async () => {
|
||||||
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'))
|
// waitForGatewayReady loops calling fetch(healthUrl) until res.ok or timeout.
|
||||||
|
// Return ok:true for health checks so the loop exits immediately (gateway
|
||||||
|
// "ready"), then the retry fetch also fails with ECONNREFUSED → 502.
|
||||||
|
mockFetch.mockImplementation((url: string) => {
|
||||||
|
if (typeof url === 'string' && url.includes('/health')) {
|
||||||
|
return Promise.resolve({ ok: true })
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error('ECONNREFUSED'))
|
||||||
|
})
|
||||||
|
|
||||||
const ctx = createMockCtx()
|
const ctx = createMockCtx()
|
||||||
await proxy(ctx)
|
await proxy(ctx)
|
||||||
|
|||||||
Reference in New Issue
Block a user