Fix bridge history, profile models, and Windows gateway handling (#845)
* feat: support profile-aware group chat bridge flows * feat: route cron jobs through hermes cli * Fix group chat routing and isolate bridge tests * Add Grok image-to-video media skill * Default Grok videos to media directory * Fix bridge profile fallback and cron repeat clearing * Refine bridge chat and gateway platform handling * Filter bridge tool-call text deltas * Preserve structured bridge chat history * Prepare beta release build artifacts * Fix Windows run profile resolution * Fix Windows path compatibility checks * Fix profile-scoped model page display * Hide Windows subprocess windows for jobs and updates * Hide Windows file backend subprocess windows * Avoid Windows gateway restart lock conflicts * Treat Windows gateway lock as running on startup * Force release Windows gateway lock on restart * Tighten Windows gateway lock cleanup * Update chat e2e source expectation * Bump package version to 0.5.30 --------- Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import { request, getBaseUrlValue, getApiKey } from '../client'
|
||||
import { getBaseUrlValue, getApiKey } from '../client'
|
||||
|
||||
export type ContentBlock =
|
||||
| { type: 'text'; text: string }
|
||||
@@ -616,7 +616,3 @@ export function startRunViaSocket(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchModels(): Promise<{ data: Array<{ id: string }> }> {
|
||||
return request('/api/hermes/v1/models')
|
||||
}
|
||||
|
||||
@@ -72,10 +72,11 @@ export async function fetchConfig(sections?: string[]): Promise<AppConfig> {
|
||||
export async function updateConfigSection(
|
||||
section: string,
|
||||
values: Record<string, any>,
|
||||
options?: { restart?: boolean },
|
||||
): Promise<void> {
|
||||
await request('/api/hermes/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ section, values }),
|
||||
body: JSON.stringify({ section, values, ...options }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { request } from '../client'
|
||||
|
||||
export interface GatewayStatus {
|
||||
profile: string
|
||||
port: number
|
||||
host: string
|
||||
url: string
|
||||
running: boolean
|
||||
pid?: number
|
||||
diagnostics?: {
|
||||
pid_path: string
|
||||
config_path: string
|
||||
pid_file_exists: boolean
|
||||
config_exists: boolean
|
||||
health_url: string
|
||||
health_checked_at: string
|
||||
health_ok?: boolean
|
||||
reason: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGateways(): Promise<GatewayStatus[]> {
|
||||
const res = await request<{ gateways: GatewayStatus[] }>('/api/hermes/gateways')
|
||||
return res.gateways
|
||||
}
|
||||
|
||||
export async function startGateway(name: string): Promise<GatewayStatus> {
|
||||
const res = await request<{ success: boolean; gateway: GatewayStatus }>(`/api/hermes/gateways/${name}/start`, { method: 'POST' })
|
||||
return res.gateway
|
||||
}
|
||||
|
||||
export async function stopGateway(name: string): Promise<void> {
|
||||
await request(`/api/hermes/gateways/${name}/stop`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function checkGatewayHealth(name: string): Promise<GatewayStatus> {
|
||||
const res = await request<{ gateway: GatewayStatus }>(`/api/hermes/gateways/${name}/health`)
|
||||
return res.gateway
|
||||
}
|
||||
@@ -30,6 +30,22 @@ export interface ChatMessage {
|
||||
senderName: string
|
||||
content: string
|
||||
timestamp: number
|
||||
role?: string
|
||||
tool_call_id?: string | null
|
||||
tool_calls?: any[] | null
|
||||
tool_name?: string | null
|
||||
finish_reason?: string | null
|
||||
reasoning?: string | null
|
||||
reasoning_details?: string | null
|
||||
reasoning_content?: string | null
|
||||
isStreaming?: boolean
|
||||
toolName?: string
|
||||
toolCallId?: string
|
||||
toolArgs?: string
|
||||
toolPreview?: string
|
||||
toolResult?: string
|
||||
toolStatus?: 'running' | 'done' | 'error'
|
||||
attachments?: Array<{ id: string; name: string; type: string; size: number; url: string }>
|
||||
}
|
||||
|
||||
export interface MemberInfo {
|
||||
|
||||
@@ -4,7 +4,6 @@ export interface HermesProfile {
|
||||
name: string
|
||||
active: boolean
|
||||
model: string
|
||||
gateway: string
|
||||
alias: string
|
||||
}
|
||||
|
||||
@@ -13,7 +12,6 @@ export interface HermesProfileDetail {
|
||||
path: string
|
||||
model: string
|
||||
provider: string
|
||||
gateway: string
|
||||
skills: number
|
||||
hasEnv: boolean
|
||||
hasSoulMd: boolean
|
||||
|
||||
@@ -2,6 +2,7 @@ import { request, getApiKey, getBaseUrlValue } from '../client'
|
||||
|
||||
export interface SessionSummary {
|
||||
id: string
|
||||
profile?: string
|
||||
source: string
|
||||
model: string
|
||||
provider?: string
|
||||
@@ -48,10 +49,11 @@ export interface HermesMessage {
|
||||
reasoning: string | null
|
||||
}
|
||||
|
||||
export async function fetchSessions(source?: string, limit?: number): Promise<SessionSummary[]> {
|
||||
export async function fetchSessions(source?: string, limit?: number, profile?: string): Promise<SessionSummary[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (source) params.set('source', source)
|
||||
if (limit) params.set('limit', String(limit))
|
||||
if (profile) params.set('profile', profile)
|
||||
const query = params.toString()
|
||||
const res = await request<{ sessions: SessionSummary[] }>(`/api/hermes/sessions${query ? `?${query}` : ''}`)
|
||||
return res.sessions
|
||||
@@ -231,9 +233,11 @@ export async function fetchSessionUsageSingle(id: string): Promise<{ input_token
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchContextLength(profile?: string): Promise<number> {
|
||||
export async function fetchContextLength(profile?: string, provider?: string, model?: string): Promise<number> {
|
||||
const params = new URLSearchParams()
|
||||
if (profile) params.set('profile', profile)
|
||||
if (provider) params.set('provider', provider)
|
||||
if (model) params.set('model', model)
|
||||
const query = params.toString()
|
||||
const res = await request<{ context_length: number }>(`/api/hermes/sessions/context-length${query ? `?${query}` : ''}`)
|
||||
return res.context_length
|
||||
|
||||
@@ -45,11 +45,19 @@ export interface AvailableModelGroup {
|
||||
model_meta?: Record<string, { preview?: boolean; disabled?: boolean; alias?: string }>
|
||||
}
|
||||
|
||||
export interface ProfileAvailableModels {
|
||||
profile: string
|
||||
default: string
|
||||
default_provider: string
|
||||
groups: AvailableModelGroup[]
|
||||
}
|
||||
|
||||
export interface AvailableModelsResponse {
|
||||
default: string
|
||||
default_provider: string
|
||||
groups: AvailableModelGroup[]
|
||||
allProviders: AvailableModelGroup[]
|
||||
profiles?: ProfileAvailableModels[]
|
||||
/** Web UI-only display aliases keyed by provider -> canonical model ID. */
|
||||
model_aliases?: Record<string, Record<string, string>>
|
||||
model_visibility?: ModelVisibility
|
||||
@@ -76,8 +84,18 @@ export async function fetchConfigModels(): Promise<ConfigModelsResponse> {
|
||||
return request<ConfigModelsResponse>('/api/hermes/config/models')
|
||||
}
|
||||
|
||||
export async function fetchAvailableModels(): Promise<AvailableModelsResponse> {
|
||||
return request<AvailableModelsResponse>('/api/hermes/available-models')
|
||||
function currentProfileName(): string {
|
||||
try {
|
||||
return localStorage.getItem('hermes_active_profile_name') || 'default'
|
||||
} catch {
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAvailableModels(profile = currentProfileName()): Promise<AvailableModelsResponse> {
|
||||
const params = new URLSearchParams()
|
||||
params.set('profile', profile || 'default')
|
||||
return request<AvailableModelsResponse>(`/api/hermes/available-models?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function fetchProviderModels(data: {
|
||||
|
||||
@@ -161,9 +161,8 @@ async function saveContextLimit() {
|
||||
|
||||
isSavingContextLimit.value = true
|
||||
try {
|
||||
const appStore = useAppStore()
|
||||
const provider = appStore.selectedProvider || ''
|
||||
const model = appStore.selectedModel || ''
|
||||
const provider = chatStore.activeSession?.provider || useAppStore().selectedProvider || ''
|
||||
const model = chatStore.activeSession?.model || useAppStore().selectedModel || ''
|
||||
|
||||
if (!provider || !model) {
|
||||
message.error(t('chat.contextEditFailed'))
|
||||
@@ -183,8 +182,13 @@ async function saveContextLimit() {
|
||||
|
||||
async function loadContextLength() {
|
||||
try {
|
||||
const profile = useProfilesStore().activeProfileName || undefined
|
||||
contextLength.value = await fetchContextLength(profile)
|
||||
const activeSession = chatStore.activeSession
|
||||
const profile = activeSession?.profile || useProfilesStore().activeProfileName || undefined
|
||||
contextLength.value = await fetchContextLength(
|
||||
profile,
|
||||
activeSession?.provider || undefined,
|
||||
activeSession?.model || undefined,
|
||||
)
|
||||
} catch {
|
||||
contextLength.value = FALLBACK_CONTEXT
|
||||
}
|
||||
@@ -192,7 +196,12 @@ async function loadContextLength() {
|
||||
|
||||
onMounted(loadContextLength)
|
||||
watch(() => useProfilesStore().activeProfileName, loadContextLength)
|
||||
watch(() => useAppStore().selectedProvider, loadContextLength)
|
||||
watch(() => useAppStore().selectedModel, loadContextLength)
|
||||
watch(() => chatStore.activeSession?.id, loadContextLength)
|
||||
watch(() => chatStore.activeSession?.profile, loadContextLength)
|
||||
watch(() => chatStore.activeSession?.provider, loadContextLength)
|
||||
watch(() => chatStore.activeSession?.model, loadContextLength)
|
||||
|
||||
const totalTokens = computed(() => {
|
||||
const input = chatStore.activeSession?.inputTokens ?? 0
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { renameSession, setSessionWorkspace, batchDeleteSessions, exportSession } from "@/api/hermes/sessions";
|
||||
import { useChatStore, type Session } from "@/stores/hermes/chat";
|
||||
import { useAppStore } from "@/stores/hermes/app";
|
||||
import { useProfilesStore } from "@/stores/hermes/profiles";
|
||||
import { useSessionBrowserPrefsStore } from "@/stores/hermes/session-browser-prefs";
|
||||
import {
|
||||
NButton,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
} 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";
|
||||
@@ -28,6 +28,7 @@ import OutlinePanel from "./OutlinePanel.vue";
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const appStore = useAppStore();
|
||||
const profilesStore = useProfilesStore();
|
||||
const sessionBrowserPrefsStore = useSessionBrowserPrefsStore();
|
||||
const message = useMessage();
|
||||
const { t } = useI18n();
|
||||
@@ -41,6 +42,8 @@ const currentMode = ref<"chat" | "live">("chat");
|
||||
// Batch selection mode
|
||||
const isBatchMode = ref(false);
|
||||
const selectedSessionIds = ref<Set<string>>(new Set());
|
||||
const showBatchDeleteConfirm = ref(false);
|
||||
const isBatchDeleting = ref(false);
|
||||
|
||||
// Initialize synchronously from the media query so first paint is correct.
|
||||
// On narrow viewports the session list is an absolute-positioned overlay
|
||||
@@ -71,6 +74,9 @@ onMounted(() => {
|
||||
mobileQuery = window.matchMedia("(max-width: 768px)");
|
||||
handleMobileChange(mobileQuery);
|
||||
mobileQuery.addEventListener("change", handleMobileChange);
|
||||
if (profilesStore.profiles.length === 0) {
|
||||
void profilesStore.fetchProfiles();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -80,15 +86,18 @@ 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") || "[]")),
|
||||
);
|
||||
const sessionProfileFilter = ref<string | null>(null);
|
||||
const profileFilterOptions = computed(() => [
|
||||
{ label: t("chat.allProfiles"), value: "__all__" },
|
||||
...profilesStore.profiles.map((profile) => ({
|
||||
label: profile.name,
|
||||
value: profile.name,
|
||||
})),
|
||||
]);
|
||||
|
||||
// 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;
|
||||
async function handleProfileFilterChange(value: string) {
|
||||
sessionProfileFilter.value = value === "__all__" ? null : value;
|
||||
await chatStore.loadSessions(sessionProfileFilter.value);
|
||||
}
|
||||
|
||||
function sortSessionsWithActiveFirst(items: Session[]): Session[] {
|
||||
@@ -97,13 +106,6 @@ function sortSessionsWithActiveFirst(items: Session[]): Session[] {
|
||||
});
|
||||
}
|
||||
|
||||
// Group sessions by source, with sort order
|
||||
interface SessionGroup {
|
||||
source: string;
|
||||
label: string;
|
||||
sessions: Session[];
|
||||
}
|
||||
|
||||
const pinnedSessions = computed(() =>
|
||||
sortSessionsWithActiveFirst(
|
||||
chatStore.sessions.filter((session) =>
|
||||
@@ -112,80 +114,12 @@ const pinnedSessions = computed(() =>
|
||||
),
|
||||
);
|
||||
|
||||
const groupedSessions = computed<SessionGroup[]>(() => {
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
return keys.map((key) => ({
|
||||
source: key,
|
||||
label: key ? getChatSourceLabel(key) : t("chat.other"),
|
||||
sessions: sortSessionsWithActiveFirst(map.get(key)!),
|
||||
}));
|
||||
});
|
||||
|
||||
function getChatSourceLabel(source?: string): string {
|
||||
if (source === "cli") return "Bridge (beta)";
|
||||
return getSourceLabel(source);
|
||||
}
|
||||
|
||||
function toggleGroup(source: string) {
|
||||
const isExpanded = !collapsedGroups.value.has(source);
|
||||
if (isExpanded) {
|
||||
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);
|
||||
if (group?.sessions.length) {
|
||||
chatStore.switchSession(group.sessions[0].id);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
collapsedGroups.value = new Set(
|
||||
groups.slice(1).map((group) => group.source),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"hermes_collapsed_groups",
|
||||
JSON.stringify([...collapsedGroups.value]),
|
||||
);
|
||||
},
|
||||
{ once: true },
|
||||
const unpinnedSessions = computed(() =>
|
||||
sortSessionsWithActiveFirst(
|
||||
chatStore.sessions.filter(
|
||||
(session) => !sessionBrowserPrefsStore.isPinned(session.id),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
watch(
|
||||
@@ -211,39 +145,110 @@ const headerTitle = computed(() =>
|
||||
: activeSessionTitle.value,
|
||||
);
|
||||
|
||||
const activeSessionSource = computed(() =>
|
||||
currentMode.value === "chat" ? chatStore.activeSession?.source || "" : "",
|
||||
);
|
||||
|
||||
const activeApproval = computed(() => chatStore.activePendingApproval);
|
||||
const visibleApproval = computed(() => activeApproval.value);
|
||||
const showNewChatModal = ref(false);
|
||||
const newChatProfile = ref<string>("default");
|
||||
const newChatProvider = ref<string>("");
|
||||
const newChatModel = ref<string>("");
|
||||
const newChatLoading = ref(false);
|
||||
|
||||
function handleNewChat() {
|
||||
chatStore.newChat();
|
||||
function getModelGroupsForProfile(profile: string) {
|
||||
const profileModels = appStore.profileModelGroups.find(
|
||||
(entry) => entry.profile === profile,
|
||||
);
|
||||
return profileModels?.groups?.length ? profileModels.groups : appStore.modelGroups;
|
||||
}
|
||||
|
||||
function handleNewCliChat() {
|
||||
const session = chatStore.newCliSession()
|
||||
chatStore.switchSession(session.id)
|
||||
function getDefaultModelForProfile(profile: string) {
|
||||
const groups = getModelGroupsForProfile(profile);
|
||||
const profileModels = appStore.profileModelGroups.find(
|
||||
(entry) => entry.profile === profile,
|
||||
);
|
||||
const defaultProvider = profileModels?.default_provider || "";
|
||||
const defaultModel = profileModels?.default || "";
|
||||
const providerGroup = defaultProvider
|
||||
? groups.find((group) => group.provider === defaultProvider)
|
||||
: undefined;
|
||||
const fallbackGroup = providerGroup || groups.find((group) => group.models.length > 0);
|
||||
return {
|
||||
provider: fallbackGroup?.provider || "",
|
||||
model: fallbackGroup?.models.includes(defaultModel)
|
||||
? defaultModel
|
||||
: fallbackGroup?.models[0] || "",
|
||||
};
|
||||
}
|
||||
|
||||
const newChatOptions = computed(() => [
|
||||
{
|
||||
label: "API",
|
||||
key: "api_server",
|
||||
},
|
||||
{
|
||||
label: "Bridge (beta)",
|
||||
key: "cli",
|
||||
},
|
||||
]);
|
||||
const newChatProfileOptions = computed(() =>
|
||||
(profilesStore.profiles.length > 0 ? profilesStore.profiles : [{ name: "default" }]).map((profile) => ({
|
||||
label: profile.name,
|
||||
value: profile.name,
|
||||
})),
|
||||
);
|
||||
|
||||
function handleNewChatSelect(key: string | number) {
|
||||
if (key === "cli") {
|
||||
handleNewCliChat();
|
||||
return;
|
||||
const newChatModelGroups = computed(() => {
|
||||
return getModelGroupsForProfile(newChatProfile.value);
|
||||
});
|
||||
|
||||
const newChatProviderOptions = computed(() =>
|
||||
newChatModelGroups.value.map((group) => ({
|
||||
label: group.label || group.provider,
|
||||
value: group.provider,
|
||||
})),
|
||||
);
|
||||
|
||||
const newChatModelOptions = computed(() => {
|
||||
const group = newChatModelGroups.value.find(
|
||||
(item) => item.provider === newChatProvider.value,
|
||||
);
|
||||
return (group?.models || []).map((model) => ({
|
||||
label: appStore.displayModelName(model, group?.provider),
|
||||
value: model,
|
||||
}));
|
||||
});
|
||||
|
||||
function syncNewChatModelSelection() {
|
||||
const defaults = getDefaultModelForProfile(newChatProfile.value);
|
||||
newChatProvider.value = defaults.provider;
|
||||
newChatModel.value = defaults.model;
|
||||
}
|
||||
|
||||
async function openNewChatModal() {
|
||||
showNewChatModal.value = true;
|
||||
newChatLoading.value = true;
|
||||
try {
|
||||
if (profilesStore.profiles.length === 0) await profilesStore.fetchProfiles();
|
||||
if (appStore.modelGroups.length === 0 && appStore.profileModelGroups.length === 0) {
|
||||
await appStore.loadModels();
|
||||
}
|
||||
newChatProfile.value =
|
||||
profilesStore.activeProfileName ||
|
||||
profilesStore.profiles.find((profile) => profile.active)?.name ||
|
||||
profilesStore.profiles[0]?.name ||
|
||||
"default";
|
||||
syncNewChatModelSelection();
|
||||
} finally {
|
||||
newChatLoading.value = false;
|
||||
}
|
||||
handleNewChat();
|
||||
}
|
||||
|
||||
function handleNewChatProfileChange(value: string) {
|
||||
newChatProfile.value = value;
|
||||
syncNewChatModelSelection();
|
||||
}
|
||||
|
||||
function handleNewChatProviderChange(value: string) {
|
||||
newChatProvider.value = value;
|
||||
newChatModel.value = newChatModelOptions.value[0]?.value || "";
|
||||
}
|
||||
|
||||
function confirmNewChat() {
|
||||
chatStore.newChat({
|
||||
profile: newChatProfile.value,
|
||||
provider: newChatProvider.value,
|
||||
model: newChatModel.value,
|
||||
});
|
||||
showNewChatModal.value = false;
|
||||
}
|
||||
|
||||
function handleApproval(choice: "once" | "session" | "always" | "deny") {
|
||||
@@ -266,19 +271,25 @@ function handleDeleteSession(id: string) {
|
||||
}
|
||||
|
||||
function toggleBatchMode() {
|
||||
if (isBatchDeleting.value) return;
|
||||
isBatchMode.value = !isBatchMode.value;
|
||||
if (!isBatchMode.value) {
|
||||
selectedSessionIds.value.clear();
|
||||
showBatchDeleteConfirm.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSessionSelection(id: string) {
|
||||
if (isBatchDeleting.value) return;
|
||||
if (selectedSessionIds.value.has(id)) {
|
||||
selectedSessionIds.value.delete(id);
|
||||
} else {
|
||||
selectedSessionIds.value.add(id);
|
||||
}
|
||||
selectedSessionIds.value = new Set(selectedSessionIds.value);
|
||||
if (selectedSessionIds.value.size === 0) {
|
||||
showBatchDeleteConfirm.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function isSessionSelected(id: string): boolean {
|
||||
@@ -286,9 +297,10 @@ function isSessionSelected(id: string): boolean {
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (selectedSessionIds.value.size === 0) return;
|
||||
if (selectedSessionIds.value.size === 0 || isBatchDeleting.value) return;
|
||||
|
||||
const ids = Array.from(selectedSessionIds.value);
|
||||
isBatchDeleting.value = true;
|
||||
try {
|
||||
const result = await batchDeleteSessions(ids);
|
||||
if (result.deleted > 0) {
|
||||
@@ -311,12 +323,20 @@ async function handleBatchDelete() {
|
||||
} catch (err: any) {
|
||||
message.error(t("chat.batchDeleteFailed"));
|
||||
} finally {
|
||||
isBatchDeleting.value = false;
|
||||
showBatchDeleteConfirm.value = false;
|
||||
isBatchMode.value = false;
|
||||
selectedSessionIds.value.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBatchDeleteConfirm() {
|
||||
void handleBatchDelete();
|
||||
return false;
|
||||
}
|
||||
|
||||
function selectAllSessions() {
|
||||
if (isBatchDeleting.value) return;
|
||||
selectedSessionIds.value.clear();
|
||||
for (const session of chatStore.sessions) {
|
||||
if (session.id !== chatStore.activeSessionId) {
|
||||
@@ -502,12 +522,21 @@ const sessionModelProvider = ref("");
|
||||
const sessionModelCustomInput = ref("");
|
||||
const sessionModelCustomProvider = ref("");
|
||||
|
||||
const sessionModelProfile = computed(() => {
|
||||
const session = chatStore.sessions.find((s) => s.id === sessionModelSessionId.value);
|
||||
return session?.profile || profilesStore.activeProfileName || "default";
|
||||
});
|
||||
|
||||
const sessionModelBaseGroups = computed(() =>
|
||||
getModelGroupsForProfile(sessionModelProfile.value),
|
||||
);
|
||||
|
||||
const sessionModelProviderOptions = computed(() =>
|
||||
appStore.modelGroups.map((group) => ({ label: group.label, value: group.provider })),
|
||||
sessionModelBaseGroups.value.map((group) => ({ label: group.label, value: group.provider })),
|
||||
);
|
||||
|
||||
const sessionModelGroupsWithCustom = computed(() =>
|
||||
appStore.modelGroups.map((group) => ({
|
||||
sessionModelBaseGroups.value.map((group) => ({
|
||||
...group,
|
||||
models: [
|
||||
...group.models,
|
||||
@@ -534,9 +563,10 @@ const filteredSessionModelGroups = computed(() => {
|
||||
|
||||
function openSessionModelModal(sessionId: string) {
|
||||
const session = chatStore.sessions.find((s) => s.id === sessionId);
|
||||
const defaults = getDefaultModelForProfile(session?.profile || profilesStore.activeProfileName || "default");
|
||||
sessionModelSessionId.value = sessionId;
|
||||
sessionModelValue.value = session?.model || appStore.selectedModel || "";
|
||||
sessionModelProvider.value = session?.provider || appStore.selectedProvider || "";
|
||||
sessionModelValue.value = session?.model || defaults.model || "";
|
||||
sessionModelProvider.value = session?.provider || defaults.provider || "";
|
||||
sessionModelCustomProvider.value = sessionModelProvider.value;
|
||||
sessionModelSearch.value = "";
|
||||
sessionModelCustomInput.value = "";
|
||||
@@ -565,7 +595,7 @@ function sessionModelAlias(model: string, provider: string) {
|
||||
}
|
||||
|
||||
async function selectSessionModel(model: string, provider: string) {
|
||||
const meta = appStore.modelGroups.find((group) => group.provider === provider)?.model_meta?.[model];
|
||||
const meta = sessionModelBaseGroups.value.find((group) => group.provider === provider)?.model_meta?.[model];
|
||||
if (meta?.disabled || !sessionModelSessionId.value) return;
|
||||
const ok = await chatStore.switchSessionModel(model, provider, sessionModelSessionId.value);
|
||||
if (ok) {
|
||||
@@ -643,7 +673,7 @@ async function handleSessionModelCustomSubmit() {
|
||||
quaternary
|
||||
size="tiny"
|
||||
@click="selectAllSessions"
|
||||
:disabled="!canSelectAll"
|
||||
:disabled="!canSelectAll || isBatchDeleting"
|
||||
:title="t('chat.selectAll')"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -662,10 +692,13 @@ async function handleSessionModelCustomSubmit() {
|
||||
</NButton>
|
||||
<NPopconfirm
|
||||
v-if="isBatchMode && selectedCount > 0"
|
||||
@positive-click="handleBatchDelete"
|
||||
v-model:show="showBatchDeleteConfirm"
|
||||
:positive-button-props="{ loading: isBatchDeleting, disabled: isBatchDeleting }"
|
||||
:negative-button-props="{ disabled: isBatchDeleting }"
|
||||
@positive-click="handleBatchDeleteConfirm"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton quaternary size="tiny" type="error">
|
||||
<NButton quaternary size="tiny" type="error" :loading="isBatchDeleting" :disabled="isBatchDeleting">
|
||||
<template #icon>
|
||||
<svg
|
||||
width="14"
|
||||
@@ -688,6 +721,7 @@ async function handleSessionModelCustomSubmit() {
|
||||
quaternary
|
||||
size="tiny"
|
||||
@click="toggleBatchMode"
|
||||
:disabled="isBatchDeleting"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
@@ -703,34 +737,31 @@ async function handleSessionModelCustomSubmit() {
|
||||
</svg>
|
||||
</template>
|
||||
</NButton>
|
||||
<NDropdown
|
||||
trigger="click"
|
||||
:options="newChatOptions"
|
||||
@select="handleNewChatSelect"
|
||||
>
|
||||
<NButton quaternary size="tiny" 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>
|
||||
</NDropdown>
|
||||
<NButton quaternary size="tiny" circle @click="openNewChatModal">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showSessions" class="session-scope-note">
|
||||
<span>{{ t("chat.sessionScopeHint") }}</span>
|
||||
<RouterLink class="session-scope-link" :to="{ name: 'hermes.history' }">
|
||||
{{ t("chat.openHistory") }}
|
||||
</RouterLink>
|
||||
<div v-if="showSessions" class="session-profile-filter">
|
||||
<NSelect
|
||||
:value="sessionProfileFilter || '__all__'"
|
||||
:options="profileFilterOptions"
|
||||
size="small"
|
||||
:loading="profilesStore.loading"
|
||||
@update:value="handleProfileFilterChange"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showSessions" class="session-items">
|
||||
<div
|
||||
@@ -761,6 +792,7 @@ async function handleSessionModelCustomSubmit() {
|
||||
:streaming="chatStore.isSessionLive(s.id)"
|
||||
:selectable="isBatchMode"
|
||||
:selected="isSessionSelected(s.id)"
|
||||
:show-profile="true"
|
||||
@select="handleSessionClick(s.id)"
|
||||
@contextmenu="handleContextMenu($event, s.id)"
|
||||
@delete="handleDeleteSession(s.id)"
|
||||
@@ -768,44 +800,25 @@ async function handleSessionModelCustomSubmit() {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<span class="session-group-label">{{ group.label }}</span>
|
||||
<span class="session-group-count">{{ group.sessions.length }}</span>
|
||||
</div>
|
||||
<template v-if="!collapsedGroups.has(group.source)">
|
||||
<SessionListItem
|
||||
v-for="s in group.sessions"
|
||||
:key="s.id"
|
||||
:session="s"
|
||||
:active="s.id === chatStore.activeSessionId"
|
||||
:pinned="false"
|
||||
:can-delete="
|
||||
s.id !== chatStore.activeSessionId ||
|
||||
chatStore.sessions.length > 1
|
||||
"
|
||||
:streaming="chatStore.isSessionLive(s.id)"
|
||||
:selectable="isBatchMode"
|
||||
:selected="isSessionSelected(s.id)"
|
||||
@select="handleSessionClick(s.id)"
|
||||
@contextmenu="handleContextMenu($event, s.id)"
|
||||
@delete="handleDeleteSession(s.id)"
|
||||
@toggle-select="toggleSessionSelection(s.id)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<SessionListItem
|
||||
v-for="s in unpinnedSessions"
|
||||
:key="s.id"
|
||||
:session="s"
|
||||
:active="s.id === chatStore.activeSessionId"
|
||||
:pinned="false"
|
||||
:can-delete="
|
||||
s.id !== chatStore.activeSessionId ||
|
||||
chatStore.sessions.length > 1
|
||||
"
|
||||
:streaming="chatStore.isSessionLive(s.id)"
|
||||
:selectable="isBatchMode"
|
||||
:selected="isSessionSelected(s.id)"
|
||||
:show-profile="true"
|
||||
@select="handleSessionClick(s.id)"
|
||||
@contextmenu="handleContextMenu($event, s.id)"
|
||||
@delete="handleDeleteSession(s.id)"
|
||||
@toggle-select="toggleSessionSelection(s.id)"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -946,6 +959,56 @@ async function handleSessionModelCustomSubmit() {
|
||||
</div>
|
||||
</NModal>
|
||||
|
||||
<NModal
|
||||
v-model:show="showNewChatModal"
|
||||
preset="card"
|
||||
:title="t('chat.newChat')"
|
||||
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
|
||||
:mask-closable="true"
|
||||
>
|
||||
<div class="new-chat-form">
|
||||
<label class="new-chat-field">
|
||||
<span class="new-chat-label">{{ t("sidebar.profiles") }}</span>
|
||||
<NSelect
|
||||
:value="newChatProfile"
|
||||
:options="newChatProfileOptions"
|
||||
:loading="newChatLoading || profilesStore.loading"
|
||||
@update:value="handleNewChatProfileChange"
|
||||
/>
|
||||
</label>
|
||||
<label class="new-chat-field">
|
||||
<span class="new-chat-label">{{ t("models.provider") }}</span>
|
||||
<NSelect
|
||||
:value="newChatProvider"
|
||||
:options="newChatProviderOptions"
|
||||
:disabled="newChatLoading"
|
||||
@update:value="handleNewChatProviderChange"
|
||||
/>
|
||||
</label>
|
||||
<label class="new-chat-field">
|
||||
<span class="new-chat-label">{{ t("models.models") }}</span>
|
||||
<NSelect
|
||||
v-model:value="newChatModel"
|
||||
:options="newChatModelOptions"
|
||||
:disabled="newChatLoading || !newChatProvider"
|
||||
filterable
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="new-chat-actions">
|
||||
<NButton @click="showNewChatModal = false">{{ t("common.cancel") }}</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
:disabled="!newChatProfile || !newChatProvider || !newChatModel"
|
||||
@click="confirmNewChat"
|
||||
>
|
||||
{{ t("chat.newChat") }}
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
|
||||
<div class="chat-main">
|
||||
<header class="chat-header">
|
||||
<div class="header-left">
|
||||
@@ -973,9 +1036,6 @@ async function handleSessionModelCustomSubmit() {
|
||||
</template>
|
||||
</NButton>
|
||||
<span class="header-session-title">{{ headerTitle }}</span>
|
||||
<span v-if="activeSessionSource" class="source-badge">{{
|
||||
getChatSourceLabel(activeSessionSource)
|
||||
}}</span>
|
||||
<span
|
||||
v-if="chatStore.activeSession?.workspace"
|
||||
class="workspace-badge"
|
||||
@@ -1041,28 +1101,22 @@ async function handleSessionModelCustomSubmit() {
|
||||
</template>
|
||||
{{ t("chat.copySessionId") }}
|
||||
</NTooltip>
|
||||
<NDropdown
|
||||
trigger="click"
|
||||
:options="newChatOptions"
|
||||
@select="handleNewChatSelect"
|
||||
>
|
||||
<NButton size="small" :circle="isMobile">
|
||||
<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>
|
||||
<template v-if="!isMobile">{{ t("chat.newChat") }}</template>
|
||||
</NButton>
|
||||
</NDropdown>
|
||||
<NButton size="small" :circle="isMobile" @click="openNewChatModal">
|
||||
<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>
|
||||
<template v-if="!isMobile">{{ t("chat.newChat") }}</template>
|
||||
</NButton>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
@@ -1460,27 +1514,32 @@ async function handleSessionModelCustomSubmit() {
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.session-scope-note {
|
||||
margin: 0 12px 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba($accent-primary, 0.16);
|
||||
border-radius: $radius-sm;
|
||||
background: rgba($accent-primary, 0.06);
|
||||
color: $text-secondary;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
.session-profile-filter {
|
||||
margin: 0 8px 10px;
|
||||
}
|
||||
|
||||
.session-scope-link {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
color: $accent-primary;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
.new-chat-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.new-chat-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.new-chat-label {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.new-chat-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-group-header {
|
||||
|
||||
@@ -44,7 +44,7 @@ const currentToolCalls = computed(() => {
|
||||
});
|
||||
|
||||
const visibleToolCalls = computed(() =>
|
||||
toolTraceVisible.value ? currentToolCalls.value.filter((tool) => !!tool.toolName) : [],
|
||||
currentToolCalls.value.filter((tool) => !!tool.toolName),
|
||||
);
|
||||
|
||||
const displayMessages = computed(() => {
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
import { computed, ref, onUnmounted } from 'vue'
|
||||
import { NPopconfirm, NCheckbox } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import multiavatar from '@multiavatar/multiavatar'
|
||||
import type { Session } from '@/stores/hermes/chat'
|
||||
import { useAppStore } from '@/stores/hermes/app'
|
||||
import { formatTimestampMs } from '@/shared/session-display'
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
session: Session
|
||||
active: boolean
|
||||
pinned: boolean
|
||||
@@ -14,7 +15,10 @@ const props = defineProps<{
|
||||
streaming?: boolean
|
||||
selectable?: boolean
|
||||
selected?: boolean
|
||||
}>()
|
||||
showProfile?: boolean
|
||||
}>(), {
|
||||
showProfile: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: []
|
||||
@@ -30,6 +34,8 @@ const sessionModelName = computed(() =>
|
||||
? appStore.displayModelName(props.session.model, props.session.provider)
|
||||
: '',
|
||||
)
|
||||
const profileName = computed(() => props.session.profile || 'default')
|
||||
const profileAvatar = computed(() => multiavatar(profileName.value))
|
||||
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const longPressTriggered = ref(false)
|
||||
@@ -107,6 +113,10 @@ onUnmounted(() => {
|
||||
<span v-if="sessionModelName" class="session-item-model" :title="session.model">{{ sessionModelName }}</span>
|
||||
<span class="session-item-time">{{ formatTimestampMs(session.createdAt) }}</span>
|
||||
</span>
|
||||
<span v-if="props.showProfile" class="session-item-profile">
|
||||
<span class="session-item-profile-avatar" v-html="profileAvatar" />
|
||||
<span class="session-item-profile-name">{{ profileName }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<NPopconfirm v-if="canDelete && !selectable" @positive-click="emit('delete')">
|
||||
<template #trigger>
|
||||
@@ -118,3 +128,38 @@ onUnmounted(() => {
|
||||
</NPopconfirm>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.session-item-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.session-item-profile-avatar {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.session-item-profile-avatar :deep(svg) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.session-item-profile-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,8 +15,8 @@ const emit = defineEmits<{
|
||||
|
||||
const roomName = ref('')
|
||||
const inviteCode = ref('')
|
||||
const userName = ref('')
|
||||
const description = ref('')
|
||||
const userName = ref(localStorage.getItem('gc_user_name') || '')
|
||||
const description = ref(localStorage.getItem('gc_user_description') || '')
|
||||
const roomInput = ref<InputLikeInstance | null>(null)
|
||||
|
||||
const compression = ref({
|
||||
|
||||
@@ -1,17 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NButton } from 'naive-ui'
|
||||
import { NButton, NSwitch, NTooltip } from 'naive-ui'
|
||||
import { useGroupChatStore } from '@/stores/hermes/group-chat'
|
||||
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
||||
import type { Attachment } from '@/stores/hermes/chat'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{ send: [content: string] }>()
|
||||
const emit = defineEmits<{ send: [content: string, attachments?: Attachment[]] }>()
|
||||
const store = useGroupChatStore()
|
||||
const { toolTraceVisible, toggleToolTraceVisible } = useToolTraceVisibility()
|
||||
|
||||
const inputText = ref('')
|
||||
const textareaRef = ref<HTMLTextAreaElement>()
|
||||
const dropdownRef = ref<HTMLDivElement>()
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
const attachments = ref<Attachment[]>([])
|
||||
const isDragging = ref(false)
|
||||
const dragCounter = ref(0)
|
||||
const isComposing = ref(false)
|
||||
const autoPlaySpeech = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const saved = localStorage.getItem('autoPlaySpeech')
|
||||
if (saved !== null) {
|
||||
autoPlaySpeech.value = saved === 'true'
|
||||
store.setAutoPlaySpeech(autoPlaySpeech.value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(autoPlaySpeech, (value) => {
|
||||
localStorage.setItem('autoPlaySpeech', String(value))
|
||||
store.setAutoPlaySpeech(value)
|
||||
})
|
||||
|
||||
// 自定义高度拖拽
|
||||
const textareaHeight = ref<number | null>(null)
|
||||
@@ -58,7 +79,7 @@ const filteredAgents = computed(() => {
|
||||
return store.agents.filter(a => a.name.toLowerCase().includes(query))
|
||||
})
|
||||
|
||||
const canSend = computed(() => !!inputText.value.trim())
|
||||
const canSend = computed(() => !!inputText.value.trim() || attachments.value.length > 0)
|
||||
|
||||
// ─── Scroll active item into view ──────────────────────
|
||||
|
||||
@@ -199,10 +220,11 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
|
||||
function handleSend() {
|
||||
const content = inputText.value.trim()
|
||||
if (!content) return
|
||||
if (!content && attachments.value.length === 0) return
|
||||
|
||||
emit('send', content)
|
||||
emit('send', content, attachments.value.length > 0 ? attachments.value : undefined)
|
||||
inputText.value = ''
|
||||
attachments.value = []
|
||||
mentionActive.value = false
|
||||
// 发送后重置到自定义高度(不清除拖拽状态)
|
||||
}
|
||||
@@ -256,11 +278,147 @@ function handleCompositionEnd() {
|
||||
updateMentionState()
|
||||
})
|
||||
}
|
||||
|
||||
function addFile(file: File) {
|
||||
if (attachments.value.find(a => a.name === file.name)) return
|
||||
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
||||
attachments.value.push({
|
||||
id,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
url: URL.createObjectURL(file),
|
||||
file,
|
||||
})
|
||||
}
|
||||
|
||||
function handleAttachClick() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function handleFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (!input.files) return
|
||||
for (const file of input.files) addFile(file)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function handlePaste(e: ClipboardEvent) {
|
||||
const items = Array.from(e.clipboardData?.items || [])
|
||||
const imageItems = items.filter(i => i.type.startsWith('image/'))
|
||||
if (!imageItems.length) return
|
||||
e.preventDefault()
|
||||
for (const item of imageItems) {
|
||||
const blob = item.getAsFile()
|
||||
if (!blob) continue
|
||||
const ext = item.type.split('/')[1] || 'png'
|
||||
addFile(new File([blob], `pasted-${Date.now()}.${ext}`, { type: item.type }))
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function handleDragEnter(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer?.types.includes('Files')) {
|
||||
dragCounter.value++
|
||||
isDragging.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragCounter.value--
|
||||
if (dragCounter.value <= 0) {
|
||||
dragCounter.value = 0
|
||||
isDragging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
dragCounter.value = 0
|
||||
isDragging.value = false
|
||||
for (const file of Array.from(e.dataTransfer?.files || [])) addFile(file)
|
||||
textareaRef.value?.focus()
|
||||
}
|
||||
|
||||
function removeAttachment(id: string) {
|
||||
const idx = attachments.value.findIndex(a => a.id === id)
|
||||
if (idx !== -1) {
|
||||
URL.revokeObjectURL(attachments.value[idx].url)
|
||||
attachments.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
function isImage(type: string): boolean {
|
||||
return type.startsWith('image/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-input-area">
|
||||
<div class="input-wrapper">
|
||||
<div class="input-top-bar">
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="tiny" circle @click="handleAttachClick">
|
||||
<template #icon>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
</template>
|
||||
</NButton>
|
||||
</template>
|
||||
{{ t('chat.attachFiles') }}
|
||||
</NTooltip>
|
||||
<div class="auto-play-speech-switch">
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<div class="switch-label">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
</div>
|
||||
</template>
|
||||
{{ t('chat.autoPlaySpeech') }}
|
||||
</NTooltip>
|
||||
<NSwitch v-model:value="autoPlaySpeech" size="small" :round="false" />
|
||||
</div>
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="tiny" class="tool-trace-toggle" :class="{ active: toolTraceVisible }" @click="toggleToolTraceVisible">
|
||||
<svg class="tool-trace-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.7 6.3a4.5 4.5 0 0 0-5.8 5.8L3.5 17.5a2.1 2.1 0 0 0 3 3l5.4-5.4a4.5 4.5 0 0 0 5.8-5.8l-3 3-3-3 3-3z"/>
|
||||
</svg>
|
||||
</NButton>
|
||||
</template>
|
||||
{{ toolTraceVisible ? t('chat.hideToolCalls') : t('chat.showToolCalls') }}
|
||||
</NTooltip>
|
||||
</div>
|
||||
<div v-if="attachments.length > 0" class="attachment-previews">
|
||||
<div v-for="att in attachments" :key="att.id" class="attachment-preview" :class="{ image: isImage(att.type) }">
|
||||
<img v-if="isImage(att.type)" :src="att.url" :alt="att.name" class="attachment-thumb" />
|
||||
<div v-else class="attachment-file">
|
||||
<span class="file-name">{{ att.name }}</span>
|
||||
<span class="file-size">{{ formatSize(att.size) }}</span>
|
||||
</div>
|
||||
<button class="attachment-remove" @click="removeAttachment(att.id)">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="input-wrapper"
|
||||
:class="{ 'drag-over': isDragging }"
|
||||
@dragover="handleDragOver"
|
||||
@dragenter="handleDragEnter"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<input ref="fileInputRef" type="file" multiple class="file-input-hidden" @change="handleFileChange" />
|
||||
<div class="resize-handle" @mousedown="startResize"></div>
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
@@ -273,6 +431,7 @@ function handleCompositionEnd() {
|
||||
@compositionstart="handleCompositionStart"
|
||||
@compositionend="handleCompositionEnd"
|
||||
@input="handleInput"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
<div class="input-actions">
|
||||
<NButton
|
||||
@@ -320,11 +479,138 @@ function handleCompositionEnd() {
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.chat-input-area {
|
||||
padding: 20px 20px 16px;
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 0 6px;
|
||||
}
|
||||
|
||||
.auto-play-speech-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid $border-light;
|
||||
margin-left: 4px;
|
||||
|
||||
.switch-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #999999;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-trace-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999999;
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
height: 22px;
|
||||
margin-left: -4px;
|
||||
padding: 0;
|
||||
background: transparent !important;
|
||||
|
||||
:deep(.n-button__state-border),
|
||||
:deep(.n-button__border),
|
||||
:deep(.n-button__ripple) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tool-trace-icon {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-previews {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0 0 10px;
|
||||
}
|
||||
|
||||
.attachment-preview {
|
||||
position: relative;
|
||||
border-radius: $radius-sm;
|
||||
overflow: hidden;
|
||||
background-color: $bg-secondary;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
&.image {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.attachment-file {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 8px 12px;
|
||||
min-width: 80px;
|
||||
max-width: 140px;
|
||||
color: $text-secondary;
|
||||
|
||||
.file-name {
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 10px;
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-remove {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: var(--text-on-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity $transition-fast;
|
||||
|
||||
.attachment-preview:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.file-input-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.typing-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -363,6 +649,11 @@ function handleCompositionEnd() {
|
||||
border-color: $accent-primary;
|
||||
}
|
||||
|
||||
&.drag-over {
|
||||
border-color: $accent-primary;
|
||||
background-color: rgba($accent-primary, 0.08);
|
||||
}
|
||||
|
||||
.dark & {
|
||||
background-color: #333333;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
import { updateRoomConfig, forceCompress } from '@/api/hermes/group-chat'
|
||||
import GroupMessageList from './GroupMessageList.vue'
|
||||
import GroupChatInput from './GroupChatInput.vue'
|
||||
import type { Attachment } from '@/stores/hermes/chat'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
@@ -42,6 +43,7 @@ function agentAvatarUrl(name: string): string {
|
||||
}
|
||||
|
||||
const hasRoom = computed(() => !!store.currentRoomId)
|
||||
const visibleApproval = computed(() => store.activePendingApproval)
|
||||
|
||||
function formatTokens(tokens: number): string {
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k tokens`
|
||||
@@ -131,9 +133,9 @@ async function handleSelectRoom(roomId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendMessage(content: string) {
|
||||
async function handleSendMessage(content: string, attachments?: Attachment[]) {
|
||||
try {
|
||||
await store.sendMessage(content)
|
||||
await store.sendMessage(content, attachments)
|
||||
} catch (err: any) {
|
||||
message.error(err.message)
|
||||
}
|
||||
@@ -217,6 +219,22 @@ async function handleRemoveAgent(agentId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInterruptAgent(agentName: string) {
|
||||
try {
|
||||
await store.interruptAgent(agentName)
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t('common.saveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApproval(choice: 'once' | 'session' | 'always' | 'deny') {
|
||||
try {
|
||||
await store.respondApproval(choice)
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t('common.saveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll on new messages
|
||||
const messageListRef = ref()
|
||||
watch(() => store.sortedMessages.length, async () => {
|
||||
@@ -370,6 +388,12 @@ watch(() => store.sortedMessages.length, async () => {
|
||||
<span v-else>
|
||||
@{{ status.agentName }} {{ t('groupChat.agentReplying') }}
|
||||
</span>
|
||||
<button class="context-stop-btn" :title="t('common.cancel')" @click="handleInterruptAgent(status.agentName)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="store.typingText" class="typing-indicator">
|
||||
@@ -379,6 +403,38 @@ watch(() => store.sortedMessages.length, async () => {
|
||||
{{ store.typingText }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="visibleApproval" class="approval-bar">
|
||||
<div class="approval-icon" aria-hidden="true">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="approval-content">
|
||||
<div class="approval-main">
|
||||
<div class="approval-kicker">{{ t('chat.approvalKicker') }}</div>
|
||||
<div class="approval-title">
|
||||
<span v-if="visibleApproval.agentName">@{{ visibleApproval.agentName }} · </span>{{ t('chat.approvalTitle') }}
|
||||
</div>
|
||||
<div class="approval-desc">{{ visibleApproval.description }}</div>
|
||||
<code class="approval-command">{{ visibleApproval.command }}</code>
|
||||
</div>
|
||||
<div class="approval-actions">
|
||||
<NButton v-if="visibleApproval.choices.includes('once')" size="small" type="primary" @click="handleApproval('once')">
|
||||
{{ t('chat.approvalAllowOnce') }}
|
||||
</NButton>
|
||||
<NButton v-if="visibleApproval.choices.includes('session')" size="small" secondary @click="handleApproval('session')">
|
||||
{{ t('chat.approvalAllowSession') }}
|
||||
</NButton>
|
||||
<NButton v-if="visibleApproval.choices.includes('always')" size="small" secondary @click="handleApproval('always')">
|
||||
{{ t('chat.approvalAlways') }}
|
||||
</NButton>
|
||||
<NButton v-if="visibleApproval.choices.includes('deny')" size="small" type="error" secondary @click="handleApproval('deny')">
|
||||
{{ t('chat.approvalDeny') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<GroupChatInput @send="handleSendMessage" />
|
||||
</template>
|
||||
|
||||
@@ -585,6 +641,143 @@ export default defineComponent({ components: { CreateRoomForm } })
|
||||
}
|
||||
}
|
||||
|
||||
.context-stop-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid rgba(var(--error-rgb), 0.18);
|
||||
border-radius: $radius-sm;
|
||||
background: rgba(var(--error-rgb), 0.06);
|
||||
color: $error;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: #ffffff;
|
||||
background: $error;
|
||||
border-color: $error;
|
||||
}
|
||||
}
|
||||
|
||||
.approval-bar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin: 0 16px 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 8px;
|
||||
background: $bg-card;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.approval-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--accent-primary);
|
||||
background: rgba(var(--accent-primary-rgb), 0.12);
|
||||
border: 1px solid rgba(var(--accent-primary-rgb), 0.2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.approval-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.approval-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.approval-kicker {
|
||||
margin-bottom: 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.approval-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.approval-desc {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.approval-command {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
max-height: 96px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: "SFMono-Regular", "Cascadia Code", "Roboto Mono", Consolas, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: $text-primary;
|
||||
background: $bg-secondary;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.approval-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid $border-color;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.approval-bar {
|
||||
margin: 0 10px 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.approval-icon {
|
||||
flex-basis: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.approval-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.approval-actions :deep(.n-button) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.approval-bar {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.approval-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,17 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import multiavatar from '@multiavatar/multiavatar'
|
||||
import MarkdownRenderer from '../chat/MarkdownRenderer.vue'
|
||||
import {
|
||||
copyTextToClipboard,
|
||||
handleCodeBlockCopyClick,
|
||||
renderHighlightedCodeBlock,
|
||||
} from '../chat/highlight'
|
||||
import { parseThinking, countThinkingChars } from '@/utils/thinking-parser'
|
||||
import { useGlobalSpeech } from '@/composables/useSpeech'
|
||||
import { useVoiceSettings } from '@/composables/useVoiceSettings'
|
||||
import { speedToEdgeRate, hzToEdgePitch } from '@/utils/ttsHelpers'
|
||||
import { getDownloadUrl } from '@/api/hermes/download'
|
||||
import type { ChatMessage, RoomAgent } from '@/api/hermes/group-chat'
|
||||
|
||||
const TOOL_PAYLOAD_DISPLAY_LIMIT = 1000
|
||||
const JSON_STRING_DISPLAY_LIMIT = 200
|
||||
const JSON_MAX_DEPTH = 6
|
||||
const JSON_MAX_NODES = 1000
|
||||
const JSON_MAX_KEYS_PER_OBJECT = 50
|
||||
const JSON_MAX_ITEMS_PER_ARRAY = 50
|
||||
const JSON_TRUNCATED_KEY = '__truncated__'
|
||||
|
||||
const props = defineProps<{
|
||||
message: ChatMessage
|
||||
agents: RoomAgent[]
|
||||
currentUserId?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useMessage()
|
||||
const speech = useGlobalSpeech()
|
||||
const voiceSettings = useVoiceSettings()
|
||||
const previewUrl = ref<string | null>(null)
|
||||
const isAgent = computed(() => {
|
||||
return props.agents.some(a => a.agentId === props.message.senderId)
|
||||
return props.agents.some(a => a.agentId === props.message.senderId || a.name === props.message.senderName)
|
||||
})
|
||||
|
||||
const isSelf = computed(() => {
|
||||
@@ -19,7 +44,7 @@ const isSelf = computed(() => {
|
||||
})
|
||||
|
||||
const agentInfo = computed(() => {
|
||||
return props.agents.find(a => a.agentId === props.message.senderId)
|
||||
return props.agents.find(a => a.agentId === props.message.senderId || a.name === props.message.senderName)
|
||||
})
|
||||
|
||||
const timeStr = computed(() => {
|
||||
@@ -32,10 +57,377 @@ const avatarSvg = computed(() => {
|
||||
})
|
||||
|
||||
const mentionNames = computed(() => props.agents.map(a => a.name).filter(Boolean))
|
||||
const parsedThinking = computed(() => parseThinking(props.message.content || '', { streaming: !!props.message.isStreaming }))
|
||||
const hasReasoningField = computed(() => !!(props.message.reasoning && props.message.reasoning.length > 0))
|
||||
const hasThinking = computed(() => hasReasoningField.value || parsedThinking.value.hasThinking)
|
||||
const thinkingFullText = computed(() => {
|
||||
const parts: string[] = []
|
||||
if (props.message.reasoning) parts.push(props.message.reasoning)
|
||||
parts.push(...parsedThinking.value.segments)
|
||||
if (parsedThinking.value.pending) parts.push(parsedThinking.value.pending)
|
||||
return parts.join('\n\n')
|
||||
})
|
||||
const thinkingCharCount = computed(() => {
|
||||
let count = countThinkingChars(parsedThinking.value)
|
||||
if (props.message.reasoning) count += props.message.reasoning.length
|
||||
return count
|
||||
})
|
||||
const thinkingStreamingNow = computed(() => {
|
||||
if (!props.message.isStreaming) return false
|
||||
if (parsedThinking.value.pending !== null) return true
|
||||
if (hasReasoningField.value && !props.message.content) return true
|
||||
return false
|
||||
})
|
||||
const thinkingOverride = ref<boolean | null>(null)
|
||||
const thinkingExpanded = computed(() => {
|
||||
if (thinkingStreamingNow.value) return true
|
||||
if (thinkingOverride.value !== null) return thinkingOverride.value
|
||||
return false
|
||||
})
|
||||
const assistantBody = computed(() => parsedThinking.value.body || props.message.content || '')
|
||||
const contentBlocks = computed(() => {
|
||||
const content = props.message.content || ''
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return null
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
return Array.isArray(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
const renderedAttachments = computed(() => {
|
||||
if (props.message.attachments?.length) return props.message.attachments
|
||||
const blocks = contentBlocks.value
|
||||
if (!blocks) return []
|
||||
return blocks.flatMap((block: any, index: number) => {
|
||||
if (block?.type !== 'image' && block?.type !== 'file') return []
|
||||
const path = String(block.path || '')
|
||||
if (!path) return []
|
||||
const name = String(block.name || `${block.type}-${index + 1}`)
|
||||
return [{
|
||||
id: `${props.message.id}_attachment_${index}`,
|
||||
name,
|
||||
type: block.type === 'image' ? String(block.media_type || 'image/*') : String(block.media_type || 'application/octet-stream'),
|
||||
size: 0,
|
||||
url: getDownloadUrl(normalizeLocalFilePath(path), name),
|
||||
}]
|
||||
})
|
||||
})
|
||||
const hasAttachments = computed(() => renderedAttachments.value.length > 0)
|
||||
const displayBody = computed(() => {
|
||||
if (props.message.role !== 'user') return assistantBody.value
|
||||
const blocks = contentBlocks.value
|
||||
if (!blocks) return assistantBody.value
|
||||
return blocks
|
||||
.filter((block: any) => block?.type === 'text' && typeof block.text === 'string')
|
||||
.map((block: any) => block.text)
|
||||
.join('\n')
|
||||
})
|
||||
const copyableContent = computed(() => {
|
||||
if (isToolMessage.value) return null
|
||||
const content = displayBody.value || ''
|
||||
return content.trim() ? content : null
|
||||
})
|
||||
|
||||
const toolExpanded = ref(false)
|
||||
const isToolMessage = computed(() => props.message.role === 'tool')
|
||||
const hasToolDetails = computed(() => !!(props.message.toolArgs || props.message.toolResult))
|
||||
const toolArgsPayload = computed(() => formatToolPayload(props.message.toolArgs))
|
||||
const toolResultPayload = computed(() => formatToolPayload(props.message.toolResult))
|
||||
const fullToolArgs = computed(() => toolArgsPayload.value.full)
|
||||
const formattedToolArgs = computed(() => toolArgsPayload.value.display)
|
||||
const fullToolResult = computed(() => toolResultPayload.value.full)
|
||||
const formattedToolResult = computed(() => toolResultPayload.value.display)
|
||||
const renderedToolArgs = computed(() => formattedToolArgs.value ? renderToolPayload(formattedToolArgs.value, toolArgsPayload.value.language) : '')
|
||||
const renderedToolResult = computed(() => formattedToolResult.value ? renderToolPayload(formattedToolResult.value, toolResultPayload.value.language) : '')
|
||||
const canPlaySpeech = computed(() => {
|
||||
if (props.message.role !== 'assistant') return false
|
||||
if (!assistantBody.value.trim()) return false
|
||||
if (voiceSettings.provider.value === 'openai' || voiceSettings.provider.value === 'custom' || voiceSettings.provider.value === 'edge' || voiceSettings.provider.value === 'mimo') return true
|
||||
return speech.isSupported
|
||||
})
|
||||
const isPlayingThisMessage = computed(() => {
|
||||
if (voiceSettings.provider.value === 'openai' || voiceSettings.provider.value === 'custom' || voiceSettings.provider.value === 'edge' || voiceSettings.provider.value === 'mimo') {
|
||||
return speech.currentCustomMessageId.value === props.message.id && speech.isCustomPlaying.value
|
||||
}
|
||||
return speech.currentMessageId.value === props.message.id && speech.isPlaying.value
|
||||
})
|
||||
const isPausedThisMessage = computed(() => {
|
||||
if (voiceSettings.provider.value === 'openai' || voiceSettings.provider.value === 'custom' || voiceSettings.provider.value === 'edge' || voiceSettings.provider.value === 'mimo') {
|
||||
return speech.currentCustomMessageId.value === props.message.id && speech.isCustomPaused.value
|
||||
}
|
||||
return speech.currentMessageId.value === props.message.id && speech.isPaused.value
|
||||
})
|
||||
|
||||
type ToolPayload = {
|
||||
full: string
|
||||
display: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
function truncateLongString(value: string, marker: string): string {
|
||||
return value.length > JSON_STRING_DISPLAY_LIMIT ? value.slice(0, JSON_STRING_DISPLAY_LIMIT) + '\n' + marker : value
|
||||
}
|
||||
|
||||
function truncateJsonValue(value: unknown, marker: string): unknown {
|
||||
let nodeCount = 0
|
||||
const seen = new WeakSet<object>()
|
||||
|
||||
function stringifyLength(candidate: unknown): number {
|
||||
return JSON.stringify(candidate, null, 2).length
|
||||
}
|
||||
|
||||
function visit(current: unknown, depth: number): unknown {
|
||||
nodeCount += 1
|
||||
if (nodeCount > JSON_MAX_NODES) return marker
|
||||
if (typeof current === 'string') return truncateLongString(current, marker)
|
||||
if (current === null || typeof current !== 'object') return current
|
||||
if (seen.has(current)) return `[Circular ${marker}]`
|
||||
if (depth >= JSON_MAX_DEPTH) return Array.isArray(current) ? `[Array ${marker}]` : `[Object ${marker}]`
|
||||
|
||||
seen.add(current)
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
const result: unknown[] = []
|
||||
const maxItems = Math.min(current.length, JSON_MAX_ITEMS_PER_ARRAY)
|
||||
for (let i = 0; i < maxItems; i += 1) {
|
||||
const remaining = current.length - i
|
||||
result.push(visit(current[i], depth + 1))
|
||||
if (stringifyLength(result) > TOOL_PAYLOAD_DISPLAY_LIMIT) {
|
||||
result.pop()
|
||||
result.push(`${marker}: ${remaining} more items`)
|
||||
seen.delete(current)
|
||||
return result
|
||||
}
|
||||
}
|
||||
if (current.length > maxItems) result.push(`${marker}: ${current.length - maxItems} more items`)
|
||||
seen.delete(current)
|
||||
return result
|
||||
}
|
||||
|
||||
const entries = Object.entries(current as Record<string, unknown>)
|
||||
const result: Record<string, unknown> = {}
|
||||
const maxKeys = Math.min(entries.length, JSON_MAX_KEYS_PER_OBJECT)
|
||||
for (let i = 0; i < maxKeys; i += 1) {
|
||||
const [key, val] = entries[i]
|
||||
const remaining = entries.length - i
|
||||
result[key] = visit(val, depth + 1)
|
||||
if (stringifyLength(result) > TOOL_PAYLOAD_DISPLAY_LIMIT) {
|
||||
delete result[key]
|
||||
result[JSON_TRUNCATED_KEY] = `${marker}: ${remaining} more keys`
|
||||
seen.delete(current)
|
||||
return result
|
||||
}
|
||||
}
|
||||
if (entries.length > maxKeys) result[JSON_TRUNCATED_KEY] = `${marker}: ${entries.length - maxKeys} more keys`
|
||||
seen.delete(current)
|
||||
return result
|
||||
}
|
||||
|
||||
const truncated = visit(value, 0)
|
||||
if (stringifyLength(truncated) <= TOOL_PAYLOAD_DISPLAY_LIMIT) return truncated
|
||||
return { [JSON_TRUNCATED_KEY]: marker }
|
||||
}
|
||||
|
||||
function formatToolPayload(raw?: string): ToolPayload {
|
||||
if (!raw) return { full: '', display: '' }
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
const full = JSON.stringify(parsed, null, 2)
|
||||
const display = full.length > TOOL_PAYLOAD_DISPLAY_LIMIT
|
||||
? JSON.stringify(truncateJsonValue(parsed, t('chat.truncated')), null, 2)
|
||||
: full
|
||||
return { full, display, language: 'json' }
|
||||
} catch {
|
||||
return {
|
||||
full: raw,
|
||||
display: raw.length > TOOL_PAYLOAD_DISPLAY_LIMIT ? raw.slice(0, TOOL_PAYLOAD_DISPLAY_LIMIT) + '\n' + t('chat.truncated') : raw,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderToolPayload(content: string, language?: string): string {
|
||||
return renderHighlightedCodeBlock(content, language, t('common.copy'), {
|
||||
maxHighlightLength: TOOL_PAYLOAD_DISPLAY_LIMIT,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleToolDetailClick(event: MouseEvent): Promise<void> {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
const button = target.closest<HTMLElement>('[data-copy-code="true"]')
|
||||
if (!button) return
|
||||
event.preventDefault()
|
||||
|
||||
const source = button.closest<HTMLElement>('[data-copy-source]')?.dataset.copySource
|
||||
if (source === 'tool-args' && fullToolArgs.value) {
|
||||
const ok = await copyTextToClipboard(fullToolArgs.value)
|
||||
if (ok) toast.success(t('common.copied'))
|
||||
else toast.error(t('chat.copyFailed'))
|
||||
return
|
||||
}
|
||||
if (source === 'tool-result' && fullToolResult.value) {
|
||||
const ok = await copyTextToClipboard(fullToolResult.value)
|
||||
if (ok) toast.success(t('common.copied'))
|
||||
else toast.error(t('chat.copyFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
const copyResult = await handleCodeBlockCopyClick(event)
|
||||
if (copyResult) toast.success(t('common.copied'))
|
||||
else if (copyResult === false) toast.error(t('chat.copyFailed'))
|
||||
}
|
||||
|
||||
function playSpeech(content: string, autoplay = false) {
|
||||
if (!content.trim()) return
|
||||
if (voiceSettings.provider.value === 'openai') {
|
||||
if (!voiceSettings.openaiBaseUrl.value) return
|
||||
const play = autoplay ? speech.openaiPlay : speech.openaiToggle
|
||||
play(props.message.id, content, {
|
||||
baseUrl: voiceSettings.openaiBaseUrl.value,
|
||||
apiKey: voiceSettings.openaiApiKey.value,
|
||||
model: voiceSettings.openaiModel.value,
|
||||
voice: voiceSettings.openaiVoice.value,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (voiceSettings.provider.value === 'custom') {
|
||||
if (!voiceSettings.customUrl.value) return
|
||||
const play = autoplay ? speech.openaiPlay : speech.openaiToggle
|
||||
play(props.message.id, content, {
|
||||
baseUrl: voiceSettings.customUrl.value,
|
||||
apiKey: voiceSettings.customApiKey.value || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (voiceSettings.provider.value === 'edge') {
|
||||
const play = autoplay ? speech.openaiPlay : speech.openaiToggle
|
||||
play(props.message.id, content, {
|
||||
baseUrl: '/api/tts/proxy',
|
||||
voice: voiceSettings.edgeVoice.value,
|
||||
rate: speedToEdgeRate(voiceSettings.edgeRate.value),
|
||||
pitch: hzToEdgePitch(voiceSettings.edgePitchHz.value),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (voiceSettings.provider.value === 'mimo') {
|
||||
if (!voiceSettings.mimoApiKey.value) return
|
||||
const play = autoplay ? speech.mimoPlay : speech.mimoToggle
|
||||
play(props.message.id, content, {
|
||||
baseUrl: voiceSettings.mimoBaseUrl.value,
|
||||
apiKey: voiceSettings.mimoApiKey.value,
|
||||
model: voiceSettings.mimoModel.value,
|
||||
voice: voiceSettings.mimoVoice.value,
|
||||
voiceDesignDesc: voiceSettings.mimoVoiceDesignDesc.value || undefined,
|
||||
stylePrompt: voiceSettings.mimoStylePrompt.value || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (voiceSettings.provider.value === 'webspeech') {
|
||||
const text = speech.extractReadableText(content)
|
||||
if (!text) return
|
||||
speech.stop(false)
|
||||
speech.speakViaBrowser(props.message.id, text, {
|
||||
voiceName: voiceSettings.webspeechVoice.value || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (autoplay) speech.enqueue(props.message.id, content)
|
||||
else speech.toggle(props.message.id, content)
|
||||
}
|
||||
|
||||
function handleSpeechToggle() {
|
||||
if (canPlaySpeech.value) playSpeech(assistantBody.value)
|
||||
}
|
||||
|
||||
async function copyBubbleContent() {
|
||||
const text = copyableContent.value
|
||||
if (!text) return
|
||||
const ok = await copyTextToClipboard(text)
|
||||
if (ok) toast.success(t('chat.copiedBubble'))
|
||||
else toast.error(t('chat.copyFailed'))
|
||||
}
|
||||
|
||||
function isImage(type: string): boolean {
|
||||
return type.startsWith('image/')
|
||||
}
|
||||
|
||||
function normalizeLocalFilePath(path: string): string {
|
||||
return /^[a-zA-Z]:\\/.test(path) ? path.replace(/\\/g, '/') : path
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
let autoPlayHandler: ((e: Event) => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
autoPlayHandler = (e: Event) => {
|
||||
const event = e as CustomEvent<{ messageId: string; content: string }>
|
||||
if (event.detail?.messageId === props.message.id && canPlaySpeech.value) {
|
||||
playSpeech(event.detail.content || assistantBody.value, true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('auto-play-speech', autoPlayHandler)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (autoPlayHandler) window.removeEventListener('auto-play-speech', autoPlayHandler)
|
||||
if (speech.currentMessageId.value === props.message.id) speech.stop()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="group-message" :class="{ agent: isAgent, self: isSelf }">
|
||||
<div v-if="isToolMessage" class="group-message tool-message">
|
||||
<div class="avatar">
|
||||
<span v-html="avatarSvg" />
|
||||
</div>
|
||||
|
||||
<div class="msg-body">
|
||||
<div class="msg-header">
|
||||
<span class="sender-name">{{ message.senderName }}</span>
|
||||
<span v-if="isAgent && agentInfo?.description" class="agent-desc">{{ agentInfo.description }}</span>
|
||||
</div>
|
||||
<div class="tool-line" :class="{ expandable: hasToolDetails }" @click="hasToolDetails && (toolExpanded = !toolExpanded)">
|
||||
<svg
|
||||
v-if="hasToolDetails"
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="tool-chevron"
|
||||
:class="{ rotated: toolExpanded }"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="tool-icon">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
</svg>
|
||||
<span class="tool-name">{{ message.toolName || message.tool_name || 'tool' }}</span>
|
||||
<span v-if="message.toolPreview && !toolExpanded" class="tool-preview">{{ message.toolPreview }}</span>
|
||||
<span v-if="message.toolStatus === 'running'" class="tool-spinner"></span>
|
||||
<span v-if="message.toolStatus === 'error'" class="tool-error-badge">{{ t('chat.error') }}</span>
|
||||
</div>
|
||||
<div v-if="toolExpanded && hasToolDetails" class="tool-details" @click="handleToolDetailClick">
|
||||
<div v-if="formattedToolArgs" class="tool-detail-section" data-copy-source="tool-args">
|
||||
<div class="tool-detail-label">{{ t('chat.arguments') }}</div>
|
||||
<div class="tool-detail-code-block" v-html="renderedToolArgs"></div>
|
||||
</div>
|
||||
<div v-if="formattedToolResult" class="tool-detail-section" data-copy-source="tool-result">
|
||||
<div class="tool-detail-label">{{ t('chat.result') }}</div>
|
||||
<div class="tool-detail-code-block" v-html="renderedToolResult"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="msg-time">{{ timeStr }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="group-message" :class="{ agent: isAgent, self: isSelf }">
|
||||
<!-- Avatar -->
|
||||
<div class="avatar">
|
||||
<span v-html="avatarSvg" />
|
||||
@@ -46,12 +438,89 @@ const mentionNames = computed(() => props.agents.map(a => a.name).filter(Boolean
|
||||
<span class="sender-name">{{ message.senderName }}</span>
|
||||
<span v-if="isAgent && agentInfo?.description" class="agent-desc">{{ agentInfo.description }}</span>
|
||||
</div>
|
||||
<div class="msg-content" :class="{ 'agent-content': isAgent }">
|
||||
<MarkdownRenderer :content="message.content" :mention-names="mentionNames" />
|
||||
<div
|
||||
class="msg-content"
|
||||
:class="{
|
||||
'agent-content': isAgent,
|
||||
'speech-playing': isPlayingThisMessage && !isPausedThisMessage,
|
||||
}"
|
||||
>
|
||||
<div v-if="hasAttachments" class="msg-attachments">
|
||||
<div
|
||||
v-for="att in renderedAttachments"
|
||||
:key="att.id"
|
||||
class="msg-attachment"
|
||||
:class="{ image: isImage(att.type) }"
|
||||
>
|
||||
<img v-if="isImage(att.type)" :src="att.url" :alt="att.name" class="msg-attachment-thumb" @click="previewUrl = att.url" />
|
||||
<a v-else class="msg-attachment-file" :href="att.url" :title="t('download.downloadFile')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<span class="att-name">{{ att.name }}</span>
|
||||
<span class="att-size">{{ formatSize(att.size) }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasThinking" class="thinking-block" :class="{ expanded: thinkingExpanded }">
|
||||
<div class="thinking-header" @click="thinkingOverride = !thinkingExpanded">
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="thinking-chevron"
|
||||
:class="{ rotated: thinkingExpanded }"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<span class="thinking-icon">💭</span>
|
||||
<span class="thinking-label">
|
||||
{{ thinkingStreamingNow ? t('chat.thinkingInProgress') : t('chat.thinkingLabel') }}
|
||||
</span>
|
||||
<span class="thinking-meta">· {{ t('chat.thinkingChars', { count: thinkingCharCount }) }}</span>
|
||||
</div>
|
||||
<div v-if="thinkingExpanded" class="thinking-body">
|
||||
<MarkdownRenderer :content="thinkingFullText" />
|
||||
</div>
|
||||
</div>
|
||||
<MarkdownRenderer v-if="displayBody" :content="displayBody" :mention-names="mentionNames" />
|
||||
<span v-if="message.isStreaming && !displayBody" class="streaming-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="message-meta">
|
||||
<button
|
||||
v-if="canPlaySpeech"
|
||||
class="speech-bubble-btn"
|
||||
:class="{ playing: isPlayingThisMessage, paused: isPausedThisMessage }"
|
||||
:title="isPlayingThisMessage ? (isPausedThisMessage ? t('chat.resumeSpeech') : t('chat.pauseSpeech')) : t('chat.playSpeech')"
|
||||
@click="handleSpeechToggle"
|
||||
>
|
||||
<svg v-if="!isPlayingThisMessage || isPausedThisMessage" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="copyableContent"
|
||||
class="copy-bubble-btn"
|
||||
:title="t('chat.copyBubble')"
|
||||
@click="copyBubbleContent"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<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>
|
||||
</button>
|
||||
<span class="message-time">{{ timeStr }}</span>
|
||||
</div>
|
||||
<span class="msg-time">{{ timeStr }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="previewUrl" class="image-preview-overlay" @click.self="previewUrl = null">
|
||||
<img :src="previewUrl" class="image-preview-img" @click="previewUrl = null" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -62,7 +531,6 @@ const mentionNames = computed(() => props.agents.map(a => a.name).filter(Boolean
|
||||
gap: 10px;
|
||||
padding: 2px 0;
|
||||
|
||||
&.agent,
|
||||
&.self {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
@@ -84,6 +552,121 @@ const mentionNames = computed(() => props.agents.map(a => a.name).filter(Boolean
|
||||
}
|
||||
}
|
||||
|
||||
.tool-message {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.tool-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 4px;
|
||||
border-radius: $radius-sm;
|
||||
color: $text-muted;
|
||||
font-size: 11px;
|
||||
|
||||
&.expandable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tool-chevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&.rotated {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.tool-icon,
|
||||
.tool-chevron {
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
flex-shrink: 0;
|
||||
font-family: $font-code;
|
||||
color: $text-muted;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.tool-preview {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.tool-spinner {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 1.5px solid $text-muted;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-error-badge {
|
||||
font-size: 9px;
|
||||
color: $error;
|
||||
background: rgba(var(--error-rgb), 0.08);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
line-height: 14px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.tool-details {
|
||||
margin-left: 16px;
|
||||
margin-top: 2px;
|
||||
border-left: 2px solid $border-light;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.tool-detail-section {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tool-detail-label {
|
||||
margin-bottom: 2px;
|
||||
color: $text-muted;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tool-detail-code-block {
|
||||
:deep(.hljs-code-block) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.code-header) {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
:deep(code.hljs) {
|
||||
font-size: 11px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
@@ -124,11 +707,117 @@ const mentionNames = computed(() => props.agents.map(a => a.name).filter(Boolean
|
||||
}
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
.msg-time,
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.6;
|
||||
margin-top: 2px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 0 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
.group-message:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-bubble-btn,
|
||||
.speech-bubble-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: $text-muted;
|
||||
cursor: pointer;
|
||||
border-radius: $radius-sm;
|
||||
padding: 0;
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: $text-secondary;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.dark & {
|
||||
color: #999999;
|
||||
|
||||
&:hover {
|
||||
color: #cccccc;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.speech-bubble-btn {
|
||||
&.playing {
|
||||
color: var(--accent-primary);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
|
||||
&.paused {
|
||||
animation: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
@@ -141,10 +830,179 @@ const mentionNames = computed(() => props.agents.map(a => a.name).filter(Boolean
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
&.speech-playing {
|
||||
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);
|
||||
animation: rainbow-glow 4s linear infinite;
|
||||
}
|
||||
|
||||
:deep(.mention-highlight) {
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.msg-attachment {
|
||||
border-radius: $radius-sm;
|
||||
overflow: hidden;
|
||||
background-color: $bg-secondary;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
&.image {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-attachment-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.msg-attachment-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 140px;
|
||||
max-width: 220px;
|
||||
padding: 8px 10px;
|
||||
color: $text-secondary;
|
||||
text-decoration: none;
|
||||
|
||||
.att-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.att-size {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.image-preview-img {
|
||||
max-width: min(96vw, 1400px);
|
||||
max-height: 92vh;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
cursor: zoom-out;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.thinking-block {
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px dashed $border-light;
|
||||
|
||||
.thinking-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: $radius-sm;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
.thinking-chevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&.rotated {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.thinking-icon {
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thinking-label {
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thinking-meta {
|
||||
color: $text-muted;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.thinking-body {
|
||||
margin-top: 6px;
|
||||
padding: 6px 10px;
|
||||
border-left: 2px solid $border-light;
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
font-style: italic;
|
||||
|
||||
:deep(p) {
|
||||
margin: 0.3em 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.streaming-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
|
||||
span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: $text-muted;
|
||||
border-radius: 50%;
|
||||
animation: pulse 1.4s infinite ease-in-out;
|
||||
|
||||
&:nth-child(2) { animation-delay: 0.2s; }
|
||||
&:nth-child(3) { animation-delay: 0.4s; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGroupChatStore } from '@/stores/hermes/group-chat'
|
||||
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
||||
import GroupMessageItem from './GroupMessageItem.vue'
|
||||
|
||||
const store = useGroupChatStore()
|
||||
const { t } = useI18n()
|
||||
const { toolTraceVisible } = useToolTraceVisibility()
|
||||
const listRef = ref<HTMLDivElement>()
|
||||
const isNearBottom = ref(true)
|
||||
const displayMessages = computed(() => store.sortedMessages.filter(msg => msg.role !== 'tool' || toolTraceVisible.value || msg.toolStatus === 'running'))
|
||||
|
||||
function checkNearBottom(): void {
|
||||
if (!listRef.value) return
|
||||
@@ -36,12 +39,12 @@ defineExpose({ scrollToBottom })
|
||||
|
||||
<template>
|
||||
<div ref="listRef" class="message-list" @scroll="handleScroll">
|
||||
<div v-if="store.sortedMessages.length === 0" class="empty-state">
|
||||
<div v-if="displayMessages.length === 0" class="empty-state">
|
||||
<img src="/logo.png" alt="Hermes" class="empty-logo" />
|
||||
<p>{{ t("chat.emptyState") }}</p>
|
||||
</div>
|
||||
<GroupMessageItem
|
||||
v-for="msg in store.sortedMessages"
|
||||
v-for="msg in displayMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
:agents="store.agents"
|
||||
|
||||
@@ -37,6 +37,11 @@ const allModels = computed(() => props.provider.available_models?.length ? props
|
||||
const visibilityRule = computed(() => appStore.getProviderVisibility(props.provider.provider))
|
||||
const isFiltered = computed(() => visibilityRule.value.mode === 'include')
|
||||
const visibleCountLabel = computed(() => `${props.provider.models.length}/${allModels.value.length}`)
|
||||
const isDefaultProvider = computed(() => modelsStore.defaultProvider === props.provider.provider)
|
||||
|
||||
function isDefaultModel(model: string) {
|
||||
return isDefaultProvider.value && modelsStore.defaultModel === model
|
||||
}
|
||||
|
||||
function modelAlias(model: string) {
|
||||
return appStore.getModelAlias(model, props.provider.provider)
|
||||
@@ -157,9 +162,12 @@ async function handleDelete() {
|
||||
<div class="provider-card">
|
||||
<div class="card-header">
|
||||
<h3 class="provider-name">{{ displayName }}</h3>
|
||||
<span class="type-badge" :class="isCustom ? 'custom' : 'builtin'">
|
||||
{{ isCustom ? t('models.customType') : t('models.builtIn') }}
|
||||
</span>
|
||||
<div class="provider-badges">
|
||||
<span v-if="isDefaultProvider" class="type-badge default">{{ t('models.currentDefault') }}</span>
|
||||
<span class="type-badge" :class="isCustom ? 'custom' : 'builtin'">
|
||||
{{ isCustom ? t('models.customType') : t('models.builtIn') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
@@ -182,11 +190,13 @@ async function handleDelete() {
|
||||
v-for="model in provider.models.slice(0, 20)"
|
||||
:key="model"
|
||||
class="model-tag model-tag-button"
|
||||
:class="{ default: isDefaultModel(model) }"
|
||||
type="button"
|
||||
:title="t('models.aliasTitleFor', { model })"
|
||||
@click="openAliasEditor(model)"
|
||||
>
|
||||
<span class="model-tag-name">{{ modelDisplayName(model) }}</span>
|
||||
<span v-if="isDefaultModel(model)" class="model-tag-default">{{ t('models.defaultShort') }}</span>
|
||||
<span v-if="modelAlias(model)" class="model-tag-id">{{ model }}</span>
|
||||
</button>
|
||||
<span v-if="provider.models.length > 20" class="model-tag model-tag-more">
|
||||
@@ -213,6 +223,7 @@ async function handleDelete() {
|
||||
<div v-for="model in provider.models" :key="model" class="alias-row">
|
||||
<div class="alias-row-text">
|
||||
<span class="alias-row-name">{{ modelDisplayName(model) }}</span>
|
||||
<span v-if="isDefaultModel(model)" class="alias-row-default">{{ t('models.defaultShort') }}</span>
|
||||
<code class="alias-row-id">{{ model }}</code>
|
||||
</div>
|
||||
<NButton size="tiny" quaternary @click="openAliasEditor(model)">{{ t('models.aliasEdit') }}</NButton>
|
||||
@@ -311,6 +322,7 @@ async function handleDelete() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -324,11 +336,20 @@ async function handleDelete() {
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.provider-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
|
||||
&.builtin {
|
||||
background: rgba(var(--accent-primary-rgb), 0.12);
|
||||
@@ -339,6 +360,11 @@ async function handleDelete() {
|
||||
background: rgba(var(--success-rgb), 0.12);
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.default {
|
||||
background: rgba(var(--warning-rgb), 0.14);
|
||||
color: $warning;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
@@ -409,6 +435,11 @@ async function handleDelete() {
|
||||
color: $accent-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.default {
|
||||
background: rgba(var(--warning-rgb), 0.14);
|
||||
color: $text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.model-tag-button {
|
||||
@@ -433,6 +464,14 @@ async function handleDelete() {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.model-tag-default,
|
||||
.alias-row-default {
|
||||
color: $warning;
|
||||
font-family: $font-ui;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, onUnmounted } from 'vue'
|
||||
import { NModal, NButton, NSpin, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { startXaiLogin, pollXaiLogin } from '@/api/hermes/xai-auth'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{ close: []; success: [] }>()
|
||||
@@ -73,6 +74,12 @@ function openLink() {
|
||||
window.open(authorizationUrl.value, '_blank')
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
const ok = await copyToClipboard(authorizationUrl.value)
|
||||
if (ok) message.success(t('common.copied'))
|
||||
else message.error(t('chat.copyFailed'))
|
||||
}
|
||||
|
||||
function retry() {
|
||||
status.value = 'idle'
|
||||
authorizationUrl.value = ''
|
||||
@@ -104,6 +111,9 @@ startLogin()
|
||||
<NButton type="primary" block @click="openLink">
|
||||
{{ t('models.xaiOpenLink') }}
|
||||
</NButton>
|
||||
<NButton block @click="copyLink">
|
||||
{{ t('models.xaiCopyLink') }}
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div v-else-if="status === 'approved'" class="xai-login__state xai-login__state--success">
|
||||
|
||||
@@ -97,10 +97,6 @@ async function handleExport() {
|
||||
<span class="info-label">{{ t('profiles.model') }}</span>
|
||||
<code class="info-value mono">{{ profile.model }}</code>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('profiles.gateway') }}</span>
|
||||
<code class="info-value mono">{{ profile.gateway }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-detail-toggle" @click="toggleDetail">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onUnmounted } from 'vue'
|
||||
import { NSwitch, NInput, NButton, useMessage } from 'naive-ui'
|
||||
import { ref, reactive, onUnmounted, watch } from 'vue'
|
||||
import { NSwitch, NInput, NButton, NSpin, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSettingsStore } from '@/stores/hermes/settings'
|
||||
import { saveCredentials as saveCredsApi, fetchWeixinQrCode, pollWeixinQrStatus, saveWeixinCredentials } from '@/api/hermes/config'
|
||||
@@ -11,43 +11,93 @@ const settingsStore = useSettingsStore()
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Track saving state per platform.field
|
||||
const saving = reactive<Record<string, boolean>>({})
|
||||
const configDrafts = reactive<Record<string, Record<string, any>>>({})
|
||||
const credentialDrafts = reactive<Record<string, Record<string, any>>>({})
|
||||
const touchedConfig = reactive<Record<string, boolean>>({})
|
||||
const touchedCredentials = reactive<Record<string, boolean>>({})
|
||||
|
||||
function savingKey(platform: string, field: string) {
|
||||
return `${platform}.${field}`
|
||||
function cloneValue<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value || {}))
|
||||
}
|
||||
|
||||
function isSaving(platform: string, field: string) {
|
||||
return !!saving[savingKey(platform, field)]
|
||||
function mergeDeep(target: Record<string, any>, values: Record<string, any>) {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
target[key] = mergeDeep({ ...(target[key] || {}) }, value as Record<string, any>)
|
||||
} else {
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// Immediate save for switches
|
||||
async function immediateSave(platform: string, field: string, saveFn: () => Promise<void>) {
|
||||
const key = savingKey(platform, field)
|
||||
saving[key] = true
|
||||
function configDraft(platform: string) {
|
||||
if (!configDrafts[platform]) {
|
||||
configDrafts[platform] = cloneValue(settingsStore[platform as keyof typeof settingsStore] as Record<string, any>)
|
||||
}
|
||||
return configDrafts[platform]
|
||||
}
|
||||
|
||||
function credentialDraft(platform: string) {
|
||||
if (!credentialDrafts[platform]) credentialDrafts[platform] = cloneValue(getCreds(platform))
|
||||
return credentialDrafts[platform]
|
||||
}
|
||||
|
||||
function setConfigDraft(platform: string, values: Record<string, any>) {
|
||||
configDrafts[platform] = mergeDeep({ ...configDraft(platform) }, values)
|
||||
touchedConfig[platform] = true
|
||||
}
|
||||
|
||||
function setCredentialDraft(platform: string, values: Record<string, any>) {
|
||||
credentialDrafts[platform] = mergeDeep({ ...credentialDraft(platform) }, values)
|
||||
touchedCredentials[platform] = true
|
||||
}
|
||||
|
||||
function sameJson(a: unknown, b: unknown) {
|
||||
return JSON.stringify(a || {}) === JSON.stringify(b || {})
|
||||
}
|
||||
|
||||
function hasConfigChanges(platform: string) {
|
||||
return !!touchedConfig[platform] && !!configDrafts[platform] && !sameJson(configDrafts[platform], settingsStore[platform as keyof typeof settingsStore])
|
||||
}
|
||||
|
||||
function hasCredentialChanges(platform: string) {
|
||||
return !!touchedCredentials[platform] && !!credentialDrafts[platform] && !sameJson(credentialDrafts[platform], getCreds(platform))
|
||||
}
|
||||
|
||||
function hasUnsavedChanges(platform: string) {
|
||||
return hasConfigChanges(platform) || hasCredentialChanges(platform)
|
||||
}
|
||||
|
||||
function isSavingPlatform(platform: string) {
|
||||
return !!saving[platform]
|
||||
}
|
||||
|
||||
async function savePlatform(platform: string) {
|
||||
saving[platform] = true
|
||||
try {
|
||||
await saveFn()
|
||||
const configChanged = hasConfigChanges(platform)
|
||||
const credentialsChanged = hasCredentialChanges(platform)
|
||||
if (configChanged) {
|
||||
await settingsStore.saveSection(platform, configDraft(platform), { restart: !credentialsChanged })
|
||||
}
|
||||
if (credentialsChanged) {
|
||||
await saveCredsApi(platform, credentialDraft(platform))
|
||||
await settingsStore.fetchSettings()
|
||||
}
|
||||
configDrafts[platform] = cloneValue(settingsStore[platform as keyof typeof settingsStore] as Record<string, any>)
|
||||
credentialDrafts[platform] = cloneValue(getCreds(platform))
|
||||
touchedConfig[platform] = false
|
||||
touchedCredentials[platform] = false
|
||||
message.success(t('settings.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(t('settings.saveFailed'))
|
||||
message.error(err?.message || t('settings.saveFailed'))
|
||||
} finally {
|
||||
saving[key] = false
|
||||
saving[platform] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveChannel(platform: string, field: string, values: Record<string, any>) {
|
||||
immediateSave(platform, field, () => settingsStore.saveSection(platform, values))
|
||||
}
|
||||
|
||||
// Save credentials to .env (matching hermes gateway setup behavior)
|
||||
async function saveCredentials(platform: string, field: string, values: Record<string, any>) {
|
||||
immediateSave(platform, field, async () => {
|
||||
await saveCredsApi(platform, values)
|
||||
await settingsStore.fetchSettings()
|
||||
})
|
||||
}
|
||||
|
||||
function getCreds(key: string) {
|
||||
return (settingsStore.platforms[key] || {}) as Record<string, any>
|
||||
}
|
||||
@@ -180,6 +230,25 @@ const platforms = [
|
||||
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 00.167-.054l1.903-1.114a.864.864 0 01.717-.098 10.16 10.16 0 002.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178A1.17 1.17 0 014.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178 1.17 1.17 0 01-1.162-1.178c0-.651.52-1.18 1.162-1.18zm3.68 4.025c-3.694 0-6.69 2.462-6.69 5.496 0 3.034 2.996 5.496 6.69 5.496.753 0 1.477-.1 2.158-.28a.66.66 0 01.548.074l1.46.854a.25.25 0 00.127.041.224.224 0 00.221-.225c0-.055-.022-.109-.037-.162l-.298-1.131a.453.453 0 01.163-.509C21.81 18.613 22.77 16.973 22.77 15.512c0-3.034-2.996-5.496-6.69-5.496h.198zm-2.454 3.347c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902zm4.912 0c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902z"/></svg>',
|
||||
},
|
||||
]
|
||||
|
||||
watch(
|
||||
() => platforms.map((platform) => ({
|
||||
key: platform.key,
|
||||
config: settingsStore[platform.key as keyof typeof settingsStore],
|
||||
credentials: getCreds(platform.key),
|
||||
})),
|
||||
(items) => {
|
||||
for (const item of items) {
|
||||
if (!touchedConfig[item.key]) {
|
||||
configDrafts[item.key] = cloneValue(item.config as Record<string, any>)
|
||||
}
|
||||
if (!touchedCredentials[item.key]) {
|
||||
credentialDrafts[item.key] = cloneValue(item.credentials)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -196,158 +265,158 @@ const platforms = [
|
||||
<!-- Telegram -->
|
||||
<template v-if="p.key === 'telegram'">
|
||||
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
|
||||
<NInput :default-value="getCreds('telegram').token || ''" :loading="isSaving('telegram', 'token')" clearable size="small" class="input-lg" placeholder="123456:ABC-DEF..." @change="v => saveCredentials('telegram', 'token', { token: v })" />
|
||||
<NInput :value="credentialDraft('telegram').token || ''" :loading="isSavingPlatform('telegram')" clearable size="small" class="input-lg" placeholder="123456:ABC-DEF..." @update:value="v => setCredentialDraft('telegram', { token: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
|
||||
<NSwitch :value="settingsStore.telegram.require_mention" :loading="isSaving('telegram', 'require_mention')" @update:value="v => saveChannel('telegram', 'require_mention', { require_mention: v })" />
|
||||
<NSwitch :value="configDraft('telegram').require_mention" :loading="isSavingPlatform('telegram')" @update:value="v => setConfigDraft('telegram', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.reactions')" :hint="t('platform.reactionsHint')">
|
||||
<NSwitch :value="settingsStore.telegram.reactions" :loading="isSaving('telegram', 'reactions')" @update:value="v => saveChannel('telegram', 'reactions', { reactions: v })" />
|
||||
<NSwitch :value="configDraft('telegram').reactions" :loading="isSavingPlatform('telegram')" @update:value="v => setConfigDraft('telegram', { reactions: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
||||
<NInput :default-value="settingsStore.telegram.free_response_chats || ''" :loading="isSaving('telegram', 'free_response_chats')" size="small" placeholder="chat_id1,chat_id2" @change="v => saveChannel('telegram', 'free_response_chats', { free_response_chats: v })" />
|
||||
<NInput :value="configDraft('telegram').free_response_chats || ''" :loading="isSavingPlatform('telegram')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => setConfigDraft('telegram', { free_response_chats: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
|
||||
<NInput :default-value="(settingsStore.telegram.mention_patterns || []).join(', ')" :loading="isSaving('telegram', 'mention_patterns')" size="small" placeholder="pattern1, pattern2" @change="v => saveChannel('telegram', 'mention_patterns', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
|
||||
<NInput :value="(configDraft('telegram').mention_patterns || []).join(', ')" :loading="isSavingPlatform('telegram')" size="small" placeholder="pattern1, pattern2" @update:value="v => setConfigDraft('telegram', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<!-- Discord -->
|
||||
<template v-if="p.key === 'discord'">
|
||||
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
|
||||
<NInput :default-value="getCreds('discord').token || ''" :loading="isSaving('discord', 'token')" clearable size="small" class="input-lg" placeholder="Bot token..." @change="v => saveCredentials('discord', 'token', { token: v })" />
|
||||
<NInput :value="credentialDraft('discord').token || ''" :loading="isSavingPlatform('discord')" clearable size="small" class="input-lg" placeholder="Bot token..." @update:value="v => setCredentialDraft('discord', { token: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
|
||||
<NSwitch :value="settingsStore.discord.require_mention" :loading="isSaving('discord', 'require_mention')" @update:value="v => saveChannel('discord', 'require_mention', { require_mention: v })" />
|
||||
<NSwitch :value="configDraft('discord').require_mention" :loading="isSavingPlatform('discord')" @update:value="v => setConfigDraft('discord', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.autoThread')" :hint="t('platform.autoThreadHint')">
|
||||
<NSwitch :value="settingsStore.discord.auto_thread" :loading="isSaving('discord', 'auto_thread')" @update:value="v => saveChannel('discord', 'auto_thread', { auto_thread: v })" />
|
||||
<NSwitch :value="configDraft('discord').auto_thread" :loading="isSavingPlatform('discord')" @update:value="v => setConfigDraft('discord', { auto_thread: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.reactions')" :hint="t('platform.reactionsHint')">
|
||||
<NSwitch :value="settingsStore.discord.reactions" :loading="isSaving('discord', 'reactions')" @update:value="v => saveChannel('discord', 'reactions', { reactions: v })" />
|
||||
<NSwitch :value="configDraft('discord').reactions" :loading="isSavingPlatform('discord')" @update:value="v => setConfigDraft('discord', { reactions: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
|
||||
<NInput :default-value="settingsStore.discord.free_response_channels || ''" :loading="isSaving('discord', 'free_response_channels')" size="small" placeholder="channel_id1,channel_id2" @change="v => saveChannel('discord', 'free_response_channels', { free_response_channels: v })" />
|
||||
<NInput :value="configDraft('discord').free_response_channels || ''" :loading="isSavingPlatform('discord')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('discord', { free_response_channels: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.allowedChannels')" :hint="t('platform.allowedChannelsHint')">
|
||||
<NInput :default-value="settingsStore.discord.allowed_channels || ''" :loading="isSaving('discord', 'allowed_channels')" size="small" placeholder="channel_id1,channel_id2" @change="v => saveChannel('discord', 'allowed_channels', { allowed_channels: v })" />
|
||||
<NInput :value="configDraft('discord').allowed_channels || ''" :loading="isSavingPlatform('discord')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('discord', { allowed_channels: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.ignoredChannels')" :hint="t('platform.ignoredChannelsHint')">
|
||||
<NInput :default-value="settingsStore.discord.ignored_channels || ''" :loading="isSaving('discord', 'ignored_channels')" size="small" placeholder="channel_id1,channel_id2" @change="v => saveChannel('discord', 'ignored_channels', { ignored_channels: v })" />
|
||||
<NInput :value="configDraft('discord').ignored_channels || ''" :loading="isSavingPlatform('discord')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('discord', { ignored_channels: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.noThreadChannels')" :hint="t('platform.noThreadChannelsHint')">
|
||||
<NInput :default-value="settingsStore.discord.no_thread_channels || ''" :loading="isSaving('discord', 'no_thread_channels')" size="small" placeholder="channel_id1,channel_id2" @change="v => saveChannel('discord', 'no_thread_channels', { no_thread_channels: v })" />
|
||||
<NInput :value="configDraft('discord').no_thread_channels || ''" :loading="isSavingPlatform('discord')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('discord', { no_thread_channels: v })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<!-- Slack -->
|
||||
<template v-if="p.key === 'slack'">
|
||||
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
|
||||
<NInput :default-value="getCreds('slack').token || ''" :loading="isSaving('slack', 'token')" clearable size="small" class="input-lg" placeholder="xoxb-..." @change="v => saveCredentials('slack', 'token', { token: v })" />
|
||||
<NInput :value="credentialDraft('slack').token || ''" :loading="isSavingPlatform('slack')" clearable size="small" class="input-lg" placeholder="xoxb-..." @update:value="v => setCredentialDraft('slack', { token: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
|
||||
<NSwitch :value="settingsStore.slack.require_mention" :loading="isSaving('slack', 'require_mention')" @update:value="v => saveChannel('slack', 'require_mention', { require_mention: v })" />
|
||||
<NSwitch :value="configDraft('slack').require_mention" :loading="isSavingPlatform('slack')" @update:value="v => setConfigDraft('slack', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.allowBots')" :hint="t('platform.allowBotsHint')">
|
||||
<NSwitch :value="settingsStore.slack.allow_bots" :loading="isSaving('slack', 'allow_bots')" @update:value="v => saveChannel('slack', 'allow_bots', { allow_bots: v })" />
|
||||
<NSwitch :value="configDraft('slack').allow_bots" :loading="isSavingPlatform('slack')" @update:value="v => setConfigDraft('slack', { allow_bots: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
|
||||
<NInput :default-value="settingsStore.slack.free_response_channels || ''" :loading="isSaving('slack', 'free_response_channels')" size="small" placeholder="channel_id1,channel_id2" @change="v => saveChannel('slack', 'free_response_channels', { free_response_channels: v })" />
|
||||
<NInput :value="configDraft('slack').free_response_channels || ''" :loading="isSavingPlatform('slack')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('slack', { free_response_channels: v })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<!-- WhatsApp -->
|
||||
<template v-if="p.key === 'whatsapp'">
|
||||
<SettingRow :label="t('platform.waEnabled')" :hint="t('platform.waEnabledHint')">
|
||||
<NSwitch :value="getCreds('whatsapp').enabled" :loading="isSaving('whatsapp', 'enabled')" @update:value="v => saveCredentials('whatsapp', 'enabled', { enabled: v })" />
|
||||
<NSwitch :value="credentialDraft('whatsapp').enabled" :loading="isSavingPlatform('whatsapp')" @update:value="v => setCredentialDraft('whatsapp', { enabled: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
|
||||
<NSwitch :value="settingsStore.whatsapp.require_mention" :loading="isSaving('whatsapp', 'require_mention')" @update:value="v => saveChannel('whatsapp', 'require_mention', { require_mention: v })" />
|
||||
<NSwitch :value="configDraft('whatsapp').require_mention" :loading="isSavingPlatform('whatsapp')" @update:value="v => setConfigDraft('whatsapp', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
||||
<NInput :default-value="settingsStore.whatsapp.free_response_chats || ''" :loading="isSaving('whatsapp', 'free_response_chats')" size="small" placeholder="chat_id1,chat_id2" @change="v => saveChannel('whatsapp', 'free_response_chats', { free_response_chats: v })" />
|
||||
<NInput :value="configDraft('whatsapp').free_response_chats || ''" :loading="isSavingPlatform('whatsapp')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => setConfigDraft('whatsapp', { free_response_chats: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
|
||||
<NInput :default-value="(settingsStore.whatsapp.mention_patterns || []).join(', ')" :loading="isSaving('whatsapp', 'mention_patterns')" size="small" placeholder="pattern1, pattern2" @change="v => saveChannel('whatsapp', 'mention_patterns', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
|
||||
<NInput :value="(configDraft('whatsapp').mention_patterns || []).join(', ')" :loading="isSavingPlatform('whatsapp')" size="small" placeholder="pattern1, pattern2" @update:value="v => setConfigDraft('whatsapp', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<!-- Matrix -->
|
||||
<template v-if="p.key === 'matrix'">
|
||||
<SettingRow :label="t('platform.accessToken')" :hint="t('platform.accessTokenHint')">
|
||||
<NInput :default-value="getCreds('matrix').token || ''" :loading="isSaving('matrix', 'token')" clearable size="small" class="input-lg" placeholder="syt_..." @change="v => saveCredentials('matrix', 'token', { token: v })" />
|
||||
<NInput :value="credentialDraft('matrix').token || ''" :loading="isSavingPlatform('matrix')" clearable size="small" class="input-lg" placeholder="syt_..." @update:value="v => setCredentialDraft('matrix', { token: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.homeserver')" :hint="t('platform.homeserverHint')">
|
||||
<NInput :default-value="getCreds('matrix').extra?.homeserver || ''" :loading="isSaving('matrix', 'homeserver')" clearable size="small" class="input-lg" placeholder="https://matrix.org" @change="v => saveCredentials('matrix', 'homeserver', { extra: { ...getCreds('matrix').extra, homeserver: v } })" />
|
||||
<NInput :value="credentialDraft('matrix').extra?.homeserver || ''" :loading="isSavingPlatform('matrix')" clearable size="small" class="input-lg" placeholder="https://matrix.org" @update:value="v => setCredentialDraft('matrix', { extra: { ...credentialDraft('matrix').extra, homeserver: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionRoom')">
|
||||
<NSwitch :value="settingsStore.matrix.require_mention" :loading="isSaving('matrix', 'require_mention')" @update:value="v => saveChannel('matrix', 'require_mention', { require_mention: v })" />
|
||||
<NSwitch :value="configDraft('matrix').require_mention" :loading="isSavingPlatform('matrix')" @update:value="v => setConfigDraft('matrix', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.autoThread')" :hint="t('platform.autoThreadHintRoom')">
|
||||
<NSwitch :value="settingsStore.matrix.auto_thread" :loading="isSaving('matrix', 'auto_thread')" @update:value="v => saveChannel('matrix', 'auto_thread', { auto_thread: v })" />
|
||||
<NSwitch :value="configDraft('matrix').auto_thread" :loading="isSavingPlatform('matrix')" @update:value="v => setConfigDraft('matrix', { auto_thread: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.dmMentionThreads')" :hint="t('platform.dmMentionThreadsHint')">
|
||||
<NSwitch :value="settingsStore.matrix.dm_mention_threads" :loading="isSaving('matrix', 'dm_mention_threads')" @update:value="v => saveChannel('matrix', 'dm_mention_threads', { dm_mention_threads: v })" />
|
||||
<NSwitch :value="configDraft('matrix').dm_mention_threads" :loading="isSavingPlatform('matrix')" @update:value="v => setConfigDraft('matrix', { dm_mention_threads: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseRooms')" :hint="t('platform.freeResponseRoomsHint')">
|
||||
<NInput :default-value="settingsStore.matrix.free_response_rooms || ''" :loading="isSaving('matrix', 'free_response_rooms')" size="small" placeholder="room_id1,room_id2" @change="v => saveChannel('matrix', 'free_response_rooms', { free_response_rooms: v })" />
|
||||
<NInput :value="configDraft('matrix').free_response_rooms || ''" :loading="isSavingPlatform('matrix')" size="small" placeholder="room_id1,room_id2" @update:value="v => setConfigDraft('matrix', { free_response_rooms: v })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<!-- Feishu -->
|
||||
<template v-if="p.key === 'feishu'">
|
||||
<SettingRow :label="t('platform.appId')" :hint="t('platform.appIdHint')">
|
||||
<NInput :default-value="getCreds('feishu').extra?.app_id || ''" :loading="isSaving('feishu', 'app_id')" clearable size="small" class="input-lg" placeholder="cli_..." @change="v => saveCredentials('feishu', 'app_id', { extra: { ...getCreds('feishu').extra, app_id: v } })" />
|
||||
<NInput :value="credentialDraft('feishu').extra?.app_id || ''" :loading="isSavingPlatform('feishu')" clearable size="small" class="input-lg" placeholder="cli_..." @update:value="v => setCredentialDraft('feishu', { extra: { ...credentialDraft('feishu').extra, app_id: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.appSecretHint')">
|
||||
<NInput :default-value="getCreds('feishu').extra?.app_secret || ''" :loading="isSaving('feishu', 'app_secret')" clearable size="small" class="input-lg" placeholder="App Secret" @change="v => saveCredentials('feishu', 'app_secret', { extra: { ...getCreds('feishu').extra, app_secret: v } })" />
|
||||
<NInput :value="credentialDraft('feishu').extra?.app_secret || ''" :loading="isSavingPlatform('feishu')" clearable size="small" class="input-lg" placeholder="App Secret" @update:value="v => setCredentialDraft('feishu', { extra: { ...credentialDraft('feishu').extra, app_secret: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
|
||||
<NSwitch :value="settingsStore.feishu.require_mention" :loading="isSaving('feishu', 'require_mention')" @update:value="v => saveChannel('feishu', 'require_mention', { require_mention: v })" />
|
||||
<NSwitch :value="configDraft('feishu').require_mention" :loading="isSavingPlatform('feishu')" @update:value="v => setConfigDraft('feishu', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
||||
<NInput :default-value="settingsStore.feishu.free_response_chats || ''" :loading="isSaving('feishu', 'free_response_chats')" size="small" placeholder="chat_id1,chat_id2" @change="v => saveChannel('feishu', 'free_response_chats', { free_response_chats: v })" />
|
||||
<NInput :value="configDraft('feishu').free_response_chats || ''" :loading="isSavingPlatform('feishu')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => setConfigDraft('feishu', { free_response_chats: v })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<!-- DingTalk -->
|
||||
<template v-if="p.key === 'dingtalk'">
|
||||
<SettingRow :label="t('platform.clientId')" :hint="t('platform.clientIdHint')">
|
||||
<NInput :default-value="getCreds('dingtalk').extra?.client_id || ''" :loading="isSaving('dingtalk', 'client_id')" clearable size="small" class="input-lg" placeholder="Client ID" @change="v => saveCredentials('dingtalk', 'client_id', { extra: { ...getCreds('dingtalk').extra, client_id: v } })" />
|
||||
<NInput :value="credentialDraft('dingtalk').extra?.client_id || ''" :loading="isSavingPlatform('dingtalk')" clearable size="small" class="input-lg" placeholder="Client ID" @update:value="v => setCredentialDraft('dingtalk', { extra: { ...credentialDraft('dingtalk').extra, client_id: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.clientSecret')" :hint="t('platform.clientSecretHint')">
|
||||
<NInput :default-value="getCreds('dingtalk').extra?.client_secret || ''" :loading="isSaving('dingtalk', 'client_secret')" clearable size="small" class="input-lg" placeholder="Client Secret" @change="v => saveCredentials('dingtalk', 'client_secret', { extra: { ...getCreds('dingtalk').extra, client_secret: v } })" />
|
||||
<NInput :value="credentialDraft('dingtalk').extra?.client_secret || ''" :loading="isSavingPlatform('dingtalk')" clearable size="small" class="input-lg" placeholder="Client Secret" @update:value="v => setCredentialDraft('dingtalk', { extra: { ...credentialDraft('dingtalk').extra, client_secret: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.allowAllUsers')" :hint="t('platform.allowAllUsersHint')">
|
||||
<NSwitch :value="boolValue(getCreds('dingtalk').allow_all_users)" :loading="isSaving('dingtalk', 'allow_all_users')" @update:value="v => saveCredentials('dingtalk', 'allow_all_users', { allow_all_users: v })" />
|
||||
<NSwitch :value="boolValue(credentialDraft('dingtalk').allow_all_users)" :loading="isSavingPlatform('dingtalk')" @update:value="v => setCredentialDraft('dingtalk', { allow_all_users: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.allowedUsers')" :hint="t('platform.allowedUsersHint')">
|
||||
<NInput :default-value="getCreds('dingtalk').allowed_users || ''" :loading="isSaving('dingtalk', 'allowed_users')" clearable size="small" class="input-lg" placeholder="user_id1,user_id2" @change="v => saveCredentials('dingtalk', 'allowed_users', { allowed_users: v })" />
|
||||
<NInput :value="credentialDraft('dingtalk').allowed_users || ''" :loading="isSavingPlatform('dingtalk')" clearable size="small" class="input-lg" placeholder="user_id1,user_id2" @update:value="v => setCredentialDraft('dingtalk', { allowed_users: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
|
||||
<NSwitch :value="settingsStore.dingtalk.require_mention" :loading="isSaving('dingtalk', 'require_mention')" @update:value="v => saveChannel('dingtalk', 'require_mention', { require_mention: v })" />
|
||||
<NSwitch :value="configDraft('dingtalk').require_mention" :loading="isSavingPlatform('dingtalk')" @update:value="v => setConfigDraft('dingtalk', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
||||
<NInput :default-value="settingsStore.dingtalk.free_response_chats || ''" :loading="isSaving('dingtalk', 'free_response_chats')" size="small" placeholder="chat_id1,chat_id2" @change="v => saveChannel('dingtalk', 'free_response_chats', { free_response_chats: v })" />
|
||||
<NInput :value="configDraft('dingtalk').free_response_chats || ''" :loading="isSavingPlatform('dingtalk')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => setConfigDraft('dingtalk', { free_response_chats: v })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<!-- QQBot -->
|
||||
<template v-if="p.key === 'qqbot'">
|
||||
<SettingRow :label="t('platform.qqAppId')" :hint="t('platform.qqAppIdHint')">
|
||||
<NInput :default-value="getCreds('qqbot').extra?.app_id || ''" :loading="isSaving('qqbot', 'app_id')" clearable size="small" class="input-lg" placeholder="App ID" @change="v => saveCredentials('qqbot', 'app_id', { extra: { ...getCreds('qqbot').extra, app_id: v } })" />
|
||||
<NInput :value="credentialDraft('qqbot').extra?.app_id || ''" :loading="isSavingPlatform('qqbot')" clearable size="small" class="input-lg" placeholder="App ID" @update:value="v => setCredentialDraft('qqbot', { extra: { ...credentialDraft('qqbot').extra, app_id: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.qqAppSecret')" :hint="t('platform.qqAppSecretHint')">
|
||||
<NInput :default-value="getCreds('qqbot').extra?.client_secret || ''" :loading="isSaving('qqbot', 'client_secret')" clearable size="small" class="input-lg" placeholder="App Secret" @change="v => saveCredentials('qqbot', 'client_secret', { extra: { ...getCreds('qqbot').extra, client_secret: v } })" />
|
||||
<NInput :value="credentialDraft('qqbot').extra?.client_secret || ''" :loading="isSavingPlatform('qqbot')" clearable size="small" class="input-lg" placeholder="App Secret" @update:value="v => setCredentialDraft('qqbot', { extra: { ...credentialDraft('qqbot').extra, client_secret: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.allowedUsers')" :hint="t('platform.allowedUsersHint')">
|
||||
<NInput :default-value="getCreds('qqbot').allowed_users || ''" :loading="isSaving('qqbot', 'allowed_users')" clearable size="small" class="input-lg" placeholder="openid1,openid2" @change="v => saveCredentials('qqbot', 'allowed_users', { allowed_users: v })" />
|
||||
<NInput :value="credentialDraft('qqbot').allowed_users || ''" :loading="isSavingPlatform('qqbot')" clearable size="small" class="input-lg" placeholder="openid1,openid2" @update:value="v => setCredentialDraft('qqbot', { allowed_users: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.allowAllUsers')" :hint="t('platform.allowAllUsersHint')">
|
||||
<NSwitch :value="boolValue(getCreds('qqbot').allow_all_users)" :loading="isSaving('qqbot', 'allow_all_users')" @update:value="v => saveCredentials('qqbot', 'allow_all_users', { allow_all_users: v })" />
|
||||
<NSwitch :value="boolValue(credentialDraft('qqbot').allow_all_users)" :loading="isSavingPlatform('qqbot')" @update:value="v => setCredentialDraft('qqbot', { allow_all_users: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.qqMarkdown')" :hint="t('platform.qqMarkdownHint')">
|
||||
<NSwitch :value="settingsStore.qqbot.extra?.markdown_support ?? true" :loading="isSaving('qqbot', 'markdown_support')" @update:value="v => saveChannel('qqbot', 'markdown_support', { extra: { ...settingsStore.qqbot.extra, markdown_support: v } })" />
|
||||
<NSwitch :value="configDraft('qqbot').extra?.markdown_support ?? true" :loading="isSavingPlatform('qqbot')" @update:value="v => setConfigDraft('qqbot', { extra: { ...configDraft('qqbot').extra, markdown_support: v } })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
@@ -371,22 +440,34 @@ const platforms = [
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow :label="t('platform.weixinToken')" :hint="t('platform.weixinTokenHint')">
|
||||
<NInput :default-value="getCreds('weixin').token || ''" :loading="isSaving('weixin', 'token')" clearable size="small" class="input-lg" placeholder="Token" @change="v => saveCredentials('weixin', 'token', { token: v })" />
|
||||
<NInput :value="credentialDraft('weixin').token || ''" :loading="isSavingPlatform('weixin')" clearable size="small" class="input-lg" placeholder="Token" @update:value="v => setCredentialDraft('weixin', { token: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.accountId')" :hint="t('platform.accountIdHint')">
|
||||
<NInput :default-value="getCreds('weixin').extra?.account_id || ''" :loading="isSaving('weixin', 'account_id')" clearable size="small" class="input-lg" placeholder="Account ID" @change="v => saveCredentials('weixin', 'account_id', { extra: { ...getCreds('weixin').extra, account_id: v } })" />
|
||||
<NInput :value="credentialDraft('weixin').extra?.account_id || ''" :loading="isSavingPlatform('weixin')" clearable size="small" class="input-lg" placeholder="Account ID" @update:value="v => setCredentialDraft('weixin', { extra: { ...credentialDraft('weixin').extra, account_id: v } })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<!-- WeCom -->
|
||||
<template v-if="p.key === 'wecom'">
|
||||
<SettingRow :label="t('platform.botId')" :hint="t('platform.botIdHint')">
|
||||
<NInput :default-value="getCreds('wecom').extra?.bot_id || ''" :loading="isSaving('wecom', 'bot_id')" clearable size="small" class="input-lg" placeholder="Bot ID" @change="v => saveCredentials('wecom', 'bot_id', { extra: { ...getCreds('wecom').extra, bot_id: v } })" />
|
||||
<NInput :value="credentialDraft('wecom').extra?.bot_id || ''" :loading="isSavingPlatform('wecom')" clearable size="small" class="input-lg" placeholder="Bot ID" @update:value="v => setCredentialDraft('wecom', { extra: { ...credentialDraft('wecom').extra, bot_id: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.wecomSecretHint')">
|
||||
<NInput :default-value="getCreds('wecom').extra?.secret || ''" :loading="isSaving('wecom', 'secret')" clearable size="small" class="input-lg" placeholder="Secret" @change="v => saveCredentials('wecom', 'secret', { extra: { ...getCreds('wecom').extra, secret: v } })" />
|
||||
<NInput :value="credentialDraft('wecom').extra?.secret || ''" :loading="isSavingPlatform('wecom')" clearable size="small" class="input-lg" placeholder="Secret" @update:value="v => setCredentialDraft('wecom', { extra: { ...credentialDraft('wecom').extra, secret: v } })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<div class="platform-actions">
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="isSavingPlatform(p.key)"
|
||||
:disabled="!hasUnsavedChanges(p.key)"
|
||||
@click="savePlatform(p.key)"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</PlatformCard>
|
||||
</section>
|
||||
</template>
|
||||
@@ -415,4 +496,12 @@ const platforms = [
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.platform-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid $border-light;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -57,9 +57,10 @@ async function viewFile(filePath: string) {
|
||||
// filePath might be absolute or relative; normalize to relative under category/skill/
|
||||
const base = `${props.category}/${props.skill}/`
|
||||
let relPath = filePath
|
||||
if (filePath.startsWith('/')) {
|
||||
if (filePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(filePath)) {
|
||||
// Strip absolute prefix to get relative path
|
||||
const segments = filePath.split('/.hermes/skills/')[1]
|
||||
const normalizedPath = filePath.replace(/\\/g, '/')
|
||||
const segments = normalizedPath.split(/(?:^|\/)(?:\.hermes|hermes)\/skills\//)[1]
|
||||
if (segments) {
|
||||
const afterSkillDir = segments.split('/').slice(2).join('/')
|
||||
relPath = afterSkillDir
|
||||
|
||||
@@ -244,15 +244,6 @@ function openChangelog() {
|
||||
</svg>
|
||||
</div>
|
||||
<div v-show="!isGroupCollapsed('system')" class="nav-group-items">
|
||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.gateways' }" @click="handleNav('hermes.gateways')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" />
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" ry="2" />
|
||||
<line x1="6" y1="6" x2="6.01" y2="6" />
|
||||
<line x1="6" y1="18" x2="6.01" y2="18" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.gateways") }}</span>
|
||||
</button>
|
||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.profiles' }" @click="handleNav('hermes.profiles')">
|
||||
<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="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { NModal, NInput, NSelect } from 'naive-ui'
|
||||
import { useAppStore } from '@/stores/hermes/app'
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const profilesStore = useProfilesStore()
|
||||
|
||||
const showModal = ref(false)
|
||||
const searchQuery = ref('')
|
||||
@@ -14,15 +16,20 @@ const customInput = ref('')
|
||||
const customProvider = ref('')
|
||||
|
||||
const selectedDisplayName = computed(() => appStore.displayModelName(appStore.selectedModel, appStore.selectedProvider))
|
||||
const activeProfileName = computed(() => profilesStore.activeProfileName || 'default')
|
||||
const activeModelGroups = computed(() => {
|
||||
const profileModels = appStore.profileModelGroups.find(entry => entry.profile === activeProfileName.value)
|
||||
return profileModels?.groups?.length ? profileModels.groups : appStore.modelGroups
|
||||
})
|
||||
|
||||
const providerOptions = computed(() => {
|
||||
const current = appStore.selectedProvider
|
||||
customProvider.value = current
|
||||
return appStore.modelGroups.map(g => ({ label: g.label, value: g.provider }))
|
||||
return activeModelGroups.value.map(g => ({ label: g.label, value: g.provider }))
|
||||
})
|
||||
|
||||
const modelGroupsWithCustom = computed(() =>
|
||||
appStore.modelGroups.map(g => ({
|
||||
activeModelGroups.value.map(g => ({
|
||||
...g,
|
||||
models: [
|
||||
...g.models,
|
||||
@@ -66,7 +73,7 @@ function isGroupCollapsed(provider: string) {
|
||||
}
|
||||
|
||||
function handleSelect(model: string, provider: string) {
|
||||
const meta = appStore.modelGroups.find(g => g.provider === provider)?.model_meta?.[model]
|
||||
const meta = activeModelGroups.value.find(g => g.provider === provider)?.model_meta?.[model]
|
||||
if (meta?.disabled) return
|
||||
appStore.switchModel(model, provider)
|
||||
showModal.value = false
|
||||
@@ -85,7 +92,7 @@ function handleCustomSubmit() {
|
||||
const model = customInput.value.trim()
|
||||
if (!model || !customProvider.value) return
|
||||
// 拦截 disabled 模型,避免 custom input 绕过列表里的灰显限制
|
||||
const meta = appStore.modelGroups.find(g => g.provider === customProvider.value)?.model_meta?.[model]
|
||||
const meta = activeModelGroups.value.find(g => g.provider === customProvider.value)?.model_meta?.[model]
|
||||
if (meta?.disabled) return
|
||||
appStore.switchModel(model, customProvider.value)
|
||||
showModal.value = false
|
||||
|
||||
@@ -158,10 +158,11 @@ export default {
|
||||
contextUsed: 'Kontext verwendet:',
|
||||
sessions: 'Sitzungen',
|
||||
webUiSessions: 'Sitzungen',
|
||||
allProfiles: 'Alle Profile',
|
||||
sessionScopeHint: 'Chat zeigt nur Web-UI/API-Server-Sitzungen. CLI-, Telegram-, Discord-, Cron- und andere Kanal-Sitzungen sind schreibgeschützt im Verlauf.',
|
||||
openHistory: 'Verlauf öffnen',
|
||||
hermesHistory: 'Hermes-Verlauf',
|
||||
historyScopeHint: 'Schreibgeschützte Hermes-Verlaufssitzungen, nach Quelle gruppiert.',
|
||||
historyScopeHint: 'Schreibgeschützte Hermes-Verlaufssitzungen des aktuellen Profils, nach Quelle gruppiert.',
|
||||
noSessions: 'Keine Sitzungen',
|
||||
newChat: 'Neuer Chat',
|
||||
approvalKicker: 'Terminal-Berechtigung',
|
||||
@@ -460,6 +461,8 @@ jobTriggered: 'Job ausgelost',
|
||||
customModelHint: 'Für vom Provider unterstützte Modelle, die die API nicht zurückgibt; keine Anzeige-Umbenennung. Enter zum Laden.',
|
||||
noProviders: 'Keine Anbieter gefunden. Fugen Sie einen benutzerdefinierten Anbieter hinzu, um zu beginnen.',
|
||||
clearVisibleModels: 'Auswahl löschen',
|
||||
currentDefault: 'Aktueller Standard',
|
||||
defaultShort: 'Standard',
|
||||
builtIn: 'Integriert',
|
||||
customType: 'Benutzerdefiniert',
|
||||
provider: 'Anbieter',
|
||||
|
||||
@@ -171,10 +171,11 @@ export default {
|
||||
contextUsed: 'Context used:',
|
||||
sessions: 'Sessions',
|
||||
webUiSessions: 'Sessions',
|
||||
allProfiles: 'All profiles',
|
||||
sessionScopeHint: 'Chat shows Web UI/API Server sessions only. CLI, Telegram, Discord, Cron, and other channel sessions are read-only in History.',
|
||||
openHistory: 'Open History',
|
||||
hermesHistory: 'Hermes History',
|
||||
historyScopeHint: 'Read-only Hermes history sessions grouped by source.',
|
||||
historyScopeHint: 'Read-only Hermes history sessions for the current profile, grouped by source.',
|
||||
noSessions: 'No sessions',
|
||||
searchTitle: 'Search Sessions',
|
||||
searchSubtitle: 'Search by title or message content',
|
||||
@@ -587,6 +588,7 @@ export default {
|
||||
xaiLoginTitle: 'xAI Grok OAuth Login',
|
||||
xaiWaiting: 'Complete authorization in the opened xAI page. This window will close automatically once approved.',
|
||||
xaiOpenLink: 'Open xAI authorization page',
|
||||
xaiCopyLink: 'Copy authorization link',
|
||||
xaiApproved: 'Sign-in succeeded!',
|
||||
xaiExpired: 'The authorization link has expired. Please retry.',
|
||||
customBadge: 'CUSTOM',
|
||||
@@ -618,6 +620,8 @@ export default {
|
||||
visibilitySaveFailed: 'Failed to save visible models',
|
||||
showAllModels: 'Show all models',
|
||||
clearVisibleModels: 'Clear selection',
|
||||
currentDefault: 'Current default',
|
||||
defaultShort: 'Default',
|
||||
builtIn: 'Built-in',
|
||||
customType: 'Custom',
|
||||
provider: 'Provider',
|
||||
|
||||
@@ -158,10 +158,11 @@ export default {
|
||||
contextUsed: 'Contexto utilizado:',
|
||||
sessions: 'Sesiones',
|
||||
webUiSessions: 'Sesiones',
|
||||
allProfiles: 'Todos los perfiles',
|
||||
sessionScopeHint: 'Chat solo muestra sesiones de Web UI/API Server. Las sesiones de CLI, Telegram, Discord, Cron y otros canales son de solo lectura en Historial.',
|
||||
openHistory: 'Abrir historial',
|
||||
hermesHistory: 'Historial de Hermes',
|
||||
historyScopeHint: 'Sesiones del historial de Hermes, de solo lectura y agrupadas por origen.',
|
||||
historyScopeHint: 'Sesiones del historial de Hermes del perfil actual, de solo lectura y agrupadas por origen.',
|
||||
noSessions: 'Sin sesiones',
|
||||
newChat: 'Nuevo chat',
|
||||
approvalKicker: 'Permiso de terminal',
|
||||
@@ -460,6 +461,8 @@ jobTriggered: 'Job ejecutado',
|
||||
customModelHint: 'Para modelos compatibles con el proveedor que la API no devuelve; no es un cambio de nombre visible. Enter para cargar.',
|
||||
noProviders: 'No se encontraron proveedores. Anade un proveedor personalizado para comenzar.',
|
||||
clearVisibleModels: 'Borrar selección',
|
||||
currentDefault: 'Predeterminado actual',
|
||||
defaultShort: 'Predeterminado',
|
||||
builtIn: 'Integrado',
|
||||
customType: 'Personalizado',
|
||||
provider: 'Proveedor',
|
||||
|
||||
@@ -158,10 +158,11 @@ export default {
|
||||
contextUsed: 'Contexte utilise :',
|
||||
sessions: 'Sessions',
|
||||
webUiSessions: 'Sessions',
|
||||
allProfiles: 'Tous les profils',
|
||||
sessionScopeHint: 'Le chat affiche uniquement les sessions Web UI/API Server. Les sessions CLI, Telegram, Discord, Cron et autres canaux sont en lecture seule dans Historique.',
|
||||
openHistory: 'Ouvrir l’historique',
|
||||
hermesHistory: 'Historique Hermes',
|
||||
historyScopeHint: 'Sessions d’historique Hermes en lecture seule, regroupées par source.',
|
||||
historyScopeHint: 'Sessions d’historique Hermes du profil actuel en lecture seule, regroupées par source.',
|
||||
noSessions: 'Aucune session',
|
||||
newChat: 'Nouvelle discussion',
|
||||
approvalKicker: 'Permission terminal',
|
||||
@@ -460,6 +461,8 @@ jobTriggered: 'Job declenche',
|
||||
customModelHint: 'Pour les modèles pris en charge par le fournisseur mais non renvoyés par l’API ; ce n’est pas un renommage affiché. Entrée pour charger.',
|
||||
noProviders: 'Aucun fournisseur trouve. Ajoutez un fournisseur personnalise pour commencer.',
|
||||
clearVisibleModels: 'Effacer la sélection',
|
||||
currentDefault: 'Par défaut actuel',
|
||||
defaultShort: 'Défaut',
|
||||
builtIn: 'Integre',
|
||||
customType: 'Personnalise',
|
||||
provider: 'Fournisseur',
|
||||
|
||||
@@ -158,10 +158,11 @@ export default {
|
||||
contextUsed: 'コンテキスト使用量:',
|
||||
sessions: 'セッション',
|
||||
webUiSessions: 'セッション',
|
||||
allProfiles: 'すべてのプロファイル',
|
||||
sessionScopeHint: 'チャットには Web UI/API Server セッションのみ表示されます。CLI、Telegram、Discord、Cron などのチャンネルセッションは履歴で読み取り専用として表示されます。',
|
||||
openHistory: '履歴を開く',
|
||||
hermesHistory: 'Hermes 履歴',
|
||||
historyScopeHint: 'ソース別にグループ化された Hermes 履歴セッションを読み取り専用で表示します。',
|
||||
historyScopeHint: '現在の profile の Hermes 履歴セッションをソース別に読み取り専用で表示します。',
|
||||
noSessions: 'セッションがありません',
|
||||
newChat: '新しいチャット',
|
||||
approvalKicker: 'ターミナル権限',
|
||||
@@ -460,6 +461,8 @@ export default {
|
||||
customModelHint: 'プロバイダーは対応しているが API が返さないモデル用です。表示名の変更ではありません。Enter で読み込み。',
|
||||
noProviders: 'プロバイダーがありません。カスタムプロバイダーを追加して始めましょう。',
|
||||
clearVisibleModels: '選択をクリア',
|
||||
currentDefault: '現在のデフォルト',
|
||||
defaultShort: 'デフォルト',
|
||||
builtIn: '組み込み',
|
||||
customType: 'カスタム',
|
||||
provider: 'プロバイダー',
|
||||
|
||||
@@ -158,10 +158,11 @@ export default {
|
||||
contextUsed: '사용된 컨텍스트:',
|
||||
sessions: '세션',
|
||||
webUiSessions: '세션',
|
||||
allProfiles: '모든 프로필',
|
||||
sessionScopeHint: '채팅에는 Web UI/API Server 세션만 표시됩니다. CLI, Telegram, Discord, Cron 등 채널 세션은 기록에서 읽기 전용으로 볼 수 있습니다.',
|
||||
openHistory: '기록 열기',
|
||||
hermesHistory: 'Hermes 기록',
|
||||
historyScopeHint: '소스별로 그룹화된 Hermes 기록 세션을 읽기 전용으로 봅니다.',
|
||||
historyScopeHint: '현재 profile의 Hermes 기록 세션을 소스별로 읽기 전용으로 봅니다.',
|
||||
noSessions: '세션 없음',
|
||||
newChat: '새 채팅',
|
||||
approvalKicker: '터미널 권한',
|
||||
@@ -460,6 +461,8 @@ export default {
|
||||
customModelHint: '제공자는 지원하지만 API가 반환하지 않는 모델용입니다. 표시 이름 변경이 아닙니다. Enter로 불러옵니다.',
|
||||
noProviders: 'Provider가 없습니다. 사용자 지정 Provider를 추가하여 시작하세요.',
|
||||
clearVisibleModels: '선택 지우기',
|
||||
currentDefault: '현재 기본값',
|
||||
defaultShort: '기본값',
|
||||
builtIn: '내장',
|
||||
customType: '사용자 지정',
|
||||
provider: 'Provider',
|
||||
|
||||
@@ -158,10 +158,11 @@ export default {
|
||||
contextUsed: 'Contexto utilizado:',
|
||||
sessions: 'Sessoes',
|
||||
webUiSessions: 'Sessões',
|
||||
allProfiles: 'Todos os perfis',
|
||||
sessionScopeHint: 'O chat mostra apenas sessões da Web UI/API Server. Sessões de CLI, Telegram, Discord, Cron e outros canais são somente leitura no Histórico.',
|
||||
openHistory: 'Abrir histórico',
|
||||
hermesHistory: 'Histórico Hermes',
|
||||
historyScopeHint: 'Sessões do histórico Hermes somente leitura, agrupadas por origem.',
|
||||
historyScopeHint: 'Sessões do histórico Hermes do perfil atual, somente leitura, agrupadas por origem.',
|
||||
noSessions: 'Sem sessoes',
|
||||
newChat: 'Novo chat',
|
||||
approvalKicker: 'Permissão do terminal',
|
||||
@@ -460,6 +461,8 @@ jobTriggered: 'Job acionado',
|
||||
customModelHint: 'Para modelos compatíveis com o provedor que a API não retorna; não é uma renomeação de exibição. Enter para carregar.',
|
||||
noProviders: 'Nenhum provedor encontrado. Adicione um provedor personalizado para comecar.',
|
||||
clearVisibleModels: 'Limpar seleção',
|
||||
currentDefault: 'Padrão atual',
|
||||
defaultShort: 'Padrão',
|
||||
builtIn: 'Integrado',
|
||||
customType: 'Personalizado',
|
||||
provider: 'Provedor',
|
||||
|
||||
@@ -170,10 +170,11 @@ export default {
|
||||
contextUsed: '上下文已用:',
|
||||
sessions: '工作階段',
|
||||
webUiSessions: '工作階段',
|
||||
allProfiles: '全部設定',
|
||||
sessionScopeHint: '這裡只顯示目前工作階段;CLI、Telegram、Discord、Cron 等頻道工作階段在歷史中以唯讀方式查看。',
|
||||
openHistory: '開啟歷史',
|
||||
hermesHistory: 'Hermes 歷史',
|
||||
historyScopeHint: '這裡按來源以唯讀方式查看 Hermes 歷史工作階段。',
|
||||
historyScopeHint: '這裡按來源以唯讀方式查看目前 profile 的 Hermes 歷史工作階段。',
|
||||
noSessions: '目前無工作階段',
|
||||
searchTitle: '搜尋工作階段',
|
||||
searchSubtitle: '依標題或訊息內容搜尋',
|
||||
@@ -610,6 +611,8 @@ export default {
|
||||
visibilitySaveFailed: '儲存可見模型失敗',
|
||||
showAllModels: '顯示全部模型',
|
||||
clearVisibleModels: '取消全選',
|
||||
currentDefault: '目前預設',
|
||||
defaultShort: '預設',
|
||||
aliasEdit: '重新命名',
|
||||
aliasTitle: '模型顯示名',
|
||||
aliasTitleFor: '{model} 的顯示名',
|
||||
|
||||
@@ -171,10 +171,11 @@ export default {
|
||||
contextUsed: '上下文已用:',
|
||||
sessions: '会话',
|
||||
webUiSessions: '会话',
|
||||
allProfiles: '全部配置',
|
||||
sessionScopeHint: '这里只显示当前会话;CLI、Telegram、Discord、Cron 等通道会话在历史中只读查看。',
|
||||
openHistory: '打开历史',
|
||||
hermesHistory: 'Hermes 历史',
|
||||
historyScopeHint: '这里按来源只读查看 Hermes 历史会话。',
|
||||
historyScopeHint: '这里按来源只读查看当前 profile 的 Hermes 历史会话。',
|
||||
noSessions: '暂无会话',
|
||||
searchTitle: '搜索会话',
|
||||
searchSubtitle: '按标题或消息内容搜索',
|
||||
@@ -587,6 +588,7 @@ export default {
|
||||
xaiLoginTitle: 'xAI Grok OAuth 登录',
|
||||
xaiWaiting: '请在打开的 xAI 页面完成授权。授权完成后窗口会自动关闭。',
|
||||
xaiOpenLink: '打开 xAI 授权页',
|
||||
xaiCopyLink: '复制授权链接',
|
||||
xaiApproved: '登录成功!',
|
||||
xaiExpired: '授权链接已过期,请重试。',
|
||||
customBadge: '自定义',
|
||||
@@ -618,6 +620,8 @@ export default {
|
||||
visibilitySaveFailed: '保存可见模型失败',
|
||||
showAllModels: '显示全部模型',
|
||||
clearVisibleModels: '取消全选',
|
||||
currentDefault: '当前默认',
|
||||
defaultShort: '默认',
|
||||
builtIn: '内置',
|
||||
customType: '自定义',
|
||||
provider: 'Provider',
|
||||
|
||||
@@ -75,11 +75,6 @@ const router = createRouter({
|
||||
name: 'hermes.settings',
|
||||
component: () => import('@/views/hermes/SettingsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/hermes/gateways',
|
||||
name: 'hermes.gateways',
|
||||
component: () => import('@/views/hermes/GatewaysView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/hermes/channels',
|
||||
name: 'hermes.channels',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
updateModelAlias,
|
||||
type AvailableModelGroup,
|
||||
type AvailableModelsResponse,
|
||||
type ProfileAvailableModels,
|
||||
type ModelVisibility,
|
||||
type ModelVisibilityRule,
|
||||
} from '@/api/hermes/system'
|
||||
@@ -31,6 +32,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
const clientOutdated = ref(false)
|
||||
const updating = ref(false)
|
||||
const modelGroups = ref<AvailableModelGroup[]>([])
|
||||
const profileModelGroups = ref<ProfileAvailableModels[]>([])
|
||||
const selectedModel = ref('')
|
||||
const selectedProvider = ref('')
|
||||
const customModels = ref<Record<string, string[]>>({})
|
||||
@@ -80,6 +82,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
|
||||
function applyAvailableModelsResponse(res: AvailableModelsResponse) {
|
||||
modelGroups.value = res.groups
|
||||
profileModelGroups.value = res.profiles || []
|
||||
modelAliases.value = res.model_aliases || {}
|
||||
modelVisibility.value = res.model_visibility || {}
|
||||
|
||||
@@ -300,6 +303,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
doUpdate,
|
||||
reloadClient,
|
||||
modelGroups,
|
||||
profileModelGroups,
|
||||
customModels,
|
||||
modelAliases,
|
||||
modelVisibility,
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface PendingApproval {
|
||||
|
||||
export interface Session {
|
||||
id: string
|
||||
profile?: string
|
||||
title: string
|
||||
source?: string
|
||||
messages: Message[]
|
||||
@@ -232,6 +233,7 @@ function mapHermesMessages(msgs: HermesMessage[]): Message[] {
|
||||
function mapHermesSession(s: SessionSummary): Session {
|
||||
return {
|
||||
id: s.id,
|
||||
profile: s.profile || 'default',
|
||||
title: s.title || '',
|
||||
source: s.source || undefined,
|
||||
messages: [],
|
||||
@@ -389,10 +391,19 @@ export const useChatStore = defineStore('chat', () => {
|
||||
return streamStates.value.has(sessionId) || serverWorking.value.has(sessionId)
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
function clearActiveSession() {
|
||||
activeSessionId.value = null
|
||||
activeSession.value = null
|
||||
focusMessageId.value = null
|
||||
setAbortState(null)
|
||||
setCompressionState(null)
|
||||
removeItem(storageKey())
|
||||
}
|
||||
|
||||
async function loadSessions(profile?: string | null) {
|
||||
isLoadingSessions.value = true
|
||||
try {
|
||||
const list = await fetchSessions()
|
||||
const list = await fetchSessions(undefined, undefined, profile || undefined)
|
||||
const fresh = list.map(mapHermesSession)
|
||||
// Preserve already-loaded messages for sessions that are still present,
|
||||
// so we don't blow away the active session's messages on refresh.
|
||||
@@ -410,6 +421,8 @@ export const useChatStore = defineStore('chat', () => {
|
||||
: sessions.value[0]?.id
|
||||
if (targetId) {
|
||||
await switchSession(targetId)
|
||||
} else {
|
||||
clearActiveSession()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load sessions:', err)
|
||||
@@ -439,14 +452,17 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
|
||||
|
||||
function createSession(): Session {
|
||||
function createSession(options: { profile?: string; model?: string; provider?: string } = {}): Session {
|
||||
const session: Session = {
|
||||
id: uid(),
|
||||
profile: options.profile || useProfilesStore().activeProfileName || 'default',
|
||||
title: '',
|
||||
source: 'api_server',
|
||||
source: 'cli',
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
model: options.model || undefined,
|
||||
provider: options.provider || '',
|
||||
}
|
||||
sessions.value.unshift(session)
|
||||
return session
|
||||
@@ -606,12 +622,13 @@ export const useChatStore = defineStore('chat', () => {
|
||||
resumeServerWorkingRun(sessionId)
|
||||
}
|
||||
|
||||
function newChat() {
|
||||
const session = createSession()
|
||||
// Inherit current global model
|
||||
function newChat(options: { profile?: string; model?: string; provider?: string } = {}) {
|
||||
const appStore = useAppStore()
|
||||
session.model = appStore.selectedModel || undefined
|
||||
session.provider = appStore.selectedProvider || ''
|
||||
const session = createSession({
|
||||
profile: options.profile,
|
||||
model: options.model || appStore.selectedModel || undefined,
|
||||
provider: options.provider || appStore.selectedProvider || '',
|
||||
})
|
||||
switchSession(session.id)
|
||||
}
|
||||
|
||||
@@ -852,7 +869,10 @@ export const useChatStore = defineStore('chat', () => {
|
||||
|
||||
// Capture session ID at send time — all callbacks use this, not activeSessionId
|
||||
const sid = activeSessionId.value!
|
||||
const isBridgeSlashCommand = activeSession.value?.source === 'cli' && content.trim().startsWith('/')
|
||||
const shouldSendInitialSessionConfig = activeSession.value
|
||||
? activeSession.value.messageCount == null || activeSession.value.messageCount === 0
|
||||
: false
|
||||
const isBridgeSlashCommand = content.trim().startsWith('/')
|
||||
const isBridgeCompressCommand = isBridgeSlashCommand && /^\/compress(?:\s|$)/i.test(content.trim())
|
||||
const wasLiveBeforeSend = isSessionLive(sid)
|
||||
const shouldQueue = wasLiveBeforeSend && !isBridgeSlashCommand
|
||||
@@ -912,19 +932,22 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const appStore = useAppStore()
|
||||
await appStore.waitForModelsForRun()
|
||||
const sessionModel = activeSession.value?.model || appStore.selectedModel
|
||||
const isBridgeSource = activeSession.value?.source === 'cli'
|
||||
const sessionProvider = activeSession.value?.provider || appStore.selectedProvider
|
||||
const runPayload = {
|
||||
input,
|
||||
session_id: sid,
|
||||
model: isBridgeSource ? undefined : sessionModel || undefined,
|
||||
provider: isBridgeSource ? undefined : sessionProvider || undefined,
|
||||
profile: shouldSendInitialSessionConfig ? activeSession.value?.profile || undefined : undefined,
|
||||
model: shouldSendInitialSessionConfig ? sessionModel || undefined : undefined,
|
||||
provider: shouldSendInitialSessionConfig ? sessionProvider || undefined : undefined,
|
||||
model_groups: appStore.modelGroups.map(group => ({
|
||||
provider: group.provider,
|
||||
models: group.models,
|
||||
})),
|
||||
queue_id: userMsg.id,
|
||||
source: (isBridgeSource ? 'cli' : 'api_server') as 'cli' | 'api_server',
|
||||
source: 'cli' as const,
|
||||
}
|
||||
if (shouldSendInitialSessionConfig && activeSession.value) {
|
||||
activeSession.value.messageCount = Math.max(activeSession.value.messageCount || 0, 1)
|
||||
}
|
||||
|
||||
if (shouldQueue) {
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { fetchGateways, startGateway, stopGateway, type GatewayStatus } from '@/api/hermes/gateways'
|
||||
|
||||
export const useGatewayStore = defineStore('gateways', () => {
|
||||
const gateways = ref<GatewayStatus[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchStatus() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchGateways()
|
||||
gateways.value = Array.isArray(data) ? data : Object.values(data || {})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function start(name: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
const status = await startGateway(name)
|
||||
// Update the specific gateway in the list
|
||||
const idx = gateways.value.findIndex(g => g.profile === name)
|
||||
if (idx >= 0) {
|
||||
gateways.value[idx] = status
|
||||
} else {
|
||||
gateways.value.push(status)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stop(name: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
await stopGateway(name)
|
||||
// Update the specific gateway in the list
|
||||
const gw = gateways.value.find(g => g.profile === name)
|
||||
if (gw) {
|
||||
gw.running = false
|
||||
gw.pid = undefined
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { gateways, loading, fetchStatus, start, stop }
|
||||
})
|
||||
@@ -1,5 +1,8 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { getApiKey } from '@/api/client'
|
||||
import { getDownloadUrl } from '@/api/hermes/download'
|
||||
import type { Attachment, ContentBlock } from './chat'
|
||||
import {
|
||||
connectGroupChat,
|
||||
disconnectGroupChat,
|
||||
@@ -22,6 +25,66 @@ import {
|
||||
clearRoomContext,
|
||||
} from '@/api/hermes/group-chat'
|
||||
|
||||
async function uploadGroupFiles(attachments: Attachment[]): Promise<{ name: string; path: string }[]> {
|
||||
const formData = new FormData()
|
||||
for (const att of attachments) {
|
||||
if (att.file) formData.append('file', att.file, att.name)
|
||||
}
|
||||
const token = getApiKey()
|
||||
const res = await fetch('/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
|
||||
const data = await res.json() as { files: { name: string; path: string }[] }
|
||||
return data.files
|
||||
}
|
||||
|
||||
function buildGroupContentBlocks(content: string, attachments: Attachment[], files: { name: string; path: string }[]): ContentBlock[] {
|
||||
const blocks: ContentBlock[] = []
|
||||
if (content.trim()) blocks.push({ type: 'text', text: content.trim() })
|
||||
for (let i = 0; i < files.length; i += 1) {
|
||||
const file = files[i]
|
||||
const attachment = attachments[i]
|
||||
if (attachment?.type.startsWith('image/')) {
|
||||
blocks.push({
|
||||
type: 'image',
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
media_type: attachment.type,
|
||||
})
|
||||
} else {
|
||||
blocks.push({
|
||||
type: 'file',
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
media_type: attachment?.type,
|
||||
})
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
function uid(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
||||
}
|
||||
|
||||
function normalizeLocalFilePath(path: string): string {
|
||||
return /^[a-zA-Z]:\\/.test(path) ? path.replace(/\\/g, '/') : path
|
||||
}
|
||||
|
||||
export interface GroupPendingApproval {
|
||||
roomId: string
|
||||
agentName: string
|
||||
approvalId: string
|
||||
command: string
|
||||
description: string
|
||||
choices: Array<'once' | 'session' | 'always' | 'deny'>
|
||||
allowPermanent: boolean
|
||||
requestedAt: number
|
||||
}
|
||||
|
||||
export const useGroupChatStore = defineStore('groupChat', () => {
|
||||
// ─── State ─────────────────────────────────────────────
|
||||
const connected = ref(false)
|
||||
@@ -35,6 +98,18 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
||||
const error = ref<string | null>(null)
|
||||
const typingUsers = ref<Map<string, { name: string; timer: ReturnType<typeof setTimeout> }>>(new Map())
|
||||
const contextStatuses = ref<Map<string, { agentName: string; status: string }>>(new Map())
|
||||
const autoPlaySpeechEnabled = ref(false)
|
||||
const pendingApprovals = ref<Map<string, GroupPendingApproval>>(new Map())
|
||||
|
||||
function setAutoPlaySpeech(enabled: boolean) {
|
||||
autoPlaySpeechEnabled.value = enabled
|
||||
}
|
||||
|
||||
function playMessageSpeech(messageId: string, content: string) {
|
||||
window.dispatchEvent(new CustomEvent('auto-play-speech', {
|
||||
detail: { messageId, content },
|
||||
}))
|
||||
}
|
||||
|
||||
// Computed: returns first active status for backward compat
|
||||
const contextStatus = computed(() => {
|
||||
@@ -43,13 +118,18 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
||||
}
|
||||
return null
|
||||
})
|
||||
const activePendingApproval = computed(() => {
|
||||
if (!currentRoomId.value) return null
|
||||
for (const approval of pendingApprovals.value.values()) {
|
||||
if (approval.roomId === currentRoomId.value) return approval
|
||||
}
|
||||
return null
|
||||
})
|
||||
const userId = ref(getStoredUserId())
|
||||
const userName = ref(getStoredUserName() || '')
|
||||
|
||||
// ─── Computed ───────────────────────────────────────────
|
||||
const sortedMessages = computed(() => {
|
||||
return [...messages.value].sort((a, b) => a.timestamp - b.timestamp)
|
||||
})
|
||||
const sortedMessages = computed(() => mapGroupMessages([...messages.value].sort((a, b) => a.timestamp - b.timestamp)))
|
||||
|
||||
const memberNames = computed(() => {
|
||||
return members.value.map(m => m.name)
|
||||
@@ -94,10 +174,89 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
||||
|
||||
socket.on('message', (msg: ChatMessage) => {
|
||||
if (msg.roomId === currentRoomId.value) {
|
||||
const idx = messages.value.findIndex(m => m.id === msg.id)
|
||||
const existing = idx >= 0 ? messages.value[idx] : null
|
||||
const resolvedMsg = {
|
||||
...msg,
|
||||
isStreaming: false,
|
||||
attachments: existing?.attachments,
|
||||
}
|
||||
if (idx >= 0) {
|
||||
messages.value[idx] = resolvedMsg
|
||||
messages.value = [...messages.value]
|
||||
} else {
|
||||
messages.value.push(resolvedMsg)
|
||||
}
|
||||
if (autoPlaySpeechEnabled.value && resolvedMsg.role === 'assistant' && resolvedMsg.content?.trim()) {
|
||||
setTimeout(() => playMessageSpeech(resolvedMsg.id, resolvedMsg.content), 300)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('message_stream_start', (msg: ChatMessage) => {
|
||||
if (msg.roomId !== currentRoomId.value) return
|
||||
messages.value = messages.value.filter(m => !(
|
||||
m.roomId === msg.roomId &&
|
||||
m.senderId === msg.senderId &&
|
||||
m.id !== msg.id &&
|
||||
m.isStreaming &&
|
||||
!m.content?.trim() &&
|
||||
!m.reasoning?.trim() &&
|
||||
!m.tool_calls?.length
|
||||
))
|
||||
msg.isStreaming = true
|
||||
const idx = messages.value.findIndex(m => m.id === msg.id)
|
||||
if (idx >= 0) {
|
||||
messages.value[idx] = { ...messages.value[idx], ...msg, isStreaming: true }
|
||||
messages.value = [...messages.value]
|
||||
} else {
|
||||
messages.value.push(msg)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('message_stream_delta', (data: { roomId: string; id: string; delta: string }) => {
|
||||
if (data.roomId !== currentRoomId.value) return
|
||||
const idx = messages.value.findIndex(m => m.id === data.id)
|
||||
if (idx < 0) return
|
||||
messages.value[idx] = {
|
||||
...messages.value[idx],
|
||||
content: messages.value[idx].content + data.delta,
|
||||
}
|
||||
messages.value = [...messages.value]
|
||||
})
|
||||
|
||||
socket.on('message_reasoning_delta', (data: { roomId: string; id: string; delta: string }) => {
|
||||
if (data.roomId !== currentRoomId.value) return
|
||||
const idx = messages.value.findIndex(m => m.id === data.id)
|
||||
if (idx < 0) return
|
||||
messages.value[idx] = {
|
||||
...messages.value[idx],
|
||||
reasoning: (messages.value[idx].reasoning || '') + data.delta,
|
||||
reasoning_content: (messages.value[idx].reasoning_content || '') + data.delta,
|
||||
isStreaming: true,
|
||||
}
|
||||
messages.value = [...messages.value]
|
||||
})
|
||||
|
||||
socket.on('message_stream_end', (data: { roomId: string; id: string }) => {
|
||||
if (data.roomId !== currentRoomId.value) return
|
||||
const idx = messages.value.findIndex(m => m.id === data.id)
|
||||
if (
|
||||
idx >= 0 &&
|
||||
!messages.value[idx].content?.trim() &&
|
||||
!messages.value[idx].reasoning?.trim() &&
|
||||
!messages.value[idx].tool_calls?.length
|
||||
) {
|
||||
messages.value.splice(idx, 1)
|
||||
} else if (idx >= 0) {
|
||||
messages.value[idx] = {
|
||||
...messages.value[idx],
|
||||
isStreaming: false,
|
||||
}
|
||||
messages.value = [...messages.value]
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('member_joined', (data: { roomId: string; members: MemberInfo[] }) => {
|
||||
if (data.roomId === currentRoomId.value) {
|
||||
members.value = data.members
|
||||
@@ -129,6 +288,18 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
||||
if (data.roomId === currentRoomId.value) {
|
||||
if (data.status === 'ready') {
|
||||
contextStatuses.value.delete(data.agentName)
|
||||
messages.value = messages.value
|
||||
.map(m => (
|
||||
m.senderName === data.agentName && m.isStreaming
|
||||
? { ...m, isStreaming: false }
|
||||
: m
|
||||
))
|
||||
.filter(m => !(
|
||||
m.senderName === data.agentName &&
|
||||
!m.content?.trim() &&
|
||||
!m.reasoning?.trim() &&
|
||||
!m.tool_calls?.length
|
||||
))
|
||||
} else {
|
||||
contextStatuses.value.set(data.agentName, { agentName: data.agentName, status: data.status })
|
||||
}
|
||||
@@ -137,6 +308,30 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('approval.requested', (data: { roomId: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean }) => {
|
||||
if (!data.approval_id) return
|
||||
const choices = (Array.isArray(data.choices) ? data.choices : ['once', 'session', 'deny'])
|
||||
.filter((choice): choice is GroupPendingApproval['choices'][number] =>
|
||||
choice === 'once' || choice === 'session' || choice === 'always' || choice === 'deny')
|
||||
pendingApprovals.value.set(data.approval_id, {
|
||||
roomId: data.roomId,
|
||||
agentName: data.agentName || '',
|
||||
approvalId: data.approval_id,
|
||||
command: data.command || '',
|
||||
description: data.description || '',
|
||||
choices: choices.length ? choices : ['once', 'session', 'deny'],
|
||||
allowPermanent: Boolean(data.allow_permanent),
|
||||
requestedAt: Date.now(),
|
||||
})
|
||||
pendingApprovals.value = new Map(pendingApprovals.value)
|
||||
})
|
||||
|
||||
socket.on('approval.resolved', (data: { approval_id?: string }) => {
|
||||
if (!data.approval_id) return
|
||||
pendingApprovals.value.delete(data.approval_id)
|
||||
pendingApprovals.value = new Map(pendingApprovals.value)
|
||||
})
|
||||
|
||||
socket.on('room_updated', (data: { roomId: string; totalTokens: number }) => {
|
||||
const room = rooms.value.find(r => r.id === data.roomId)
|
||||
if (room) room.totalTokens = data.totalTokens
|
||||
@@ -149,6 +344,7 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
||||
messages.value = []
|
||||
typingUsers.value.clear()
|
||||
contextStatuses.value.clear()
|
||||
pendingApprovals.value.clear()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -163,6 +359,7 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
||||
roomName.value = ''
|
||||
typingUsers.value.clear()
|
||||
contextStatuses.value.clear()
|
||||
pendingApprovals.value.clear()
|
||||
}
|
||||
|
||||
function setUserInfo(name: string, description: string) {
|
||||
@@ -194,7 +391,11 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
||||
const socket = getSocket()
|
||||
if (socket) {
|
||||
await new Promise<void>((resolve) => {
|
||||
socket.emit('join', { roomId, name: userName.value || undefined }, (res: any) => {
|
||||
socket.emit('join', {
|
||||
roomId,
|
||||
name: userName.value || undefined,
|
||||
description: localStorage.getItem('gc_user_description') || undefined,
|
||||
}, (res: any) => {
|
||||
if (!res?.error) {
|
||||
members.value = res.members || []
|
||||
if (res.agents) agents.value = res.agents
|
||||
@@ -222,14 +423,34 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(content: string) {
|
||||
async function sendMessage(content: string, attachments?: Attachment[]) {
|
||||
const socket = getSocket()
|
||||
if (!socket || !currentRoomId.value) return
|
||||
emitStopTyping()
|
||||
const messageId = uid()
|
||||
let finalContent: string | ContentBlock[] = content.trim()
|
||||
if (attachments?.length) {
|
||||
const uploaded = await uploadGroupFiles(attachments)
|
||||
finalContent = buildGroupContentBlocks(content, attachments, uploaded)
|
||||
const urlMap = new Map(uploaded.map(f => {
|
||||
return [f.name, getDownloadUrl(normalizeLocalFilePath(f.path), f.name)]
|
||||
}))
|
||||
messages.value.push({
|
||||
id: messageId,
|
||||
roomId: currentRoomId.value,
|
||||
senderId: userId.value,
|
||||
senderName: userName.value || 'You',
|
||||
content: JSON.stringify(finalContent),
|
||||
timestamp: Date.now(),
|
||||
role: 'user',
|
||||
attachments: attachments.map(att => ({ ...att, url: urlMap.get(att.name) || att.url, file: undefined })),
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
socket!.emit('message', { roomId: currentRoomId.value, content }, (res: { id?: string; error?: string }) => {
|
||||
socket!.emit('message', { roomId: currentRoomId.value, id: messageId, content: finalContent }, (res: { id?: string; error?: string }) => {
|
||||
if (res.error) {
|
||||
messages.value = messages.value.filter(m => m.id !== messageId)
|
||||
reject(new Error(res.error))
|
||||
return
|
||||
}
|
||||
@@ -365,6 +586,35 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
||||
if (_typingTimer) { clearTimeout(_typingTimer); _typingTimer = null }
|
||||
}
|
||||
|
||||
async function interruptAgent(agentName: string) {
|
||||
const socket = getSocket()
|
||||
if (!socket || !currentRoomId.value) return
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.emit('interrupt_agent', { roomId: currentRoomId.value, agentName }, (res: any) => {
|
||||
if (res?.error) reject(new Error(res.error))
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function respondApproval(choice: GroupPendingApproval['choices'][number]) {
|
||||
const socket = getSocket()
|
||||
const pending = activePendingApproval.value
|
||||
if (!socket || !pending) return
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.emit('approval.respond', {
|
||||
roomId: pending.roomId,
|
||||
approval_id: pending.approvalId,
|
||||
choice,
|
||||
}, (res: any) => {
|
||||
if (res?.error) reject(new Error(res.error))
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
pendingApprovals.value.delete(pending.approvalId)
|
||||
pendingApprovals.value = new Map(pendingApprovals.value)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
connected,
|
||||
@@ -378,6 +628,9 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
||||
error,
|
||||
contextStatus,
|
||||
contextStatuses,
|
||||
pendingApprovals,
|
||||
activePendingApproval,
|
||||
autoPlaySpeechEnabled,
|
||||
userId,
|
||||
userName,
|
||||
// Computed
|
||||
@@ -389,11 +642,14 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
||||
connect,
|
||||
disconnect,
|
||||
setUserInfo,
|
||||
setAutoPlaySpeech,
|
||||
joinRoom,
|
||||
sendMessage,
|
||||
loadRooms,
|
||||
emitTyping,
|
||||
emitStopTyping,
|
||||
interruptAgent,
|
||||
respondApproval,
|
||||
createNewRoom,
|
||||
joinByCode,
|
||||
deleteRoom,
|
||||
@@ -404,3 +660,85 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
||||
removeAgentFromRoom,
|
||||
}
|
||||
})
|
||||
|
||||
function mapGroupMessages(msgs: ChatMessage[]): ChatMessage[] {
|
||||
const toolNameMap = new Map<string, string>()
|
||||
const toolArgsMap = new Map<string, string>()
|
||||
for (const msg of msgs) {
|
||||
if (msg.role === 'assistant' && msg.tool_calls?.length) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
if (!tc?.id) continue
|
||||
if (tc.function?.name) toolNameMap.set(tc.id, tc.function.name)
|
||||
if (tc.function?.arguments) toolArgsMap.set(tc.id, tc.function.arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result: ChatMessage[] = []
|
||||
for (const msg of msgs) {
|
||||
if (
|
||||
msg.role !== 'tool' &&
|
||||
!msg.tool_calls?.length &&
|
||||
!msg.content?.trim() &&
|
||||
!msg.reasoning?.trim() &&
|
||||
(!msg.isStreaming || msg.finish_reason === 'streaming')
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (msg.role === 'assistant' && msg.tool_calls?.length && !msg.content?.trim()) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
result.push({
|
||||
...msg,
|
||||
id: `${msg.id}_${tc.id}`,
|
||||
role: 'tool',
|
||||
content: '',
|
||||
toolName: tc.function?.name || undefined,
|
||||
toolCallId: tc.id,
|
||||
toolArgs: tc.function?.arguments || undefined,
|
||||
toolStatus: 'running',
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (msg.role === 'tool') {
|
||||
const tcId = msg.tool_call_id || ''
|
||||
const toolName = msg.tool_name || toolNameMap.get(tcId) || undefined
|
||||
const toolArgs = toolArgsMap.get(tcId) || undefined
|
||||
let preview = ''
|
||||
if (msg.content) {
|
||||
try {
|
||||
const parsed = JSON.parse(msg.content)
|
||||
preview = parsed.url || parsed.title || parsed.preview || parsed.summary || ''
|
||||
} catch {
|
||||
preview = msg.content.slice(0, 80)
|
||||
}
|
||||
}
|
||||
const placeholderIdx = result.findIndex(
|
||||
m => m.role === 'tool' && m.toolCallId === tcId && !m.toolResult
|
||||
)
|
||||
const merged: ChatMessage = {
|
||||
...msg,
|
||||
id: placeholderIdx !== -1 ? result[placeholderIdx].id : msg.id,
|
||||
senderId: placeholderIdx !== -1 ? result[placeholderIdx].senderId : msg.senderId,
|
||||
senderName: placeholderIdx !== -1 ? result[placeholderIdx].senderName : msg.senderName,
|
||||
timestamp: placeholderIdx !== -1 ? result[placeholderIdx].timestamp : msg.timestamp,
|
||||
role: 'tool',
|
||||
content: '',
|
||||
toolName: toolName || (placeholderIdx !== -1 ? result[placeholderIdx].toolName : undefined),
|
||||
toolCallId: tcId || undefined,
|
||||
toolArgs: toolArgs || (placeholderIdx !== -1 ? result[placeholderIdx].toolArgs : undefined),
|
||||
toolPreview: typeof preview === 'string' ? preview.slice(0, 100) || undefined : undefined,
|
||||
toolResult: msg.content || undefined,
|
||||
toolStatus: 'done',
|
||||
}
|
||||
if (placeholderIdx !== -1) result[placeholderIdx] = merged
|
||||
else result.push(merged)
|
||||
continue
|
||||
}
|
||||
|
||||
result.push(msg)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const useModelsStore = defineStore('models', () => {
|
||||
const providers = ref<AvailableModelGroup[]>([])
|
||||
const allProviders = ref<AvailableModelGroup[]>([])
|
||||
const defaultModel = ref('')
|
||||
const defaultProvider = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const customProviders = computed(() =>
|
||||
@@ -26,7 +27,7 @@ export const useModelsStore = defineStore('models', () => {
|
||||
provider: g.provider,
|
||||
label: g.label,
|
||||
base_url: g.base_url,
|
||||
isDefault: m === defaultModel.value,
|
||||
isDefault: m === defaultModel.value && g.provider === defaultProvider.value,
|
||||
})),
|
||||
),
|
||||
)
|
||||
@@ -39,6 +40,7 @@ export const useModelsStore = defineStore('models', () => {
|
||||
providers.value = res.groups
|
||||
allProviders.value = res.allProviders
|
||||
defaultModel.value = res.default
|
||||
defaultProvider.value = res.default_provider || ''
|
||||
const appStore = useAppStore()
|
||||
appStore.applyAvailableModelsResponse(res)
|
||||
} catch (err) {
|
||||
@@ -51,6 +53,7 @@ export const useModelsStore = defineStore('models', () => {
|
||||
async function setDefaultModel(modelId: string, provider: string) {
|
||||
await systemApi.updateDefaultModel({ default: modelId, provider })
|
||||
defaultModel.value = modelId
|
||||
defaultProvider.value = provider
|
||||
const appStore = useAppStore()
|
||||
appStore.reloadModels()
|
||||
}
|
||||
@@ -69,6 +72,7 @@ export const useModelsStore = defineStore('models', () => {
|
||||
providers,
|
||||
allProviders,
|
||||
defaultModel,
|
||||
defaultProvider,
|
||||
loading,
|
||||
customProviders,
|
||||
builtinProviders,
|
||||
|
||||
@@ -83,10 +83,10 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSection(section: string, values: Record<string, any>) {
|
||||
async function saveSection(section: string, values: Record<string, any>, options?: { restart?: boolean }) {
|
||||
saving.value = true
|
||||
try {
|
||||
await configApi.updateConfigSection(section, values)
|
||||
await configApi.updateConfigSection(section, values, options)
|
||||
switch (section) {
|
||||
case 'display': display.value = { ...display.value, ...values }; break
|
||||
case 'agent': agent.value = { ...agent.value, ...values }; break
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { NSpin, NButton, NTag, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGatewayStore } from '@/stores/hermes/gateways'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const gatewayStore = useGatewayStore()
|
||||
|
||||
onMounted(() => {
|
||||
gatewayStore.fetchStatus()
|
||||
})
|
||||
|
||||
async function handleToggle(name: string, running: boolean) {
|
||||
try {
|
||||
if (running) {
|
||||
await gatewayStore.stop(name)
|
||||
message.success(`${t('gateways.stopped')}: ${name}`)
|
||||
} else {
|
||||
await gatewayStore.start(name)
|
||||
message.success(`${t('gateways.started')}: ${name}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err.message)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gateways-view">
|
||||
<header class="page-header">
|
||||
<h2 class="header-title">{{ t('gateways.title') }}</h2>
|
||||
</header>
|
||||
|
||||
<div class="gateways-content">
|
||||
<NSpin :show="gatewayStore.loading" size="large">
|
||||
<div v-if="gatewayStore.gateways.length === 0" class="empty-state">
|
||||
{{ t('common.noData') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="gateway-list">
|
||||
<div v-for="gw in gatewayStore.gateways" :key="gw.profile" class="gateway-card">
|
||||
<div class="gateway-info">
|
||||
<div class="gateway-name">{{ gw.profile }}</div>
|
||||
<div class="gateway-meta">
|
||||
<span class="meta-item">{{ gw.host }}:{{ gw.port }}</span>
|
||||
<span v-if="gw.pid" class="meta-item">PID: {{ gw.pid }}</span>
|
||||
</div>
|
||||
<div v-if="gw.diagnostics" class="gateway-diagnostics">
|
||||
<span class="diag-item">{{ gw.diagnostics.reason }}</span>
|
||||
<span class="diag-item">PID: {{ gw.diagnostics.pid_path }}</span>
|
||||
<span class="diag-item">Config: {{ gw.diagnostics.config_path }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gateway-actions">
|
||||
<NTag :type="gw.running ? 'success' : 'default'" size="small" round>
|
||||
{{ gw.running ? t('gateways.running') : t('gateways.stopped') }}
|
||||
</NTag>
|
||||
<NButton
|
||||
size="small"
|
||||
:type="gw.running ? 'warning' : 'primary'"
|
||||
round
|
||||
@click="handleToggle(gw.profile, gw.running)"
|
||||
>
|
||||
{{ gw.running ? t('common.stop') : t('common.start') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NSpin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.gateways-view {
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gateways-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: $text-muted;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.gateway-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.gateway-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
background-color: $bg-card;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
transition: border-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.gateway-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gateway-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.gateway-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.gateway-diagnostics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.diag-item {
|
||||
max-width: 100%;
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
background: rgba(127, 127, 127, 0.08);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.gateway-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.gateways-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.gateway-card {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.gateway-diagnostics {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.diag-item {
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.gateway-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,7 +11,7 @@ import { copyToClipboard } from '@/utils/clipboard'
|
||||
import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue'
|
||||
import SessionListItem from '@/components/hermes/chat/SessionListItem.vue'
|
||||
import OutlinePanel from '@/components/hermes/chat/OutlinePanel.vue'
|
||||
import { fetchHermesSessions, fetchHermesSession, type SessionSummary } from '@/api/hermes/sessions'
|
||||
import { deleteSession, fetchHermesSessions, fetchHermesSession, type SessionSummary } from '@/api/hermes/sessions'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const appStore = useAppStore()
|
||||
@@ -132,11 +132,13 @@ const collapsedGroups = ref<Set<string>>(new Set(JSON.parse(localStorage.getItem
|
||||
function sessionSummaryToSession(summary: SessionSummary): Session {
|
||||
return {
|
||||
id: summary.id,
|
||||
profile: summary.profile,
|
||||
title: summary.title || '',
|
||||
source: summary.source,
|
||||
createdAt: summary.started_at * 1000,
|
||||
updatedAt: (summary.last_active || summary.started_at) * 1000,
|
||||
model: summary.model,
|
||||
provider: summary.provider,
|
||||
messageCount: summary.message_count,
|
||||
inputTokens: summary.input_tokens,
|
||||
outputTokens: summary.output_tokens,
|
||||
@@ -269,6 +271,26 @@ async function copySessionId(id?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSession(id: string) {
|
||||
const ok = await deleteSession(id)
|
||||
if (!ok) {
|
||||
message.error(t('common.deleteFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
sessionBrowserPrefsStore.removePinned(id)
|
||||
hermesSessions.value = hermesSessions.value.filter(s => s.id !== id)
|
||||
|
||||
if (historySessionId.value === id) {
|
||||
historySessionId.value = null
|
||||
historySession.value = null
|
||||
const next = historySessions.value[0]
|
||||
if (next) await handleSessionClick(next.id)
|
||||
}
|
||||
|
||||
message.success(t('chat.sessionDeleted'))
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -301,9 +323,11 @@ async function copySessionId(id?: string) {
|
||||
:session="s"
|
||||
:active="s.id === historySessionId"
|
||||
:pinned="true"
|
||||
:can-delete="false"
|
||||
:can-delete="true"
|
||||
:streaming="false"
|
||||
:show-profile="false"
|
||||
@select="handleSessionClick(s.id)"
|
||||
@delete="handleDeleteSession(s.id)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -320,9 +344,11 @@ async function copySessionId(id?: string) {
|
||||
:session="s"
|
||||
:active="s.id === historySessionId"
|
||||
:pinned="false"
|
||||
:can-delete="false"
|
||||
:can-delete="true"
|
||||
:streaming="false"
|
||||
:show-profile="false"
|
||||
@select="handleSessionClick(s.id)"
|
||||
@delete="handleDeleteSession(s.id)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user