diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7779df6..b690c95 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - main + - base jobs: build: diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index f126564..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,87 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [0.5.0] - 2025-04-29 - -### Added - -#### Multi-Profile Support -- **Profile-based usage tracking**: Added `profile` field to `session_usage` table for filtering statistics by profile -- **Profile-aware session management**: All sessions now track their originating profile (default, hermes, custom) -- **Group chat agent profiles**: Each agent can run with its own Hermes profile configuration -- **Cross-profile usage aggregation**: Usage stats page correctly filters by active profile - -#### Group Chat Enhancements -- **Context compression with multi-profile**: Group chat compression now uses agent's own profile -- **Usage tracking for compression**: Token usage from context compression runs is recorded with room ID -- **Session profile mapping**: New `gc_session_profiles` table tracks ephemeral session to profile relationships - -#### Single Chat Improvements -- **Ephemeral session cleanup**: Automatic deletion of temporary Hermes sessions after sync -- **User message persistence**: User messages are now properly saved to local database -- **Usage synchronization**: Token usage from Hermes sessions correctly syncs to local usage store - -### Fixed - -#### Token Estimation -- **Fixed overestimation**: Removed `senderName` from token calculation to avoid inflated estimates -- **Configurable estimation**: Token estimation now uses `charsPerToken` config instead of hardcoded value -- **Adjusted compression trigger**: Increased `charsPerToken` from 4 to 6 for more conservative estimation - - This prevents premature compression triggering in group chats - - Better matches actual LLM tokenization (~6-8 chars/token for English) - -#### WSL Compatibility -- **Auto-detect WSL environment**: Database path automatically uses WSL local filesystem when detected -- **Improved SQLite settings**: Changed to WAL mode with `synchronous=NORMAL` and `busy_timeout=5000` - - Fixes cross-filesystem write failures in WSL2 environments - - Better concurrency and reliability - -#### Database Schema -- **Unified table initialization**: Created `initAllStores()` for consistent table creation across all stores -- **Session usage schema**: Added `id` PRIMARY KEY AUTOINCREMENT for better query performance -- **Production environment**: Set `NODE_ENV=production` in production start scripts for correct database path - -#### Logging -- **Enhanced error logging**: Improved error messages in `syncFromHermes` with detailed context -- **Database path logging**: Added explicit logging of Hermes state.db path for debugging - -### Changed - -- **Default compression trigger**: Group chat rooms now default to 100,000 tokens (was 10,000) -- **Database location**: In WSL, database always uses `~/.hermes-web-ui/` to avoid cross-filesystem issues - -### Technical Details - -#### Database Tables -- `sessions`: Added `profile` field -- `session_usage`: Added `profile` field and `id` PRIMARY KEY -- `gc_pending_session_deletes`: Tracks profile-specific session cleanup -- `gc_session_profiles`: Maps ephemeral sessions to profiles and rooms - -#### Code Organization -- Created `packages/server/src/db/hermes/init.ts`: Unified store initialization -- Updated `packages/server/src/db/index.ts`: WSL detection and improved SQLite settings -- Refactored `packages/server/src/services/hermes/context-engine/`: Better token estimation - ---- - -## [0.4.x] - Previous Releases - -### Features -- Real-time streaming chat via SSE -- Multi-session management -- Platform channel integration (Telegram, Discord, Slack, WhatsApp) -- Usage statistics and cost tracking -- Scheduled jobs management -- Skills browsing and memory management -- Integrated terminal with node-pty - -### Technical Stack -- **Frontend**: Vue 3, Naive UI, Pinia, SCSS -- **Backend**: Koa 2, @koa/router, node-pty -- **Database**: SQLite (node:sqlite) -- **Language**: TypeScript (strict mode) diff --git a/packages/client/src/api/hermes/profiles.ts b/packages/client/src/api/hermes/profiles.ts index bacc343..e9fa7f9 100644 --- a/packages/client/src/api/hermes/profiles.ts +++ b/packages/client/src/api/hermes/profiles.ts @@ -39,13 +39,14 @@ export interface CreateProfileResult { strippedConfigCredentials?: string[] } -export async function createProfile(name: string, clone?: boolean): Promise { +export async function createProfile(name: string, clone?: boolean): Promise { try { const res = await request<{ success: boolean strippedCredentials?: string[] disabledPlatforms?: string[] strippedConfigCredentials?: string[] + error?: string }>('/api/hermes/profiles', { method: 'POST', body: JSON.stringify({ name, clone }), @@ -55,9 +56,10 @@ export async function createProfile(name: string, clone?: boolean): Promise -import { renameSession, setSessionWorkspace } from '@/api/hermes/sessions' -import { useChatStore, type Session } from '@/stores/hermes/chat' -import { useSessionBrowserPrefsStore } from '@/stores/hermes/session-browser-prefs' -import { NButton, NDropdown, NInput, NModal, NTooltip, useMessage } from 'naive-ui' -import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' -import { useI18n } from 'vue-i18n' -import { getSourceLabel } from '@/shared/session-display' -import { copyToClipboard } from '@/utils/clipboard' -import FolderPicker from './FolderPicker.vue' -import ChatInput from './ChatInput.vue' -import ConversationMonitorPane from './ConversationMonitorPane.vue' -import MessageList from './MessageList.vue' -import SessionListItem from './SessionListItem.vue' +import { renameSession, setSessionWorkspace } from "@/api/hermes/sessions"; +import { useChatStore, type Session } from "@/stores/hermes/chat"; +import { useSessionBrowserPrefsStore } from "@/stores/hermes/session-browser-prefs"; +import { + NButton, + NDropdown, + NInput, + NModal, + NTooltip, + useMessage, +} from "naive-ui"; +import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue"; +import { useI18n } from "vue-i18n"; +import { getSourceLabel } from "@/shared/session-display"; +import { copyToClipboard } from "@/utils/clipboard"; +import FolderPicker from "./FolderPicker.vue"; +import ChatInput from "./ChatInput.vue"; +import ConversationMonitorPane from "./ConversationMonitorPane.vue"; +import MessageList from "./MessageList.vue"; +import SessionListItem from "./SessionListItem.vue"; +import DrawerPanel from "./DrawerPanel.vue"; -const chatStore = useChatStore() -const sessionBrowserPrefsStore = useSessionBrowserPrefsStore() -const message = useMessage() -const { t } = useI18n() +const chatStore = useChatStore(); +const sessionBrowserPrefsStore = useSessionBrowserPrefsStore(); +const message = useMessage(); +const { t } = useI18n(); -const currentMode = ref<'chat' | 'live'>('chat') +const showDrawer = ref(false); +const drawerActiveTab = ref<"terminal" | "files">("files"); + +const currentMode = ref<"chat" | "live">("chat"); // Initialize synchronously from the media query so first paint is correct. // On narrow viewports the session list is an absolute-positioned overlay @@ -27,275 +38,363 @@ const currentMode = ref<'chat' | 'live'>('chat') // where the session list covers the chat content ("auto-fixes after a // moment" — that was the race). const showSessions = ref( - typeof window === 'undefined' || !window.matchMedia('(max-width: 768px)').matches, -) -let mobileQuery: MediaQueryList | null = null -const isMobile = ref(false) + typeof window === "undefined" || + !window.matchMedia("(max-width: 768px)").matches, +); +let mobileQuery: MediaQueryList | null = null; +const isMobile = ref(false); function handleSessionClick(sessionId: string) { - chatStore.switchSession(sessionId) - if (mobileQuery?.matches) showSessions.value = false + chatStore.switchSession(sessionId); + if (mobileQuery?.matches) showSessions.value = false; } function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) { - isMobile.value = e.matches + isMobile.value = e.matches; if (e.matches && showSessions.value) { - showSessions.value = false + showSessions.value = false; } } onMounted(() => { - mobileQuery = window.matchMedia('(max-width: 768px)') - handleMobileChange(mobileQuery) - mobileQuery.addEventListener('change', handleMobileChange) -}) + mobileQuery = window.matchMedia("(max-width: 768px)"); + handleMobileChange(mobileQuery); + mobileQuery.addEventListener("change", handleMobileChange); +}); onUnmounted(() => { - mobileQuery?.removeEventListener('change', handleMobileChange) -}) -const showRenameModal = ref(false) -const renameValue = ref('') -const renameSessionId = ref(null) -const renameInputRef = ref | null>(null) -const collapsedGroups = ref>(new Set(JSON.parse(localStorage.getItem('hermes_collapsed_groups') || '[]'))) + mobileQuery?.removeEventListener("change", handleMobileChange); +}); +const showRenameModal = ref(false); +const renameValue = ref(""); +const renameSessionId = ref(null); +const renameInputRef = ref | null>(null); +const collapsedGroups = ref>( + new Set(JSON.parse(localStorage.getItem("hermes_collapsed_groups") || "[]")), +); // Source sort order: api_server first, cron last, others alphabetical function sourceSortKey(source: string): number { - if (source === 'api_server') return -1 - if (source === 'cron') return 999 - return 0 + if (source === "api_server") return -1; + if (source === "cron") return 999; + return 0; } function sortSessionsWithActiveFirst(items: Session[]): Session[] { return [...items].sort((a, b) => { - return (b.updatedAt || 0) - (a.updatedAt || 0) - }) + return (b.updatedAt || 0) - (a.updatedAt || 0); + }); } // Group sessions by source, with sort order interface SessionGroup { - source: string - label: string - sessions: Session[] + source: string; + label: string; + sessions: Session[]; } const pinnedSessions = computed(() => - sortSessionsWithActiveFirst(chatStore.sessions.filter(session => sessionBrowserPrefsStore.isPinned(session.id))), -) + sortSessionsWithActiveFirst( + chatStore.sessions.filter((session) => + sessionBrowserPrefsStore.isPinned(session.id), + ), + ), +); const groupedSessions = computed(() => { - const map = new Map() + const map = new Map(); for (const s of chatStore.sessions) { - if (sessionBrowserPrefsStore.isPinned(s.id)) continue - const key = s.source || '' - if (!map.has(key)) map.set(key, []) - map.get(key)!.push(s) + if (sessionBrowserPrefsStore.isPinned(s.id)) continue; + const key = s.source || ""; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(s); } const keys = [...map.keys()].sort((a, b) => { - const ka = sourceSortKey(a) - const kb = sourceSortKey(b) - if (ka !== kb) return ka - kb - return a.localeCompare(b) - }) + const ka = sourceSortKey(a); + const kb = sourceSortKey(b); + if (ka !== kb) return ka - kb; + return a.localeCompare(b); + }); - return keys.map(key => ({ + return keys.map((key) => ({ source: key, - label: key ? getSourceLabel(key) : t('chat.other'), + label: key ? getSourceLabel(key) : t("chat.other"), sessions: sortSessionsWithActiveFirst(map.get(key)!), - })) -}) + })); +}); function toggleGroup(source: string) { - const isExpanded = !collapsedGroups.value.has(source) + const isExpanded = !collapsedGroups.value.has(source); if (isExpanded) { - collapsedGroups.value = new Set([...collapsedGroups.value, source]) + collapsedGroups.value = new Set([...collapsedGroups.value, source]); } else { collapsedGroups.value = new Set( - groupedSessions.value.map(g => g.source).filter(s => s !== source), - ) - const group = groupedSessions.value.find(g => g.source === source) + groupedSessions.value.map((g) => g.source).filter((s) => s !== source), + ); + const group = groupedSessions.value.find((g) => g.source === source); if (group?.sessions.length) { - chatStore.switchSession(group.sessions[0].id) + chatStore.switchSession(group.sessions[0].id); } } - localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value])) + localStorage.setItem( + "hermes_collapsed_groups", + JSON.stringify([...collapsedGroups.value]), + ); } -watch(groupedSessions, groups => { - if (localStorage.getItem('hermes_collapsed_groups') !== null) { - const activeSource = chatStore.activeSession?.source - if (activeSource && collapsedGroups.value.has(activeSource)) { - collapsedGroups.value = new Set([...collapsedGroups.value].filter(source => source !== activeSource)) - localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value])) +watch( + groupedSessions, + (groups) => { + if (localStorage.getItem("hermes_collapsed_groups") !== null) { + const activeSource = chatStore.activeSession?.source; + if (activeSource && collapsedGroups.value.has(activeSource)) { + collapsedGroups.value = new Set( + [...collapsedGroups.value].filter( + (source) => source !== activeSource, + ), + ); + localStorage.setItem( + "hermes_collapsed_groups", + JSON.stringify([...collapsedGroups.value]), + ); + } + return; } - return - } - collapsedGroups.value = new Set(groups.slice(1).map(group => group.source)) - localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value])) -}, { once: true }) + collapsedGroups.value = new Set( + groups.slice(1).map((group) => group.source), + ); + localStorage.setItem( + "hermes_collapsed_groups", + JSON.stringify([...collapsedGroups.value]), + ); + }, + { once: true }, +); watch( - () => [chatStore.sessionsLoaded, ...chatStore.sessions.map(session => session.id)], - value => { - const sessionIds = value.slice(1) as string[] - if (!value[0] || sessionIds.length === 0) return - sessionBrowserPrefsStore.pruneMissingSessions(sessionIds) + () => [ + chatStore.sessionsLoaded, + ...chatStore.sessions.map((session) => session.id), + ], + (value) => { + const sessionIds = value.slice(1) as string[]; + if (!value[0] || sessionIds.length === 0) return; + sessionBrowserPrefsStore.pruneMissingSessions(sessionIds); }, { immediate: true }, -) +); -const activeSessionTitle = computed(() => - chatStore.activeSession?.title || t('chat.newChat'), -) +const activeSessionTitle = computed( + () => chatStore.activeSession?.title || t("chat.newChat"), +); const headerTitle = computed(() => - currentMode.value === 'live' ? t('chat.liveSessions') : activeSessionTitle.value, -) + currentMode.value === "live" + ? t("chat.liveSessions") + : activeSessionTitle.value, +); const activeSessionSource = computed(() => - currentMode.value === 'chat' ? (chatStore.activeSession?.source || '') : '', -) + currentMode.value === "chat" ? chatStore.activeSession?.source || "" : "", +); function handleNewChat() { - chatStore.newChat() + chatStore.newChat(); } async function copySessionId(id?: string) { - const sessionId = id || chatStore.activeSessionId + const sessionId = id || chatStore.activeSessionId; if (sessionId) { - const ok = await copyToClipboard(sessionId) - if (ok) message.success(t('common.copied')) - else message.error(t('common.copied') + ' ✗') + const ok = await copyToClipboard(sessionId); + if (ok) message.success(t("common.copied")); + else message.error(t("common.copied") + " ✗"); } } function handleDeleteSession(id: string) { - sessionBrowserPrefsStore.removePinned(id) - chatStore.deleteSession(id) - message.success(t('chat.sessionDeleted')) + sessionBrowserPrefsStore.removePinned(id); + chatStore.deleteSession(id); + message.success(t("chat.sessionDeleted")); } -const contextSessionId = ref(null) +const contextSessionId = ref(null); const contextSessionPinned = computed(() => - contextSessionId.value ? sessionBrowserPrefsStore.isPinned(contextSessionId.value) : false, -) + contextSessionId.value + ? sessionBrowserPrefsStore.isPinned(contextSessionId.value) + : false, +); const contextMenuOptions = computed(() => [ - { label: t(contextSessionPinned.value ? 'chat.unpin' : 'chat.pin'), key: 'pin' }, - { label: t('chat.rename'), key: 'rename' }, - { label: t('chat.setWorkspace'), key: 'workspace' }, - { label: t('chat.copySessionId'), key: 'copy-id' }, -]) + { + label: t(contextSessionPinned.value ? "chat.unpin" : "chat.pin"), + key: "pin", + }, + { label: t("chat.rename"), key: "rename" }, + { label: t("chat.setWorkspace"), key: "workspace" }, + { label: t("chat.copySessionId"), key: "copy-id" }, +]); function handleContextMenu(e: MouseEvent, sessionId: string) { - e.preventDefault() - contextSessionId.value = sessionId - showContextMenu.value = true - contextMenuX.value = e.clientX - contextMenuY.value = e.clientY + e.preventDefault(); + contextSessionId.value = sessionId; + showContextMenu.value = true; + contextMenuX.value = e.clientX; + contextMenuY.value = e.clientY; } -const showContextMenu = ref(false) -const contextMenuX = ref(0) -const contextMenuY = ref(0) +const showContextMenu = ref(false); +const contextMenuX = ref(0); +const contextMenuY = ref(0); function handleContextMenuSelect(key: string) { - showContextMenu.value = false - if (!contextSessionId.value) return - if (key === 'pin') { - sessionBrowserPrefsStore.togglePinned(contextSessionId.value) - return + showContextMenu.value = false; + if (!contextSessionId.value) return; + if (key === "pin") { + sessionBrowserPrefsStore.togglePinned(contextSessionId.value); + return; } - if (key === 'copy-id') { - copySessionId(contextSessionId.value) - } else if (key === 'workspace') { - const session = chatStore.sessions.find(s => s.id === contextSessionId.value) - workspaceSessionId.value = contextSessionId.value - workspaceValue.value = session?.workspace || '' - showWorkspaceModal.value = true - } else if (key === 'rename') { - const session = chatStore.sessions.find(s => s.id === contextSessionId.value) - renameSessionId.value = contextSessionId.value - renameValue.value = session?.title || '' - showRenameModal.value = true + if (key === "copy-id") { + copySessionId(contextSessionId.value); + } else if (key === "workspace") { + const session = chatStore.sessions.find( + (s) => s.id === contextSessionId.value, + ); + workspaceSessionId.value = contextSessionId.value; + workspaceValue.value = session?.workspace || ""; + showWorkspaceModal.value = true; + } else if (key === "rename") { + const session = chatStore.sessions.find( + (s) => s.id === contextSessionId.value, + ); + renameSessionId.value = contextSessionId.value; + renameValue.value = session?.title || ""; + showRenameModal.value = true; nextTick(() => { - renameInputRef.value?.focus() - }) + renameInputRef.value?.focus(); + }); } } function handleClickOutside() { - showContextMenu.value = false + showContextMenu.value = false; } async function handleRenameConfirm() { - if (!renameSessionId.value || !renameValue.value.trim()) return - const ok = await renameSession(renameSessionId.value, renameValue.value.trim()) + if (!renameSessionId.value || !renameValue.value.trim()) return; + const ok = await renameSession( + renameSessionId.value, + renameValue.value.trim(), + ); if (ok) { - const session = chatStore.sessions.find(s => s.id === renameSessionId.value) - if (session) session.title = renameValue.value.trim() + const session = chatStore.sessions.find( + (s) => s.id === renameSessionId.value, + ); + if (session) session.title = renameValue.value.trim(); if (chatStore.activeSession?.id === renameSessionId.value) { - chatStore.activeSession.title = renameValue.value.trim() + chatStore.activeSession.title = renameValue.value.trim(); } - message.success(t('chat.renamed')) + message.success(t("chat.renamed")); } else { - message.error(t('chat.renameFailed')) + message.error(t("chat.renameFailed")); } - showRenameModal.value = false + showRenameModal.value = false; } -const showWorkspaceModal = ref(false) -const workspaceValue = ref('') -const workspaceSessionId = ref(null) +const showWorkspaceModal = ref(false); +const workspaceValue = ref(""); +const workspaceSessionId = ref(null); async function handleWorkspaceConfirm() { - if (!workspaceSessionId.value) return - const ok = await setSessionWorkspace(workspaceSessionId.value, workspaceValue.value || null) + if (!workspaceSessionId.value) return; + const ok = await setSessionWorkspace( + workspaceSessionId.value, + workspaceValue.value || null, + ); if (ok) { - const session = chatStore.sessions.find(s => s.id === workspaceSessionId.value) - if (session) session.workspace = workspaceValue.value || null + const session = chatStore.sessions.find( + (s) => s.id === workspaceSessionId.value, + ); + if (session) session.workspace = workspaceValue.value || null; if (chatStore.activeSession?.id === workspaceSessionId.value) { - chatStore.activeSession.workspace = workspaceValue.value || null + chatStore.activeSession.workspace = workspaceValue.value || null; } - message.success(t('chat.workspaceSet')) + message.success(t("chat.workspaceSet")); } else { - message.error(t('chat.workspaceSetFailed')) + message.error(t("chat.workspaceSetFailed")); } - showWorkspaceModal.value = false + showWorkspaceModal.value = false; } diff --git a/packages/client/src/components/hermes/chat/DrawerPanel.vue b/packages/client/src/components/hermes/chat/DrawerPanel.vue new file mode 100644 index 0000000..7ef025d --- /dev/null +++ b/packages/client/src/components/hermes/chat/DrawerPanel.vue @@ -0,0 +1,184 @@ + + + + + + + diff --git a/packages/client/src/components/hermes/chat/FilesPanel.vue b/packages/client/src/components/hermes/chat/FilesPanel.vue new file mode 100644 index 0000000..b5fecc8 --- /dev/null +++ b/packages/client/src/components/hermes/chat/FilesPanel.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/packages/client/src/components/hermes/chat/TerminalPanel.vue b/packages/client/src/components/hermes/chat/TerminalPanel.vue new file mode 100644 index 0000000..833e51a --- /dev/null +++ b/packages/client/src/components/hermes/chat/TerminalPanel.vue @@ -0,0 +1,788 @@ + + + + + diff --git a/packages/client/src/components/hermes/files/FileEditor.vue b/packages/client/src/components/hermes/files/FileEditor.vue index d6fe73c..347076f 100644 --- a/packages/client/src/components/hermes/files/FileEditor.vue +++ b/packages/client/src/components/hermes/files/FileEditor.vue @@ -126,6 +126,12 @@ function handleClose() { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + max-width: 300px; + + @media (max-width: $breakpoint-mobile) { + max-width: 120px; + font-size: 12px; + } } .editor-container { diff --git a/packages/client/src/components/hermes/files/FileToolbar.vue b/packages/client/src/components/hermes/files/FileToolbar.vue index 394706d..4499381 100644 --- a/packages/client/src/components/hermes/files/FileToolbar.vue +++ b/packages/client/src/components/hermes/files/FileToolbar.vue @@ -24,17 +24,17 @@ async function handleRefresh() { diff --git a/packages/client/src/components/hermes/profiles/ProfileCard.vue b/packages/client/src/components/hermes/profiles/ProfileCard.vue index d314f5a..a3d8395 100644 --- a/packages/client/src/components/hermes/profiles/ProfileCard.vue +++ b/packages/client/src/components/hermes/profiles/ProfileCard.vue @@ -40,7 +40,9 @@ async function handleSwitch() { try { const ok = await profilesStore.switchProfile(props.profile.name) if (ok) { - window.location.reload() + message.success(t('profiles.switchSuccess', { name: props.profile.name })) + // Reload to refresh all profile-dependent data + setTimeout(() => window.location.reload(), 500) } else { message.error(t('profiles.switchFailed')) } diff --git a/packages/client/src/components/hermes/profiles/ProfileCreateModal.vue b/packages/client/src/components/hermes/profiles/ProfileCreateModal.vue index 6c01bba..7b4a14b 100644 --- a/packages/client/src/components/hermes/profiles/ProfileCreateModal.vue +++ b/packages/client/src/components/hermes/profiles/ProfileCreateModal.vue @@ -17,13 +17,30 @@ const showModal = ref(true) const loading = ref(false) const name = ref('') const clone = ref(false) +const nameValidationMessage = ref('') + +function handleNameInput(value: string) { + // 过滤掉不符合规则的字符,只保留小写字母、数字、下划线和连字符 + const filtered = value.toLowerCase().replace(/[^a-z0-9_-]/g, '') + if (filtered !== value) { + nameValidationMessage.value = t('profiles.nameValidation') + } else { + nameValidationMessage.value = '' + } + name.value = filtered +} async function handleSave() { - if (!name.value.trim()) { + if (!name.value) { message.warning(t('profiles.namePlaceholder')) return } + if (!/^[a-z0-9_-]+$/.test(name.value)) { + message.error(t('profiles.nameValidation')) + return + } + loading.value = true try { const res = await profilesStore.createProfile(name.value.trim(), clone.value) @@ -42,7 +59,8 @@ async function handleSave() { } emit('saved') } else { - message.error(t('profiles.createFailed')) + const errorMsg = res.error || t('profiles.createFailed') + message.error(errorMsg) } } finally { loading.value = false @@ -67,12 +85,14 @@ function handleClose() { + + {{ nameValidationMessage }} + diff --git a/packages/client/src/components/hermes/profiles/ProfileRenameModal.vue b/packages/client/src/components/hermes/profiles/ProfileRenameModal.vue index f66094f..a2da73a 100644 --- a/packages/client/src/components/hermes/profiles/ProfileRenameModal.vue +++ b/packages/client/src/components/hermes/profiles/ProfileRenameModal.vue @@ -1,6 +1,6 @@