Release v0.5.8 (#422)
* fix: add missing i18n key and unify session data source (#408) - Add `chat.sessionNotFound` translation key to all 8 locales - Fix history page data source inconsistency: - Change `getHermesSession` to prioritize database over CLI - Now consistent with `listHermesSessions` behavior - Prevents "session in list but detail not found" issue - Update CI workflow to trigger on base branch PRs - Remove debug log from sessions-db Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: filter special characters and emoji in speech playback (#409) - Update extractReadableText to filter special characters like *# - Only keep common punctuation marks for speech synthesis - Remove emoji, symbols, and special unicode characters - Improve text-to-speech readability Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add drawer panel with mobile sidebar support and customizable button (#412) * feat: add drawer panel with mobile sidebar support - Add DrawerPanel component with Terminal and Files tabs - Extract TerminalPanel and FilesPanel from existing views - Add mobile sidebar toggle functionality with overlay - Add rainbow breathing light effect to drawer button - Remove Tools section from AppSidebar (Terminal/Files entries) - Add i18n support for drawer and file tree - Optimize mobile button layout and spacing - Fix z-index hierarchy for proper layering - Add responsive sidebar behavior (PC: always visible, Mobile: toggle) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: customize drawer button with arc rainbow border - Change drawer button to semi-circle shape贴着右边 - Add arrow icon pointing left (向左箭头) - Add rainbow border from top to bottom through semi-circle arc - Slow down animation from 4s to 8s for smoother effect - Move drawer button wrapper to messages area only (not贯穿header和input) - Add semi-transparent accent color background to button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve profile switching state sync issue (#414) (#415) * fix: resolve profile switching state sync issue (#414) Fix bug where switching to a different profile would still show the old profile name in the UI and prevent switching back to default. Root cause: - Frontend relied entirely on fetchProfiles() return value to set activeProfileName - Backend Hermes CLI may return stale active flag due to timing issues between profile use and profile list commands - This caused frontend to display wrong profile and prevented switching back to default Solution: - Immediately set activeProfileName when switchProfile API succeeds - Don't rely solely on listProfiles() result which may have stale data - Use activeProfileName instead of activeProfile?.name in ProfileSelector Changes: - profiles store: Set activeProfileName immediately after successful switch - ProfileSelector: Use activeProfileName computed property - Add test to verify activeProfileName updates on switch Fixes #414 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refine: improve error handling for profile switching failures Add proper error handling for edge cases: - If fetchProfiles() fails after successful switch, keep the updated activeProfileName (don't let fetchProfiles failure undo the switch) - Add test cases to verify: 1. API failure doesn't change state 2. fetchProfiles failure doesn't affect successful switch This ensures the UI remains consistent even when profile list refresh fails after a successful profile switch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refine: add rollback mechanism for profile switching verification Add backend verification after profile switch: - Save old activeProfileName before setting new value - After fetchProfiles, verify backend reports expected active profile - If backend reports different profile, rollback frontend state and return false - This handles edge case where API returns 200 but backend didn't actually switch Test cases: - ✅ Normal switch: updates and verifies successfully - ✅ API failure: doesn't change state - ✅ fetchProfiles failure: assumes success (API returned 200) - ✅ Backend verification fails: rolls back to old profile This ensures frontend state always matches backend reality, even in edge cases where hermes profile use succeeded but gateway/cleanup steps failed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refine: add user feedback for profile operations Improve user experience with success/error messages: - ProfileSelector: Add error message when switch fails - ProfileCard: Add success message before reload on switch - ProfileSelector: Use async/await for better error handling - ProfileCard: Add 500ms delay before reload to show success message Before: Silent failures, no feedback After: Clear success/error messages for all operations Example feedback: - Success: "已切换到配置 qinghe" - Failure: "切换配置失败,网关可能需要手动重启" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: update frontend changelog for v0.5.7 (#419) * docs: update frontend changelog for v0.5.7 - Update changelog.ts with v0.5.7 release date and changes - Add i18n translation keys for all languages (en, zh, de, es, fr, ja, ko, pt) - Include v0.5.7 changelog entries: - Optimize context compression and session sync - Add startup delays to prevent database race conditions Changes: - packages/client/src/data/changelog.ts: Update v0.5.7 entry - packages/client/src/i18n/locales/*.ts: Add changelog translation section This enables the changelog modal in the UI to display v0.5.7 release notes. * feat: add v0.5.7 changelog translations to all supported languages Add new_0_5_7_1, new_0_5_7_2, and new_0_5_7_3 changelog entries to all locale files (en, zh, de, es, fr, ja, ko, pt) with proper translations for each language. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove duplicate changelog sections causing syntax errors Remove duplicate changelog object sections that were causing TypeScript syntax errors in all locale files (en, zh, de, es, fr, ja, ko, pt). The actual changelog entries are already correctly placed in the main changelog section of each file. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add v0.5.8 changelog and fix profile parsing issue Add v0.5.8 changelog entries based on PRs merged since v0.5.7: - Drawer panel with mobile sidebar support (#412) - Profile switching state sync fix (#414) - Speech playback special character filtering (#409) - Missing i18n key and session data source unification (#408) - Vite build optimization for faster Docker builds (#403) Also fix issue #417: Profile names with long hyphenated names fail to parse in profile list regex. Change \s{2,} to \s+ to handle compressed column spacing when profile names are long. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove enter key submit from profile creation and rename modals Remove @keyup.enter handlers from NInput components in: - ProfileCreateModal: prevent accidental profile creation when pressing enter - ProfileRenameModal: prevent accidental profile rename when pressing enter Users must now explicitly click the confirm button to submit, preventing unintended profile operations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: allow free text input for profile names Remove frontend character filtering from profile creation and rename modals. Users can now input any characters including spaces and uppercase letters to test backend Hermes CLI validation. Changes: - ProfileCreateModal: Remove toLowerCase() and character filtering - ProfileRenameModal: Remove toLowerCase() and character filtering - Use v-model:value binding instead of :value with @input Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: improve error handling for profile creation Display backend error messages when profile creation fails instead of generic "failed" message. This helps users understand why their profile name was rejected (e.g., invalid characters). Changes: - API layer: Capture and return error messages from backend - ProfileCreateModal: Display specific error message from backend Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add profile name validation with i18n support Add client-side validation for profile names to prevent invalid input before sending to backend. Only lowercase letters, numbers, underscores, and hyphens are allowed. Changes: - ProfileCreateModal: Add input validation with real-time feedback - ProfileRenameModal: Add input validation with real-time feedback - Add nameValidation i18n key for all 8 languages - Filter invalid characters on input and show warning message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: revert profile parsing regex changes Revert the regex changes in hermes-cli.ts and gateway-manager.ts back to requiring \s{2,} (at least 2 spaces). Since frontend now validates profile names to only allow lowercase letters, numbers, underscores, and hyphens, the relaxed regex is no longer needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: revert profile parsing regex changes Revert the regex changes in gateway-manager.ts and hermes-cli.ts back to requiring \s{2,} (at least 2 spaces). Since frontend now validates profile names to only allow lowercase letters, numbers, underscores, and hyphens, the relaxed regex is no longer needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: remove tooltip from drawer button Remove the NTooltip wrapper from the floating drawer button. The "Terminal & Files" tooltip is no longer shown on hover. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * Update assets images (#421) Updated two asset images in the client package. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- base
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -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)
|
||||
@@ -39,13 +39,14 @@ export interface CreateProfileResult {
|
||||
strippedConfigCredentials?: string[]
|
||||
}
|
||||
|
||||
export async function createProfile(name: string, clone?: boolean): Promise<CreateProfileResult> {
|
||||
export async function createProfile(name: string, clone?: boolean): Promise<CreateProfileResult & { error?: string }> {
|
||||
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<Crea
|
||||
strippedCredentials: res.strippedCredentials,
|
||||
disabledPlatforms: res.disabledPlatforms,
|
||||
strippedConfigCredentials: res.strippedConfigCredentials,
|
||||
error: res.error,
|
||||
}
|
||||
} catch {
|
||||
return { success: false }
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message || 'Unknown error' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 131 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 305 KiB |
@@ -1,24 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
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<string | null>(null)
|
||||
const renameInputRef = ref<InstanceType<typeof NInput> | null>(null)
|
||||
const collapsedGroups = ref<Set<string>>(new Set(JSON.parse(localStorage.getItem('hermes_collapsed_groups') || '[]')))
|
||||
mobileQuery?.removeEventListener("change", handleMobileChange);
|
||||
});
|
||||
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(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<SessionGroup[]>(() => {
|
||||
const map = new Map<string, Session[]>()
|
||||
const map = new Map<string, Session[]>();
|
||||
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<string | null>(null)
|
||||
const contextSessionId = ref<string | null>(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<string | null>(null)
|
||||
const showWorkspaceModal = ref(false);
|
||||
const workspaceValue = ref("");
|
||||
const workspaceSessionId = ref<string | null>(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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-panel">
|
||||
<div v-if="currentMode === 'chat'" class="session-backdrop" :class="{ active: showSessions }" @click="showSessions = false" />
|
||||
<aside v-if="currentMode === 'chat'" class="session-list" :class="{ collapsed: !showSessions }">
|
||||
<div
|
||||
v-if="currentMode === 'chat'"
|
||||
class="session-backdrop"
|
||||
:class="{ active: showSessions }"
|
||||
@click="showSessions = false"
|
||||
/>
|
||||
<aside
|
||||
v-if="currentMode === 'chat'"
|
||||
class="session-list"
|
||||
:class="{ collapsed: !showSessions }"
|
||||
>
|
||||
<div class="session-list-header">
|
||||
<span v-if="showSessions" class="session-list-title">{{ t('chat.webUiSessions') }}</span>
|
||||
<span v-if="showSessions" class="session-list-title">{{
|
||||
t("chat.webUiSessions")
|
||||
}}</span>
|
||||
<div class="session-list-actions">
|
||||
<button class="session-close-btn" @click="showSessions = false">
|
||||
<svg width="14" height="14" 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>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showSessions" class="session-scope-note">
|
||||
<span>{{ t('chat.sessionScopeHint') }}</span>
|
||||
<span>{{ t("chat.sessionScopeHint") }}</span>
|
||||
<RouterLink class="session-scope-link" :to="{ name: 'hermes.history' }">
|
||||
{{ t('chat.openHistory') }}
|
||||
{{ t("chat.openHistory") }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div v-if="showSessions" class="session-items">
|
||||
<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>
|
||||
<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-if="pinnedSessions.length > 0">
|
||||
<div class="session-group-header session-group-header--static">
|
||||
<span class="session-group-label">{{ t('chat.pinned') }}</span>
|
||||
<span class="session-group-label">{{ t("chat.pinned") }}</span>
|
||||
<span class="session-group-count">{{ pinnedSessions.length }}</span>
|
||||
</div>
|
||||
<SessionListItem
|
||||
@@ -304,7 +403,10 @@ async function handleWorkspaceConfirm() {
|
||||
:session="s"
|
||||
:active="s.id === chatStore.activeSessionId"
|
||||
:pinned="true"
|
||||
:can-delete="s.id !== chatStore.activeSessionId || chatStore.sessions.length > 1"
|
||||
:can-delete="
|
||||
s.id !== chatStore.activeSessionId ||
|
||||
chatStore.sessions.length > 1
|
||||
"
|
||||
:streaming="chatStore.isSessionLive(s.id)"
|
||||
@select="handleSessionClick(s.id)"
|
||||
@contextmenu="handleContextMenu($event, s.id)"
|
||||
@@ -314,7 +416,18 @@ async function handleWorkspaceConfirm() {
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<span class="session-group-label">{{ group.label }}</span>
|
||||
<span class="session-group-count">{{ group.sessions.length }}</span>
|
||||
</div>
|
||||
@@ -325,7 +438,10 @@ async function handleWorkspaceConfirm() {
|
||||
:session="s"
|
||||
:active="s.id === chatStore.activeSessionId"
|
||||
:pinned="false"
|
||||
:can-delete="s.id !== chatStore.activeSessionId || chatStore.sessions.length > 1"
|
||||
:can-delete="
|
||||
s.id !== chatStore.activeSessionId ||
|
||||
chatStore.sessions.length > 1
|
||||
"
|
||||
:streaming="chatStore.isSessionLive(s.id)"
|
||||
@select="handleSessionClick(s.id)"
|
||||
@contextmenu="handleContextMenu($event, s.id)"
|
||||
@@ -378,33 +494,89 @@ async function handleWorkspaceConfirm() {
|
||||
<div class="chat-main">
|
||||
<header class="chat-header">
|
||||
<div class="header-left">
|
||||
<NButton v-if="currentMode === 'chat'" quaternary size="small" @click="showSessions = !showSessions" circle>
|
||||
<NButton
|
||||
v-if="currentMode === 'chat'"
|
||||
quaternary
|
||||
size="small"
|
||||
@click="showSessions = !showSessions"
|
||||
circle
|
||||
>
|
||||
<template #icon>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
</template>
|
||||
</NButton>
|
||||
<span class="header-session-title">{{ headerTitle }}</span>
|
||||
<span v-if="activeSessionSource" class="source-badge">{{ getSourceLabel(activeSessionSource) }}</span>
|
||||
<span v-if="chatStore.activeSession?.workspace" class="workspace-badge" :title="chatStore.activeSession.workspace">📁 {{ chatStore.activeSession.workspace.split('/').pop() || chatStore.activeSession.workspace }}</span>
|
||||
<span v-if="activeSessionSource" class="source-badge">{{
|
||||
getSourceLabel(activeSessionSource)
|
||||
}}</span>
|
||||
<span
|
||||
v-if="chatStore.activeSession?.workspace"
|
||||
class="workspace-badge"
|
||||
:title="chatStore.activeSession.workspace"
|
||||
>📁
|
||||
{{
|
||||
chatStore.activeSession.workspace.split("/").pop() ||
|
||||
chatStore.activeSession.workspace
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<!-- chat/live mode toggle hidden -->
|
||||
<template v-if="currentMode === 'chat'">
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="small" @click="copySessionId()" circle>
|
||||
<NButton
|
||||
quaternary
|
||||
size="small"
|
||||
@click="copySessionId()"
|
||||
circle
|
||||
>
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path
|
||||
d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</NButton>
|
||||
</template>
|
||||
{{ t('chat.copySessionId') }}
|
||||
{{ t("chat.copySessionId") }}
|
||||
</NTooltip>
|
||||
<NButton size="small" :circle="isMobile" @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>
|
||||
<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>
|
||||
<template v-if="!isMobile">{{ t('chat.newChat') }}</template>
|
||||
<template v-if="!isMobile">{{ t("chat.newChat") }}</template>
|
||||
</NButton>
|
||||
</template>
|
||||
</div>
|
||||
@@ -414,13 +586,36 @@ async function handleWorkspaceConfirm() {
|
||||
<MessageList />
|
||||
<ChatInput />
|
||||
</template>
|
||||
<ConversationMonitorPane v-else :human-only="sessionBrowserPrefsStore.humanOnly" />
|
||||
<ConversationMonitorPane
|
||||
v-else
|
||||
:human-only="sessionBrowserPrefsStore.humanOnly"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Floating drawer button -->
|
||||
<div class="drawer-button-wrapper">
|
||||
<div class="drawer-button" @click="showDrawer = true">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
<line x1="15" y1="3" x2="15" y2="21" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DrawerPanel v-model:show="showDrawer" :active-tab="drawerActiveTab" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
@@ -434,7 +629,9 @@ async function handleWorkspaceConfirm() {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
transition: width $transition-normal, opacity $transition-normal;
|
||||
transition:
|
||||
width $transition-normal,
|
||||
opacity $transition-normal;
|
||||
overflow: hidden;
|
||||
|
||||
&.collapsed {
|
||||
@@ -659,8 +856,12 @@ async function handleWorkspaceConfirm() {
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.session-item-pin) {
|
||||
@@ -792,4 +993,125 @@ async function handleWorkspaceConfirm() {
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
// ─── Drawer button ─────────────────────────────────────────────
|
||||
|
||||
.drawer-button-wrapper {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 100;
|
||||
background: $bg-card;
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 0 10px rgba(255, 107, 107, 0.4),
|
||||
0 0 20px rgba(255, 107, 107, 0.2);
|
||||
animation: rainbow-glow 8s linear infinite;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
animation-play-state: paused;
|
||||
box-shadow:
|
||||
0 0 15px rgba(255, 107, 107, 0.6),
|
||||
0 0 30px rgba(255, 107, 107, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--accent-primary-rgb), 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rainbow-glow {
|
||||
0% {
|
||||
box-shadow:
|
||||
0 0 0 2px #ff6b6b,
|
||||
0 0 10px rgba(255, 107, 107, 0.4),
|
||||
0 0 20px rgba(255, 107, 107, 0.2);
|
||||
border-color: #ff6b6b;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
16.66% {
|
||||
box-shadow:
|
||||
0 0 0 2px #feca57,
|
||||
0 0 10px rgba(254, 202, 87, 0.4),
|
||||
0 0 20px rgba(254, 202, 87, 0.2);
|
||||
border-color: #feca57;
|
||||
color: #feca57;
|
||||
}
|
||||
33.33% {
|
||||
box-shadow:
|
||||
0 0 0 2px #48dbfb,
|
||||
0 0 10px rgba(72, 219, 251, 0.4),
|
||||
0 0 20px rgba(72, 219, 251, 0.2);
|
||||
border-color: #48dbfb;
|
||||
color: #48dbfb;
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 2px #ff9ff3,
|
||||
0 0 10px rgba(255, 159, 243, 0.4),
|
||||
0 0 20px rgba(255, 159, 243, 0.2);
|
||||
border-color: #ff9ff3;
|
||||
color: #ff9ff3;
|
||||
}
|
||||
66.66% {
|
||||
box-shadow:
|
||||
0 0 0 2px #54a0ff,
|
||||
0 0 10px rgba(84, 160, 255, 0.4),
|
||||
0 0 20px rgba(84, 160, 255, 0.2);
|
||||
border-color: #54a0ff;
|
||||
color: #54a0ff;
|
||||
}
|
||||
83.33% {
|
||||
box-shadow:
|
||||
0 0 0 2px #5f27cd,
|
||||
0 0 10px rgba(95, 39, 205, 0.4),
|
||||
0 0 20px rgba(95, 39, 205, 0.2);
|
||||
border-color: #5f27cd;
|
||||
color: #5f27cd;
|
||||
}
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 2px #ff6b6b,
|
||||
0 0 10px rgba(255, 107, 107, 0.4),
|
||||
0 0 20px rgba(255, 107, 107, 0.2);
|
||||
border-color: #ff6b6b;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.drawer-button-wrapper {
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.drawer-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TerminalPanel from './TerminalPanel.vue'
|
||||
import FilesPanel from './FilesPanel.vue'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
activeTab?: 'terminal' | 'files'
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:show', value: boolean): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
activeTab: 'files'
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const activeTab = ref<'terminal' | 'files'>(props.activeTab)
|
||||
|
||||
watch(() => props.activeTab, (newVal) => {
|
||||
if (newVal) activeTab.value = newVal
|
||||
})
|
||||
|
||||
function handleClose() {
|
||||
emit('update:show', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="show" class="drawer-overlay" @click="handleClose"></div>
|
||||
<div :class="['drawer-panel', { show }]">
|
||||
<div class="drawer-header">
|
||||
<div class="drawer-tabs">
|
||||
<button
|
||||
:class="['tab-button', { active: activeTab === 'files' }]"
|
||||
@click="activeTab = 'files'"
|
||||
>
|
||||
{{ t('drawer.files') }}
|
||||
</button>
|
||||
<button
|
||||
:class="['tab-button', { active: activeTab === 'terminal' }]"
|
||||
@click="activeTab = 'terminal'"
|
||||
>
|
||||
{{ t('drawer.terminal') }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="close-button" @click="handleClose">
|
||||
<svg width="20" height="20" 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>
|
||||
</div>
|
||||
|
||||
<div class="drawer-content">
|
||||
<div v-show="activeTab === 'files'" class="drawer-pane">
|
||||
<FilesPanel />
|
||||
</div>
|
||||
<div v-show="activeTab === 'terminal'" class="drawer-pane">
|
||||
<TerminalPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.drawer-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.drawer-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: -900px;
|
||||
width: 900px;
|
||||
height: 100vh;
|
||||
background: $bg-card;
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
transition: right 0.3s ease;
|
||||
|
||||
&.show {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
width: 100%;
|
||||
right: -100%;
|
||||
|
||||
&.show {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.drawer-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: $text-secondary;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
border-radius: $radius-sm;
|
||||
|
||||
&:hover {
|
||||
color: $text-primary;
|
||||
background: rgba(var(--accent-primary-rgb), 0.05);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--accent-primary);
|
||||
background: rgba(var(--accent-primary-rgb), 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.close-button {
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background: rgba(var(--accent-primary-rgb), 0.08);
|
||||
color: $text-secondary;
|
||||
cursor: pointer;
|
||||
border-radius: $radius-sm;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: $text-primary;
|
||||
background: rgba(var(--accent-primary-rgb), 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.drawer-pane {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NButton } from 'naive-ui'
|
||||
import FileTree from '@/components/hermes/files/FileTree.vue'
|
||||
import FileBreadcrumb from '@/components/hermes/files/FileBreadcrumb.vue'
|
||||
import FileToolbar from '@/components/hermes/files/FileToolbar.vue'
|
||||
import FileList from '@/components/hermes/files/FileList.vue'
|
||||
import FileContextMenu from '@/components/hermes/files/FileContextMenu.vue'
|
||||
import FileEditor from '@/components/hermes/files/FileEditor.vue'
|
||||
import FilePreview from '@/components/hermes/files/FilePreview.vue'
|
||||
import FileUploadModal from '@/components/hermes/files/FileUploadModal.vue'
|
||||
import FileRenameModal from '@/components/hermes/files/FileRenameModal.vue'
|
||||
import type { FileEntry } from '@/api/hermes/files'
|
||||
|
||||
const filesStore = useFilesStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const contextMenuRef = ref<InstanceType<typeof FileContextMenu> | null>(null)
|
||||
const showUpload = ref(false)
|
||||
const showRenameModal = ref(false)
|
||||
const renameMode = ref<'newFile' | 'newFolder' | 'rename'>('newFile')
|
||||
const renameEntry = ref<FileEntry | null>(null)
|
||||
const showSidebar = ref(false)
|
||||
|
||||
function handleContextMenu(e: MouseEvent, entry: FileEntry) {
|
||||
contextMenuRef.value?.show(e, entry)
|
||||
}
|
||||
|
||||
function handleShowNewFile() {
|
||||
renameMode.value = 'newFile'
|
||||
renameEntry.value = null
|
||||
showRenameModal.value = true
|
||||
}
|
||||
|
||||
function handleShowNewFolder() {
|
||||
renameMode.value = 'newFolder'
|
||||
renameEntry.value = null
|
||||
showRenameModal.value = true
|
||||
}
|
||||
|
||||
function handleRename(entry: FileEntry) {
|
||||
renameMode.value = 'rename'
|
||||
renameEntry.value = entry
|
||||
showRenameModal.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
filesStore.fetchEntries('')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="files-panel-drawer">
|
||||
<div
|
||||
v-if="showSidebar"
|
||||
class="sidebar-overlay"
|
||||
@click="showSidebar = false"
|
||||
></div>
|
||||
<div
|
||||
class="files-tree-panel"
|
||||
:class="{ 'mobile-visible': showSidebar }"
|
||||
>
|
||||
<FileTree />
|
||||
</div>
|
||||
<div class="files-main-panel">
|
||||
<div class="main-toolbar">
|
||||
<NButton
|
||||
size="small"
|
||||
@click="showSidebar = !showSidebar"
|
||||
class="sidebar-toggle"
|
||||
>
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ t('files.fileTree') }}
|
||||
</NButton>
|
||||
<FileToolbar
|
||||
@show-new-file="handleShowNewFile"
|
||||
@show-new-folder="handleShowNewFolder"
|
||||
@show-upload="showUpload = true"
|
||||
/>
|
||||
</div>
|
||||
<FileBreadcrumb />
|
||||
<div class="files-content">
|
||||
<FileEditor v-if="filesStore.editingFile" />
|
||||
<FilePreview v-else-if="filesStore.previewFile" />
|
||||
<FileList v-else @contextmenu-entry="handleContextMenu" />
|
||||
</div>
|
||||
</div>
|
||||
<FileContextMenu ref="contextMenuRef" @rename="handleRename" />
|
||||
<FileUploadModal v-model:show="showUpload" />
|
||||
<FileRenameModal v-model:show="showRenameModal" :mode="renameMode" :entry="renameEntry" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.files-panel-drawer {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 50;
|
||||
|
||||
@media (min-width: $breakpoint-mobile + 1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.files-tree-panel {
|
||||
width: 200px;
|
||||
min-width: 150px;
|
||||
max-width: 300px;
|
||||
border-right: 1px solid $border-color;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 80%;
|
||||
max-width: 300px;
|
||||
z-index: 51;
|
||||
background: $bg-card;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&.mobile-visible {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.files-main-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
gap: 4px;
|
||||
padding: 8px 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
@media (min-width: $breakpoint-mobile + 1) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
font-size: 12px;
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.files-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,788 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from "vue";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { getApiKey, getBaseUrlValue } from "@/api/client";
|
||||
import { NButton, NPopconfirm, NTooltip, NSelect, useMessage } from "naive-ui";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
|
||||
const { t } = useI18n();
|
||||
const message = useMessage();
|
||||
|
||||
// ─── Terminal themes ────────────────────────────────────────────
|
||||
|
||||
const TERMINAL_THEMES: Record<string, { label: string; theme: ITheme }> = {
|
||||
default: {
|
||||
label: "Default",
|
||||
theme: {
|
||||
background: "#1a1a2e",
|
||||
foreground: "#e0e0e0",
|
||||
cursor: "#4cc9f0",
|
||||
cursorAccent: "#1a1a2e",
|
||||
selectionBackground: "rgba(76, 201, 240, 0.3)",
|
||||
black: "#000000", red: "#e06c75", green: "#98c379", yellow: "#e5c07b",
|
||||
blue: "#61afef", magenta: "#c678dd", cyan: "#56b6c2", white: "#abb2bf",
|
||||
brightBlack: "#5c6370", brightRed: "#e06c75", brightGreen: "#98c379",
|
||||
brightYellow: "#e5c07b", brightBlue: "#61afef", brightMagenta: "#c678dd",
|
||||
brightCyan: "#56b6c2", brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
"solarized-dark": {
|
||||
label: "Solarized Dark",
|
||||
theme: {
|
||||
background: "#002b36", foreground: "#839496",
|
||||
cursor: "#93a1a1", cursorAccent: "#002b36",
|
||||
selectionBackground: "rgba(147, 161, 161, 0.3)",
|
||||
black: "#073642", red: "#dc322f", green: "#859900", yellow: "#b58900",
|
||||
blue: "#268bd2", magenta: "#d33682", cyan: "#2aa198", white: "#eee8d5",
|
||||
brightBlack: "#002b36", brightRed: "#cb4b16", brightGreen: "#586e75",
|
||||
brightYellow: "#657b83", brightBlue: "#839496", brightMagenta: "#6c71c4",
|
||||
brightCyan: "#93a1a1", brightWhite: "#fdf6e3",
|
||||
},
|
||||
},
|
||||
"tokyo-night": {
|
||||
label: "Tokyo Night",
|
||||
theme: {
|
||||
background: "#1a1b26", foreground: "#a9b1d6",
|
||||
cursor: "#c0caf5", cursorAccent: "#1a1b26",
|
||||
selectionBackground: "rgba(192, 202, 245, 0.2)",
|
||||
black: "#15161e", red: "#f7768e", green: "#9ece6a", yellow: "#e0af68",
|
||||
blue: "#7aa2f7", magenta: "#bb9af7", cyan: "#7dcfff", white: "#a9b1d6",
|
||||
brightBlack: "#414868", brightRed: "#f7768e", brightGreen: "#9ece6a",
|
||||
brightYellow: "#e0af68", brightBlue: "#7aa2f7", brightMagenta: "#bb9af7",
|
||||
brightCyan: "#7dcfff", brightWhite: "#c0caf5",
|
||||
},
|
||||
},
|
||||
"github-dark": {
|
||||
label: "GitHub Dark",
|
||||
theme: {
|
||||
background: "#0d1117", foreground: "#c9d1d9",
|
||||
cursor: "#58a6ff", cursorAccent: "#0d1117",
|
||||
selectionBackground: "rgba(88, 166, 255, 0.25)",
|
||||
black: "#484f58", red: "#ff7b72", green: "#7ee787", yellow: "#ffa657",
|
||||
blue: "#79c0ff", magenta: "#d2a8ff", cyan: "#a5d6ff", white: "#c9d1d9",
|
||||
brightBlack: "#6e7681", brightRed: "#ffa198", brightGreen: "#56d364",
|
||||
brightYellow: "#e3b341", brightBlue: "#58a6ff", brightMagenta: "#bc8cff",
|
||||
brightCyan: "#79c0ff", brightWhite: "#f0f6fc",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const STORAGE_KEY_THEME = "hermes_terminal_theme";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
interface SessionInfo {
|
||||
id: string;
|
||||
shell: string;
|
||||
pid: number;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
exited: boolean;
|
||||
}
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────
|
||||
|
||||
const terminalRef = ref<HTMLDivElement | null>(null);
|
||||
const sessions = ref<SessionInfo[]>([]);
|
||||
const activeSessionId = ref<string | null>(null);
|
||||
const selectedTheme = ref(localStorage.getItem(STORAGE_KEY_THEME) || "default");
|
||||
const connectionError = ref<string | null>(null);
|
||||
const isConnecting = ref(false);
|
||||
const showSidebar = ref(false);
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
const termMap = new Map<string, { term: Terminal; fitAddon: FitAddon; opened: boolean }>();
|
||||
let activeTerm: Terminal | null = null;
|
||||
let activeFitAddon: FitAddon | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
const MAX_RECONNECT_ATTEMPTS = 3;
|
||||
|
||||
// ─── Computed ──────────────────────────────────────────────────
|
||||
|
||||
const activeSession = computed(
|
||||
() => sessions.value.find((s) => s.id === activeSessionId.value) || null,
|
||||
);
|
||||
|
||||
const themeOptions = computed(() =>
|
||||
Object.entries(TERMINAL_THEMES).map(([key, val]) => ({
|
||||
label: val.label,
|
||||
value: key,
|
||||
})),
|
||||
);
|
||||
|
||||
const terminalBg = computed(
|
||||
() => TERMINAL_THEMES[selectedTheme.value]?.theme.background ?? "#1a1a2e",
|
||||
);
|
||||
|
||||
// ─── WebSocket ──────────────────────────────────────────────────
|
||||
|
||||
function buildWsUrl(): string {
|
||||
const token = getApiKey();
|
||||
const base = getBaseUrlValue();
|
||||
const wsProtocol = base
|
||||
? base.startsWith("https")
|
||||
? "wss:"
|
||||
: "ws:"
|
||||
: location.protocol === "https:"
|
||||
? "wss:"
|
||||
: "ws:";
|
||||
|
||||
if (base) {
|
||||
return `${wsProtocol}//${new URL(base).host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||
}
|
||||
|
||||
const host = import.meta.env.DEV
|
||||
? `${location.hostname}:8648`
|
||||
: location.host;
|
||||
return `${wsProtocol}//${host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
connectionError.value = t('terminal.connectionFailed');
|
||||
isConnecting.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const url = buildWsUrl();
|
||||
connectionError.value = null;
|
||||
isConnecting.value = true;
|
||||
reconnectAttempts++;
|
||||
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempts = 0;
|
||||
isConnecting.value = false;
|
||||
connectionError.value = null;
|
||||
// Server auto-creates the first session
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = typeof event.data === "string" ? event.data : "";
|
||||
if (data.charCodeAt(0) === 0x7b) {
|
||||
try {
|
||||
handleControl(JSON.parse(data));
|
||||
} catch {}
|
||||
} else {
|
||||
activeTerm?.write(data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
isConnecting.value = false;
|
||||
|
||||
// 如果是正常关闭(code 1000)或认证失败,不重连
|
||||
if (event.code === 1000 || event.code === 1003 || event.code === 1008) {
|
||||
connectionError.value = t('terminal.connectionClosed');
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他情况尝试重连
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[Terminal] WebSocket error:', error);
|
||||
connectionError.value = t('terminal.connectionError');
|
||||
};
|
||||
}
|
||||
|
||||
function send(data: object | string) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(typeof data === "string" ? data : JSON.stringify(data));
|
||||
}
|
||||
|
||||
// ─── Control message handlers ──────────────────────────────────
|
||||
|
||||
function handleControl(msg: any) {
|
||||
switch (msg.type) {
|
||||
case "created":
|
||||
sessions.value.push({
|
||||
id: msg.id,
|
||||
shell: msg.shell,
|
||||
pid: msg.pid,
|
||||
title: `${msg.shell} #${sessions.value.length + 1}`,
|
||||
createdAt: Date.now(),
|
||||
exited: false,
|
||||
});
|
||||
switchSession(msg.id);
|
||||
break;
|
||||
|
||||
case "exited": {
|
||||
const s = sessions.value.find((s) => s.id === msg.id);
|
||||
if (s) {
|
||||
s.exited = true;
|
||||
if (activeSessionId.value === msg.id) {
|
||||
activeTerm?.write(
|
||||
`\r\n\x1b[90m[${t("terminal.processExited", { code: msg.exitCode })}]\x1b[0m\r\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "error":
|
||||
message.error(msg.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Session actions ────────────────────────────────────────────
|
||||
|
||||
function createSession() {
|
||||
send({ type: "create" });
|
||||
}
|
||||
|
||||
function getOrCreateTerm(id: string): { term: Terminal; fitAddon: FitAddon } {
|
||||
let entry = termMap.get(id);
|
||||
if (!entry) {
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: { ...TERMINAL_THEMES[selectedTheme.value].theme },
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
term.onData((data) => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
entry = { term, fitAddon, opened: false };
|
||||
termMap.set(id, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function switchSession(id: string) {
|
||||
if (activeSessionId.value === id) return;
|
||||
activeSessionId.value = id;
|
||||
const entry = getOrCreateTerm(id);
|
||||
activeTerm = entry.term;
|
||||
activeFitAddon = entry.fitAddon;
|
||||
mountActiveTerminal();
|
||||
send({ type: "switch", sessionId: id });
|
||||
}
|
||||
|
||||
function closeSession(id: string) {
|
||||
send({ type: "close", sessionId: id });
|
||||
sessions.value = sessions.value.filter((s) => s.id !== id);
|
||||
const entry = termMap.get(id);
|
||||
if (entry) {
|
||||
entry.term.dispose();
|
||||
termMap.delete(id);
|
||||
}
|
||||
if (activeSessionId.value === id) {
|
||||
activeSessionId.value = sessions.value.length > 0 ? sessions.value[0].id : null;
|
||||
activeTerm = null;
|
||||
activeFitAddon = null;
|
||||
if (activeSessionId.value) {
|
||||
switchSession(activeSessionId.value);
|
||||
} else {
|
||||
unmountActiveTerminal();
|
||||
createSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Terminal mount/unmount ─────────────────────────────────────
|
||||
|
||||
function mountActiveTerminal() {
|
||||
if (!terminalRef.value) return;
|
||||
const container = terminalRef.value;
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
|
||||
const entry = termMap.get(activeSessionId.value!);
|
||||
if (!entry) return;
|
||||
|
||||
if (!entry.opened) {
|
||||
entry.term.open(container);
|
||||
entry.opened = true;
|
||||
} else {
|
||||
const termEl = entry.term.element;
|
||||
if (termEl) {
|
||||
container.appendChild(termEl);
|
||||
}
|
||||
}
|
||||
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
tryFit();
|
||||
sendResize();
|
||||
});
|
||||
resizeObserver.observe(terminalRef.value);
|
||||
|
||||
setTimeout(() => tryFit(), 50);
|
||||
setTimeout(() => tryFit(), 200);
|
||||
}
|
||||
|
||||
function unmountActiveTerminal() {
|
||||
if (!terminalRef.value) return;
|
||||
const container = terminalRef.value;
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
}
|
||||
|
||||
function tryFit() {
|
||||
if (!activeFitAddon) return;
|
||||
try {
|
||||
activeFitAddon.fit();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function sendResize() {
|
||||
if (!activeTerm || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
send({
|
||||
type: "resize",
|
||||
cols: activeTerm.cols,
|
||||
rows: activeTerm.rows,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ─── Theme ───────────────────────────────────────────────────────
|
||||
|
||||
function applyTheme(themeName: string) {
|
||||
selectedTheme.value = themeName;
|
||||
localStorage.setItem(STORAGE_KEY_THEME, themeName);
|
||||
const themeObj = TERMINAL_THEMES[themeName]?.theme;
|
||||
if (!themeObj) return;
|
||||
for (const entry of termMap.values()) {
|
||||
entry.term.options.theme = { ...themeObj };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
function formatTime(ts: number) {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// ─── Lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
onMounted(() => {
|
||||
connect();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
unmountActiveTerminal();
|
||||
for (const entry of termMap.values()) {
|
||||
entry.term.dispose();
|
||||
}
|
||||
termMap.clear();
|
||||
activeTerm = null;
|
||||
activeFitAddon = null;
|
||||
ws?.close();
|
||||
ws = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="terminal-panel-drawer">
|
||||
<div
|
||||
v-if="showSidebar"
|
||||
class="sidebar-overlay"
|
||||
@click="showSidebar = false"
|
||||
></div>
|
||||
<div
|
||||
class="terminal-sidebar"
|
||||
:class="{ 'mobile-visible': showSidebar }"
|
||||
>
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">{{ t("terminal.sessions") }}</span>
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="tiny" @click="createSession" 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>
|
||||
</template>
|
||||
</NButton>
|
||||
</template>
|
||||
{{ t("terminal.newTab") }}
|
||||
</NTooltip>
|
||||
</div>
|
||||
<div class="session-list">
|
||||
<div v-if="connectionError" class="session-error">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<span>{{ connectionError }}</span>
|
||||
<NButton size="tiny" @click="connect">{{ t("common.retry") }}</NButton>
|
||||
</div>
|
||||
<div v-else-if="sessions.length === 0" class="session-empty">
|
||||
<template v-if="isConnecting">
|
||||
{{ t("common.loading") }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t("terminal.noSessions") }}
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
v-for="s in sessions"
|
||||
:key="s.id"
|
||||
class="session-item"
|
||||
:class="{ active: s.id === activeSessionId, exited: s.exited }"
|
||||
@click="switchSession(s.id)"
|
||||
>
|
||||
<div class="session-item-content">
|
||||
<span class="session-item-title">{{ s.title }}</span>
|
||||
<span class="session-item-meta">
|
||||
<span class="session-item-shell">{{ s.shell }}</span>
|
||||
<span v-if="s.exited" class="session-item-status">{{
|
||||
t("terminal.sessionExited")
|
||||
}}</span>
|
||||
<span v-else class="session-item-time">{{
|
||||
formatTime(s.createdAt)
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
<NPopconfirm v-if="sessions.length > 1" @positive-click="closeSession(s.id)">
|
||||
<template #trigger>
|
||||
<button class="session-item-delete" @click.stop>
|
||||
<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>
|
||||
{{ t("terminal.closeSession") }}
|
||||
</NPopconfirm>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="terminal-main">
|
||||
<header class="terminal-header">
|
||||
<span v-if="activeSession" class="header-session-title">{{
|
||||
activeSession.title
|
||||
}}</span>
|
||||
<div class="header-actions">
|
||||
<NButton
|
||||
size="small"
|
||||
@click="showSidebar = !showSidebar"
|
||||
class="sidebar-toggle"
|
||||
>
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ t("terminal.sessions") }}
|
||||
</NButton>
|
||||
<NSelect
|
||||
:value="selectedTheme"
|
||||
:options="themeOptions"
|
||||
size="small"
|
||||
:consistent-menu-width="false"
|
||||
class="theme-select"
|
||||
@update:value="applyTheme"
|
||||
/>
|
||||
<NButton size="small" @click="createSession">
|
||||
<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>
|
||||
{{ t("terminal.newTab") }}
|
||||
</NButton>
|
||||
</div>
|
||||
</header>
|
||||
<div class="terminal-container">
|
||||
<div ref="terminalRef" class="terminal-xterm" :style="{ backgroundColor: terminalBg }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.terminal-panel-drawer {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 50;
|
||||
|
||||
@media (min-width: $breakpoint-mobile + 1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-sidebar {
|
||||
width: 180px;
|
||||
border-right: 1px solid $border-color;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 80%;
|
||||
max-width: 300px;
|
||||
z-index: 51;
|
||||
background: $bg-card;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&.mobile-visible {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.session-empty {
|
||||
padding: 16px 8px;
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.session-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 12px;
|
||||
font-size: 12px;
|
||||
color: $error;
|
||||
text-align: center;
|
||||
|
||||
svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: $radius-sm;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: $text-secondary;
|
||||
transition: all $transition-fast;
|
||||
margin-bottom: 2px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--accent-primary-rgb), 0.06);
|
||||
color: $text-primary;
|
||||
|
||||
.session-item-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(var(--accent-primary-rgb), 0.1);
|
||||
color: $text-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.exited {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.session-item-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.session-item-title {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.session-item-shell {
|
||||
font-size: 9px;
|
||||
color: $accent-primary;
|
||||
background: rgba(var(--accent-primary-rgb), 0.08);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.session-item-time,
|
||||
.session-item-status {
|
||||
font-size: 10px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.session-item-delete {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
padding: 2px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $text-muted;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $error;
|
||||
background: rgba(var(--error-rgb), 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-session-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
@media (min-width: $breakpoint-mobile + 1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
margin: 8px;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.terminal-xterm {
|
||||
flex: 1;
|
||||
border-radius: $radius-md;
|
||||
overflow: hidden;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
:deep(.xterm) {
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
:deep(.xterm-viewport) {
|
||||
overflow-y: scroll !important;
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-viewport::-webkit-scrollbar) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-screen) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-scrollable-element) {
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-scrollable-element::-webkit-scrollbar) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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 {
|
||||
|
||||
@@ -24,17 +24,17 @@ async function handleRefresh() {
|
||||
|
||||
<template>
|
||||
<div class="file-toolbar">
|
||||
<NSpace>
|
||||
<NButton size="small" @click="emit('showNewFile')">
|
||||
<NSpace :size="8" :wrap="true" class="toolbar-space">
|
||||
<NButton size="small" @click="emit('showNewFile')" class="toolbar-btn">
|
||||
{{ t('files.newFile') }}
|
||||
</NButton>
|
||||
<NButton size="small" @click="emit('showNewFolder')">
|
||||
<NButton size="small" @click="emit('showNewFolder')" class="toolbar-btn">
|
||||
{{ t('files.newFolder') }}
|
||||
</NButton>
|
||||
<NButton size="small" @click="emit('showUpload')">
|
||||
<NButton size="small" @click="emit('showUpload')" class="toolbar-btn">
|
||||
{{ t('files.upload') }}
|
||||
</NButton>
|
||||
<NButton size="small" @click="handleRefresh">
|
||||
<NButton size="small" @click="handleRefresh" class="toolbar-btn">
|
||||
{{ t('files.refresh') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
@@ -42,8 +42,31 @@ async function handleRefresh() {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.file-toolbar {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
padding: 8px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-space {
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
:deep(.n-space) {
|
||||
gap: 4px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
font-size: 12px;
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<NForm label-placement="top">
|
||||
<NFormItem :label="t('profiles.name')" required>
|
||||
<NInput
|
||||
:value="name"
|
||||
v-model:value="name"
|
||||
:placeholder="t('profiles.namePlaceholder')"
|
||||
@input="name = $event.toLowerCase().replace(/[^a-z0-9_-]/g, '')"
|
||||
@keyup.enter="handleSave"
|
||||
@input="handleNameInput"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NText v-if="nameValidationMessage" depth="3" type="warning" style="font-size: 12px;">
|
||||
{{ nameValidationMessage }}
|
||||
</NText>
|
||||
|
||||
<NFormItem :label="t('profiles.cloneFromCurrent')">
|
||||
<NSwitch v-model:value="clone" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { NModal, NForm, NFormItem, NInput, NButton, useMessage } from 'naive-ui'
|
||||
import { NModal, NForm, NFormItem, NInput, NButton, NText, useMessage } from 'naive-ui'
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -17,13 +17,30 @@ const message = useMessage()
|
||||
const showModal = ref(true)
|
||||
const loading = ref(false)
|
||||
const newName = ref('')
|
||||
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 = ''
|
||||
}
|
||||
newName.value = filtered
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!newName.value.trim()) {
|
||||
if (!newName.value) {
|
||||
message.warning(t('profiles.newNamePlaceholder'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9_-]+$/.test(newName.value)) {
|
||||
message.error(t('profiles.nameValidation'))
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const ok = await profilesStore.renameProfile(props.profileName, newName.value.trim())
|
||||
@@ -56,12 +73,14 @@ function handleClose() {
|
||||
<NForm label-placement="top">
|
||||
<NFormItem :label="t('profiles.newName')" required>
|
||||
<NInput
|
||||
:value="newName"
|
||||
v-model:value="newName"
|
||||
:placeholder="t('profiles.newNamePlaceholder')"
|
||||
@input="newName = $event.toLowerCase().replace(/[^a-z0-9_-]/g, '')"
|
||||
@keyup.enter="handleSave"
|
||||
@input="handleNameInput"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NText v-if="nameValidationMessage" depth="3" type="warning" style="font-size: 12px;">
|
||||
{{ nameValidationMessage }}
|
||||
</NText>
|
||||
</NForm>
|
||||
|
||||
<template #footer>
|
||||
|
||||
@@ -200,31 +200,6 @@ function openChangelog() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tools -->
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label" @click="toggleGroup('tools')">
|
||||
<span>{{ t("sidebar.groupTools") }}</span>
|
||||
<svg class="nav-group-arrow" :class="{ collapsed: isGroupCollapsed('tools') }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div v-show="!isGroupCollapsed('tools')">
|
||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.terminal' }" @click="handleNav('hermes.terminal')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.terminal") }}</span>
|
||||
</button>
|
||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.files' }" @click="handleNav('hermes.files')">
|
||||
<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="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.files") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System -->
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label" @click="toggleGroup('system')">
|
||||
|
||||
@@ -15,16 +15,18 @@ const options = computed(() =>
|
||||
})),
|
||||
)
|
||||
|
||||
const activeName = computed(() => profilesStore.activeProfile?.name ?? '')
|
||||
const activeName = computed(() => profilesStore.activeProfileName ?? '')
|
||||
|
||||
function handleChange(value: string | number | Array<string | number>) {
|
||||
async function handleChange(value: string | number | Array<string | number>) {
|
||||
if (typeof value === 'string' && value !== activeName.value) {
|
||||
profilesStore.switchProfile(value).then(ok => {
|
||||
if (ok) {
|
||||
message.success(t('profiles.switchSuccess', { name: value }))
|
||||
window.location.reload()
|
||||
}
|
||||
})
|
||||
const ok = await profilesStore.switchProfile(value)
|
||||
if (ok) {
|
||||
message.success(t('profiles.switchSuccess', { name: value }))
|
||||
// Reload to refresh all profile-dependent data
|
||||
window.location.reload()
|
||||
} else {
|
||||
message.error(t('profiles.switchFailed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,11 @@ export function useSpeech() {
|
||||
// 移除 HTML 标签
|
||||
text = text.replace(/<[^>]+>/g, '')
|
||||
|
||||
// 只保留:字母、数字、空格、常用标点、中文
|
||||
// 保留的标点:。!?;,,。!?;:、""''()【】《》
|
||||
// 移除:*# 等特殊符号、表情符号、emoji 等
|
||||
text = text.replace(/[^\p{L}\p{N}\s。!?;,,。!?;:、""''()【】《》\n一-鿿㐀-䶿]/gu, '')
|
||||
|
||||
// 移除多余的空白
|
||||
text = text.replace(/\s+/g, ' ').trim()
|
||||
|
||||
|
||||
@@ -5,6 +5,17 @@ export interface ChangelogEntry {
|
||||
}
|
||||
|
||||
export const changelog: ChangelogEntry[] = [
|
||||
{
|
||||
version: '0.5.8',
|
||||
date: '2026-05-03',
|
||||
changes: [
|
||||
'changelog.new_0_5_8_1',
|
||||
'changelog.new_0_5_8_2',
|
||||
'changelog.new_0_5_8_3',
|
||||
'changelog.new_0_5_8_4',
|
||||
'changelog.new_0_5_8_5',
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '0.5.7',
|
||||
date: '2026-05-02',
|
||||
|
||||
@@ -95,6 +95,12 @@ export default {
|
||||
noChangelog: 'Kein Anderungsprotokoll verfugbar',
|
||||
},
|
||||
|
||||
// Drawer
|
||||
drawer: {
|
||||
terminal: 'Terminal',
|
||||
files: 'Arbeitsbereich',
|
||||
},
|
||||
|
||||
// Chat
|
||||
chat: {
|
||||
contextRemaining: 'übrig',
|
||||
@@ -130,6 +136,7 @@ export default {
|
||||
renamed: 'Umbenannt',
|
||||
renameFailed: 'Umbenennung fehlgeschlagen',
|
||||
renameSession: 'Sitzung umbenennen',
|
||||
sessionNotFound: 'Sitzung nicht gefunden',
|
||||
enterNewTitle: 'Neuen Titel eingeben',
|
||||
other: 'Sonstige',
|
||||
runFailed: 'Ausfuhrung fehlgeschlagen',
|
||||
@@ -363,6 +370,7 @@ jobTriggered: 'Job ausgelost',
|
||||
importInvalidFile: 'Bitte wahlen Sie ein gultiges Archiv (.tar.gz, .tgz, .gz, .zip)',
|
||||
name: 'Profilname',
|
||||
namePlaceholder: 'Nur Buchstaben, Zahlen und Bindestriche',
|
||||
nameValidation: 'Profilname darf nur Kleinbuchstaben, Zahlen, Unterstriche und Bindestriche enthalten',
|
||||
newName: 'Neuer Name',
|
||||
newNamePlaceholder: 'Neuen Namen eingeben',
|
||||
cloneFromCurrent: 'Aus aktuellem Profil klonen',
|
||||
@@ -561,6 +569,10 @@ jobTriggered: 'Job ausgelost',
|
||||
closeSession: 'Diese Sitzung schliessen?',
|
||||
sessionExited: 'Beendet',
|
||||
processExited: 'Prozess beendet mit Code {code}',
|
||||
noSessions: 'Keine Terminal-Sitzungen',
|
||||
connectionFailed: 'Terminaldienstverbindung fehlgeschlagen',
|
||||
connectionClosed: 'Terminalverbindung geschlossen',
|
||||
connectionError: 'Terminalverbindungsfehler',
|
||||
},
|
||||
|
||||
// Usage
|
||||
@@ -597,6 +609,14 @@ jobTriggered: 'Job ausgelost',
|
||||
new_0_5_6_6: 'Attachment-Verarbeitung neu gestaltet mit Anthropic-Stil ContentBlock-Array-Format (Text, Bild, Datei)',
|
||||
new_0_5_6_7: 'Frontend-Dateidownload-Funktion für ContentBlock- und Markdown-Formate mit Authentifizierung hinzugefügt',
|
||||
new_0_5_6_8: 'Multi-Prozess-Konflikt behoben, der SQLite-Database-Resets verursacht hat, durch Entfernen redundanter nodemon-Instanzen',
|
||||
new_0_5_8_1: 'Drawer-Panel mit Mobile-Sidebar-Support und anpassbarem Rainbow-Button hinzugefügt',
|
||||
new_0_5_8_2: 'Profile-Switching-State-Sync-Problem behoben mit sofortiger UI-Aktualisierung und Backend-Verifizierung',
|
||||
new_0_5_8_3: 'Sonderzeichen und Emoji in der Sprachwiedergabe gefiltert für bessere Text-to-Speech',
|
||||
new_0_5_8_4: 'Fehlende i18n-Schlüssel hinzugefügt und Sitzungsdatenquelle vereinheitlicht (Datenbank-Priorität)',
|
||||
new_0_5_8_5: 'Vite-Build-Konfiguration für schnellere Docker-Builds mit esbuild und Chunk-Splitting optimiert',
|
||||
new_0_5_7_1: 'Kontextkomprimierung für rich content (Bilder, Dateien) optimiert, verbesserte Verarbeitung von Tool-Nachrichten',
|
||||
new_0_5_7_2: 'Synchronisation von Sitzungen verbessert mit Batch-Inserts und Transaktionsschutz für Datenkonsistenz',
|
||||
new_0_5_7_3: 'Empfang von usage.updated-Ereignissen behoben, um genaues Token-Tracking über Läufe hinweg zu gewährleisten',
|
||||
new_0_5_5_1: '🎉 Tag der Arbeit! Heute wird nicht gearbeitet, bitte habt Verständnis',
|
||||
new_0_5_5_2: 'Verlaufsseite für Hermes-Sitzungshistorie hinzugefügt',
|
||||
new_0_5_5_3: 'Verlaufsseite verwaltet Sitzungen unabhängig ohne Störung des aktiven Chats',
|
||||
|
||||
@@ -105,6 +105,12 @@ export default {
|
||||
noChangelog: 'No changelog available',
|
||||
},
|
||||
|
||||
// Drawer
|
||||
drawer: {
|
||||
terminal: 'Terminal',
|
||||
files: 'Workspace',
|
||||
},
|
||||
|
||||
// Chat
|
||||
chat: {
|
||||
contextRemaining: 'remaining',
|
||||
@@ -153,6 +159,7 @@ export default {
|
||||
renamed: 'Renamed',
|
||||
renameFailed: 'Rename failed',
|
||||
renameSession: 'Rename Session',
|
||||
sessionNotFound: 'Session not found',
|
||||
enterNewTitle: 'Enter new title',
|
||||
workspace: 'Workspace',
|
||||
setWorkspace: 'Set Workspace',
|
||||
@@ -405,6 +412,7 @@ export default {
|
||||
importInvalidFile: 'Please select a valid archive (.tar.gz, .tgz, .gz, .zip)',
|
||||
name: 'Profile Name',
|
||||
namePlaceholder: 'Lowercase letters, numbers, hyphens only',
|
||||
nameValidation: 'Profile name can only contain lowercase letters, numbers, underscores, and hyphens',
|
||||
newName: 'New Name',
|
||||
newNamePlaceholder: 'Lowercase letters, numbers, hyphens',
|
||||
cloneFromCurrent: 'Clone from current profile',
|
||||
@@ -621,6 +629,10 @@ export default {
|
||||
closeSession: 'Close this session?',
|
||||
sessionExited: 'Exited',
|
||||
processExited: 'Process exited with code {code}',
|
||||
noSessions: 'No terminal sessions',
|
||||
connectionFailed: 'Terminal service connection failed',
|
||||
connectionClosed: 'Terminal connection closed',
|
||||
connectionError: 'Terminal connection error',
|
||||
},
|
||||
|
||||
// Group Chat
|
||||
@@ -697,6 +709,7 @@ export default {
|
||||
// Files
|
||||
files: {
|
||||
title: 'Files',
|
||||
fileTree: 'File Tree',
|
||||
tree: 'Directory Tree',
|
||||
list: 'File List',
|
||||
breadcrumbRoot: 'Home',
|
||||
@@ -767,6 +780,11 @@ export default {
|
||||
new_0_5_6_6: 'Redesigned attachment handling using Anthropic-style ContentBlock array format with type discriminated unions (text, image, file)',
|
||||
new_0_5_6_7: 'Added frontend file download functionality supporting both ContentBlock and Markdown formats with authentication',
|
||||
new_0_5_6_8: 'Fixed multi-process conflict causing SQLite database resets by eliminating redundant nodemon instances',
|
||||
new_0_5_8_1: 'Add drawer panel with mobile sidebar support and customizable rainbow button',
|
||||
new_0_5_8_2: 'Fix profile switching state sync issue with immediate UI update and backend verification',
|
||||
new_0_5_8_3: 'Filter special characters and emoji in speech playback for better text-to-speech',
|
||||
new_0_5_8_4: 'Add missing i18n key and unify session data source to prioritize database',
|
||||
new_0_5_8_5: 'Optimize Vite build configuration for faster Docker builds with esbuild and chunk splitting',
|
||||
new_0_5_7_1: 'Optimize context compression to support rich content (images, files) with improved tool message handling',
|
||||
new_0_5_7_2: 'Improve session sync with batch inserts and transaction protection for data consistency',
|
||||
new_0_5_7_3: 'Fix usage.updated event reception to ensure accurate token tracking across runs',
|
||||
|
||||
@@ -95,6 +95,12 @@ export default {
|
||||
noChangelog: 'No hay registro de cambios',
|
||||
},
|
||||
|
||||
// Drawer
|
||||
drawer: {
|
||||
terminal: 'Terminal',
|
||||
files: 'Espacio de trabajo',
|
||||
},
|
||||
|
||||
// Chat
|
||||
chat: {
|
||||
contextRemaining: 'restante',
|
||||
@@ -130,6 +136,7 @@ export default {
|
||||
renamed: 'Renombrada',
|
||||
renameFailed: 'Error al renombrar',
|
||||
renameSession: 'Renombrar sesion',
|
||||
sessionNotFound: 'Sesion no encontrada',
|
||||
enterNewTitle: 'Introduce un nuevo titulo',
|
||||
other: 'Otro',
|
||||
runFailed: 'Error en la ejecucion',
|
||||
@@ -363,6 +370,7 @@ jobTriggered: 'Job ejecutado',
|
||||
importInvalidFile: 'Por favor, selecciona un archivo valido (.tar.gz, .tgz, .gz, .zip)',
|
||||
name: 'Nombre del perfil',
|
||||
namePlaceholder: 'Solo letras, numeros y guiones',
|
||||
nameValidation: 'El nombre del perfil solo puede contener letras minúsculas, números, guiones bajos y guiones',
|
||||
newName: 'Nuevo nombre',
|
||||
newNamePlaceholder: 'Introduce un nuevo nombre',
|
||||
cloneFromCurrent: 'Clonar desde el perfil actual',
|
||||
@@ -597,6 +605,14 @@ jobTriggered: 'Job ejecutado',
|
||||
new_0_5_6_6: 'Rediseñado el manejo de adjuntos usando formato de matriz ContentBlock estilo Anthropic (texto, imagen, archivo)',
|
||||
new_0_5_6_7: 'Añadida funcionalidad de descarga de archivos en frontend soportando formatos ContentBlock y Markdown con autenticación',
|
||||
new_0_5_6_8: 'Corregido conflicto de múltiples procesos que causaba reinicios de base de datos SQLite eliminando instancias nodemon redundantes',
|
||||
new_0_5_8_1: 'Añadir panel de cajón con soporte de barra lateral móvil y botón arcoíris personalizable',
|
||||
new_0_5_8_2: 'Corregir problema de sincronización de estado de cambio de perfil con actualización inmediata de UI y verificación de backend',
|
||||
new_0_5_8_3: 'Filtrar caracteres especiales y emoji en reproducción de voz para mejor síntesis de voz',
|
||||
new_0_5_8_4: 'Añadir clave i18n faltante y unificar fuente de datos de sesión para priorizar base de datos',
|
||||
new_0_5_8_5: 'Optimizar configuración de build Vite para builds Docker más rápidas con esbuild y división de chunks',
|
||||
new_0_5_7_1: 'Optimizar compresión de contexto para soportar contenido rico (imágenes, archivos) con mejora en el manejo de mensajes de herramientas',
|
||||
new_0_5_7_2: 'Mejorar sincronización de sesiones con inserciones por lotes y protección de transacciones para consistencia de datos',
|
||||
new_0_5_7_3: 'Corregir recepción de eventos usage.updated para asegurar seguimiento preciso de tokens entre ejecuciones',
|
||||
new_0_5_5_1: '🎉 ¡Feliz Día del Trabajo! Hoy no se trabaja, agradezcan su comprensión',
|
||||
new_0_5_5_2: 'Añadida página de historial para sesiones Hermes',
|
||||
new_0_5_5_3: 'La página de historial gestiona sesiones de forma independiente',
|
||||
|
||||
@@ -95,6 +95,12 @@ export default {
|
||||
noChangelog: 'Aucun journal disponible',
|
||||
},
|
||||
|
||||
// Drawer
|
||||
drawer: {
|
||||
terminal: 'Terminal',
|
||||
files: 'Espace de travail',
|
||||
},
|
||||
|
||||
// Chat
|
||||
chat: {
|
||||
contextRemaining: 'restant',
|
||||
@@ -130,6 +136,7 @@ export default {
|
||||
renamed: 'Renomme',
|
||||
renameFailed: 'Echec du renommage',
|
||||
renameSession: 'Renommer la session',
|
||||
sessionNotFound: 'Session non trouvee',
|
||||
enterNewTitle: 'Entrez un nouveau titre',
|
||||
other: 'Autre',
|
||||
runFailed: 'Echec de l\'execution',
|
||||
@@ -363,6 +370,7 @@ jobTriggered: 'Job declenche',
|
||||
importInvalidFile: 'Veuillez selectionner une archive valide (.tar.gz, .tgz, .gz, .zip)',
|
||||
name: 'Nom du profil',
|
||||
namePlaceholder: 'Lettres, chiffres et tirets uniquement',
|
||||
nameValidation: 'Le nom du profil ne peut contenir que des lettres minuscules, des chiffres, des tirets bas et des tirets',
|
||||
newName: 'Nouveau nom',
|
||||
newNamePlaceholder: 'Entrez un nouveau nom',
|
||||
cloneFromCurrent: 'Cloner depuis le profil actuel',
|
||||
@@ -597,6 +605,14 @@ jobTriggered: 'Job declenche',
|
||||
new_0_5_6_6: 'Repensé la gestion des pièces jointes en utilisant le format de tableau ContentBlock style Anthropic (texte, image, fichier)',
|
||||
new_0_5_6_7: 'Ajouté la fonctionnalité de téléchargement de fichiers frontend supportant les formats ContentBlock et Markdown avec authentification',
|
||||
new_0_5_6_8: 'Corrigé le conflit multi-processus causant des réinitialisations de base de données SQLite en éliminant les instances nodemon redondantes',
|
||||
new_0_5_8_1: 'Ajouter le panneau de tiroir avec support de barre latérale mobile et bouton arc-en-ciel personnalisable',
|
||||
new_0_5_8_2: 'Corriger le problème de synchronisation d\'état de changement de profil avec mise à jour immédiate de l\'UI et vérification du backend',
|
||||
new_0_5_8_3: 'Filtrer les caractères spéciaux et emoji dans la lecture vocale pour une meilleure synthèse vocale',
|
||||
new_0_5_8_4: 'Ajouter la clé i18n manquante et unifier la source de données de session pour prioriser la base de données',
|
||||
new_0_5_8_5: 'Optimiser la configuration de build Vite pour des builds Docker plus rapides avec esbuild et la division de chunks',
|
||||
new_0_5_7_1: 'Optimiser la compression du contexte pour supporter le contenu riche (images, fichiers) avec amélioration du traitement des messages d\'outils',
|
||||
new_0_5_7_2: 'Améliorer la synchronisation des sessions avec des insertions par lot et la protection des transactions pour la cohérence des données',
|
||||
new_0_5_7_3: 'Corriger la réception des événements usage.updated pour assurer un suivi précis des tokens entre les exécutions',
|
||||
new_0_5_5_1: '🎉 Joyeuse Fête du Travail! Pas de travail aujourd\'hui, merci de votre compréhension',
|
||||
new_0_5_5_2: 'Ajout d\'une page d\'historique pour les sessions Hermes',
|
||||
new_0_5_5_3: 'La page d\'historique gère les sessions de manière indépendante',
|
||||
|
||||
@@ -95,6 +95,12 @@ export default {
|
||||
noChangelog: '更新履歴はありません',
|
||||
},
|
||||
|
||||
// ドロワー
|
||||
drawer: {
|
||||
terminal: 'ターミナル',
|
||||
files: 'ワークスペース',
|
||||
},
|
||||
|
||||
// チャット
|
||||
chat: {
|
||||
contextRemaining: '残り',
|
||||
@@ -130,6 +136,7 @@ export default {
|
||||
renamed: '名前を変更しました',
|
||||
renameFailed: '名前の変更に失敗しました',
|
||||
renameSession: 'セッション名の変更',
|
||||
sessionNotFound: 'セッションが見つかりません',
|
||||
enterNewTitle: '新しいタイトルを入力',
|
||||
other: 'その他',
|
||||
runFailed: '実行に失敗しました',
|
||||
@@ -363,6 +370,7 @@ export default {
|
||||
importInvalidFile: '有効なアーカイブファイルを選択してください (.tar.gz, .tgz, .gz, .zip)',
|
||||
name: 'プロファイル名',
|
||||
namePlaceholder: '英数字、ハイフンのみ',
|
||||
nameValidation: 'プロファイル名には小文字、数字、アンダースコア、ハイフンのみ使用できます',
|
||||
newName: '新しい名前',
|
||||
newNamePlaceholder: '新しい名前を入力',
|
||||
cloneFromCurrent: '現在のプロファイルから複製',
|
||||
@@ -597,6 +605,14 @@ export default {
|
||||
new_0_5_6_6: 'AnthropicスタイルのContentBlock配列形式(テキスト、画像、ファイル)を使用して添付ファイル処理を再設計',
|
||||
new_0_5_6_7: 'ContentBlockおよびMarkdown形式をサポートし、認証付きのフロントエンドファイルダウンロード機能を追加',
|
||||
new_0_5_6_8: '重複するnodemonインスタンスを削除し、SQLiteデータベースのリセットを引き起こすマルチプロセス競合を修正',
|
||||
new_0_5_8_1: 'モバイルサイドバーサポートとカスタマイズ可能なレインボーボタン付きドロワーパネルを追加',
|
||||
new_0_5_8_2: 'プロファイル切り替え状態同期問題を修正し、UIを即座に更新してバックエンドを検証',
|
||||
new_0_5_8_3: '音声合成を改善するため、音声再生の特殊文字と絵文字をフィルタリング',
|
||||
new_0_5_8_4: '不足しているi18nキーを追加し、セッションデータソースを統一してデータベースを優先',
|
||||
new_0_5_8_5: 'esbuildとチャンク分割を使用してDockerビルドを高速化するためにViteビルド設定を最適化',
|
||||
new_0_5_7_1: 'リッチコンテンツ(画像、ファイル)をサポートするためのコンテキスト圧縮を最適化し、ツールメッセージ処理を改善',
|
||||
new_0_5_7_2: 'バッチ挿入とトランザクション保護でデータ整合性を確保しながらセッション同期を改善',
|
||||
new_0_5_7_3: '実行間で正確なトークン追跡を確保するため、usage.updatedイベントの受信を修正',
|
||||
new_0_5_5_1: '🎉 労働者の日!今日はお休みです、何卒ご理解ください',
|
||||
new_0_5_5_2: 'Hermesセッション履歴ページを追加',
|
||||
new_0_5_5_3: '履歴ページはアクティブチャットに干渉せずにセッション管理',
|
||||
|
||||
@@ -95,6 +95,12 @@ export default {
|
||||
noChangelog: '변경 이력이 없습니다',
|
||||
},
|
||||
|
||||
// 서랍
|
||||
drawer: {
|
||||
terminal: '터미널',
|
||||
files: '작업 공간',
|
||||
},
|
||||
|
||||
// 채팅
|
||||
chat: {
|
||||
contextRemaining: '남음',
|
||||
@@ -130,6 +136,7 @@ export default {
|
||||
renamed: '이름이 변경되었습니다',
|
||||
renameFailed: '이름 변경 실패',
|
||||
renameSession: '세션 이름 변경',
|
||||
sessionNotFound: '세션을 찾을 수 없습니다',
|
||||
enterNewTitle: '새 제목을 입력하세요',
|
||||
other: '기타',
|
||||
runFailed: '실행 실패',
|
||||
@@ -363,6 +370,7 @@ export default {
|
||||
importInvalidFile: '유효한 아카이브 파일을 선택해 주세요 (.tar.gz, .tgz, .gz, .zip)',
|
||||
name: '프로필 이름',
|
||||
namePlaceholder: '영문, 숫자, 하이픈만 사용 가능',
|
||||
nameValidation: '프로필 이름에는 소문자, 숫자, 밑줄, 하이픈만 사용할 수 있습니다',
|
||||
newName: '새 이름',
|
||||
newNamePlaceholder: '새 이름을 입력하세요',
|
||||
cloneFromCurrent: '현재 프로필에서 복제',
|
||||
@@ -597,6 +605,14 @@ export default {
|
||||
new_0_5_6_6: 'Anthropic 스타일의 ContentBlock 배열 형식(텍스트, 이미지, 파일)을 사용하여 첨부파일 처리를 재설계',
|
||||
new_0_5_6_7: '인증이 포함된 ContentBlock 및 Markdown 형식을 지원하는 프론트엔드 파일 다운로드 기능 추가',
|
||||
new_0_5_6_8: '중복된 nodemon 인스턴스를 제거하여 SQLite 데이터베이스 재설정을 일으키는 다중 프로세스 충돌 수정',
|
||||
new_0_5_8_1: '모바일 사이드바 지원 및 사용자 정의 가능한 무지개 버튼이 포함된 서랍 패널 추가',
|
||||
new_0_5_8_2: '프로필 전환 상태 동기화 문제를 수정하고 즉시 UI 업데이트 및 백엔드 검증',
|
||||
new_0_5_8_3: '음성 합성 개선을 위해 음성 재생의 특수 문자 및 이모지 필터링',
|
||||
new_0_5_8_4: '누락된 i18n 키를 추가하고 데이터베이스 우선 순위를 위해 세션 데이터 소스 통합',
|
||||
new_0_5_8_5: 'esbuild 및 청크 분할을 사용하여 Docker 빌드 속도를 높이기 위해 Vite 빌드 구성 최적화',
|
||||
new_0_5_7_1: '도구 메시지 처리를 개선하여 리치 콘텐츠(이미지, 파일)를 지원하는 컨텍스트 압축 최적화',
|
||||
new_0_5_7_2: '일괄 삽입 및 트랜잭션 보호로 데이터 일관성을 보장하는 세션 동기화 개선',
|
||||
new_0_5_7_3: '실행 간 정확한 토큰 추적을 보장하기 위한 usage.updated 이벤트 수신 수정',
|
||||
new_0_5_5_1: '🎉 노동절 감사합니다! 오늘은 쉬니까 양해 부탁드립니다',
|
||||
new_0_5_5_2: 'Hermes 세션 기록 페이지 추가',
|
||||
new_0_5_5_3: '기록 페이지는 독립적으로 세션 관리',
|
||||
|
||||
@@ -95,6 +95,12 @@ export default {
|
||||
noChangelog: 'Nenhum registro disponivel',
|
||||
},
|
||||
|
||||
// Gaveta
|
||||
drawer: {
|
||||
terminal: 'Terminal',
|
||||
files: 'Espaço de trabalho',
|
||||
},
|
||||
|
||||
// Chat
|
||||
chat: {
|
||||
contextRemaining: 'restante',
|
||||
@@ -130,6 +136,7 @@ export default {
|
||||
renamed: 'Renomeado',
|
||||
renameFailed: 'Falha ao renomear',
|
||||
renameSession: 'Renomear sessao',
|
||||
sessionNotFound: 'Sessao nao encontrada',
|
||||
enterNewTitle: 'Digite um novo titulo',
|
||||
other: 'Outro',
|
||||
runFailed: 'Falha na execucao',
|
||||
@@ -363,6 +370,7 @@ jobTriggered: 'Job acionado',
|
||||
importInvalidFile: 'Por favor, selecione um arquivo valido (.tar.gz, .tgz, .gz, .zip)',
|
||||
name: 'Nome do perfil',
|
||||
namePlaceholder: 'Apenas letras, numeros e hifens',
|
||||
nameValidation: 'O nome do perfil só pode conter letras minúsculas, números, sublinhados e hifens',
|
||||
newName: 'Novo nome',
|
||||
newNamePlaceholder: 'Digite um novo nome',
|
||||
cloneFromCurrent: 'Clonar do perfil atual',
|
||||
@@ -597,6 +605,14 @@ jobTriggered: 'Job acionado',
|
||||
new_0_5_6_6: 'Processamento de anexos reprojetado usando formato de matriz ContentBlock estilo Anthropic (texto, imagem, arquivo)',
|
||||
new_0_5_6_7: 'Adicionada funcionalidade de download de arquivos frontend suportando formatos ContentBlock e Markdown com autenticação',
|
||||
new_0_5_6_8: 'Corrigido conflito de múltiplos processos que causava redefinições do banco de dados SQLite eliminando instâncias nodemon redundantes',
|
||||
new_0_5_8_1: 'Adicionar painel de gaveta com suporte de barra lateral móvel e botão arco-íris personalizável',
|
||||
new_0_5_8_2: 'Corrigir problema de sincronização de estado de troca de perfil com atualização imediata de IU e verificação de backend',
|
||||
new_0_5_8_3: 'Filtrar caracteres especiais e emoji na reprodução de voz para melhor síntese de voz',
|
||||
new_0_5_8_4: 'Adicionar chave i18n ausente e unificar fonte de dados de sessão para priorizar banco de dados',
|
||||
new_0_5_8_5: 'Otimizar configuração de build Vite para builds Docker mais rápidos com esbuild e divisão de chunks',
|
||||
new_0_5_7_1: 'Otimizar compressão de contexto para suportar conteúdo rico (imagens, arquivos) com melhoria no manuseio de mensagens de ferramentas',
|
||||
new_0_5_7_2: 'Melhorar sincronização de sessões com inserções em lote e proteção de transações para consistência de dados',
|
||||
new_0_5_7_3: 'Corrigir recepção de eventos usage.updated para garantir rastreamento preciso de tokens entre execuções',
|
||||
new_0_5_5_1: '🎉 Feliz Dia do Trabalhador! Hoje não se trabalha, obrigado pela compreensão',
|
||||
new_0_5_5_2: 'Adicionada página de histórico para sessões Hermes',
|
||||
new_0_5_5_3: 'Página de histórico gerencia sessões de forma independente',
|
||||
|
||||
@@ -105,6 +105,12 @@ export default {
|
||||
noChangelog: '暂无更新日志',
|
||||
},
|
||||
|
||||
// 抽屉
|
||||
drawer: {
|
||||
terminal: '终端',
|
||||
files: '工作区',
|
||||
},
|
||||
|
||||
// 对话
|
||||
chat: {
|
||||
contextRemaining: '剩余',
|
||||
@@ -153,6 +159,7 @@ export default {
|
||||
renamed: '已重命名',
|
||||
renameFailed: '重命名失败',
|
||||
renameSession: '重命名会话',
|
||||
sessionNotFound: '会话未找到',
|
||||
enterNewTitle: '输入新标题',
|
||||
workspace: '工作区',
|
||||
setWorkspace: '设置工作区',
|
||||
@@ -397,6 +404,7 @@ export default {
|
||||
importInvalidFile: '请选择有效的归档文件 (.tar.gz, .tgz, .gz, .zip)',
|
||||
name: '配置名称',
|
||||
namePlaceholder: '仅限小写字母、数字、连字符',
|
||||
nameValidation: '配置名称只能包含小写字母、数字、下划线和连字符',
|
||||
newName: '新名称',
|
||||
newNamePlaceholder: '小写字母、数字、连字符',
|
||||
cloneFromCurrent: '从当前配置克隆',
|
||||
@@ -623,6 +631,10 @@ export default {
|
||||
closeSession: '关闭此会话?',
|
||||
sessionExited: '已退出',
|
||||
processExited: '进程已退出,代码 {code}',
|
||||
noSessions: '暂无终端会话',
|
||||
connectionFailed: '终端服务连接失败',
|
||||
connectionClosed: '终端连接已关闭',
|
||||
connectionError: '终端连接错误',
|
||||
},
|
||||
|
||||
// 群聊
|
||||
@@ -699,6 +711,7 @@ export default {
|
||||
// 文件管理
|
||||
files: {
|
||||
title: '文件',
|
||||
fileTree: '文件树',
|
||||
tree: '目录树',
|
||||
list: '文件列表',
|
||||
breadcrumbRoot: '根目录',
|
||||
@@ -769,6 +782,14 @@ export default {
|
||||
new_0_5_6_6: '重新设计附件处理,采用 Anthropic 风格的 ContentBlock 数组格式,支持类型区分(文本、图片、文件)',
|
||||
new_0_5_6_7: '新增前端文件下载功能,支持 ContentBlock 和 Markdown 两种格式,带身份验证',
|
||||
new_0_5_6_8: '修复多进程冲突导致的 SQLite 数据库重置问题,清理冗余 nodemon 进程',
|
||||
new_0_5_8_1: '新增抽屉面板支持移动端侧边栏,可自定义彩虹边框按钮',
|
||||
new_0_5_8_2: '修复 profile 切换状态同步问题,立即更新 UI 并验证后端状态',
|
||||
new_0_5_8_3: '过滤语音播放中的特殊字符和表情符号,改善语音合成效果',
|
||||
new_0_5_8_4: '添加缺失的 i18n 键并统一会话数据源,优先使用数据库',
|
||||
new_0_5_8_5: '优化 Vite 构建配置加快 Docker 构建,使用 esbuild 和代码分割',
|
||||
new_0_5_7_1: '优化上下文压缩以支持富内容(图片、文件),改进工具消息处理',
|
||||
new_0_5_7_2: '改进会话同步,使用批量插入和事务保护确保数据一致性',
|
||||
new_0_5_7_3: '修复 usage.updated 事件接收,确保跨运行准确追踪 token',
|
||||
new_0_5_5_1: '🎉 五一劳动节快乐!这个劳动节就不劳动啦,如果有问题大家忍忍',
|
||||
new_0_5_5_2: '新增历史页面,用于浏览 Hermes 会话历史记录',
|
||||
new_0_5_5_3: '历史页面独立管理会话状态,不影响当前聊天页面的活动会话',
|
||||
|
||||
@@ -78,7 +78,43 @@ export const useProfilesStore = defineStore('profiles', () => {
|
||||
switching.value = true
|
||||
try {
|
||||
const ok = await profilesApi.switchProfile(name)
|
||||
if (ok) await fetchProfiles()
|
||||
if (ok) {
|
||||
// 保存旧值,用于可能的回滚
|
||||
const oldName = activeProfileName.value
|
||||
|
||||
// 立即更新 activeProfileName,确保前端显示正确
|
||||
// 不要完全依赖 fetchProfiles 的返回值,以防后端数据同步延迟
|
||||
activeProfileName.value = name
|
||||
localStorage.setItem(ACTIVE_PROFILE_STORAGE_KEY, name)
|
||||
|
||||
// 尝试刷新 profiles 列表并验证
|
||||
try {
|
||||
await fetchProfiles()
|
||||
|
||||
// 验证:检查后端返回的 active profile 是否与我们期望的一致
|
||||
// 如果不一致,说明后端实际上没有切换成功,需要回滚
|
||||
const actualActive = profiles.value.find(p => p.active)
|
||||
if (actualActive && actualActive.name !== name) {
|
||||
console.warn(
|
||||
`[switchProfile] Backend verification failed: expected active profile "${name}", ` +
|
||||
`but backend reports "${actualActive.name}". Rolling back frontend state.`
|
||||
)
|
||||
// 回滚到旧值
|
||||
activeProfileName.value = oldName
|
||||
if (oldName) {
|
||||
localStorage.setItem(ACTIVE_PROFILE_STORAGE_KEY, oldName)
|
||||
} else {
|
||||
localStorage.removeItem(ACTIVE_PROFILE_STORAGE_KEY)
|
||||
}
|
||||
// 返回 false 以触发 UI 错误提示
|
||||
return false
|
||||
}
|
||||
} catch (err) {
|
||||
// fetchProfiles 失败,无法验证
|
||||
// 假设切换成功(API 返回了 200),保持已设置的状态
|
||||
console.warn('Failed to refresh profiles list after switch, assuming switch succeeded:', err)
|
||||
}
|
||||
}
|
||||
return ok
|
||||
} finally {
|
||||
switching.value = false
|
||||
|
||||
@@ -116,7 +116,7 @@ a {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 12px;
|
||||
z-index: 1001;
|
||||
z-index: 99;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||
import { listConversationSummaries, getConversationDetail } from '../../services/hermes/conversations'
|
||||
import { listConversationSummariesFromDb, getConversationDetailFromDb } from '../../db/hermes/conversations-db'
|
||||
import { listSessionSummaries, searchSessionSummaries, getUsageStatsFromDb } from '../../db/hermes/sessions-db'
|
||||
import { listSessionSummaries, searchSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb } from '../../db/hermes/sessions-db'
|
||||
import {
|
||||
listSessions as localListSessions,
|
||||
searchSessions as localSearchSessions,
|
||||
@@ -232,7 +232,18 @@ export async function get(ctx: any) {
|
||||
* GET /api/hermes/sessions/hermes/:id
|
||||
*/
|
||||
export async function getHermesSession(ctx: any) {
|
||||
// Try database first (consistent with listHermesSessions)
|
||||
try {
|
||||
const session = await getSessionDetailFromDb(ctx.params.id)
|
||||
if (session && session.source !== 'api_server' && session.source !== 'cron') {
|
||||
ctx.body = { session }
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(err, 'Hermes Session DB: detail query failed, falling back to CLI')
|
||||
}
|
||||
|
||||
// Fallback to CLI
|
||||
const session = await hermesCli.getSession(ctx.params.id)
|
||||
if (!session) {
|
||||
ctx.status = 404
|
||||
|
||||
@@ -573,7 +573,6 @@ async function openSessionDb() {
|
||||
}
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const dbPath = sessionDbPath()
|
||||
console.log(`[sessions-db] Opening session db: ${dbPath}`)
|
||||
try {
|
||||
return new DatabaseSync(dbPath, { open: true, readOnly: true })
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -103,4 +103,80 @@ describe('Profiles Store', () => {
|
||||
await switchPromise
|
||||
expect(store.switching).toBe(false)
|
||||
})
|
||||
|
||||
it('switchProfile updates activeProfileName immediately', async () => {
|
||||
mockProfilesApi.switchProfile.mockResolvedValue(true)
|
||||
mockProfilesApi.fetchProfiles.mockResolvedValue([
|
||||
{ name: 'default', active: false, model: 'gpt-4', gateway: 'stopped', alias: '' },
|
||||
{ name: 'dev', active: true, model: 'gpt-4', gateway: 'running', alias: '' },
|
||||
])
|
||||
|
||||
const store = useProfilesStore()
|
||||
await store.switchProfile('dev')
|
||||
|
||||
// activeProfileName should be updated immediately
|
||||
expect(store.activeProfileName).toBe('dev')
|
||||
// localStorage should also be updated
|
||||
expect(localStorage.getItem('hermes_active_profile_name')).toBe('dev')
|
||||
})
|
||||
|
||||
it('switchProfile does not update state when API fails', async () => {
|
||||
const initialName = 'default'
|
||||
localStorage.setItem('hermes_active_profile_name', initialName)
|
||||
|
||||
mockProfilesApi.switchProfile.mockResolvedValue(false) // API failed
|
||||
|
||||
const store = useProfilesStore()
|
||||
store.activeProfileName = initialName
|
||||
const result = await store.switchProfile('dev')
|
||||
|
||||
// Should return false
|
||||
expect(result).toBe(false)
|
||||
// activeProfileName should NOT change
|
||||
expect(store.activeProfileName).toBe(initialName)
|
||||
// localStorage should NOT change
|
||||
expect(localStorage.getItem('hermes_active_profile_name')).toBe(initialName)
|
||||
})
|
||||
|
||||
it('switchProfile keeps activeProfileName even if fetchProfiles fails', async () => {
|
||||
const initialName = 'default'
|
||||
localStorage.setItem('hermes_active_profile_name', initialName)
|
||||
|
||||
mockProfilesApi.switchProfile.mockResolvedValue(true)
|
||||
mockProfilesApi.fetchProfiles.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const store = useProfilesStore()
|
||||
store.activeProfileName = initialName
|
||||
const result = await store.switchProfile('dev')
|
||||
|
||||
// Should return true (API succeeded)
|
||||
expect(result).toBe(true)
|
||||
// activeProfileName should be updated even though fetchProfiles failed
|
||||
expect(store.activeProfileName).toBe('dev')
|
||||
// localStorage should be updated
|
||||
expect(localStorage.getItem('hermes_active_profile_name')).toBe('dev')
|
||||
})
|
||||
|
||||
it('switchProfile rolls back if backend reports different active profile', async () => {
|
||||
const initialName = 'default'
|
||||
localStorage.setItem('hermes_active_profile_name', initialName)
|
||||
|
||||
mockProfilesApi.switchProfile.mockResolvedValue(true)
|
||||
// Backend returns success, but active profile is still default (not the one we switched to)
|
||||
mockProfilesApi.fetchProfiles.mockResolvedValue([
|
||||
{ name: 'default', active: true, model: 'gpt-4', gateway: 'running', alias: '' },
|
||||
{ name: 'dev', active: false, model: 'gpt-4', gateway: 'stopped', alias: '' },
|
||||
])
|
||||
|
||||
const store = useProfilesStore()
|
||||
store.activeProfileName = initialName
|
||||
const result = await store.switchProfile('dev')
|
||||
|
||||
// Should return false (backend verification failed)
|
||||
expect(result).toBe(false)
|
||||
// activeProfileName should be rolled back to default
|
||||
expect(store.activeProfileName).toBe('default')
|
||||
// localStorage should be rolled back
|
||||
expect(localStorage.getItem('hermes_active_profile_name')).toBe('default')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user