feat: add i18n, platform channels page, and WeChat QR login

- Add vue-i18n with auto-detect browser language and manual toggle (EN/中文)
- Move platform channels to separate page with credential management
- Support Telegram, Discord, Slack, WhatsApp, Matrix, Feishu, Weixin, WeCom
- Add WeChat QR code login (opens in browser, polls status, auto-saves)
- Write platform credentials to ~/.hermes/.env matching hermes gateway setup
- Auto restart gateway after platform config changes
- Add settings store with per-section save for all config categories
- Persist session group collapse state across navigation
- Fix pre-existing TypeScript build errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-13 15:15:14 +08:00
parent 9e069a20a1
commit e89a240f1d
42 changed files with 2627 additions and 378 deletions
+6 -4
View File
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { NButton, NTooltip } from 'naive-ui'
import { useChatStore } from '@/stores/chat'
import type { Attachment } from '@/stores/chat'
const chatStore = useChatStore()
const { t } = useI18n()
const inputText = ref('')
const textareaRef = ref<HTMLTextAreaElement>()
const fileInputRef = ref<HTMLInputElement>()
@@ -201,7 +203,7 @@ function isImage(type: string): boolean {
ref="textareaRef"
v-model="inputText"
class="input-textarea"
placeholder="Type a message... (Enter to send, Shift+Enter for new line)"
:placeholder="t('chat.inputPlaceholder')"
rows="1"
@keydown="handleKeydown"
@input="handleInput"
@@ -216,7 +218,7 @@ function isImage(type: string): boolean {
</template>
</NButton>
</template>
Attach files
{{ t('chat.attachFiles') }}
</NTooltip>
<NButton
v-if="chatStore.isStreaming"
@@ -224,7 +226,7 @@ function isImage(type: string): boolean {
type="error"
@click="chatStore.stopStreaming()"
>
Stop
{{ t('chat.stop') }}
</NButton>
<NButton
size="small"
@@ -235,7 +237,7 @@ function isImage(type: string): boolean {
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</template>
Send
{{ t('chat.send') }}
</NButton>
</div>
</div>
+28 -24
View File
@@ -3,18 +3,20 @@ import { renameSession } from '@/api/sessions'
import { useChatStore, type Session } from '@/stores/chat'
import { NButton, NDropdown, NInput, NModal, NPopconfirm, NTooltip, useMessage } from 'naive-ui'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ChatInput from './ChatInput.vue'
import MessageList from './MessageList.vue'
const chatStore = useChatStore()
const message = useMessage()
const { t } = useI18n()
const showSessions = ref(true)
const showRenameModal = ref(false)
const renameValue = ref('')
const renameSessionId = ref<string | null>(null)
const renameInputRef = ref<InstanceType<typeof NInput> | null>(null)
const collapsedGroups = ref<Set<string>>(new Set())
const collapsedGroups = ref<Set<string>>(new Set(JSON.parse(localStorage.getItem('hermes_collapsed_groups') || '[]')))
const sourceLabel: Record<string, string> = {
telegram: 'Telegram',
@@ -74,7 +76,7 @@ const groupedSessions = computed<SessionGroup[]>(() => {
return keys.map(key => ({
source: key,
label: key ? getSourceLabel(key) : 'Other',
label: key ? getSourceLabel(key) : t('chat.other'),
sessions: map.get(key)!,
}))
})
@@ -93,16 +95,18 @@ function toggleGroup(source: string) {
chatStore.switchSession(group.sessions[0].id)
}
}
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
}
// Default: expand only the first group, collapse the rest
// Default: expand only the first group if no saved state
watch(groupedSessions, (groups) => {
if (collapsedGroups.value.size > 0) return
collapsedGroups.value = new Set(groups.map(g => g.source))
if (localStorage.getItem('hermes_collapsed_groups') !== null) return
collapsedGroups.value = new Set(groups.slice(1).map(g => g.source))
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
}, { once: true })
const activeSessionTitle = computed(() =>
chatStore.activeSession?.title || 'New Chat',
chatStore.activeSession?.title || t('chat.newChat'),
)
const activeSessionSource = computed(() =>
@@ -117,13 +121,13 @@ function copySessionId(id?: string) {
const sessionId = id || chatStore.activeSessionId
if (sessionId) {
navigator.clipboard.writeText(sessionId)
message.success('Copied')
message.success(t('common.copied'))
}
}
function handleDeleteSession(id: string) {
chatStore.deleteSession(id)
message.success('Session deleted')
message.success(t('chat.sessionDeleted'))
}
function formatTime(ts: number) {
@@ -135,10 +139,10 @@ function formatTime(ts: number) {
}
// Context menu
const contextMenuOptions = [
{ label: 'Rename', key: 'rename' },
{ label: 'Copy Session ID', key: 'copy-id' },
]
const contextMenuOptions = computed(() => [
{ label: t('chat.rename'), key: 'rename' },
{ label: t('chat.copySessionId'), key: 'copy-id' },
])
const contextSessionId = ref<string | null>(null)
function handleContextMenu(e: MouseEvent, sessionId: string) {
@@ -182,9 +186,9 @@ async function handleRenameConfirm() {
if (chatStore.activeSession?.id === renameSessionId.value) {
chatStore.activeSession.title = renameValue.value.trim()
}
message.success('Renamed')
message.success(t('chat.renamed'))
} else {
message.error('Rename failed')
message.error(t('chat.renameFailed'))
}
showRenameModal.value = false
}
@@ -195,7 +199,7 @@ async function handleRenameConfirm() {
<!-- Session List -->
<aside class="session-list" :class="{ collapsed: !showSessions }">
<div class="session-list-header">
<span v-if="showSessions" class="session-list-title">Sessions</span>
<span v-if="showSessions" class="session-list-title">{{ t('chat.sessions') }}</span>
<NButton quaternary size="tiny" @click="handleNewChat" circle>
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
@@ -203,8 +207,8 @@ async function handleRenameConfirm() {
</NButton>
</div>
<div v-if="showSessions" class="session-items">
<div v-if="chatStore.isLoadingSessions && chatStore.sessions.length === 0" class="session-loading">Loading...</div>
<div v-else-if="chatStore.sessions.length === 0" class="session-empty">No sessions</div>
<div v-if="chatStore.isLoadingSessions && chatStore.sessions.length === 0" class="session-loading">{{ t('common.loading') }}</div>
<div v-else-if="chatStore.sessions.length === 0" class="session-empty">{{ t('chat.noSessions') }}</div>
<template v-for="group in groupedSessions" :key="group.source">
<div class="session-group-header" @click="toggleGroup(group.source)">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="group-chevron" :class="{ collapsed: collapsedGroups.has(group.source) }"><polyline points="9 18 15 12 9 6"/></svg>
@@ -236,7 +240,7 @@ async function handleRenameConfirm() {
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</template>
Delete this session?
{{ t('chat.deleteSession') }}
</NPopconfirm>
</button>
</template>
@@ -260,15 +264,15 @@ async function handleRenameConfirm() {
<NModal
v-model:show="showRenameModal"
preset="dialog"
title="Rename Session"
positive-text="OK"
negative-text="Cancel"
:title="t('chat.renameSession')"
:positive-text="t('common.ok')"
:negative-text="t('common.cancel')"
@positive-click="handleRenameConfirm"
>
<NInput
ref="renameInputRef"
v-model:value="renameValue"
placeholder="Enter new title"
:placeholder="t('chat.enterNewTitle')"
@keydown.enter="handleRenameConfirm"
/>
</NModal>
@@ -294,13 +298,13 @@ async function handleRenameConfirm() {
</template>
</NButton>
</template>
Copy Session ID
{{ t('chat.copySessionId') }}
</NTooltip>
<NButton size="small" @click="handleNewChat">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</template>
New Chat
{{ t('chat.newChat') }}
</NButton>
</div>
</header>
+4 -2
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
const props = defineProps<{ content: string }>()
const { t } = useI18n()
const md: MarkdownIt = new MarkdownIt({
html: false,
@@ -12,12 +14,12 @@ const md: MarkdownIt = new MarkdownIt({
highlight(str: string, lang: string): string {
if (lang && hljs.getLanguage(lang)) {
try {
return `<pre class="hljs-code-block"><div class="code-header"><span class="code-lang">${lang}</span><button class="copy-btn" onclick="navigator.clipboard.writeText(this.closest('.hljs-code-block').querySelector('code').textContent)">Copy</button></div><code class="hljs language-${lang}">${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`
return `<pre class="hljs-code-block"><div class="code-header"><span class="code-lang">${lang}</span><button class="copy-btn" onclick="navigator.clipboard.writeText(this.closest('.hljs-code-block').querySelector('code').textContent)">${t('common.copy')}</button></div><code class="hljs language-${lang}">${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`
} catch {
// fall through
}
}
return `<pre class="hljs-code-block"><div class="code-header"><button class="copy-btn" onclick="navigator.clipboard.writeText(this.closest('.hljs-code-block').querySelector('code').textContent)">Copy</button></div><code class="hljs">${md.utils.escapeHtml(str)}</code></pre>`
return `<pre class="hljs-code-block"><div class="code-header"><button class="copy-btn" onclick="navigator.clipboard.writeText(this.closest('.hljs-code-block').querySelector('code').textContent)">${t('common.copy')}</button></div><code class="hljs">${md.utils.escapeHtml(str)}</code></pre>`
},
})
+7 -5
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import type { Message } from '@/stores/chat';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import MarkdownRenderer from './MarkdownRenderer.vue';
const props = defineProps<{ message: Message }>()
const { t } = useI18n()
const isSystem = computed(() => props.message.role === 'system')
const toolExpanded = ref(false)
@@ -42,11 +44,11 @@ const formattedToolResult = computed(() => {
const parsed = JSON.parse(props.message.toolResult)
const str = JSON.stringify(parsed, null, 2)
// Truncate very long output
if (str.length > 2000) return str.slice(0, 2000) + '\n... (truncated)'
if (str.length > 2000) return str.slice(0, 2000) + '\n' + t('chat.truncated')
return str
} catch {
const raw = props.message.toolResult
if (raw.length > 2000) return raw.slice(0, 2000) + '\n... (truncated)'
if (raw.length > 2000) return raw.slice(0, 2000) + '\n' + t('chat.truncated')
return raw
}
})
@@ -61,15 +63,15 @@ const formattedToolResult = computed(() => {
<span class="tool-name">{{ message.toolName }}</span>
<span v-if="message.toolPreview && !toolExpanded" class="tool-preview">{{ message.toolPreview }}</span>
<span v-if="message.toolStatus === 'running'" class="tool-spinner"></span>
<span v-if="message.toolStatus === 'error'" class="tool-error-badge">error</span>
<span v-if="message.toolStatus === 'error'" class="tool-error-badge">{{ t('chat.error') }}</span>
</div>
<div v-if="toolExpanded && hasToolDetails" class="tool-details">
<div v-if="formattedToolArgs" class="tool-detail-section">
<div class="tool-detail-label">Arguments</div>
<div class="tool-detail-label">{{ t('chat.arguments') }}</div>
<pre class="tool-detail-code">{{ formattedToolArgs }}</pre>
</div>
<div v-if="formattedToolResult" class="tool-detail-section">
<div class="tool-detail-label">Result</div>
<div class="tool-detail-label">{{ t('chat.result') }}</div>
<pre class="tool-detail-code">{{ formattedToolResult }}</pre>
</div>
</div>
+3 -1
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import MessageItem from './MessageItem.vue'
import { useChatStore } from '@/stores/chat'
const chatStore = useChatStore()
const { t } = useI18n()
const listRef = ref<HTMLElement>()
function scrollToBottom() {
@@ -23,7 +25,7 @@ watch(() => chatStore.isStreaming, (v) => { if (v) scrollToBottom() })
<div ref="listRef" class="message-list">
<div v-if="chatStore.messages.length === 0" class="empty-state">
<img src="/assets/logo.png" alt="Hermes" class="empty-logo" />
<p>Start a conversation with Hermes Agent</p>
<p>{{ t('chat.emptyState') }}</p>
</div>
<MessageItem
v-for="msg in chatStore.messages"
+23 -21
View File
@@ -3,22 +3,24 @@ import { computed } from 'vue'
import { NButton, NTooltip, useMessage } from 'naive-ui'
import type { Job } from '@/api/jobs'
import { useJobsStore } from '@/stores/jobs'
import { useI18n } from 'vue-i18n'
const props = defineProps<{ job: Job }>()
const emit = defineEmits<{
edit: [jobId: string]
}>()
const { t } = useI18n()
const jobsStore = useJobsStore()
const message = useMessage()
const jobId = computed(() => props.job.job_id || props.job.id)
const statusLabel = computed(() => {
if (props.job.state === 'running') return 'Running'
if (props.job.state === 'paused') return 'Paused'
if (!props.job.enabled) return 'Disabled'
return 'Scheduled'
if (props.job.state === 'running') return t('jobs.status.running')
if (props.job.state === 'paused') return t('jobs.status.paused')
if (!props.job.enabled) return t('jobs.status.disabled')
return t('jobs.status.scheduled')
})
const statusType = computed(() => {
@@ -42,7 +44,7 @@ const formatTime = (t?: string | null) => {
async function handlePause() {
try {
await jobsStore.pauseJob(jobId.value)
message.success('Job paused')
message.success(t('jobs.jobPaused'))
} catch (e: any) {
message.error(e.message)
}
@@ -51,7 +53,7 @@ async function handlePause() {
async function handleResume() {
try {
await jobsStore.resumeJob(jobId.value)
message.success('Job resumed')
message.success(t('jobs.jobResumed'))
} catch (e: any) {
message.error(e.message)
}
@@ -60,7 +62,7 @@ async function handleResume() {
async function handleRun() {
try {
await jobsStore.runJob(jobId.value)
message.info('Job triggered')
message.info(t('jobs.jobTriggered'))
} catch (e: any) {
message.error(e.message)
}
@@ -69,7 +71,7 @@ async function handleRun() {
async function handleDelete() {
try {
await jobsStore.deleteJob(jobId.value)
message.success('Job deleted')
message.success(t('jobs.jobDeleted'))
} catch (e: any) {
message.error(e.message)
}
@@ -85,11 +87,11 @@ async function handleDelete() {
<div class="card-body">
<div class="info-row">
<span class="info-label">Schedule</span>
<span class="info-label">{{ t('jobs.info.schedule') }}</span>
<code class="info-value mono">{{ scheduleExpr }}</code>
</div>
<div class="info-row">
<span class="info-label">Last Run</span>
<span class="info-label">{{ t('jobs.info.lastRun') }}</span>
<span class="info-value">
{{ formatTime(job.last_run_at) }}
<span v-if="job.last_status" class="run-status" :class="{ ok: job.last_status === 'ok', err: job.last_status !== 'ok' }">
@@ -98,15 +100,15 @@ async function handleDelete() {
</span>
</div>
<div class="info-row">
<span class="info-label">Next Run</span>
<span class="info-label">{{ t('jobs.info.nextRun') }}</span>
<span class="info-value">{{ formatTime(job.next_run_at) }}</span>
</div>
<div class="info-row">
<span class="info-label">Deliver</span>
<span class="info-label">{{ t('jobs.info.deliver') }}</span>
<span class="info-value">{{ job.deliver }}<template v-if="job.origin"> ({{ job.origin.platform }})</template></span>
</div>
<div v-if="job.repeat" class="info-row">
<span class="info-label">Repeat</span>
<span class="info-label">{{ t('jobs.info.repeat') }}</span>
<span class="info-value">
<template v-if="typeof job.repeat === 'string'">{{ job.repeat }}</template>
<template v-else>{{ job.repeat.completed }} / {{ job.repeat.times ?? '' }}</template>
@@ -117,24 +119,24 @@ async function handleDelete() {
<div class="card-actions">
<NTooltip v-if="job.state !== 'paused' && job.enabled">
<template #trigger>
<NButton size="tiny" quaternary @click="handlePause">Pause</NButton>
<NButton size="tiny" quaternary @click="handlePause">{{ t('jobs.action.pause') }}</NButton>
</template>
Pause job
{{ t('jobs.action.pauseJob') }}
</NTooltip>
<NTooltip v-else-if="job.state === 'paused'">
<template #trigger>
<NButton size="tiny" quaternary @click="handleResume">Resume</NButton>
<NButton size="tiny" quaternary @click="handleResume">{{ t('jobs.action.resume') }}</NButton>
</template>
Resume job
{{ t('jobs.action.resumeJob') }}
</NTooltip>
<NTooltip>
<template #trigger>
<NButton size="tiny" quaternary @click="handleRun">Run Now</NButton>
<NButton size="tiny" quaternary @click="handleRun">{{ t('jobs.action.runNow') }}</NButton>
</template>
Trigger immediately
{{ t('jobs.action.triggerImmediately') }}
</NTooltip>
<NButton size="tiny" quaternary @click="emit('edit', jobId)">Edit</NButton>
<NButton size="tiny" quaternary type="error" @click="handleDelete">Delete</NButton>
<NButton size="tiny" quaternary @click="emit('edit', jobId)">{{ t('common.edit') }}</NButton>
<NButton size="tiny" quaternary type="error" @click="handleDelete">{{ t('common.delete') }}</NButton>
</div>
</div>
</template>
+35 -32
View File
@@ -2,6 +2,9 @@
import { ref, onMounted, computed } from 'vue'
import { NModal, NForm, NFormItem, NInput, NButton, NSelect, NInputNumber, useMessage } from 'naive-ui'
import { useJobsStore } from '@/stores/jobs'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
jobId: string | null
@@ -30,20 +33,20 @@ const presetValue = ref<string | null>(null)
const isEdit = computed(() => !!props.jobId)
const schedulePresets = [
{ label: 'Every minute', value: '* * * * *' },
{ label: 'Every 5 minutes', value: '*/5 * * * *' },
{ label: 'Every hour', value: '0 * * * *' },
{ label: 'Every day at 00:00', value: '0 0 * * *' },
{ label: 'Every day at 09:00', value: '0 9 * * *' },
{ label: 'Every Monday at 09:00', value: '0 9 * * 1' },
{ label: 'Every month 1st at 09:00', value: '0 9 1 * *' },
]
const schedulePresets = computed(() => [
{ label: t('jobs.presetEveryMinute'), value: '* * * * *' },
{ label: t('jobs.presetEvery5Min'), value: '*/5 * * * *' },
{ label: t('jobs.presetEveryHour'), value: '0 * * * *' },
{ label: t('jobs.presetEveryDay'), value: '0 0 * * *' },
{ label: t('jobs.presetEveryDay9'), value: '0 9 * * *' },
{ label: t('jobs.presetEveryMonday'), value: '0 9 * * 1' },
{ label: t('jobs.presetEveryMonth'), value: '0 9 1 * *' },
])
const targetOptions = [
{ label: 'Origin', value: 'origin' },
{ label: 'Local', value: 'local' },
]
const targetOptions = computed(() => [
{ label: t('jobs.origin'), value: 'origin' },
{ label: t('jobs.local'), value: 'local' },
])
onMounted(async () => {
if (props.jobId) {
@@ -58,18 +61,18 @@ onMounted(async () => {
repeat_times: typeof job.repeat === 'number' ? job.repeat : (typeof job.repeat === 'object' ? job.repeat.times : null),
}
} catch (e: any) {
message.error('Failed to load job: ' + e.message)
message.error(t('jobs.loadFailed') + ': ' + e.message)
}
}
})
async function handleSave() {
if (!formData.value.name.trim()) {
message.warning('Name is required')
message.warning(t('jobs.nameRequired'))
return
}
if (!formData.value.schedule.trim()) {
message.warning('Schedule is required')
message.warning(t('jobs.scheduleRequired'))
return
}
@@ -85,10 +88,10 @@ async function handleSave() {
if (isEdit.value) {
await jobsStore.updateJob(props.jobId!, payload)
message.success('Job updated')
message.success(t('jobs.jobUpdated'))
} else {
await jobsStore.createJob(payload)
message.success('Job created')
message.success(t('jobs.jobCreated'))
}
emit('saved')
} catch (e: any) {
@@ -108,60 +111,60 @@ function handleClose() {
<NModal
v-model:show="showModal"
preset="card"
:title="isEdit ? 'Edit Job' : 'Create Job'"
:title="isEdit ? t('jobs.editJob') : t('jobs.createJob')"
:style="{ width: '520px' }"
:mask-closable="!loading"
@after-leave="emit('close')"
>
<NForm label-placement="top">
<NFormItem label="Name" required>
<NFormItem :label="t('jobs.name')" required>
<NInput
v-model:value="formData.name"
placeholder="Job name"
:placeholder="t('jobs.namePlaceholder')"
maxlength="200"
show-count
/>
</NFormItem>
<NFormItem label="Schedule (Cron Expression)" required>
<NFormItem :label="t('jobs.schedule')" required>
<NInput
v-model:value="formData.schedule"
placeholder="e.g. 0 9 * * *"
:placeholder="t('jobs.schedulePlaceholder')"
/>
</NFormItem>
<NFormItem label="Quick Presets">
<NFormItem :label="t('jobs.quickPresets')">
<NSelect
v-model:value="presetValue"
:options="schedulePresets"
placeholder="Select a preset..."
:placeholder="t('jobs.selectPreset')"
@update:value="v => formData.schedule = v"
/>
</NFormItem>
<NFormItem label="Prompt" required>
<NFormItem :label="t('jobs.prompt')" required>
<NInput
v-model:value="formData.prompt"
type="textarea"
placeholder="The prompt to execute"
:placeholder="t('jobs.promptPlaceholder')"
:rows="4"
maxlength="5000"
show-count
/>
</NFormItem>
<NFormItem label="Deliver Target">
<NFormItem :label="t('jobs.deliverTarget')">
<NSelect
v-model:value="formData.deliver"
:options="targetOptions"
/>
</NFormItem>
<NFormItem label="Repeat Count (optional)">
<NFormItem :label="t('jobs.repeatCount')">
<NInputNumber
v-model:value="formData.repeat_times"
:min="1"
placeholder="Leave empty for infinite"
:placeholder="t('jobs.repeatPlaceholder')"
clearable
style="width: 100%"
/>
@@ -170,9 +173,9 @@ function handleClose() {
<template #footer>
<div class="modal-footer">
<NButton @click="handleClose">Cancel</NButton>
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="loading" @click="handleSave">
{{ isEdit ? 'Update' : 'Create' }}
{{ isEdit ? t('common.update') : t('common.create') }}
</NButton>
</div>
</template>
+4 -1
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import JobCard from './JobCard.vue'
import { useJobsStore } from '@/stores/jobs'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const emit = defineEmits<{
edit: [jobId: string]
@@ -17,7 +20,7 @@ const jobsStore = useJobsStore()
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<p>No scheduled jobs yet. Create one to get started.</p>
<p>{{ t('jobs.noJobs') }}</p>
</div>
<div v-else class="jobs-grid">
<JobCard
+34 -7
View File
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import ModelSelector from './ModelSelector.vue'
import LanguageSwitch from './LanguageSwitch.vue'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
@@ -31,7 +34,7 @@ function handleNav(key: string) {
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
<span>Chat</span>
<span>{{ t('sidebar.chat') }}</span>
</button>
<button
@@ -45,7 +48,7 @@ function handleNav(key: string) {
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>Jobs</span>
<span>{{ t('sidebar.jobs') }}</span>
</button>
<button
@@ -60,7 +63,18 @@ function handleNav(key: string) {
<path d="M4.22 4.22l2.83 2.83" /><path d="M16.95 16.95l2.83 2.83" />
<path d="M4.22 19.78l2.83-2.83" /><path d="M16.95 7.05l2.83-2.83" />
</svg>
<span>Models</span>
<span>{{ t('sidebar.models') }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'channels' }"
@click="handleNav('channels')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
<span>{{ t('sidebar.channels') }}</span>
</button>
<button
@@ -73,7 +87,7 @@ function handleNav(key: string) {
<polyline points="2 17 12 22 22 17" />
<polyline points="2 12 12 17 22 12" />
</svg>
<span>Skills</span>
<span>{{ t('sidebar.skills') }}</span>
</button>
<button
@@ -86,7 +100,7 @@ function handleNav(key: string) {
<path d="M10 22h4" />
<path d="M12 2a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2z" />
</svg>
<span>Memory</span>
<span>{{ t('sidebar.memory') }}</span>
</button>
<button
@@ -101,7 +115,19 @@ function handleNav(key: string) {
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<span>Logs</span>
<span>{{ t('sidebar.logs') }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'settings' }"
@click="handleNav('settings')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span>{{ t('sidebar.settings') }}</span>
</button>
</nav>
@@ -111,8 +137,9 @@ function handleNav(key: string) {
<div class="status-row">
<div class="status-indicator" :class="{ connected: appStore.connected, disconnected: !appStore.connected }">
<span class="status-dot"></span>
<span class="status-text">{{ appStore.connected ? 'Connected' : 'Disconnected' }}</span>
<span class="status-text">{{ appStore.connected ? t('sidebar.connected') : t('sidebar.disconnected') }}</span>
</div>
<LanguageSwitch />
</div>
<div class="version-info">Hermes {{ appStore.serverVersion || 'v0.1.0' }}</div>
</div>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { NSelect } from 'naive-ui'
const { locale, availableLocales } = useI18n()
const options = computed(() =>
availableLocales.map(loc => ({
label: loc === 'zh' ? '中文' : 'English',
value: loc,
})),
)
function handleChange(val: string) {
locale.value = val
localStorage.setItem('hermes_locale', val)
}
</script>
<template>
<NSelect
:value="locale"
:options="options"
size="tiny"
:consistent-menu-width="false"
style="width: 90px"
@update:value="handleChange"
/>
</template>
+11 -9
View File
@@ -3,9 +3,11 @@ import { computed } from 'vue'
import { NButton, useMessage, useDialog } from 'naive-ui'
import type { AvailableModelGroup } from '@/api/system'
import { useModelsStore } from '@/stores/models'
import { useI18n } from 'vue-i18n'
const props = defineProps<{ provider: AvailableModelGroup }>()
const { t } = useI18n()
const modelsStore = useModelsStore()
const message = useMessage()
const dialog = useDialog()
@@ -15,14 +17,14 @@ const displayName = computed(() => props.provider.label)
async function handleDelete() {
dialog.warning({
title: 'Delete Provider',
content: `Are you sure you want to delete "${displayName.value}"?`,
positiveText: 'Delete',
negativeText: 'Cancel',
title: t('models.deleteProvider'),
content: t('models.deleteConfirm', { name: displayName.value }),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
await modelsStore.removeProvider(props.provider.provider)
message.success('Provider deleted')
message.success(t('models.providerDeleted'))
} catch (e: any) {
message.error(e.message)
}
@@ -36,23 +38,23 @@ async function handleDelete() {
<div class="card-header">
<h3 class="provider-name">{{ displayName }}</h3>
<span class="type-badge" :class="isCustom ? 'custom' : 'builtin'">
{{ isCustom ? 'Custom' : 'Built-in' }}
{{ isCustom ? t('models.customType') : t('models.builtIn') }}
</span>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">Provider</span>
<span class="info-label">{{ t('models.provider') }}</span>
<code class="info-value mono">{{ provider.provider }}</code>
</div>
<div class="info-row">
<span class="info-label">Base URL</span>
<span class="info-label">{{ t('models.baseUrl') }}</span>
<code class="info-value mono">{{ provider.base_url }}</code>
</div>
</div>
<div class="card-actions">
<NButton size="tiny" quaternary type="error" @click="handleDelete">Delete</NButton>
<NButton size="tiny" quaternary type="error" @click="handleDelete">{{ t('common.delete') }}</NButton>
</div>
</div>
</template>
+31 -28
View File
@@ -3,6 +3,9 @@ import { ref, watch } from 'vue'
import { NModal, NForm, NFormItem, NInput, NButton, NSelect, useMessage } from 'naive-ui'
import { useModelsStore } from '@/stores/models'
import { PROVIDER_PRESETS } from '@/shared/providers'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const emit = defineEmits<{
close: []
@@ -27,7 +30,7 @@ const formData = ref({
const modelOptions = ref<Array<{ label: string; value: string }>>([])
const PRESET_PROVIDERS = PROVIDER_PRESETS
const PRESET_PROVIDERS = PROVIDER_PRESETS as any[]
function autoGenerateName(url: string): string {
const clean = url.replace(/^https?:\/\//, '').replace(/\/v1\/?$/, '')
@@ -45,7 +48,7 @@ watch(selectedPreset, (val) => {
if (preset) {
formData.value.name = preset.label
formData.value.base_url = preset.base_url
modelOptions.value = preset.models.map(m => ({ label: m, value: m }))
modelOptions.value = preset.models.map((m: string) => ({ label: m, value: m }))
if (preset.models.length > 0) {
formData.value.model = preset.models[0]
}
@@ -68,7 +71,7 @@ watch(providerType, () => {
async function fetchModels() {
const { base_url } = formData.value
if (!base_url.trim()) {
message.warning('Please enter Base URL first')
message.warning(t('models.enterBaseUrl'))
return
}
@@ -82,15 +85,15 @@ async function fetchModels() {
const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json() as { data?: Array<{ id: string }> }
if (!Array.isArray(data.data)) throw new Error('Unexpected response format')
if (!Array.isArray(data.data)) throw new Error(t('models.unexpectedFormat'))
modelOptions.value = data.data.map(m => ({ label: m.id, value: m.id }))
if (modelOptions.value.length > 0 && !formData.value.model) {
formData.value.model = modelOptions.value[0].value
}
message.success(`Found ${modelOptions.value.length} models`)
message.success(t('models.foundModels', { count: modelOptions.value.length }))
} catch (e: any) {
message.error('Failed to fetch models: ' + e.message)
message.error(t('models.fetchFailed') + ': ' + e.message)
} finally {
fetchingModels.value = false
}
@@ -98,19 +101,19 @@ async function fetchModels() {
async function handleSave() {
if (providerType.value === 'preset' && !selectedPreset.value) {
message.warning('Please select a provider')
message.warning(t('models.selectProviderRequired'))
return
}
if (!formData.value.base_url.trim()) {
message.warning('Base URL is required')
message.warning(t('models.baseUrlRequired'))
return
}
if (!formData.value.api_key.trim()) {
message.warning('API Key is required')
message.warning(t('models.apiKeyRequired'))
return
}
if (!formData.value.model) {
message.warning('Default Model is required')
message.warning(t('models.modelRequired'))
return
}
@@ -127,7 +130,7 @@ async function handleSave() {
model: formData.value.model,
providerKey,
})
message.success('Provider added')
message.success(t('models.providerAdded'))
emit('saved')
} catch (e: any) {
message.error(e.message)
@@ -146,72 +149,72 @@ function handleClose() {
<NModal
v-model:show="showModal"
preset="card"
title="Add Provider"
:title="t('models.addProvider')"
:style="{ width: '520px' }"
:mask-closable="!loading"
@after-leave="emit('close')"
>
<NForm label-placement="top">
<NFormItem label="Provider Type">
<NFormItem :label="t('models.providerType')">
<div style="display: flex; gap: 12px">
<NButton
:type="providerType === 'preset' ? 'primary' : 'default'"
size="small"
@click="providerType = 'preset'"
>
Preset
{{ t('models.preset') }}
</NButton>
<NButton
:type="providerType === 'custom' ? 'primary' : 'default'"
size="small"
@click="providerType = 'custom'"
>
Custom
{{ t('models.custom') }}
</NButton>
</div>
</NFormItem>
<NFormItem v-if="providerType === 'preset'" label="Select Provider" required>
<NFormItem v-if="providerType === 'preset'" :label="t('models.selectProvider')" required>
<NSelect
v-model:value="selectedPreset"
:options="PRESET_PROVIDERS"
placeholder="Choose a provider..."
:placeholder="t('models.chooseProvider')"
filterable
/>
</NFormItem>
<NFormItem v-if="providerType === 'custom'" label="Name">
<NFormItem v-if="providerType === 'custom'" :label="t('models.name')">
<NInput
v-model:value="formData.name"
placeholder="Auto-generated from Base URL"
:placeholder="t('models.autoGeneratedName')"
disabled
/>
</NFormItem>
<NFormItem label="Base URL" required>
<NFormItem :label="t('models.baseUrl')" required>
<NInput
v-model:value="formData.base_url"
placeholder="e.g. https://api.example.com/v1"
:placeholder="t('models.baseUrlPlaceholder')"
:disabled="providerType === 'preset'"
/>
</NFormItem>
<NFormItem label="API Key" required>
<NFormItem :label="t('models.apiKey')" required>
<NInput
v-model:value="formData.api_key"
type="password"
show-password-on="click"
placeholder="sk-..."
:placeholder="t('models.apiKeyPlaceholder')"
/>
</NFormItem>
<NFormItem label="Default Model" required>
<NFormItem :label="t('models.defaultModel')" required>
<div style="display: flex; gap: 8px; width: 100%">
<NSelect
v-model:value="formData.model"
:options="modelOptions"
filterable
placeholder="Select a model..."
:placeholder="t('models.selectModel')"
style="flex: 1"
/>
<NButton
@@ -219,7 +222,7 @@ function handleClose() {
:loading="fetchingModels"
@click="fetchModels"
>
Fetch
{{ t('common.fetch') }}
</NButton>
</div>
</NFormItem>
@@ -227,9 +230,9 @@ function handleClose() {
<template #footer>
<div class="modal-footer">
<NButton @click="handleClose">Cancel</NButton>
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="loading" @click="handleSave">
Add
{{ t('common.add') }}
</NButton>
</div>
</template>
+3 -1
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import ProviderCard from './ProviderCard.vue'
import { useModelsStore } from '@/stores/models'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const modelsStore = useModelsStore()
</script>
@@ -12,7 +14,7 @@ const modelsStore = useModelsStore()
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
<p>No providers found. Add a custom provider to get started.</p>
<p>{{ t('models.noProviders') }}</p>
</div>
<div v-else class="providers-grid">
<ProviderCard
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { NInputNumber, NSelect, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
async function save(values: Record<string, any>) {
try {
await settingsStore.saveSection('agent', values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.agent.maxTurns')" :hint="t('settings.agent.maxTurnsHint')">
<NInputNumber
:value="settingsStore.agent.max_turns"
:min="1" :max="200" :step="5"
size="small" style="width: 120px"
@update:value="v => v != null && save({ max_turns: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.agent.gatewayTimeout')" :hint="t('settings.agent.gatewayTimeoutHint')">
<NInputNumber
:value="settingsStore.agent.gateway_timeout"
:min="60" :max="7200" :step="60"
size="small" style="width: 120px"
@update:value="v => v != null && save({ gateway_timeout: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.agent.restartDrainTimeout')" :hint="t('settings.agent.restartDrainTimeoutHint')">
<NInputNumber
:value="settingsStore.agent.restart_drain_timeout"
:min="10" :max="300" :step="10"
size="small" style="width: 120px"
@update:value="v => v != null && save({ restart_drain_timeout: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.agent.toolEnforcement')" :hint="t('settings.agent.toolEnforcementHint')">
<NSelect
:value="settingsStore.agent.tool_use_enforcement || 'auto'"
:options="[
{ label: t('settings.agent.auto'), value: 'auto' },
{ label: t('settings.agent.always'), value: 'always' },
{ label: t('settings.agent.never'), value: 'never' },
]"
size="small" style="width: 120px"
@update:value="v => save({ tool_use_enforcement: v })"
/>
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { NSwitch, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
async function save(values: Record<string, any>) {
try {
await settingsStore.saveSection('display', values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.display.streaming')" :hint="t('settings.display.streamingHint')">
<NSwitch :value="settingsStore.display.streaming" @update:value="v => save({ streaming: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.compact')" :hint="t('settings.display.compactHint')">
<NSwitch :value="settingsStore.display.compact" @update:value="v => save({ compact: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.showReasoning')" :hint="t('settings.display.showReasoningHint')">
<NSwitch :value="settingsStore.display.show_reasoning" @update:value="v => save({ show_reasoning: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.showCost')" :hint="t('settings.display.showCostHint')">
<NSwitch :value="settingsStore.display.show_cost" @update:value="v => save({ show_cost: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.inlineDiffs')" :hint="t('settings.display.inlineDiffsHint')">
<NSwitch :value="settingsStore.display.inline_diffs" @update:value="v => save({ inline_diffs: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.bellOnComplete')" :hint="t('settings.display.bellOnCompleteHint')">
<NSwitch :value="settingsStore.display.bell_on_complete" @update:value="v => save({ bell_on_complete: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.busyInputMode')" :hint="t('settings.display.busyInputModeHint')">
<NSwitch :value="settingsStore.display.busy_input_mode === 'interrupt'" @update:value="v => save({ busy_input_mode: v ? 'interrupt' : 'off' })" />
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { NSwitch, NInputNumber, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
async function save(values: Record<string, any>) {
try {
await settingsStore.saveSection('memory', values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.memory.enabled')" :hint="t('settings.memory.enabledHint')">
<NSwitch :value="settingsStore.memory.memory_enabled" @update:value="v => save({ memory_enabled: v })" />
</SettingRow>
<SettingRow :label="t('settings.memory.userProfile')" :hint="t('settings.memory.userProfileHint')">
<NSwitch :value="settingsStore.memory.user_profile_enabled" @update:value="v => save({ user_profile_enabled: v })" />
</SettingRow>
<SettingRow :label="t('settings.memory.charLimit')" :hint="t('settings.memory.charLimitHint')">
<NInputNumber
:value="settingsStore.memory.memory_char_limit"
:min="100" :max="10000" :step="100"
size="small" style="width: 120px"
@update:value="v => v != null && save({ memory_char_limit: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.memory.userCharLimit')" :hint="t('settings.memory.userCharLimitHint')">
<NInputNumber
:value="settingsStore.memory.user_char_limit"
:min="100" :max="10000" :step="100"
size="small" style="width: 120px"
@update:value="v => v != null && save({ user_char_limit: v })"
/>
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NTag } from 'naive-ui'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
name: string
icon: string
config: Record<string, any>
credentials?: Record<string, any>
}>()
const expanded = ref(true)
const { t } = useI18n()
const configured = computed(() => {
const creds = props.credentials
if (!creds) return false
const keys = ['token', 'api_key', 'app_id', 'client_id', 'secret', 'app_secret', 'client_secret', 'access_token', 'bot_id', 'account_id', 'enabled']
// Check top-level and nested extra.*
const targets = [creds, creds.extra].filter(Boolean)
return targets.some(obj =>
keys.some(key => {
const val = (obj as Record<string, any>)[key]
return val !== undefined && val !== null && val !== '' && val !== false
})
)
})
</script>
<template>
<div class="platform-card" :class="{ configured }">
<div class="platform-card-header" @click="expanded = !expanded">
<div class="platform-info">
<span class="platform-icon" v-html="icon" />
<span class="platform-name">{{ name }}</span>
<NTag :type="configured ? 'success' : 'default'" size="small" round>
{{ configured ? t('common.configured') : t('common.notConfigured') }}
</NTag>
</div>
<span class="expand-icon" :class="{ expanded }">&#9662;</span>
</div>
<div v-if="expanded" class="platform-card-body">
<slot />
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.platform-card {
background-color: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
margin-bottom: 12px;
overflow: hidden;
&.configured {
border-color: rgba($success, 0.2);
}
}
.platform-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
cursor: pointer;
user-select: none;
&:hover {
background-color: rgba($text-primary, 0.03);
}
}
.platform-info {
display: flex;
align-items: center;
gap: 10px;
}
.platform-icon {
width: 18px;
height: 18px;
color: $text-secondary;
flex-shrink: 0;
}
.platform-name {
font-size: 14px;
font-weight: 500;
color: $text-primary;
}
.expand-icon {
font-size: 12px;
color: $text-muted;
transition: transform 0.2s;
&.expanded {
transform: rotate(0deg);
}
&:not(.expanded) {
transform: rotate(-90deg);
}
}
.platform-card-body {
padding: 0 16px 12px;
border-top: 1px solid $border-light;
}
</style>
@@ -0,0 +1,361 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { NSwitch, NInput, NButton, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import { saveCredentials as saveCredsApi, fetchWeixinQrCode, pollWeixinQrStatus, saveWeixinCredentials } from '@/api/config'
import PlatformCard from './PlatformCard.vue'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
async function saveChannel(platform: string, values: Record<string, any>) {
try {
await settingsStore.saveSection(platform, values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
// Save credentials to .env (matching hermes gateway setup behavior)
async function saveCredentials(platform: string, values: Record<string, any>) {
try {
await saveCredsApi(platform, values)
// Refresh to pick up new .env values
await settingsStore.fetchSettings()
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
function getCreds(key: string) {
return (settingsStore.platforms[key] || {}) as Record<string, any>
}
// Weixin QR code login state
const wxQrUrl = ref('')
const wxQrId = ref('')
const wxQrStatus = ref<'idle' | 'loading' | 'waiting' | 'scaned' | 'confirmed' | 'error' | 'expired'>('idle')
let wxPollTimer: ReturnType<typeof setTimeout> | null = null
async function startWeixinQrLogin() {
wxQrStatus.value = 'loading'
wxQrUrl.value = ''
wxQrId.value = ''
stopWeixinPoll()
try {
const data = await fetchWeixinQrCode()
wxQrId.value = data.qrcode
wxQrUrl.value = data.qrcode_url
window.open(data.qrcode_url, '_blank')
wxQrStatus.value = 'waiting'
pollWeixinStatus()
} catch (err: any) {
wxQrStatus.value = 'error'
message.error(err.message || 'Failed to get QR code')
}
}
function pollWeixinStatus() {
if (!wxQrId.value) return
wxPollTimer = setTimeout(async () => {
try {
const data = await pollWeixinQrStatus(wxQrId.value)
if (data.status === 'wait') {
pollWeixinStatus()
} else if (data.status === 'scaned') {
wxQrStatus.value = 'scaned'
pollWeixinStatus()
} else if (data.status === 'expired') {
wxQrStatus.value = 'expired'
} else if (data.status === 'confirmed') {
wxQrStatus.value = 'confirmed'
// Save credentials to .env
await saveWeixinCredentials({
account_id: data.account_id!,
token: data.token!,
base_url: data.base_url,
})
await settingsStore.fetchSettings()
message.success(t('settings.saved'))
}
} catch {
// Retry poll on network error
pollWeixinStatus()
}
}, 3000)
}
function stopWeixinPoll() {
if (wxPollTimer) {
clearTimeout(wxPollTimer)
wxPollTimer = null
}
}
onUnmounted(() => {
stopWeixinPoll()
})
const platforms = [
{
key: 'telegram',
name: 'Telegram',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.479.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>',
},
{
key: 'discord',
name: 'Discord',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z"/></svg>',
},
{
key: 'slack',
name: 'Slack',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 0a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V5.042zm-1.27 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.27a2.527 2.527 0 0 1 2.523-2.52h6.313A2.528 2.528 0 0 1 24 18.956a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>',
},
{
key: 'whatsapp',
name: 'WhatsApp',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>',
},
{
key: 'matrix',
name: 'Matrix',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M.632.55v22.9H2.28V24H0V0h2.28v.55zm7.043 7.26v1.157h.033c.309-.443.683-.784 1.117-1.024.433-.245.936-.365 1.5-.365.54 0 1.033.107 1.48.324.448.217.786.619 1.017 1.205.24-.376.558-.702.956-.98.398-.277.872-.414 1.424-.414.41 0 .784.065 1.122.194.34.13.629.325.87.588.241.263.428.59.56.984.132.393.198.85.198 1.368v5.89h-2.49v-4.893c0-.268-.016-.525-.048-.77a1.627 1.627 0 00-.2-.63 1.028 1.028 0 00-.392-.426 1.294 1.294 0 00-.616-.134c-.277 0-.508.05-.693.15a1.043 1.043 0 00-.43.41 1.768 1.768 0 00-.214.616 4.15 4.15 0 00-.06.74v4.937H9.29v-4.937c0-.25-.01-.498-.032-.742a1.84 1.84 0 00-.166-.638.998.998 0 00-.363-.448 1.206 1.206 0 00-.624-.154c-.26 0-.483.048-.67.144a1.055 1.055 0 00-.436.402 1.744 1.744 0 00-.227.616 4.108 4.108 0 00-.063.74v4.937H5.21V7.81zm15.693 15.64V.55H21.72V0H24v24h-2.28v-.55z"/></svg>',
},
{
key: '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>',
},
// {
// 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',
name: 'Weixin',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 00.167-.054l1.903-1.114a.864.864 0 01.717-.098 10.16 10.16 0 002.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178A1.17 1.17 0 014.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178 1.17 1.17 0 01-1.162-1.178c0-.651.52-1.18 1.162-1.18zm3.68 4.025c-3.694 0-6.69 2.462-6.69 5.496 0 3.034 2.996 5.496 6.69 5.496.753 0 1.477-.1 2.158-.28a.66.66 0 01.548.074l1.46.854a.25.25 0 00.127.041.224.224 0 00.221-.225c0-.055-.022-.109-.037-.162l-.298-1.131a.453.453 0 01.163-.509C21.81 18.613 22.77 16.973 22.77 15.512c0-3.034-2.996-5.496-6.69-5.496h.198zm-2.454 3.347c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902zm4.912 0c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902z"/></svg>',
},
{
key: 'wecom',
name: 'WeCom',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 00.167-.054l1.903-1.114a.864.864 0 01.717-.098 10.16 10.16 0 002.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178A1.17 1.17 0 014.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178 1.17 1.17 0 01-1.162-1.178c0-.651.52-1.18 1.162-1.18zm3.68 4.025c-3.694 0-6.69 2.462-6.69 5.496 0 3.034 2.996 5.496 6.69 5.496.753 0 1.477-.1 2.158-.28a.66.66 0 01.548.074l1.46.854a.25.25 0 00.127.041.224.224 0 00.221-.225c0-.055-.022-.109-.037-.162l-.298-1.131a.453.453 0 01.163-.509C21.81 18.613 22.77 16.973 22.77 15.512c0-3.034-2.996-5.496-6.69-5.496h.198zm-2.454 3.347c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902zm4.912 0c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902z"/></svg>',
},
]
</script>
<template>
<section class="settings-section">
<PlatformCard
v-for="p in platforms"
:key="p.key"
:name="p.name"
:icon="p.icon"
:config="settingsStore[p.key as keyof typeof settingsStore] as Record<string, any>"
:credentials="getCreds(p.key)"
>
<!-- Telegram -->
<template v-if="p.key === 'telegram'">
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
<NInput :value="getCreds('telegram').token || ''" clearable size="small" style="width: 300px" placeholder="123456:ABC-DEF..." @update:value="v => saveCredentials('telegram', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.telegram.require_mention" @update:value="v => saveChannel('telegram', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.reactions')" :hint="t('platform.reactionsHint')">
<NSwitch :value="settingsStore.telegram.reactions" @update:value="v => saveChannel('telegram', { reactions: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :value="settingsStore.telegram.free_response_chats || ''" size="small" placeholder="chat_id1,chat_id2" @update:value="v => saveChannel('telegram', { free_response_chats: v })" />
</SettingRow>
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
<NInput :value="(settingsStore.telegram.mention_patterns || []).join(', ')" size="small" placeholder="pattern1, pattern2" @update:value="v => saveChannel('telegram', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
</SettingRow>
</template>
<!-- Discord -->
<template v-if="p.key === 'discord'">
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
<NInput :value="getCreds('discord').token || ''" clearable size="small" style="width: 300px" placeholder="Bot token..." @update:value="v => saveCredentials('discord', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
<NSwitch :value="settingsStore.discord.require_mention" @update:value="v => saveChannel('discord', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.autoThread')" :hint="t('platform.autoThreadHint')">
<NSwitch :value="settingsStore.discord.auto_thread" @update:value="v => saveChannel('discord', { auto_thread: v })" />
</SettingRow>
<SettingRow :label="t('platform.reactions')" :hint="t('platform.reactionsHint')">
<NSwitch :value="settingsStore.discord.reactions" @update:value="v => saveChannel('discord', { reactions: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
<NInput :value="settingsStore.discord.free_response_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('discord', { free_response_channels: v })" />
</SettingRow>
<SettingRow :label="t('platform.allowedChannels')" :hint="t('platform.allowedChannelsHint')">
<NInput :value="settingsStore.discord.allowed_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('discord', { allowed_channels: v })" />
</SettingRow>
<SettingRow :label="t('platform.ignoredChannels')" :hint="t('platform.ignoredChannelsHint')">
<NInput :value="settingsStore.discord.ignored_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('discord', { ignored_channels: v })" />
</SettingRow>
<SettingRow :label="t('platform.noThreadChannels')" :hint="t('platform.noThreadChannelsHint')">
<NInput :value="settingsStore.discord.no_thread_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('discord', { no_thread_channels: v })" />
</SettingRow>
</template>
<!-- Slack -->
<template v-if="p.key === 'slack'">
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
<NInput :value="getCreds('slack').token || ''" clearable size="small" style="width: 300px" placeholder="xoxb-..." @update:value="v => saveCredentials('slack', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
<NSwitch :value="settingsStore.slack.require_mention" @update:value="v => saveChannel('slack', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.allowBots')" :hint="t('platform.allowBotsHint')">
<NSwitch :value="settingsStore.slack.allow_bots" @update:value="v => saveChannel('slack', { allow_bots: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
<NInput :value="settingsStore.slack.free_response_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('slack', { free_response_channels: v })" />
</SettingRow>
</template>
<!-- WhatsApp -->
<template v-if="p.key === 'whatsapp'">
<SettingRow :label="t('platform.waEnabled')" :hint="t('platform.waEnabledHint')">
<NSwitch :value="getCreds('whatsapp').enabled" @update:value="v => saveCredentials('whatsapp', { enabled: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.whatsapp.require_mention" @update:value="v => saveChannel('whatsapp', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :value="settingsStore.whatsapp.free_response_chats || ''" size="small" placeholder="chat_id1,chat_id2" @update:value="v => saveChannel('whatsapp', { free_response_chats: v })" />
</SettingRow>
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
<NInput :value="(settingsStore.whatsapp.mention_patterns || []).join(', ')" size="small" placeholder="pattern1, pattern2" @update:value="v => saveChannel('whatsapp', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
</SettingRow>
</template>
<!-- Matrix -->
<template v-if="p.key === 'matrix'">
<SettingRow :label="t('platform.accessToken')" :hint="t('platform.accessTokenHint')">
<NInput :value="getCreds('matrix').token || ''" clearable size="small" style="width: 300px" placeholder="syt_..." @update:value="v => saveCredentials('matrix', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.homeserver')" :hint="t('platform.homeserverHint')">
<NInput :value="getCreds('matrix').extra?.homeserver || ''" clearable size="small" style="width: 300px" placeholder="https://matrix.org" @update:value="v => saveCredentials('matrix', { extra: { ...getCreds('matrix').extra, homeserver: v } })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionRoom')">
<NSwitch :value="settingsStore.matrix.require_mention" @update:value="v => saveChannel('matrix', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.autoThread')" :hint="t('platform.autoThreadHintRoom')">
<NSwitch :value="settingsStore.matrix.auto_thread" @update:value="v => saveChannel('matrix', { auto_thread: v })" />
</SettingRow>
<SettingRow :label="t('platform.dmMentionThreads')" :hint="t('platform.dmMentionThreadsHint')">
<NSwitch :value="settingsStore.matrix.dm_mention_threads" @update:value="v => saveChannel('matrix', { dm_mention_threads: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseRooms')" :hint="t('platform.freeResponseRoomsHint')">
<NInput :value="settingsStore.matrix.free_response_rooms || ''" size="small" placeholder="room_id1,room_id2" @update:value="v => saveChannel('matrix', { free_response_rooms: v })" />
</SettingRow>
</template>
<!-- Feishu -->
<template v-if="p.key === 'feishu'">
<SettingRow :label="t('platform.appId')" :hint="t('platform.appIdHint')">
<NInput :value="getCreds('feishu').extra?.app_id || ''" clearable size="small" style="width: 300px" placeholder="cli_..." @update:value="v => saveCredentials('feishu', { extra: { ...getCreds('feishu').extra, app_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.appSecretHint')">
<NInput :value="getCreds('feishu').extra?.app_secret || ''" clearable size="small" style="width: 300px" placeholder="App Secret" @update:value="v => saveCredentials('feishu', { extra: { ...getCreds('feishu').extra, app_secret: v } })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.feishu.require_mention" @update:value="v => saveChannel('feishu', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :value="settingsStore.feishu.free_response_chats || ''" size="small" placeholder="chat_id1,chat_id2" @update:value="v => saveChannel('feishu', { free_response_chats: v })" />
</SettingRow>
</template>
<!-- DingTalk -->
<template v-if="p.key === 'dingtalk'">
<SettingRow :label="t('platform.clientId')" :hint="t('platform.clientIdHint')">
<NInput :value="getCreds('dingtalk').extra?.client_id || ''" clearable size="small" style="width: 300px" placeholder="Client ID" @update:value="v => saveCredentials('dingtalk', { extra: { ...getCreds('dingtalk').extra, client_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.clientSecret')" :hint="t('platform.clientSecretHint')">
<NInput :value="getCreds('dingtalk').extra?.client_secret || ''" clearable size="small" style="width: 300px" placeholder="Client Secret" @update:value="v => saveCredentials('dingtalk', { extra: { ...getCreds('dingtalk').extra, client_secret: v } })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.dingtalk.require_mention" @update:value="v => saveChannel('dingtalk', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :value="settingsStore.dingtalk.free_response_chats || ''" size="small" placeholder="chat_id1,chat_id2" @update:value="v => saveChannel('dingtalk', { free_response_chats: v })" />
</SettingRow>
</template>
<!-- Weixin -->
<template v-if="p.key === 'weixin'">
<div class="weixin-qr-section">
<NButton
v-if="wxQrStatus === 'idle' || wxQrStatus === 'error' || wxQrStatus === 'expired' || wxQrStatus === 'confirmed'"
type="primary"
size="small"
@click="startWeixinQrLogin"
>
{{ wxQrStatus === 'confirmed' ? t('platform.qrRelogin') : t('platform.qrLogin') }}
</NButton>
<div v-if="wxQrStatus === 'loading'" class="weixin-qr-loading">
<NSpin size="small" />
<span>{{ t('platform.qrFetching') }}</span>
</div>
<div v-if="wxQrStatus === 'waiting' || wxQrStatus === 'scaned'" class="weixin-qr-hint">
{{ wxQrStatus === 'scaned' ? t('platform.qrScanedHint') : t('platform.qrScanHint') }}
</div>
</div>
<SettingRow :label="t('platform.weixinToken')" :hint="t('platform.weixinTokenHint')">
<NInput :value="getCreds('weixin').token || ''" clearable size="small" style="width: 300px" placeholder="Token" @update:value="v => saveCredentials('weixin', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.accountId')" :hint="t('platform.accountIdHint')">
<NInput :value="getCreds('weixin').extra?.account_id || ''" clearable size="small" style="width: 300px" placeholder="Account ID" @update:value="v => saveCredentials('weixin', { extra: { ...getCreds('weixin').extra, account_id: v } })" />
</SettingRow>
</template>
<!-- WeCom -->
<template v-if="p.key === 'wecom'">
<SettingRow :label="t('platform.botId')" :hint="t('platform.botIdHint')">
<NInput :value="getCreds('wecom').extra?.bot_id || ''" clearable size="small" style="width: 300px" placeholder="Bot ID" @update:value="v => saveCredentials('wecom', { extra: { ...getCreds('wecom').extra, bot_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.wecomSecretHint')">
<NInput :value="getCreds('wecom').extra?.secret || ''" clearable size="small" style="width: 300px" placeholder="Secret" @update:value="v => saveCredentials('wecom', { extra: { ...getCreds('wecom').extra, secret: v } })" />
</SettingRow>
</template>
</PlatformCard>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
.weixin-qr-section {
margin-top: 12px;
margin-bottom: 12px;
}
.weixin-qr-loading {
display: flex;
align-items: center;
gap: 8px;
color: $text-muted;
font-size: 13px;
}
.weixin-qr-hint {
font-size: 13px;
color: $text-secondary;
}
</style>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { NSwitch, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
async function save(values: Record<string, any>) {
try {
await settingsStore.saveSection('privacy', values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.privacy.redactPii')" :hint="t('settings.privacy.redactPiiHint')">
<NSwitch :value="settingsStore.privacy.redact_pii" @update:value="v => save({ redact_pii: v })" />
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { NInputNumber, NSelect, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
async function save(values: Record<string, any>) {
try {
await settingsStore.saveSection('session_reset', values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.session.mode')" :hint="t('settings.session.modeHint')">
<NSelect
:value="settingsStore.sessionReset.mode || 'both'"
:options="[
{ label: t('settings.session.modeBoth'), value: 'both' },
{ label: t('settings.session.modeIdle'), value: 'idle' },
{ label: t('settings.session.modeHourly'), value: 'hourly' },
]"
size="small" style="width: 140px"
@update:value="v => save({ mode: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.session.idleMinutes')" :hint="t('settings.session.idleMinutesHint')">
<NInputNumber
:value="settingsStore.sessionReset.idle_minutes"
:min="10" :max="10080" :step="30"
size="small" style="width: 120px"
@update:value="v => v != null && save({ idle_minutes: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.session.atHour')" :hint="t('settings.session.atHourHint')">
<NInputNumber
:value="settingsStore.sessionReset.at_hour"
:min="0" :max="23" :step="1"
size="small" style="width: 120px"
@update:value="v => v != null && save({ at_hour: v })"
/>
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
defineProps<{
label: string
hint?: string
}>()
</script>
<template>
<div class="setting-row">
<div class="setting-info">
<label class="setting-label">{{ label }}</label>
<p v-if="hint" class="setting-hint">{{ hint }}</p>
</div>
<div class="setting-control">
<slot />
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid $border-light;
&:last-child {
border-bottom: none;
}
}
.setting-info {
flex: 1;
margin-right: 16px;
}
.setting-label {
font-size: 13px;
color: $text-primary;
display: block;
}
.setting-hint {
font-size: 12px;
color: $text-muted;
margin-top: 2px;
}
.setting-control {
flex-shrink: 0;
}
</style>
+8 -5
View File
@@ -2,6 +2,9 @@
import { ref, watch } from 'vue'
import MarkdownRenderer from '@/components/chat/MarkdownRenderer.vue'
import { fetchSkillContent, fetchSkillFiles, type SkillFileEntry } from '@/api/skills'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
category: string
@@ -30,7 +33,7 @@ async function loadSkill() {
content.value = skillContent
files.value = skillFiles.filter(f => !f.isDir && f.path !== 'SKILL.md')
} catch (err: any) {
content.value = `Failed to load skill: ${err.message}`
content.value = t('skills.loadFailed') + `: ${err.message}`
} finally {
loading.value = false
}
@@ -53,7 +56,7 @@ async function viewFile(filePath: string) {
}
fileContent.value = await fetchSkillContent(`${base}${relPath}`)
} catch (err: any) {
fileContent.value = `Failed to load file: ${err.message}`
fileContent.value = t('skills.fileLoadFailed') + `: ${err.message}`
} finally {
fileLoading.value = false
}
@@ -76,7 +79,7 @@ watch(() => `${props.category}/${props.skill}`, loadSkill, { immediate: true })
<span class="detail-name">{{ skill }}</span>
</div>
<div v-if="loading && !content" class="detail-loading">Loading...</div>
<div v-if="loading && !content" class="detail-loading">{{ t('common.loading') }}</div>
<template v-else>
<!-- Breadcrumb for file view -->
@@ -85,7 +88,7 @@ watch(() => `${props.category}/${props.skill}`, loadSkill, { immediate: true })
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to {{ skill }}
{{ t('skills.backTo') }} {{ skill }}
</button>
<span class="breadcrumb-path">{{ viewingFile }}</span>
</div>
@@ -98,7 +101,7 @@ watch(() => `${props.category}/${props.skill}`, loadSkill, { immediate: true })
<!-- Attached files -->
<div v-if="!viewingFile && files.length > 0" class="detail-files">
<div class="files-header">Attached Files</div>
<div class="files-header">{{ t('skills.attachedFiles') }}</div>
<div class="files-list">
<button
v-for="f in files"
+4 -1
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { SkillCategory } from '@/api/skills'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
categories: SkillCategory[]
@@ -43,7 +46,7 @@ function handleSelect(category: string, skill: string) {
<template>
<div class="skill-list">
<div v-if="filteredCategories.length === 0" class="skill-empty">
{{ searchQuery ? 'No skills match your search' : 'No skills found' }}
{{ searchQuery ? t('skills.noMatch') : t('skills.noSkills') }}
</div>
<div
v-for="cat in filteredCategories"