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>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import * as hermesCli from '../services/hermes/hermes-cli'
|
||||
import { getGatewayManagerInstance } from '../services/gateway-bootstrap'
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
|
||||
@@ -69,21 +68,11 @@ export function startVersionCheck(): void {
|
||||
export async function healthCheck(ctx: any) {
|
||||
const raw = await hermesCli.getVersion()
|
||||
const hermesVersion = raw.split('\n')[0].replace('Hermes Agent ', '') || ''
|
||||
let gatewayOk = false
|
||||
try {
|
||||
const mgr = getGatewayManagerInstance()
|
||||
const upstream = mgr?.getUpstream()
|
||||
if (!upstream) {
|
||||
throw new Error('GatewayManager not initialized')
|
||||
}
|
||||
const res = await fetch(`${upstream.replace(/\/$/, '')}/health`, { signal: AbortSignal.timeout(5000) })
|
||||
gatewayOk = res.ok
|
||||
} catch { }
|
||||
ctx.body = {
|
||||
status: gatewayOk ? 'ok' : 'error',
|
||||
status: 'ok',
|
||||
platform: 'hermes-agent',
|
||||
version: hermesVersion,
|
||||
gateway: gatewayOk ? 'running' : 'stopped',
|
||||
gateway: 'running',
|
||||
webui_version: LOCAL_VERSION,
|
||||
webui_latest: cachedLatestVersion,
|
||||
webui_update_available: Boolean(LOCAL_VERSION && cachedLatestVersion && cachedLatestVersion !== LOCAL_VERSION),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFile } from 'fs/promises'
|
||||
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
|
||||
import { getActiveConfigPath, getActiveEnvPath } from '../../services/hermes/hermes-profile'
|
||||
import { getActiveConfigPath, getActiveEnvPath, getActiveProfileName } from '../../services/hermes/hermes-profile'
|
||||
import { AgentBridgeClient } from '../../services/hermes/agent-bridge'
|
||||
import { restartGateway } from '../../services/hermes/hermes-cli'
|
||||
import { saveEnvValue } from '../../services/config-helpers'
|
||||
import { logger } from '../../services/logger'
|
||||
import { safeFileStore } from '../../services/safe-file-store'
|
||||
@@ -78,6 +79,15 @@ function deepMerge(target: Record<string, any>, source: Record<string, any>): Re
|
||||
return target
|
||||
}
|
||||
|
||||
async function destroyBridgeProfile(profile: string): Promise<void> {
|
||||
try {
|
||||
const result = await new AgentBridgeClient().destroyProfile(profile)
|
||||
logger.info('[config] destroyed bridge sessions after gateway restart profile=%s destroyed=%s', profile, result.destroyed)
|
||||
} catch (err) {
|
||||
logger.warn(err, '[config] failed to destroy bridge sessions after gateway restart profile=%s', profile)
|
||||
}
|
||||
}
|
||||
|
||||
async function readEnvPlatforms(): Promise<Record<string, any>> {
|
||||
try {
|
||||
const raw = await readFile(envPath(), 'utf-8')
|
||||
@@ -127,7 +137,7 @@ export async function getConfig(ctx: any) {
|
||||
}
|
||||
|
||||
export async function updateConfig(ctx: any) {
|
||||
const { section, values } = ctx.request.body as { section: string; values: Record<string, any> }
|
||||
const { section, values, restart } = ctx.request.body as { section: string; values: Record<string, any>; restart?: boolean }
|
||||
if (!section || !values) {
|
||||
ctx.status = 400; ctx.body = { error: 'Missing section or values' }; return
|
||||
}
|
||||
@@ -142,17 +152,19 @@ export async function updateConfig(ctx: any) {
|
||||
},
|
||||
})
|
||||
|
||||
// 使用 GatewayManager 重启平台网关
|
||||
if (PLATFORM_SECTIONS.has(section)) {
|
||||
const mgr = getGatewayManagerInstance()
|
||||
if (mgr) {
|
||||
try {
|
||||
const activeProfile = mgr.getActiveProfile()
|
||||
await mgr.stop(activeProfile)
|
||||
await mgr.start(activeProfile)
|
||||
} catch (err) {
|
||||
logger.error(err, 'GatewayManager restart failed')
|
||||
}
|
||||
// Platform adapters still run through Hermes gateway; restart it so channel
|
||||
// config changes (Feishu/Weixin/etc.) are applied, then refresh bridge sessions.
|
||||
if (restart !== false && PLATFORM_SECTIONS.has(section)) {
|
||||
const activeProfile = getActiveProfileName()
|
||||
try {
|
||||
const restartResult = await restartGateway()
|
||||
logger.info('[config] gateway restarted after config update section=%s profile=%s result=%s', section, activeProfile, restartResult)
|
||||
await destroyBridgeProfile(activeProfile)
|
||||
} catch (err) {
|
||||
logger.error(err, 'Gateway restart failed')
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err instanceof Error ? err.message : 'Gateway restart failed' }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,16 +220,18 @@ export async function updateCredentials(ctx: any) {
|
||||
},
|
||||
})
|
||||
|
||||
// 使用 GatewayManager 重启平台网关
|
||||
const mgr = getGatewayManagerInstance()
|
||||
if (mgr) {
|
||||
try {
|
||||
const activeProfile = mgr.getActiveProfile()
|
||||
await mgr.stop(activeProfile)
|
||||
await mgr.start(activeProfile)
|
||||
} catch (err) {
|
||||
logger.error(err, 'GatewayManager restart failed')
|
||||
}
|
||||
// Platform adapters still run through Hermes gateway; restart it so channel
|
||||
// credentials are applied, then refresh bridge sessions.
|
||||
const activeProfile = getActiveProfileName()
|
||||
try {
|
||||
const restartResult = await restartGateway()
|
||||
logger.info('[config] gateway restarted after credentials update platform=%s profile=%s result=%s', platform, activeProfile, restartResult)
|
||||
await destroyBridgeProfile(activeProfile)
|
||||
} catch (err) {
|
||||
logger.error(err, 'Gateway restart failed')
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err instanceof Error ? err.message : 'Gateway restart failed' }
|
||||
return
|
||||
}
|
||||
|
||||
ctx.body = { success: true }
|
||||
|
||||
@@ -250,7 +250,14 @@ export async function readRun(ctx: Context) {
|
||||
}
|
||||
|
||||
// Prevent path traversal
|
||||
if (jobId.includes('..') || fileName.includes('..') || jobId.includes('/') || fileName.includes('/')) {
|
||||
if (
|
||||
jobId.includes('..')
|
||||
|| fileName.includes('..')
|
||||
|| jobId.includes('/')
|
||||
|| fileName.includes('/')
|
||||
|| jobId.includes('\\')
|
||||
|| fileName.includes('\\')
|
||||
) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Invalid path' }
|
||||
return
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
|
||||
|
||||
export async function list(ctx: any) {
|
||||
const mgr = getGatewayManagerInstance()
|
||||
if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return }
|
||||
const gateways = await mgr.listAll()
|
||||
ctx.body = { gateways }
|
||||
}
|
||||
|
||||
export async function start(ctx: any) {
|
||||
const mgr = getGatewayManagerInstance()
|
||||
if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return }
|
||||
try {
|
||||
const status = await mgr.start(ctx.params.name)
|
||||
ctx.body = { success: true, gateway: status }
|
||||
} catch (err: any) { ctx.status = 500; ctx.body = { error: err.message } }
|
||||
}
|
||||
|
||||
export async function stop(ctx: any) {
|
||||
const mgr = getGatewayManagerInstance()
|
||||
if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return }
|
||||
try {
|
||||
await mgr.stop(ctx.params.name)
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) { ctx.status = 500; ctx.body = { error: err.message } }
|
||||
}
|
||||
|
||||
export async function health(ctx: any) {
|
||||
const mgr = getGatewayManagerInstance()
|
||||
if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return }
|
||||
const status = await mgr.detectStatus(ctx.params.name)
|
||||
ctx.body = { gateway: status }
|
||||
}
|
||||
@@ -1,135 +1,310 @@
|
||||
import type { Context } from 'koa'
|
||||
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
|
||||
import { execFile } from 'child_process'
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { getHermesBin } from '../../services/hermes/hermes-path'
|
||||
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
|
||||
|
||||
function getUpstream(profile: string): string {
|
||||
const mgr = getGatewayManagerInstance()
|
||||
if (!mgr) {
|
||||
throw new Error('GatewayManager not initialized')
|
||||
}
|
||||
return mgr.getUpstream(profile)
|
||||
}
|
||||
const execFileAsync = promisify(execFile)
|
||||
const TIMEOUT_MS = 60_000
|
||||
|
||||
function getApiKey(profile: string): string | null {
|
||||
const mgr = getGatewayManagerInstance()
|
||||
return mgr?.getApiKey(profile) ?? null
|
||||
}
|
||||
type JobRecord = Record<string, any>
|
||||
|
||||
function resolveProfile(ctx: Context): string {
|
||||
// Use header/query from request first, then fall back to authoritative source
|
||||
const requestedProfile = ctx.get('x-hermes-profile') || (ctx.query.profile as string)
|
||||
return requestedProfile || getActiveProfileName()
|
||||
}
|
||||
|
||||
if (requestedProfile) {
|
||||
return requestedProfile
|
||||
}
|
||||
function resolveProfileDir(profile: string): string {
|
||||
return getProfileDir(profile || 'default')
|
||||
}
|
||||
|
||||
// Fallback: read from authoritative source (active_profile file)
|
||||
try {
|
||||
const { getActiveProfileName } = require('../../services/hermes/hermes-profile')
|
||||
return getActiveProfileName()
|
||||
} catch {
|
||||
return 'default'
|
||||
function getJobsPath(profile: string): string {
|
||||
return join(resolveProfileDir(profile), 'cron', 'jobs.json')
|
||||
}
|
||||
|
||||
function normalizeJob(job: JobRecord): JobRecord {
|
||||
const id = job.job_id || job.id
|
||||
const skills = Array.isArray(job.skills)
|
||||
? job.skills
|
||||
: (job.skill ? [job.skill] : [])
|
||||
|
||||
return {
|
||||
...job,
|
||||
id,
|
||||
job_id: id,
|
||||
skills,
|
||||
skill: job.skill ?? skills[0] ?? null,
|
||||
model: job.model ?? null,
|
||||
provider: job.provider ?? null,
|
||||
base_url: job.base_url ?? null,
|
||||
script: job.script ?? null,
|
||||
schedule_display: job.schedule_display ?? job.schedule?.display ?? job.schedule?.expr ?? '',
|
||||
repeat: job.repeat ?? { times: null, completed: 0 },
|
||||
enabled: job.enabled ?? true,
|
||||
state: job.state ?? ((job.enabled ?? true) ? 'scheduled' : 'paused'),
|
||||
paused_at: job.paused_at ?? null,
|
||||
paused_reason: job.paused_reason ?? null,
|
||||
created_at: job.created_at ?? '',
|
||||
next_run_at: job.next_run_at ?? null,
|
||||
last_run_at: job.last_run_at ?? null,
|
||||
last_status: job.last_status ?? null,
|
||||
last_error: job.last_error ?? null,
|
||||
deliver: job.deliver ?? 'local',
|
||||
origin: job.origin ?? null,
|
||||
last_delivery_error: job.last_delivery_error ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function buildHeaders(profile: string): Record<string, string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
const apiKey = getApiKey(profile)
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
||||
return headers
|
||||
function readJobs(profile: string, includeDisabled = true): JobRecord[] {
|
||||
const jobsPath = getJobsPath(profile)
|
||||
if (!existsSync(jobsPath)) return []
|
||||
|
||||
const parsed = JSON.parse(readFileSync(jobsPath, 'utf-8'))
|
||||
const rawJobs = Array.isArray(parsed) ? parsed : parsed?.jobs
|
||||
const jobs = Array.isArray(rawJobs) ? rawJobs.map(normalizeJob) : []
|
||||
|
||||
if (includeDisabled) return jobs
|
||||
return jobs.filter((job) => job.enabled !== false)
|
||||
}
|
||||
|
||||
const TIMEOUT_MS = 30_000
|
||||
function findJob(profile: string, jobId: string): JobRecord | null {
|
||||
return readJobs(profile, true).find((job) => job.job_id === jobId || job.id === jobId) ?? null
|
||||
}
|
||||
|
||||
async function readUpstreamError(res: Response): Promise<unknown> {
|
||||
const contentType = res.headers.get('content-type') || ''
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
return await res.json()
|
||||
} catch {
|
||||
// Fall through to a stable error shape below.
|
||||
function boolQuery(value: unknown, defaultValue: boolean): boolean {
|
||||
if (value == null) return defaultValue
|
||||
const text = String(value).toLowerCase()
|
||||
return text === '1' || text === 'true' || text === 'yes'
|
||||
}
|
||||
|
||||
function getBody(ctx: Context): Record<string, any> {
|
||||
return (ctx.request.body && typeof ctx.request.body === 'object')
|
||||
? ctx.request.body as Record<string, any>
|
||||
: {}
|
||||
}
|
||||
|
||||
function getRepeatValue(repeat: unknown): number | null {
|
||||
if (repeat == null || repeat === '') return null
|
||||
if (typeof repeat === 'number' && Number.isFinite(repeat)) return repeat
|
||||
if (typeof repeat === 'object') {
|
||||
const times = (repeat as any).times
|
||||
if (typeof times === 'number' && Number.isFinite(times)) return times
|
||||
if (typeof times === 'string' && times.trim()) {
|
||||
const parsed = Number(times)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const text = await res.text().catch(() => '')
|
||||
return { error: { message: text || `Upstream error: ${res.status} ${res.statusText}` } }
|
||||
const parsed = Number(repeat)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
async function proxyRequest(ctx: Context, upstreamPath: string, method?: string): Promise<void> {
|
||||
const profile = resolveProfile(ctx)
|
||||
let upstream: string
|
||||
try {
|
||||
upstream = getUpstream(profile)
|
||||
} catch (e: any) {
|
||||
ctx.status = 503
|
||||
ctx.set('Content-Type', 'application/json')
|
||||
ctx.body = { error: { message: e?.message || 'GatewayManager not initialized' } }
|
||||
return
|
||||
function hasRepeatField(body: Record<string, any>): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(body, 'repeat')
|
||||
}
|
||||
|
||||
function getSkills(body: Record<string, any>): string[] | null {
|
||||
if (Array.isArray(body.skills)) {
|
||||
return body.skills.map((skill) => String(skill || '').trim()).filter(Boolean)
|
||||
}
|
||||
const params = new URLSearchParams(ctx.search || '')
|
||||
params.delete('token')
|
||||
const search = params.toString()
|
||||
const url = `${upstream}${upstreamPath}${search ? `?${search}` : ''}`
|
||||
if (typeof body.skill === 'string') {
|
||||
const skill = body.skill.trim()
|
||||
return skill ? [skill] : []
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const headers = buildHeaders(profile)
|
||||
const body = ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD'
|
||||
? JSON.stringify(ctx.request.body || {})
|
||||
: undefined
|
||||
|
||||
let res: Response
|
||||
async function runHermesCron(profile: string, args: string[]): Promise<void> {
|
||||
const profileDir = resolveProfileDir(profile)
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: method || ctx.req.method,
|
||||
headers,
|
||||
body,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
await execFileAsync(getHermesBin(), args, {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, HERMES_HOME: profileDir },
|
||||
timeout: TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024,
|
||||
windowsHide: true,
|
||||
})
|
||||
} catch (e: any) {
|
||||
ctx.status = 502
|
||||
ctx.set('Content-Type', 'application/json')
|
||||
ctx.body = { error: { message: `Proxy error: ${e.message}` } }
|
||||
return
|
||||
} catch (error: any) {
|
||||
const stderr = String(error?.stderr || '').trim()
|
||||
const stdout = String(error?.stdout || '').trim()
|
||||
throw new Error(stderr || stdout || error?.message || 'Hermes cron command failed')
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
ctx.status = res.status
|
||||
ctx.set('Content-Type', 'application/json')
|
||||
ctx.body = await readUpstreamError(res)
|
||||
return
|
||||
}
|
||||
function sendJobNotFound(ctx: Context): void {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: { message: 'Job not found' } }
|
||||
}
|
||||
|
||||
ctx.status = res.status
|
||||
ctx.set('Content-Type', res.headers.get('content-type') || 'application/json')
|
||||
ctx.body = await res.json()
|
||||
function sendCommandError(ctx: Context, error: any): void {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: { message: error?.message || 'Hermes cron command failed' } }
|
||||
}
|
||||
|
||||
function findCreatedJob(beforeJobs: JobRecord[], afterJobs: JobRecord[]): JobRecord | null {
|
||||
const beforeIds = new Set(beforeJobs.map((job) => job.job_id || job.id))
|
||||
const created = afterJobs.find((job) => !beforeIds.has(job.job_id || job.id))
|
||||
if (created) return created
|
||||
|
||||
return [...afterJobs].sort((a, b) => {
|
||||
const aTime = Date.parse(a.created_at || '') || 0
|
||||
const bTime = Date.parse(b.created_at || '') || 0
|
||||
return bTime - aTime
|
||||
})[0] ?? null
|
||||
}
|
||||
|
||||
export async function list(ctx: Context) {
|
||||
await proxyRequest(ctx, '/api/jobs')
|
||||
const profile = resolveProfile(ctx)
|
||||
const includeDisabled = boolQuery(ctx.query.include_disabled, false)
|
||||
ctx.body = { jobs: readJobs(profile, includeDisabled) }
|
||||
}
|
||||
|
||||
export async function get(ctx: Context) {
|
||||
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}`)
|
||||
const profile = resolveProfile(ctx)
|
||||
const job = findJob(profile, ctx.params.id)
|
||||
if (!job) return sendJobNotFound(ctx)
|
||||
ctx.body = { job }
|
||||
}
|
||||
|
||||
export async function create(ctx: Context) {
|
||||
await proxyRequest(ctx, '/api/jobs')
|
||||
const profile = resolveProfile(ctx)
|
||||
const body = getBody(ctx)
|
||||
const schedule = String(body.schedule || body.schedule_display || '').trim()
|
||||
const prompt = String(body.prompt || '').trim()
|
||||
|
||||
if (!schedule) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: { message: 'Schedule is required' } }
|
||||
return
|
||||
}
|
||||
|
||||
const beforeJobs = readJobs(profile, true)
|
||||
const args = ['cron', 'create']
|
||||
const name = String(body.name || '').trim()
|
||||
if (name) args.push('--name', name)
|
||||
if (body.deliver != null && String(body.deliver).trim()) args.push('--deliver', String(body.deliver).trim())
|
||||
|
||||
const repeat = getRepeatValue(body.repeat)
|
||||
if (repeat != null) {
|
||||
args.push('--repeat', String(repeat))
|
||||
} else if (hasRepeatField(body)) {
|
||||
// Hermes CLI normalizes repeat <= 0 to an unbounded/null repeat.
|
||||
args.push('--repeat', '0')
|
||||
}
|
||||
|
||||
const skills = getSkills(body)
|
||||
for (const skill of skills || []) args.push('--skill', skill)
|
||||
|
||||
if (body.script != null && String(body.script).trim()) args.push('--script', String(body.script).trim())
|
||||
if (body.workdir != null) args.push('--workdir', String(body.workdir))
|
||||
if (body.no_agent === true) args.push('--no-agent')
|
||||
|
||||
args.push(schedule)
|
||||
if (prompt) args.push(prompt)
|
||||
|
||||
try {
|
||||
await runHermesCron(profile, args)
|
||||
const job = findCreatedJob(beforeJobs, readJobs(profile, true))
|
||||
ctx.body = { job }
|
||||
} catch (error: any) {
|
||||
sendCommandError(ctx, error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function update(ctx: Context) {
|
||||
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}`)
|
||||
const profile = resolveProfile(ctx)
|
||||
const body = getBody(ctx)
|
||||
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
|
||||
|
||||
const args = ['cron', 'edit', ctx.params.id]
|
||||
if (body.schedule != null || body.schedule_display != null) {
|
||||
args.push('--schedule', String(body.schedule ?? body.schedule_display))
|
||||
}
|
||||
if (body.prompt != null) args.push('--prompt', String(body.prompt))
|
||||
if (body.name != null) args.push('--name', String(body.name))
|
||||
if (body.deliver != null) args.push('--deliver', String(body.deliver))
|
||||
|
||||
const repeat = getRepeatValue(body.repeat)
|
||||
if (repeat != null) {
|
||||
args.push('--repeat', String(repeat))
|
||||
} else if (hasRepeatField(body)) {
|
||||
// Hermes CLI normalizes repeat <= 0 to an unbounded/null repeat.
|
||||
args.push('--repeat', '0')
|
||||
}
|
||||
|
||||
const skills = getSkills(body)
|
||||
if (skills) {
|
||||
if (skills.length === 0) {
|
||||
args.push('--clear-skills')
|
||||
} else {
|
||||
for (const skill of skills) args.push('--skill', skill)
|
||||
}
|
||||
}
|
||||
|
||||
if (body.script != null) args.push('--script', String(body.script))
|
||||
if (body.workdir != null) args.push('--workdir', String(body.workdir))
|
||||
if (body.no_agent === true) args.push('--no-agent')
|
||||
if (body.no_agent === false) args.push('--agent')
|
||||
|
||||
try {
|
||||
await runHermesCron(profile, args)
|
||||
const job = findJob(profile, ctx.params.id)
|
||||
if (!job) return sendJobNotFound(ctx)
|
||||
ctx.body = { job }
|
||||
} catch (error: any) {
|
||||
sendCommandError(ctx, error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(ctx: Context) {
|
||||
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}`)
|
||||
const profile = resolveProfile(ctx)
|
||||
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
|
||||
|
||||
try {
|
||||
await runHermesCron(profile, ['cron', 'remove', ctx.params.id])
|
||||
ctx.body = { ok: true }
|
||||
} catch (error: any) {
|
||||
sendCommandError(ctx, error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function pause(ctx: Context) {
|
||||
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}/pause`)
|
||||
const profile = resolveProfile(ctx)
|
||||
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
|
||||
|
||||
try {
|
||||
await runHermesCron(profile, ['cron', 'pause', ctx.params.id])
|
||||
const job = findJob(profile, ctx.params.id)
|
||||
ctx.body = { job }
|
||||
} catch (error: any) {
|
||||
sendCommandError(ctx, error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function resume(ctx: Context) {
|
||||
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}/resume`)
|
||||
const profile = resolveProfile(ctx)
|
||||
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
|
||||
|
||||
try {
|
||||
await runHermesCron(profile, ['cron', 'resume', ctx.params.id])
|
||||
const job = findJob(profile, ctx.params.id)
|
||||
ctx.body = { job }
|
||||
} catch (error: any) {
|
||||
sendCommandError(ctx, error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(ctx: Context) {
|
||||
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}/run`)
|
||||
const profile = resolveProfile(ctx)
|
||||
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
|
||||
|
||||
try {
|
||||
await runHermesCron(profile, ['cron', 'run', ctx.params.id])
|
||||
const job = findJob(profile, ctx.params.id)
|
||||
ctx.body = { job }
|
||||
} catch (error: any) {
|
||||
sendCommandError(ctx, error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFile } from 'fs/promises'
|
||||
import { resolve, normalize } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import * as kanbanCli from '../../services/hermes/hermes-kanban'
|
||||
import { isPathWithin } from '../../services/hermes/hermes-path'
|
||||
import {
|
||||
searchSessionSummariesWithProfile,
|
||||
getSessionDetailFromDbWithProfile,
|
||||
@@ -596,7 +597,7 @@ export async function readArtifact(ctx: Context) {
|
||||
const kanbanDir = resolve(homedir(), '.hermes', 'kanban', 'workspaces')
|
||||
const resolved = resolve(normalize(filePath))
|
||||
|
||||
if (!resolved.startsWith(kanbanDir)) {
|
||||
if (!isPathWithin(resolved, kanbanDir)) {
|
||||
ctx.status = 403
|
||||
ctx.body = { error: 'Path must be within kanban workspaces' }
|
||||
return
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { Context } from 'koa'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { dirname, extname, isAbsolute, join, resolve } from 'path'
|
||||
import { getActiveAuthPath } from '../../services/hermes/hermes-profile'
|
||||
import { config } from '../../config'
|
||||
|
||||
const XAI_VIDEO_GENERATIONS_URL = 'https://api.x.ai/v1/videos/generations'
|
||||
const XAI_VIDEO_STATUS_URL = 'https://api.x.ai/v1/videos'
|
||||
const XAI_VIDEO_MODEL = 'grok-imagine-video'
|
||||
const MAX_IMAGE_BYTES = 25 * 1024 * 1024
|
||||
const DEFAULT_POLL_INTERVAL_MS = 5000
|
||||
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
type AuthJson = {
|
||||
providers?: Record<string, any>
|
||||
credential_pool?: Record<string, any[]>
|
||||
}
|
||||
|
||||
function readJsonFile(path: string): any {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function resolveXaiToken(): { token: string; source: string } | null {
|
||||
const envToken = String(process.env.XAI_API_KEY || '').trim()
|
||||
if (envToken) return { token: envToken, source: 'XAI_API_KEY' }
|
||||
|
||||
const auth = readJsonFile(getActiveAuthPath()) as AuthJson | null
|
||||
const providerToken = String(auth?.providers?.['xai-oauth']?.tokens?.access_token || auth?.providers?.['xai-oauth']?.access_token || '').trim()
|
||||
if (providerToken) return { token: providerToken, source: 'xai-oauth' }
|
||||
|
||||
const pool = auth?.credential_pool?.['xai-oauth']
|
||||
if (Array.isArray(pool)) {
|
||||
const poolToken = String(pool.find(entry => entry?.access_token)?.access_token || '').trim()
|
||||
if (poolToken) return { token: poolToken, source: 'xai-oauth' }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function mimeFromPath(path: string): string | null {
|
||||
const ext = extname(path).toLowerCase()
|
||||
if (ext === '.png') return 'image/png'
|
||||
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
||||
if (ext === '.webp') return 'image/webp'
|
||||
return null
|
||||
}
|
||||
|
||||
function mimeFromMagic(buffer: Buffer): string | null {
|
||||
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
|
||||
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg'
|
||||
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp'
|
||||
return null
|
||||
}
|
||||
|
||||
function imagePathToDataUri(imagePath: string): string {
|
||||
const resolvedPath = isAbsolute(imagePath) ? imagePath : resolve(process.cwd(), imagePath)
|
||||
const image = readFileSync(resolvedPath)
|
||||
if (image.length > MAX_IMAGE_BYTES) {
|
||||
const err: any = new Error(`image is too large (max ${MAX_IMAGE_BYTES} bytes)`)
|
||||
err.status = 413
|
||||
throw err
|
||||
}
|
||||
const mime = mimeFromMagic(image) || mimeFromPath(resolvedPath)
|
||||
if (!mime) {
|
||||
const err: any = new Error('unsupported image type; use png, jpeg, or webp')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
return `data:${mime};base64,${image.toString('base64')}`
|
||||
}
|
||||
|
||||
function normalizeImageInput(body: any): string {
|
||||
const imageUrl = typeof body.image_url === 'string' ? body.image_url.trim() : ''
|
||||
if (imageUrl) return imageUrl
|
||||
|
||||
const imageBase64 = typeof body.image_base64 === 'string' ? body.image_base64.trim() : ''
|
||||
if (imageBase64) {
|
||||
if (imageBase64.startsWith('data:image/')) return imageBase64
|
||||
const mime = typeof body.mime_type === 'string' ? body.mime_type.trim() : ''
|
||||
if (!mime.startsWith('image/')) {
|
||||
const err: any = new Error('mime_type is required when image_base64 is not a data URI')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
return `data:${mime};base64,${imageBase64}`
|
||||
}
|
||||
|
||||
const imagePath = typeof body.image_path === 'string' ? body.image_path.trim() : ''
|
||||
if (!imagePath) {
|
||||
const err: any = new Error('image_path, image_url, or image_base64 is required')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
if (!existsSync(isAbsolute(imagePath) ? imagePath : resolve(process.cwd(), imagePath))) {
|
||||
const err: any = new Error('image_path does not exist')
|
||||
err.status = 404
|
||||
throw err
|
||||
}
|
||||
return imagePathToDataUri(imagePath)
|
||||
}
|
||||
|
||||
function normalizeDuration(value: unknown): number {
|
||||
const duration = Number(value || 8)
|
||||
if (!Number.isFinite(duration) || duration < 1 || duration > 15) {
|
||||
const err: any = new Error('duration must be between 1 and 15 seconds')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
return duration
|
||||
}
|
||||
|
||||
export function defaultMediaOutputPath(requestId: string, now = new Date()): string {
|
||||
const safeRequestId = requestId.replace(/[^A-Za-z0-9_-]/g, '_') || `video_${now.getTime()}`
|
||||
return join(config.appHome, 'media', `${safeRequestId}.mp4`)
|
||||
}
|
||||
|
||||
async function requestXaiJson(url: string, token: string, init: RequestInit = {}): Promise<any> {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(init.headers || {}),
|
||||
},
|
||||
})
|
||||
const text = await res.text()
|
||||
let data: any = null
|
||||
try { data = text ? JSON.parse(text) : null } catch {}
|
||||
if (!res.ok) {
|
||||
const detail = data?.error?.message || data?.error || text || res.statusText
|
||||
const err: any = new Error(`xAI request failed: ${res.status} ${detail}`)
|
||||
err.status = res.status === 401 || res.status === 403 ? 502 : 502
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
async function downloadVideo(url: string, outputPath: string): Promise<void> {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`failed to download generated video: ${res.status} ${res.statusText}`)
|
||||
const arrayBuffer = await res.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
writeFileSync(outputPath, buffer)
|
||||
}
|
||||
|
||||
export async function grokImageToVideo(ctx: Context) {
|
||||
const tokenInfo = resolveXaiToken()
|
||||
if (!tokenInfo) {
|
||||
ctx.status = 401
|
||||
ctx.body = {
|
||||
error: 'Missing xAI token. Set XAI_API_KEY or complete xAI OAuth login first.',
|
||||
code: 'missing_xai_token',
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const body = ctx.request.body as any
|
||||
const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''
|
||||
if (!prompt) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'prompt is required', code: 'missing_prompt' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const image = normalizeImageInput(body)
|
||||
const duration = normalizeDuration(body.duration)
|
||||
const rawTimeoutMs = Number(body.timeout_ms || DEFAULT_TIMEOUT_MS)
|
||||
const timeoutMs = Number.isFinite(rawTimeoutMs)
|
||||
? Math.max(10000, Math.min(rawTimeoutMs, 30 * 60 * 1000))
|
||||
: DEFAULT_TIMEOUT_MS
|
||||
const requestedOutputPath = typeof body.output_path === 'string' ? body.output_path.trim() : ''
|
||||
|
||||
const started = await requestXaiJson(XAI_VIDEO_GENERATIONS_URL, tokenInfo.token, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: XAI_VIDEO_MODEL,
|
||||
prompt,
|
||||
image: { url: image },
|
||||
duration,
|
||||
}),
|
||||
})
|
||||
const requestId = String(started?.request_id || '').trim()
|
||||
if (!requestId) throw new Error('xAI response missing request_id')
|
||||
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let latest: any = null
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_INTERVAL_MS))
|
||||
latest = await requestXaiJson(`${XAI_VIDEO_STATUS_URL}/${encodeURIComponent(requestId)}`, tokenInfo.token)
|
||||
if (latest?.status === 'done') {
|
||||
const videoUrl = String(latest?.video?.url || '').trim()
|
||||
const outputPath = requestedOutputPath || defaultMediaOutputPath(requestId)
|
||||
if (videoUrl) await downloadVideo(videoUrl, outputPath)
|
||||
ctx.body = {
|
||||
request_id: requestId,
|
||||
status: latest.status,
|
||||
video_url: videoUrl,
|
||||
output_path: outputPath,
|
||||
token_source: tokenInfo.source,
|
||||
}
|
||||
return
|
||||
}
|
||||
if (latest?.status === 'expired' || latest?.status === 'failed' || latest?.status === 'error') {
|
||||
ctx.status = 502
|
||||
ctx.body = { request_id: requestId, status: latest.status, error: latest?.error || 'xAI video generation failed' }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.status = 504
|
||||
ctx.body = { request_id: requestId, status: latest?.status || 'pending', error: 'Timed out waiting for xAI video generation' }
|
||||
} catch (err: any) {
|
||||
ctx.status = err.status || 500
|
||||
ctx.body = { error: err.message || String(err) }
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { readFile } from 'fs/promises'
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { getActiveEnvPath, getActiveAuthPath } from '../../services/hermes/hermes-profile'
|
||||
import { readConfigYaml, updateConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers'
|
||||
import { join } from 'path'
|
||||
import { getActiveEnvPath, getActiveAuthPath, getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile'
|
||||
import { readConfigYaml, readConfigYamlForProfile, updateConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers'
|
||||
import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers'
|
||||
import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMeta } from '../../services/hermes/copilot-models'
|
||||
import { readAppConfig, writeAppConfig, type ModelVisibilityRule } from '../../services/app-config'
|
||||
@@ -118,6 +119,73 @@ function resolveVisibleDefault(defaultModel: string, defaultProvider: string, gr
|
||||
return { defaultModel: fallback?.models[0] || '', defaultProvider: fallback?.provider || '' }
|
||||
}
|
||||
|
||||
function profileEnvPath(profile: string): string {
|
||||
return join(getProfileDir(profile), '.env')
|
||||
}
|
||||
|
||||
function profileAuthPath(profile: string): string {
|
||||
return join(getProfileDir(profile), 'auth.json')
|
||||
}
|
||||
|
||||
function envReader(envContent: string) {
|
||||
const envHasValue = (key: string): boolean => {
|
||||
if (!key) return false
|
||||
const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
|
||||
return !!match && match[1].trim() !== '' && !match[1].trim().startsWith('#')
|
||||
}
|
||||
const envGetValue = (key: string): string => {
|
||||
if (!key) return ''
|
||||
const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
|
||||
return match?.[1]?.trim() || ''
|
||||
}
|
||||
return { envHasValue, envGetValue }
|
||||
}
|
||||
|
||||
function providerKeyForCustom(name: string): string {
|
||||
return `custom:${name.trim().toLowerCase().replace(/ /g, '-')}`
|
||||
}
|
||||
|
||||
function mergeAvailableGroups(groups: AvailableGroup[]): AvailableGroup[] {
|
||||
const byProvider = new Map<string, AvailableGroup>()
|
||||
for (const group of groups) {
|
||||
const existing = byProvider.get(group.provider)
|
||||
if (!existing) {
|
||||
byProvider.set(group.provider, {
|
||||
...group,
|
||||
models: [...new Set(group.models)],
|
||||
available_models: [...new Set(group.available_models || group.models)],
|
||||
model_meta: group.model_meta ? { ...group.model_meta } : undefined,
|
||||
})
|
||||
continue
|
||||
}
|
||||
existing.models = [...new Set([...existing.models, ...group.models])]
|
||||
existing.available_models = [...new Set([...(existing.available_models || existing.models), ...(group.available_models || group.models)])]
|
||||
existing.api_key = existing.api_key || group.api_key
|
||||
existing.base_url = existing.base_url || group.base_url
|
||||
existing.builtin = existing.builtin || group.builtin
|
||||
existing.model_meta = { ...(existing.model_meta || {}), ...(group.model_meta || {}) }
|
||||
if (existing.model_meta && Object.keys(existing.model_meta).length === 0) delete existing.model_meta
|
||||
}
|
||||
return [...byProvider.values()]
|
||||
}
|
||||
|
||||
type ProviderFetchCache = Map<string, Promise<string[]>>
|
||||
|
||||
function cachedProviderModels(
|
||||
cache: ProviderFetchCache,
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
freeOnly = false,
|
||||
): Promise<string[]> {
|
||||
const key = `${baseUrl.replace(/\/+$/, '')}\n${apiKey}\n${freeOnly ? 'free' : 'all'}`
|
||||
let pending = cache.get(key)
|
||||
if (!pending) {
|
||||
pending = fetchProviderModels(baseUrl, apiKey, freeOnly)
|
||||
cache.set(key, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
|
||||
// Copilot 授权检测:复用同一套 token 解析逻辑(含 ~/.config/github-copilot/apps.json
|
||||
// 与 ghp_ PAT 跳过),与 getCopilotModels 行为一致,避免出现"模型能拉到却被判未授权"。
|
||||
@@ -125,8 +193,244 @@ async function isCopilotAuthorized(envContent: string): Promise<boolean> {
|
||||
return !!(await resolveCopilotOAuthToken(envContent))
|
||||
}
|
||||
|
||||
async function buildAvailableForProfile(
|
||||
profile: string,
|
||||
fetchCache: ProviderFetchCache,
|
||||
appConfig: Awaited<ReturnType<typeof readAppConfig>>,
|
||||
): Promise<{
|
||||
profile: string
|
||||
default: string
|
||||
default_provider: string
|
||||
groups: AvailableGroup[]
|
||||
}> {
|
||||
const config = await readConfigYamlForProfile(profile)
|
||||
const modelSection = config.model
|
||||
let currentDefault = ''
|
||||
let currentDefaultProvider = ''
|
||||
if (typeof modelSection === 'object' && modelSection !== null) {
|
||||
currentDefault = String(modelSection.default || '').trim()
|
||||
currentDefaultProvider = String(modelSection.provider || '').trim()
|
||||
if (currentDefaultProvider === 'custom' && currentDefault) {
|
||||
const cps = Array.isArray(config.custom_providers) ? config.custom_providers as any[] : []
|
||||
const match = cps.find(
|
||||
(cp: any) => cp.base_url?.replace(/\/+$/, '') === String(modelSection.base_url || '').replace(/\/+$/, '')
|
||||
&& cp.model === currentDefault,
|
||||
)
|
||||
if (match) currentDefaultProvider = providerKeyForCustom(String(match.name || ''))
|
||||
}
|
||||
} else if (typeof modelSection === 'string') {
|
||||
currentDefault = modelSection.trim()
|
||||
}
|
||||
|
||||
let envContent = ''
|
||||
try { envContent = await readFile(profileEnvPath(profile), 'utf-8') } catch {}
|
||||
const { envHasValue, envGetValue } = envReader(envContent)
|
||||
|
||||
const isOAuthAuthorized = (providerKey: string): boolean => {
|
||||
try {
|
||||
const authPath = profileAuthPath(profile)
|
||||
if (!existsSync(authPath)) return false
|
||||
const auth = JSON.parse(readFileSync(authPath, 'utf-8'))
|
||||
const provider = auth.providers?.[providerKey]
|
||||
const pool = auth.credential_pool?.[providerKey]
|
||||
return !!(
|
||||
provider?.tokens?.access_token ||
|
||||
provider?.access_token ||
|
||||
(Array.isArray(pool) && pool.some((entry: any) => entry?.access_token))
|
||||
)
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
let copilotLiveModels: CopilotModelMeta[] | null = null
|
||||
const getCopilotLive = async (): Promise<CopilotModelMeta[]> => {
|
||||
if (copilotLiveModels !== null) return copilotLiveModels
|
||||
try { copilotLiveModels = await getCopilotModelsDetailed(envContent) }
|
||||
catch { copilotLiveModels = [] }
|
||||
return copilotLiveModels
|
||||
}
|
||||
|
||||
const groups: AvailableGroup[] = []
|
||||
const seenProviders = new Set<string>()
|
||||
const addGroup = (provider: string, label: string, base_url: string, models: string[], api_key: string, builtin?: boolean, model_meta?: Record<string, ModelMeta>) => {
|
||||
if (seenProviders.has(provider)) return
|
||||
seenProviders.add(provider)
|
||||
const availableModels = [...new Set(models)]
|
||||
groups.push({ provider, label, base_url, models: availableModels, available_models: availableModels, api_key, ...(builtin ? { builtin: true } : {}), ...(model_meta ? { model_meta } : {}) })
|
||||
}
|
||||
|
||||
const copilotEnabled = appConfig.copilotEnabled === true
|
||||
if (!copilotEnabled && currentDefaultProvider.toLowerCase() === 'copilot') {
|
||||
currentDefault = ''
|
||||
currentDefaultProvider = ''
|
||||
}
|
||||
|
||||
for (const [providerKey, envMapping] of Object.entries(PROVIDER_ENV_MAP)) {
|
||||
if (envMapping.api_key_env && !envHasValue(envMapping.api_key_env)) continue
|
||||
if (!envMapping.api_key_env) {
|
||||
if (providerKey === 'copilot') {
|
||||
if (!copilotEnabled) continue
|
||||
if (!(await isCopilotAuthorized(envContent))) continue
|
||||
} else if (!isOAuthAuthorized(providerKey)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
const preset = PROVIDER_PRESETS.find((p: any) => p.value === providerKey)
|
||||
const label = preset?.label || providerKey.replace(/^custom:/, '')
|
||||
let baseUrl = preset?.base_url || ''
|
||||
if (envMapping.base_url_env && envHasValue(envMapping.base_url_env)) {
|
||||
baseUrl = envGetValue(envMapping.base_url_env) || baseUrl
|
||||
}
|
||||
const catalogModels = PROVIDER_MODEL_CATALOG[providerKey]
|
||||
let modelsList: string[] = catalogModels && catalogModels.length > 0 ? [...catalogModels] : []
|
||||
let modelMeta: Record<string, ModelMeta> | undefined
|
||||
if (providerKey === 'copilot') {
|
||||
const live = await getCopilotLive()
|
||||
if (live.length > 0) {
|
||||
modelsList = live.map((m) => m.id)
|
||||
modelMeta = {}
|
||||
for (const m of live) {
|
||||
if (m.preview || m.disabled) {
|
||||
modelMeta[m.id] = {
|
||||
...(m.preview ? { preview: true } : {}),
|
||||
...(m.disabled ? { disabled: true } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(modelMeta).length === 0) modelMeta = undefined
|
||||
}
|
||||
} else if (providerKey === 'openrouter' || providerKey === 'cliproxyapi' || providerKey === 'ollama-cloud') {
|
||||
if (envMapping.api_key_env) {
|
||||
const apiKey = envGetValue(envMapping.api_key_env)
|
||||
if (apiKey) {
|
||||
const fetched = await cachedProviderModels(fetchCache, baseUrl, apiKey, providerKey === 'openrouter')
|
||||
if (fetched.length > 0) modelsList = fetched
|
||||
}
|
||||
}
|
||||
}
|
||||
if (modelsList.length > 0) {
|
||||
const apiKey = envMapping.api_key_env ? envGetValue(envMapping.api_key_env) : ''
|
||||
addGroup(providerKey, label, baseUrl, modelsList, apiKey, true, modelMeta)
|
||||
}
|
||||
}
|
||||
|
||||
const customProviders = Array.isArray(config.custom_providers)
|
||||
? config.custom_providers as Array<{ name: string; base_url: string; model: string; api_key?: string }>
|
||||
: []
|
||||
const customFetches = await Promise.allSettled(
|
||||
customProviders.map(async cp => {
|
||||
if (!cp.base_url) return null
|
||||
const providerKey = providerKeyForCustom(cp.name)
|
||||
const baseUrl = cp.base_url.replace(/\/+$/, '')
|
||||
let models = [cp.model].filter(Boolean)
|
||||
if (cp.api_key) {
|
||||
const fetched = await cachedProviderModels(fetchCache, baseUrl, cp.api_key)
|
||||
if (fetched.length > 0) models = [...new Set([...models, ...fetched])]
|
||||
}
|
||||
return { providerKey, label: cp.name, base_url: baseUrl, models, api_key: cp.api_key || '' }
|
||||
}),
|
||||
)
|
||||
for (const result of customFetches) {
|
||||
if (result.status === 'fulfilled' && result.value?.models.length) {
|
||||
const { providerKey, label, base_url, models, api_key } = result.value
|
||||
addGroup(providerKey, label, base_url, models, api_key)
|
||||
}
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
const fallback = buildModelGroups(config)
|
||||
for (const group of fallback.groups) {
|
||||
const models = group.models.map(model => model.id)
|
||||
if (models.length) addGroup(group.provider, group.provider, '', models, '')
|
||||
}
|
||||
currentDefault = currentDefault || fallback.default
|
||||
}
|
||||
|
||||
for (const g of groups) {
|
||||
g.models = Array.from(new Set(g.models))
|
||||
g.available_models = Array.from(new Set(g.available_models || g.models))
|
||||
}
|
||||
|
||||
return { profile, default: currentDefault, default_provider: currentDefaultProvider, groups }
|
||||
}
|
||||
|
||||
export async function getAvailable(ctx: any) {
|
||||
try {
|
||||
const requestedProfile = typeof ctx.query.profile === 'string' && ctx.query.profile.trim()
|
||||
? ctx.query.profile.trim()
|
||||
: ''
|
||||
if (!requestedProfile) {
|
||||
const appConfig = await readAppConfig()
|
||||
const modelAliases = normalizeAliases(appConfig.modelAliases)
|
||||
const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility)
|
||||
const fetchCache: ProviderFetchCache = new Map()
|
||||
const profileResults = await Promise.all(
|
||||
listProfileNamesFromDisk().map(profile => buildAvailableForProfile(profile, fetchCache, appConfig)),
|
||||
)
|
||||
const mergedGroups = mergeAvailableGroups(profileResults.flatMap(result => result.groups))
|
||||
const groupsWithAliases = applyModelAliases(mergedGroups, modelAliases)
|
||||
const visibleGroups = applyModelVisibility(groupsWithAliases, modelVisibility)
|
||||
const activeProfile = getActiveProfileName()
|
||||
const defaultProfile = profileResults.find(result => result.profile === activeProfile && (result.default || result.default_provider))
|
||||
|| profileResults.find(result => result.default && result.default_provider)
|
||||
|| profileResults.find(result => result.default)
|
||||
const visibleDefault = resolveVisibleDefault(
|
||||
defaultProfile?.default || '',
|
||||
defaultProfile?.default_provider || '',
|
||||
visibleGroups,
|
||||
)
|
||||
const allProvidersBase = PROVIDER_PRESETS.map((p: any) => ({
|
||||
provider: p.value,
|
||||
label: p.label,
|
||||
base_url: p.base_url,
|
||||
models: p.models,
|
||||
api_key: '',
|
||||
}))
|
||||
ctx.body = {
|
||||
default: visibleDefault.defaultModel,
|
||||
default_provider: visibleDefault.defaultProvider,
|
||||
groups: visibleGroups,
|
||||
allProviders: applyModelAliases(allProvidersBase, modelAliases),
|
||||
model_aliases: modelAliases,
|
||||
model_visibility: modelVisibility,
|
||||
profiles: profileResults.map(result => ({
|
||||
profile: result.profile,
|
||||
default: result.default,
|
||||
default_provider: result.default_provider,
|
||||
groups: applyModelVisibility(applyModelAliases(result.groups, modelAliases), modelVisibility),
|
||||
})),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const appConfigForProfile = await readAppConfig()
|
||||
const modelAliasesForProfile = normalizeAliases(appConfigForProfile.modelAliases)
|
||||
const modelVisibilityForProfile = normalizeModelVisibility(appConfigForProfile.modelVisibility)
|
||||
const profileResult = await buildAvailableForProfile(requestedProfile, new Map(), appConfigForProfile)
|
||||
const profileGroupsWithAliases = applyModelAliases(profileResult.groups, modelAliasesForProfile)
|
||||
const visibleProfileGroups = applyModelVisibility(profileGroupsWithAliases, modelVisibilityForProfile)
|
||||
const visibleProfileDefault = resolveVisibleDefault(profileResult.default, profileResult.default_provider, visibleProfileGroups)
|
||||
ctx.body = {
|
||||
default: visibleProfileDefault.defaultModel,
|
||||
default_provider: visibleProfileDefault.defaultProvider,
|
||||
groups: visibleProfileGroups,
|
||||
allProviders: applyModelAliases(PROVIDER_PRESETS.map((p: any) => ({
|
||||
provider: p.value,
|
||||
label: p.label,
|
||||
base_url: p.base_url,
|
||||
models: p.models,
|
||||
api_key: '',
|
||||
})), modelAliasesForProfile),
|
||||
model_aliases: modelAliasesForProfile,
|
||||
model_visibility: modelVisibilityForProfile,
|
||||
profiles: [{
|
||||
profile: profileResult.profile,
|
||||
default: profileResult.default,
|
||||
default_provider: profileResult.default_provider,
|
||||
groups: visibleProfileGroups,
|
||||
}],
|
||||
}
|
||||
return
|
||||
|
||||
const config = await readConfigYaml()
|
||||
const modelSection = config.model
|
||||
let currentDefault = ''
|
||||
@@ -239,16 +543,16 @@ export async function getAvailable(ctx: any) {
|
||||
const live = await getCopilotLive()
|
||||
if (live.length > 0) {
|
||||
modelsList = live.map((m) => m.id)
|
||||
modelMeta = {}
|
||||
const nextModelMeta: Record<string, ModelMeta> = {}
|
||||
for (const m of live) {
|
||||
if (m.preview || m.disabled) {
|
||||
modelMeta[m.id] = {
|
||||
nextModelMeta[m.id] = {
|
||||
...(m.preview ? { preview: true } : {}),
|
||||
...(m.disabled ? { disabled: true } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(modelMeta).length === 0) modelMeta = undefined
|
||||
modelMeta = Object.keys(nextModelMeta).length > 0 ? nextModelMeta : undefined
|
||||
}
|
||||
} else if (providerKey === 'openrouter' || providerKey === 'cliproxyapi' || providerKey === 'ollama-cloud') {
|
||||
// OpenRouter and local CLIProxyAPI expose dynamic OpenAI-compatible /models catalogs.
|
||||
@@ -286,8 +590,9 @@ export async function getAvailable(ctx: any) {
|
||||
)
|
||||
|
||||
for (const result of customFetches) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
const { providerKey, label, base_url, models, api_key: cpApiKey, builtin: cpBuiltin } = result.value as any
|
||||
const value = (result as { value?: any }).value
|
||||
if (value) {
|
||||
const { providerKey, label, base_url, models, api_key: cpApiKey, builtin: cpBuiltin } = value
|
||||
addGroup(providerKey, label, base_url, models, cpApiKey, cpBuiltin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { basename, join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||
import { SessionDeleter } from '../../services/hermes/session-deleter'
|
||||
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
|
||||
import { AgentBridgeClient } from '../../services/hermes/agent-bridge'
|
||||
import { logger } from '../../services/logger'
|
||||
import { smartCloneCleanup } from '../../services/hermes/profile-credentials'
|
||||
import { detectHermesRootHome } from '../../services/hermes/hermes-path'
|
||||
@@ -42,7 +42,6 @@ function listProfilesFromDisk(activeProfileName: string): HermesProfile[] {
|
||||
name: 'default',
|
||||
active: activeProfileName === 'default',
|
||||
model: '—',
|
||||
gateway: 'stopped',
|
||||
alias: '',
|
||||
}]
|
||||
const profilesDir = join(base, 'profiles')
|
||||
@@ -56,7 +55,6 @@ function listProfilesFromDisk(activeProfileName: string): HermesProfile[] {
|
||||
name,
|
||||
active: name === activeProfileName,
|
||||
model: '—',
|
||||
gateway: 'stopped',
|
||||
alias: '',
|
||||
})
|
||||
}
|
||||
@@ -186,12 +184,6 @@ export async function create(ctx: any) {
|
||||
}
|
||||
}
|
||||
|
||||
const mgr = getGatewayManagerInstance()
|
||||
if (mgr) {
|
||||
try { await mgr.start(name) } catch (err: any) {
|
||||
logger.error(err, 'Failed to start gateway for profile "%s"', name)
|
||||
}
|
||||
}
|
||||
ctx.body = {
|
||||
success: true,
|
||||
message: output.trim(),
|
||||
@@ -223,8 +215,12 @@ export async function remove(ctx: any) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const mgr = getGatewayManagerInstance()
|
||||
if (mgr) { try { await mgr.stop(name) } catch { } }
|
||||
try {
|
||||
const result = await new AgentBridgeClient().destroyProfile(name)
|
||||
logger.info('[profiles] destroyed bridge sessions for deleted profile "%s" destroyed=%s', name, result.destroyed)
|
||||
} catch (err) {
|
||||
logger.warn(err, '[profiles] failed to destroy bridge sessions for deleted profile "%s"', name)
|
||||
}
|
||||
const ok = await hermesCli.deleteProfile(name)
|
||||
if (ok) {
|
||||
ctx.body = { success: true }
|
||||
@@ -296,10 +292,6 @@ export async function switchProfile(ctx: any) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update GatewayManager to match the authoritative source
|
||||
const mgr = getGatewayManagerInstance()
|
||||
if (mgr) { mgr.setActiveProfile(name) }
|
||||
|
||||
// Destroy all bridge sessions so they get recreated with the new profile config
|
||||
try {
|
||||
const { AgentBridgeClient } = await import('../../services/hermes/agent-bridge')
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||
import { listSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb } from '../../db/hermes/sessions-db'
|
||||
import { listSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb, getExactSessionDetailFromDbWithProfile } from '../../db/hermes/sessions-db'
|
||||
import {
|
||||
listSessions as localListSessions,
|
||||
searchSessions as localSearchSessions,
|
||||
getSession as localGetSession,
|
||||
getSessionDetail as localGetSessionDetail,
|
||||
deleteSession as localDeleteSession,
|
||||
renameSession as localRenameSession,
|
||||
} from '../../db/hermes/session-store'
|
||||
import { ExportCompressor } from '../../lib/context-compressor/export-compressor'
|
||||
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
|
||||
import { deleteUsage, getUsage, getUsageBatch } from '../../db/hermes/usage-store'
|
||||
import type { UsageStatsModelRow, UsageStatsDailyRow } from '../../db/hermes/usage-store'
|
||||
import { getModelContextLength } from '../../services/hermes/model-context'
|
||||
import { getActiveProfileName } from '../../services/hermes/hermes-profile'
|
||||
import { getActiveProfileName, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile'
|
||||
import { isPathWithin } from '../../services/hermes/hermes-path'
|
||||
import { getGroupChatServer } from '../../routes/hermes/group-chat'
|
||||
import { logger } from '../../services/logger'
|
||||
import type { ConversationSummary } from '../../services/hermes/conversations'
|
||||
@@ -31,6 +32,43 @@ function filterPendingDeletedConversationSummaries(items: ConversationSummary[])
|
||||
return filterPendingDeletedSessions(items)
|
||||
}
|
||||
|
||||
interface HermesDeleteResult {
|
||||
attempted: boolean
|
||||
deleted: boolean
|
||||
profile?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
function hasProfileOnDisk(profile: string): boolean {
|
||||
return listProfileNamesFromDisk().includes(profile || 'default')
|
||||
}
|
||||
|
||||
async function deleteHermesSessionIfPresent(sessionId: string, profile?: string | null): Promise<HermesDeleteResult> {
|
||||
const targetProfile = profile || 'default'
|
||||
if (!hasProfileOnDisk(targetProfile)) {
|
||||
return { attempted: false, deleted: false, profile: targetProfile }
|
||||
}
|
||||
|
||||
try {
|
||||
const hermesSession = await getExactSessionDetailFromDbWithProfile(sessionId, targetProfile)
|
||||
if (!hermesSession) {
|
||||
return { attempted: false, deleted: false, profile: targetProfile }
|
||||
}
|
||||
|
||||
const deleted = await hermesCli.deleteSessionForProfile(sessionId, targetProfile)
|
||||
return {
|
||||
attempted: true,
|
||||
deleted,
|
||||
profile: targetProfile,
|
||||
error: deleted ? undefined : 'Failed to delete Hermes session',
|
||||
}
|
||||
} catch (err: any) {
|
||||
const message = err?.message || 'Failed to inspect Hermes session'
|
||||
logger.warn({ err, sessionId, profile: targetProfile }, 'Hermes Session: profile delete skipped')
|
||||
return { attempted: true, deleted: false, profile: targetProfile, error: message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function listConversations(ctx: any) {
|
||||
const source = (ctx.query.source as string) || undefined
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
||||
@@ -98,11 +136,19 @@ export async function getConversationMessages(ctx: any) {
|
||||
export async function list(ctx: any) {
|
||||
const source = (ctx.query.source as string) || undefined
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
||||
const profile = getActiveProfileName()
|
||||
const profile = typeof ctx.query.profile === 'string' && ctx.query.profile.trim()
|
||||
? ctx.query.profile.trim()
|
||||
: undefined
|
||||
const effectiveLimit = limit && limit > 0 ? limit : 2000
|
||||
|
||||
const allSessions = localListSessions(profile, source, effectiveLimit)
|
||||
ctx.body = { sessions: filterPendingDeletedSessions(allSessions.filter(s => s.source === 'api_server' || s.source === 'cli')) }
|
||||
const knownProfiles = profile ? null : new Set(listProfileNamesFromDisk())
|
||||
ctx.body = {
|
||||
sessions: filterPendingDeletedSessions(allSessions.filter(s =>
|
||||
(s.source === 'api_server' || s.source === 'cli') &&
|
||||
(!knownProfiles || knownProfiles.has(s.profile || 'default')),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,14 +217,17 @@ export async function getHermesSession(ctx: any) {
|
||||
|
||||
export async function remove(ctx: any) {
|
||||
const sessionId = ctx.params.id
|
||||
const ok = localDeleteSession(sessionId)
|
||||
if (!ok) {
|
||||
const existing = localGetSession(sessionId)
|
||||
const hermesProfile = existing?.profile || getActiveProfileName()
|
||||
const hermes = await deleteHermesSessionIfPresent(sessionId, hermesProfile)
|
||||
const localDeleted = existing ? localDeleteSession(sessionId) : true
|
||||
if (!localDeleted) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: 'Failed to delete session' }
|
||||
return
|
||||
}
|
||||
deleteUsage(sessionId)
|
||||
ctx.body = { ok: true }
|
||||
ctx.body = { ok: true, deleted: Boolean(existing), hermes }
|
||||
}
|
||||
|
||||
export async function batchRemove(ctx: any) {
|
||||
@@ -199,10 +248,22 @@ export async function batchRemove(ctx: any) {
|
||||
const results = {
|
||||
deleted: 0,
|
||||
failed: 0,
|
||||
errors: [] as Array<{ id: string; error: string }>
|
||||
hermesDeleted: 0,
|
||||
hermesFailed: 0,
|
||||
errors: [] as Array<{ id: string; error: string }>,
|
||||
hermesErrors: [] as Array<{ id: string; profile?: string; error: string }>
|
||||
}
|
||||
|
||||
for (const id of validIds) {
|
||||
const existing = localGetSession(id)
|
||||
const hermes = await deleteHermesSessionIfPresent(id, existing?.profile)
|
||||
if (hermes.deleted) {
|
||||
results.hermesDeleted++
|
||||
} else if (hermes.attempted && hermes.error) {
|
||||
results.hermesFailed++
|
||||
results.hermesErrors.push({ id, profile: hermes.profile, error: hermes.error })
|
||||
}
|
||||
|
||||
const ok = localDeleteSession(id)
|
||||
if (ok) {
|
||||
deleteUsage(id)
|
||||
@@ -292,7 +353,9 @@ export async function setModel(ctx: any) {
|
||||
|
||||
export async function contextLength(ctx: any) {
|
||||
const profile = (ctx.query.profile as string) || undefined
|
||||
ctx.body = { context_length: getModelContextLength(profile) }
|
||||
const model = typeof ctx.query.model === 'string' ? ctx.query.model : undefined
|
||||
const provider = typeof ctx.query.provider === 'string' ? ctx.query.provider : undefined
|
||||
ctx.body = { context_length: getModelContextLength({ profile, model, provider }) }
|
||||
}
|
||||
|
||||
export async function usageStats(ctx: any) {
|
||||
@@ -365,7 +428,7 @@ export async function listWorkspaceFolders(ctx: any) {
|
||||
|
||||
// Security: prevent path traversal
|
||||
const fullPath = resolve(join(WORKSPACE_BASE, subPath))
|
||||
if (!fullPath.startsWith(resolve(WORKSPACE_BASE))) {
|
||||
if (!isPathWithin(fullPath, WORKSPACE_BASE)) {
|
||||
ctx.status = 403
|
||||
ctx.body = { error: 'Access denied' }
|
||||
return
|
||||
@@ -437,10 +500,9 @@ export async function exportSession(ctx: any) {
|
||||
}
|
||||
|
||||
async function compressSession(session: any) {
|
||||
const mgr = getGatewayManagerInstance()
|
||||
const profile = getActiveProfileName()
|
||||
const upstream = mgr ? mgr.getUpstream(profile).replace(/\/$/, '') : ''
|
||||
const apiKey = mgr ? mgr.getApiKey(profile) || undefined : undefined
|
||||
const profile = session.profile || getActiveProfileName()
|
||||
const upstream = ''
|
||||
const apiKey = undefined
|
||||
const messages = (session.messages || []).map((m: any) => ({
|
||||
role: m.role,
|
||||
content: m.content || '',
|
||||
@@ -450,7 +512,11 @@ async function compressSession(session: any) {
|
||||
reasoning_content: m.reasoning,
|
||||
}))
|
||||
|
||||
return exportCompressor.compress(messages, upstream, apiKey, session.id, profile)
|
||||
return exportCompressor.compress(messages, upstream, apiKey, session.id, {
|
||||
profile,
|
||||
model: session.model,
|
||||
provider: session.provider,
|
||||
})
|
||||
}
|
||||
|
||||
function serializeAsText(title: string | null, messages: any[]): string {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
safeReadFile, extractDescription, listFilesRecursive, getHermesDir,
|
||||
} from '../../services/config-helpers'
|
||||
import { pinSkill } from '../../services/hermes/hermes-cli'
|
||||
import { isPathWithin } from '../../services/hermes/hermes-path'
|
||||
import { getSkillUsageStatsFromDb } from '../../db/hermes/sessions-db'
|
||||
|
||||
/** Read bundled manifest as a name→hash map from ~/.hermes/skills/.bundled_manifest */
|
||||
@@ -301,7 +302,7 @@ export async function readFile_(ctx: any) {
|
||||
realPath = filePath.slice(5)
|
||||
}
|
||||
const fullPath = resolve(join(hd, 'skills', realPath))
|
||||
if (!fullPath.startsWith(join(hd, 'skills'))) {
|
||||
if (!isPathWithin(fullPath, join(hd, 'skills'))) {
|
||||
ctx.status = 403
|
||||
ctx.body = { error: 'Access denied' }
|
||||
return
|
||||
|
||||
@@ -13,9 +13,11 @@ const XAI_OAUTH_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828'
|
||||
const XAI_OAUTH_SCOPE = 'openid profile email offline_access grok-cli:access api:access'
|
||||
const XAI_DEFAULT_BASE_URL = 'https://api.x.ai/v1'
|
||||
const XAI_REDIRECT_HOST = '127.0.0.1'
|
||||
const XAI_CALLBACK_BIND_HOST = process.env.HERMES_WEB_UI_XAI_CALLBACK_BIND_HOST?.trim() || XAI_REDIRECT_HOST
|
||||
const XAI_REDIRECT_PORT = 56121
|
||||
const XAI_REDIRECT_PATH = '/callback'
|
||||
const POLL_MAX_DURATION = 15 * 60 * 1000
|
||||
const XAI_DEFAULT_MODEL = 'grok-4.3'
|
||||
|
||||
interface XaiSession {
|
||||
id: string
|
||||
@@ -41,6 +43,18 @@ interface AuthJson {
|
||||
|
||||
const sessions = new Map<string, XaiSession>()
|
||||
|
||||
export function applyXaiOAuthDefaultModel(config: Record<string, any>): Record<string, any> {
|
||||
if (typeof config.model !== 'object' || config.model === null) config.model = {}
|
||||
const currentDefault = String(config.model.default || '').trim()
|
||||
config.model.provider = 'xai-oauth'
|
||||
config.model.default = currentDefault.toLowerCase().startsWith('grok-')
|
||||
? currentDefault
|
||||
: XAI_DEFAULT_MODEL
|
||||
delete config.model.base_url
|
||||
delete config.model.api_key
|
||||
return config
|
||||
}
|
||||
|
||||
function cleanupExpiredSessions() {
|
||||
const now = Date.now()
|
||||
sessions.forEach((session, id) => {
|
||||
@@ -181,14 +195,7 @@ async function saveTokens(session: XaiSession, tokenData: any) {
|
||||
}]
|
||||
saveAuthJson(authPath, auth)
|
||||
|
||||
await updateConfigYaml((config) => {
|
||||
if (typeof config.model !== 'object' || config.model === null) config.model = {}
|
||||
config.model.provider = 'xai-oauth'
|
||||
config.model.default = config.model.default || 'grok-4.3'
|
||||
delete config.model.base_url
|
||||
delete config.model.api_key
|
||||
return config
|
||||
})
|
||||
await updateConfigYaml(applyXaiOAuthDefaultModel)
|
||||
}
|
||||
|
||||
async function exchangeCode(session: XaiSession, code: string) {
|
||||
@@ -257,7 +264,7 @@ function startCallbackServer(sessionId: string, preferredPort = XAI_REDIRECT_POR
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
server.listen(preferredPort, XAI_REDIRECT_HOST, () => {
|
||||
server.listen(preferredPort, XAI_CALLBACK_BIND_HOST, () => {
|
||||
const address = server.address()
|
||||
const port = typeof address === 'object' && address ? address.port : preferredPort
|
||||
resolve({ server, redirectUri: `http://${XAI_REDIRECT_HOST}:${port}${XAI_REDIRECT_PATH}` })
|
||||
|
||||
@@ -65,6 +65,7 @@ function runNpm(args: string[], options: { timeout?: number } = {}) {
|
||||
timeout: options.timeout,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: getCurrentNodeEnv(),
|
||||
windowsHide: true,
|
||||
}).trim()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { join } from 'path'
|
||||
import { getActiveProfileDir } from '../../services/hermes/hermes-profile'
|
||||
import type {
|
||||
ConversationDetail,
|
||||
@@ -62,7 +63,7 @@ interface ConversationSessionRow {
|
||||
}
|
||||
|
||||
function conversationDbPath(): string {
|
||||
return `${getActiveProfileDir()}/state.db`
|
||||
return join(getActiveProfileDir(), 'state.db')
|
||||
}
|
||||
|
||||
function normalizeNumber(value: unknown, fallback = 0): number {
|
||||
|
||||
@@ -118,6 +118,7 @@ export const GC_ROOMS_SCHEMA: Record<string, string> = {
|
||||
maxHistoryTokens: 'INTEGER NOT NULL DEFAULT 32000',
|
||||
tailMessageCount: 'INTEGER NOT NULL DEFAULT 10',
|
||||
totalTokens: 'INTEGER NOT NULL DEFAULT 0',
|
||||
sessionSeed: "TEXT NOT NULL DEFAULT '0'",
|
||||
}
|
||||
|
||||
export const GC_MESSAGES_TABLE = 'gc_messages'
|
||||
@@ -129,6 +130,14 @@ export const GC_MESSAGES_SCHEMA: Record<string, string> = {
|
||||
senderName: 'TEXT NOT NULL',
|
||||
content: 'TEXT NOT NULL',
|
||||
timestamp: 'INTEGER NOT NULL',
|
||||
role: "TEXT NOT NULL DEFAULT 'user'",
|
||||
tool_call_id: 'TEXT',
|
||||
tool_calls: 'TEXT',
|
||||
tool_name: 'TEXT',
|
||||
finish_reason: 'TEXT',
|
||||
reasoning: 'TEXT',
|
||||
reasoning_details: 'TEXT',
|
||||
reasoning_content: 'TEXT',
|
||||
}
|
||||
|
||||
export const GC_ROOM_AGENTS_TABLE = 'gc_room_agents'
|
||||
|
||||
@@ -219,9 +219,10 @@ export function renameSession(id: string, title: string): boolean {
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function listSessions(profile: string, source?: string, limit = 2000): HermesSessionRow[] {
|
||||
export function listSessions(profile?: string, source?: string, limit = 2000): HermesSessionRow[] {
|
||||
if (!isSqliteAvailable()) return []
|
||||
const db = getDb()!
|
||||
const profileFilter = profile?.trim()
|
||||
|
||||
// Use a subquery to generate preview from first user message if not set
|
||||
const sql = `
|
||||
@@ -239,13 +240,17 @@ export function listSessions(profile: string, source?: string, limit = 2000): He
|
||||
''
|
||||
) AS preview
|
||||
FROM ${SESSIONS_TABLE} s
|
||||
WHERE s.profile = ?
|
||||
WHERE 1 = 1
|
||||
${profileFilter ? 'AND s.profile = ?' : ''}
|
||||
${source ? 'AND s.source = ?' : ''}
|
||||
ORDER BY s.last_active DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
const params: any[] = [profile]
|
||||
const params: any[] = []
|
||||
if (profileFilter) {
|
||||
params.push(profileFilter)
|
||||
}
|
||||
if (source) {
|
||||
params.push(source)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getActiveProfileDir, getProfileDir } from '../../services/hermes/hermes-profile'
|
||||
import { join } from 'path'
|
||||
import type { LocalUsageStats } from './usage-store'
|
||||
|
||||
const SQLITE_AVAILABLE = (() => {
|
||||
@@ -66,7 +67,7 @@ interface HermesSessionInternalRow extends HermesSessionRow {
|
||||
}
|
||||
|
||||
function sessionDbPath(): string {
|
||||
return `${getActiveProfileDir()}/state.db`
|
||||
return join(getActiveProfileDir(), 'state.db')
|
||||
}
|
||||
|
||||
function normalizeNumber(value: unknown, fallback = 0): number {
|
||||
@@ -643,7 +644,7 @@ export async function getSessionDetailFromDb(sessionId: string): Promise<HermesS
|
||||
|
||||
export async function getSessionDetailFromDbWithProfile(sessionId: string, profile: string): Promise<HermesSessionDetailRow | null> {
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const dbPath = `${getProfileDir(profile)}/state.db`
|
||||
const dbPath = join(getProfileDir(profile), 'state.db')
|
||||
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
|
||||
try {
|
||||
const idx = loadAllSessions(db)
|
||||
@@ -670,7 +671,7 @@ export async function getSessionDetailFromDbWithProfile(sessionId: string, profi
|
||||
|
||||
export async function getExactSessionDetailFromDbWithProfile(sessionId: string, profile: string): Promise<HermesSessionDetailRow | null> {
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const dbPath = `${getProfileDir(profile)}/state.db`
|
||||
const dbPath = join(getProfileDir(profile), 'state.db')
|
||||
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
|
||||
try {
|
||||
const idx = loadAllSessions(db)
|
||||
@@ -702,7 +703,7 @@ export async function findLatestExactSessionIdWithProfile(
|
||||
if (!trimmed) return null
|
||||
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const dbPath = `${getProfileDir(profile)}/state.db`
|
||||
const dbPath = join(getProfileDir(profile), 'state.db')
|
||||
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
|
||||
const loweredQuery = trimmed.toLowerCase()
|
||||
const likePattern = buildLikePattern(loweredQuery)
|
||||
@@ -1212,7 +1213,7 @@ export async function listSessionSummaries(source?: string, limit = 2000, profil
|
||||
}
|
||||
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const dbPath = profile ? `${getProfileDir(profile)}/state.db` : sessionDbPath()
|
||||
const dbPath = profile ? join(getProfileDir(profile), 'state.db') : sessionDbPath()
|
||||
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
|
||||
|
||||
try {
|
||||
@@ -1259,7 +1260,7 @@ export async function searchSessionSummariesWithProfile(
|
||||
if (!trimmed) return []
|
||||
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const dbPath = `${getProfileDir(profile)}/state.db`
|
||||
const dbPath = join(getProfileDir(profile), 'state.db')
|
||||
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
|
||||
const normalized = sanitizeFtsQuery(trimmed)
|
||||
const prefixQuery = toPrefixQuery(normalized)
|
||||
|
||||
@@ -10,7 +10,6 @@ import { readFileSync } from 'fs'
|
||||
import { config } from './config'
|
||||
import { getToken, requireAuth } from './services/auth'
|
||||
import { initLoginLimiter } from './services/login-limiter'
|
||||
import { initGatewayManager, getGatewayManagerInstance } from './services/gateway-bootstrap'
|
||||
import { bindShutdown } from './services/shutdown'
|
||||
import { setupTerminalWebSocket } from './routes/hermes/terminal'
|
||||
import { setupKanbanEventsWebSocket } from './routes/hermes/kanban-events'
|
||||
@@ -21,6 +20,8 @@ import { setChatRunServer } from './routes/hermes/chat-run'
|
||||
import { GroupChatServer } from './services/hermes/group-chat'
|
||||
import { ChatRunSocket } from './services/hermes/run-chat'
|
||||
import { startAgentBridgeManager } from './services/hermes/agent-bridge'
|
||||
import { HermesSkillInjector } from './services/hermes/skill-injector'
|
||||
import { ensureProfileGatewaysRunning } from './services/hermes/gateway-autostart'
|
||||
import { logger } from './services/logger'
|
||||
|
||||
// Injected by esbuild at build time; fallback to reading package.json in dev mode
|
||||
@@ -88,14 +89,30 @@ export async function bootstrap() {
|
||||
|
||||
const authToken = await getToken()
|
||||
await initLoginLimiter()
|
||||
try {
|
||||
const skillInjector = new HermesSkillInjector()
|
||||
const injectionResult = await skillInjector.injectMissingSkills()
|
||||
if (injectionResult.injected.length > 0) {
|
||||
console.log('[bootstrap] bundled skills injected:', injectionResult.injected.join(', '))
|
||||
}
|
||||
if (injectionResult.updated.length > 0) {
|
||||
console.log('[bootstrap] bundled skills updated:', injectionResult.updated.join(', '))
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(err, '[bootstrap] failed to inject bundled skills')
|
||||
console.warn('[bootstrap] failed to inject bundled skills:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
|
||||
// Debug: log environment variable
|
||||
console.log('[bootstrap] HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN =', process.env.HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN)
|
||||
try {
|
||||
await ensureProfileGatewaysRunning()
|
||||
console.log('[bootstrap] profile gateways checked')
|
||||
} catch (err) {
|
||||
logger.warn(err, '[bootstrap] failed to ensure profile gateways')
|
||||
console.warn('[bootstrap] failed to ensure profile gateways:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
|
||||
const app = new Koa()
|
||||
|
||||
await initGatewayManager()
|
||||
console.log('[bootstrap] gateway manager initialized')
|
||||
try {
|
||||
agentBridgeManager = await startAgentBridgeManager()
|
||||
console.log('[bootstrap] agent bridge started')
|
||||
@@ -151,10 +168,9 @@ export async function bootstrap() {
|
||||
// Group chat Socket.IO (must be after server is created)
|
||||
const groupChatServer = new GroupChatServer(servers)
|
||||
setGroupChatServer(groupChatServer)
|
||||
groupChatServer.setGatewayManager(getGatewayManagerInstance())
|
||||
|
||||
// Chat run Socket.IO — shares the same Server instance, just adds /chat-run namespace
|
||||
chatRunServer = new ChatRunSocket(groupChatServer.getIO(), getGatewayManagerInstance())
|
||||
chatRunServer = new ChatRunSocket(groupChatServer.getIO())
|
||||
setChatRunServer(chatRunServer)
|
||||
chatRunServer.init()
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type ChatMessage,
|
||||
type CompressionConfig,
|
||||
type CompressedResult,
|
||||
type SummarizerOptions,
|
||||
DEFAULT_COMPRESSION_CONFIG,
|
||||
countTokens,
|
||||
serializeForSummary,
|
||||
@@ -35,7 +36,7 @@ export class ExportCompressor {
|
||||
upstream: string,
|
||||
apiKey: string | undefined,
|
||||
sessionId?: string,
|
||||
profile?: string,
|
||||
summarizer?: string | SummarizerOptions,
|
||||
): Promise<CompressedResult> {
|
||||
const total = messages.length
|
||||
|
||||
@@ -57,7 +58,7 @@ export class ExportCompressor {
|
||||
sessionId, snapshot.lastMessageIndex,
|
||||
)
|
||||
return this.incrementalCompress(
|
||||
messages, snapshot, upstream, apiKey, meta, profile,
|
||||
messages, snapshot, upstream, apiKey, meta, summarizer,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,7 +66,7 @@ export class ExportCompressor {
|
||||
'[export-compressor] session=%s: full compress %d messages',
|
||||
sessionId, total,
|
||||
)
|
||||
return this.fullCompress(messages, upstream, apiKey, meta, profile)
|
||||
return this.fullCompress(messages, upstream, apiKey, meta, summarizer)
|
||||
}
|
||||
|
||||
private async incrementalCompress(
|
||||
@@ -74,7 +75,7 @@ export class ExportCompressor {
|
||||
upstream: string,
|
||||
apiKey: string | undefined,
|
||||
meta: CompressedResult['meta'],
|
||||
profile?: string,
|
||||
summarizer?: string | SummarizerOptions,
|
||||
): Promise<CompressedResult> {
|
||||
const { summary: previousSummary, lastMessageIndex } = snapshot
|
||||
const newMessages = messages.slice(lastMessageIndex + 1)
|
||||
@@ -86,7 +87,7 @@ export class ExportCompressor {
|
||||
const history = buildConversationHistory(newMessages)
|
||||
|
||||
const t0 = Date.now()
|
||||
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, previousSummary, profile)
|
||||
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, previousSummary, summarizer)
|
||||
logger.info('[export-compressor] incremental-llm done in %dms, %d chars', Date.now() - t0, summary!.length)
|
||||
} catch (err: any) {
|
||||
logger.warn('[export-compressor] incremental-llm failed: %s — reusing previous summary', err.message)
|
||||
@@ -112,7 +113,7 @@ export class ExportCompressor {
|
||||
upstream: string,
|
||||
apiKey: string | undefined,
|
||||
meta: CompressedResult['meta'],
|
||||
profile?: string,
|
||||
summarizer?: string | SummarizerOptions,
|
||||
): Promise<CompressedResult> {
|
||||
if (messages.length === 0) {
|
||||
return { messages: [], meta }
|
||||
@@ -125,7 +126,7 @@ export class ExportCompressor {
|
||||
const history = buildConversationHistory(messages)
|
||||
|
||||
const t0 = Date.now()
|
||||
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, undefined, profile)
|
||||
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, undefined, summarizer)
|
||||
logger.info('[export-compressor] full-llm done in %dms, %d chars', Date.now() - t0, summary!.length)
|
||||
} catch (err: any) {
|
||||
logger.warn('[export-compressor] full-llm failed: %s', err.message)
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
*/
|
||||
|
||||
import { encodingForModel, getEncoding } from 'js-tiktoken'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { logger } from '../../services/logger'
|
||||
import { AgentBridgeClient, type AgentBridgeRunResult } from '../../services/hermes/agent-bridge'
|
||||
import {
|
||||
getCompressionSnapshot,
|
||||
saveCompressionSnapshot,
|
||||
@@ -70,6 +72,12 @@ export interface CompressedResult {
|
||||
}
|
||||
}
|
||||
|
||||
export interface SummarizerOptions {
|
||||
profile?: string
|
||||
model?: string | null
|
||||
provider?: string | null
|
||||
}
|
||||
|
||||
// ─── Token counting ─────────────────────────────────────
|
||||
|
||||
let _encoder: ReturnType<typeof getEncoding> | null = null
|
||||
@@ -372,8 +380,14 @@ export async function callSummarizer(
|
||||
history: Array<{ role: string; content: string }>,
|
||||
timeoutMs: number,
|
||||
previousSummary?: string,
|
||||
profile?: string,
|
||||
summarizer?: string | SummarizerOptions,
|
||||
): Promise<string> {
|
||||
void upstream
|
||||
void apiKey
|
||||
const options: SummarizerOptions = typeof summarizer === 'string'
|
||||
? { profile: summarizer }
|
||||
: summarizer || {}
|
||||
const profile = options.profile || 'default'
|
||||
const convHistory: Array<{ role: string; content: string }> = [...history]
|
||||
|
||||
if (previousSummary) {
|
||||
@@ -383,60 +397,38 @@ export async function callSummarizer(
|
||||
)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
||||
const bridge = new AgentBridgeClient({ timeoutMs: timeoutMs + 15_000 })
|
||||
const sessionId = `compress_${Date.now().toString(36)}_${randomUUID().replace(/-/g, '').slice(0, 12)}`
|
||||
|
||||
const res = await fetch(`${upstream.replace(/\/$/, '')}/v1/responses`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
input: prompt,
|
||||
try {
|
||||
const result = await bridge.request<AgentBridgeRunResult>({
|
||||
action: 'chat',
|
||||
session_id: sessionId,
|
||||
message: prompt,
|
||||
conversation_history: convHistory,
|
||||
stream: true,
|
||||
store: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
})
|
||||
profile,
|
||||
source: 'api_server',
|
||||
wait: true,
|
||||
timeout: Math.ceil(timeoutMs / 1000),
|
||||
...(options.model ? { model: options.model } : {}),
|
||||
...(options.provider ? { provider: options.provider } : {}),
|
||||
}, { timeoutMs: timeoutMs + 15_000 })
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Summarization response failed: ${res.status}`)
|
||||
if (result.status === 'error') {
|
||||
throw new Error(result.error || 'Summarization bridge run failed')
|
||||
}
|
||||
|
||||
const payload = result.result as any
|
||||
const output = String(
|
||||
payload?.final_response ||
|
||||
result.output ||
|
||||
'',
|
||||
).trim()
|
||||
if (!output) throw new Error('Empty summarization response')
|
||||
return output
|
||||
} finally {
|
||||
await bridge.destroy(sessionId, profile).catch(() => undefined)
|
||||
}
|
||||
|
||||
if (!res.body) {
|
||||
throw new Error('Summarization response stream missing')
|
||||
}
|
||||
|
||||
let output = ''
|
||||
for await (const frame of readSseFrames(res.body)) {
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = JSON.parse(frame.data)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const eventType = parsed.type || frame.event || parsed.event
|
||||
|
||||
if (eventType === 'response.output_text.delta' && parsed.delta) {
|
||||
output += parsed.delta
|
||||
continue
|
||||
}
|
||||
|
||||
if (eventType === 'response.completed') {
|
||||
const response = parsed.response || parsed
|
||||
const finalText = extractResponseText(response)
|
||||
if (!output && finalText) output = finalText
|
||||
if (!output || output.trim() === '') {
|
||||
throw new Error('Empty summarization response')
|
||||
}
|
||||
return output.trim()
|
||||
}
|
||||
|
||||
if (eventType === 'response.failed') {
|
||||
throw new Error(parsed.error?.message || parsed.error || 'Summarization response failed')
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Summarization response stream ended without a terminal event')
|
||||
}
|
||||
|
||||
// ─── Main Compressor ────────────────────────────────────
|
||||
@@ -465,7 +457,7 @@ export class ChatContextCompressor {
|
||||
upstream: string,
|
||||
apiKey: string | undefined,
|
||||
sessionId?: string,
|
||||
profile?: string,
|
||||
summarizer?: string | SummarizerOptions,
|
||||
): Promise<CompressedResult> {
|
||||
const total = messages.length
|
||||
|
||||
@@ -489,7 +481,7 @@ export class ChatContextCompressor {
|
||||
sessionId, snapshot.lastMessageIndex,
|
||||
)
|
||||
return this.incrementalCompress(
|
||||
messages, snapshot, upstream, apiKey, sessionId!, makeMeta(), profile,
|
||||
messages, snapshot, upstream, apiKey, sessionId!, makeMeta(), summarizer,
|
||||
)
|
||||
} else {
|
||||
// No snapshot → full compress (compress all messages)
|
||||
@@ -497,7 +489,7 @@ export class ChatContextCompressor {
|
||||
'[context-compressor] session=%s: full compress %d messages',
|
||||
sessionId, total,
|
||||
)
|
||||
return this.fullCompress(messages, upstream, apiKey, sessionId!, makeMeta(), profile)
|
||||
return this.fullCompress(messages, upstream, apiKey, sessionId!, makeMeta(), summarizer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,7 +500,7 @@ export class ChatContextCompressor {
|
||||
apiKey: string | undefined,
|
||||
sessionId: string,
|
||||
meta: CompressedResult['meta'],
|
||||
profile?: string,
|
||||
summarizer?: string | SummarizerOptions,
|
||||
): Promise<CompressedResult> {
|
||||
const { summary: previousSummary, lastMessageIndex } = snapshot
|
||||
const total = messages.length
|
||||
@@ -550,7 +542,7 @@ export class ChatContextCompressor {
|
||||
const history = buildConversationHistory(toCompress)
|
||||
|
||||
const t0 = Date.now()
|
||||
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, previousSummary, profile)
|
||||
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, previousSummary, summarizer)
|
||||
logger.info('[context-compressor] incremental-llm done in %dms, %d chars', Date.now() - t0, summary.length)
|
||||
} catch (err: any) {
|
||||
logger.warn('[context-compressor] incremental-llm failed: %s — keeping new messages verbatim', err.message)
|
||||
@@ -599,7 +591,7 @@ export class ChatContextCompressor {
|
||||
apiKey: string | undefined,
|
||||
sessionId: string,
|
||||
meta: CompressedResult['meta'],
|
||||
profile?: string,
|
||||
summarizer?: string | SummarizerOptions,
|
||||
): Promise<CompressedResult> {
|
||||
const total = messages.length
|
||||
const cleaned = pruneOldToolResults(messages, this.config.tailMessageCount)
|
||||
@@ -625,7 +617,7 @@ export class ChatContextCompressor {
|
||||
let summary: string | null = null
|
||||
try {
|
||||
const t0 = Date.now()
|
||||
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, undefined, profile)
|
||||
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, undefined, summarizer)
|
||||
logger.info('[context-compressor] full-llm done in %dms, %d chars', Date.now() - t0, summary.length)
|
||||
} catch (err: any) {
|
||||
logger.warn('[context-compressor] full-llm failed: %s', err.message)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../../controllers/hermes/gateways'
|
||||
|
||||
export const gatewayRoutes = new Router()
|
||||
|
||||
gatewayRoutes.get('/api/hermes/gateways', ctrl.list)
|
||||
gatewayRoutes.post('/api/hermes/gateways/:name/start', ctrl.start)
|
||||
gatewayRoutes.post('/api/hermes/gateways/:name/stop', ctrl.stop)
|
||||
gatewayRoutes.get('/api/hermes/gateways/:name/health', ctrl.health)
|
||||
@@ -0,0 +1,6 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../../controllers/hermes/media'
|
||||
|
||||
export const mediaRoutes = new Router()
|
||||
|
||||
mediaRoutes.post('/api/hermes/media/grok-image-to-video', ctrl.grokImageToVideo)
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { Context } from 'koa'
|
||||
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
|
||||
import { updateUsage } from '../../db/hermes/usage-store'
|
||||
|
||||
function getGatewayManager() { return getGatewayManagerInstance() }
|
||||
let gatewayManager: any = null
|
||||
|
||||
export function setGatewayManagerForTest(manager: any): void {
|
||||
gatewayManager = manager
|
||||
}
|
||||
|
||||
function getGatewayManager() { return gatewayManager }
|
||||
|
||||
// --- run_id → session_id mapping (in-memory, ephemeral) ---
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import { codexAuthRoutes } from './hermes/codex-auth'
|
||||
import { nousAuthRoutes } from './hermes/nous-auth'
|
||||
import { copilotAuthRoutes } from './hermes/copilot-auth'
|
||||
import { xaiAuthRoutes } from './hermes/xai-auth'
|
||||
import { gatewayRoutes } from './hermes/gateways'
|
||||
import { weixinRoutes } from './hermes/weixin'
|
||||
import { fileRoutes } from './hermes/files'
|
||||
import { downloadRoutes } from './hermes/download'
|
||||
@@ -29,6 +28,7 @@ import { jobRoutes } from './hermes/jobs'
|
||||
import { cronHistoryRoutes } from './hermes/cron-history'
|
||||
import { kanbanRoutes } from './hermes/kanban'
|
||||
import { ttsRoutes } from './hermes/tts'
|
||||
import { mediaRoutes } from './hermes/media'
|
||||
import { proxyRoutes, proxyMiddleware } from './hermes/proxy'
|
||||
import { groupChatRoutes, setGroupChatServer } from './hermes/group-chat'
|
||||
|
||||
@@ -64,7 +64,6 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next)
|
||||
app.use(nousAuthRoutes.routes())
|
||||
app.use(copilotAuthRoutes.routes())
|
||||
app.use(xaiAuthRoutes.routes())
|
||||
app.use(gatewayRoutes.routes())
|
||||
app.use(weixinRoutes.routes())
|
||||
app.use(groupChatRoutes.routes()) // Must be before proxy
|
||||
app.use(fileRoutes.routes()) // Must be before proxy (proxy catch-all matches everything)
|
||||
@@ -72,6 +71,7 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next)
|
||||
app.use(jobRoutes.routes()) // Must be before proxy
|
||||
app.use(cronHistoryRoutes.routes()) // Must be before proxy
|
||||
app.use(kanbanRoutes.routes()) // Must be before proxy
|
||||
app.use(mediaRoutes.routes()) // Must be before proxy
|
||||
app.use(proxyRoutes.routes())
|
||||
|
||||
// Proxy catch-all middleware (must be last)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { readFile, chmod } from 'fs/promises'
|
||||
import { readdir, stat } from 'fs/promises'
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getActiveAuthPath } from './hermes/hermes-profile'
|
||||
import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getActiveAuthPath, getProfileDir } from './hermes/hermes-profile'
|
||||
import { logger } from './logger'
|
||||
import { safeFileStore } from './safe-file-store'
|
||||
|
||||
@@ -76,6 +76,10 @@ export async function readConfigYaml(): Promise<Record<string, any>> {
|
||||
return safeFileStore.readYaml(configPath())
|
||||
}
|
||||
|
||||
export async function readConfigYamlForProfile(profile: string): Promise<Record<string, any>> {
|
||||
return safeFileStore.readYaml(join(getProfileDir(profile), 'config.yaml'))
|
||||
}
|
||||
|
||||
export async function writeConfigYaml(config: Record<string, any>): Promise<void> {
|
||||
await safeFileStore.writeYaml(configPath(), config, { backup: true })
|
||||
}
|
||||
@@ -86,6 +90,20 @@ export async function updateConfigYaml<T = void>(
|
||||
return safeFileStore.updateYaml(configPath(), updater, { backup: true })
|
||||
}
|
||||
|
||||
export function stripLegacyApiServerGatewayConfig(config: Record<string, any>): { config: Record<string, any>; changed: boolean } {
|
||||
if (!config.platforms || typeof config.platforms !== 'object' || Array.isArray(config.platforms)) {
|
||||
return { config, changed: false }
|
||||
}
|
||||
|
||||
if (config.platforms.api_server !== undefined) {
|
||||
delete config.platforms.api_server
|
||||
if (Object.keys(config.platforms).length === 0) delete config.platforms
|
||||
return { config, changed: true }
|
||||
}
|
||||
|
||||
return { config, changed: false }
|
||||
}
|
||||
|
||||
// --- .env helpers ---
|
||||
|
||||
function assertValidEnvKey(key: string): void {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
let gatewayManager: any = null
|
||||
|
||||
export function getGatewayManagerInstance(): any {
|
||||
return gatewayManager
|
||||
}
|
||||
|
||||
export async function initGatewayManager(): Promise<void> {
|
||||
const { GatewayManager } = await import('./hermes/gateway-manager')
|
||||
const { getActiveProfileName } = await import('./hermes/hermes-profile')
|
||||
const activeProfile = getActiveProfileName()
|
||||
gatewayManager = new GatewayManager(activeProfile)
|
||||
|
||||
await gatewayManager.detectAllOnStartup()
|
||||
await gatewayManager.startAll()
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
import { setTimeout as delay } from 'timers/promises'
|
||||
import { createConnection, type Socket } from 'net'
|
||||
import { tmpdir } from 'os'
|
||||
import { URL } from 'url'
|
||||
import { join } from 'path'
|
||||
import { bridgeLogger } from '../../logger'
|
||||
import { getActiveProfileName, getProfileDir } from '../hermes-profile'
|
||||
|
||||
export const DEFAULT_AGENT_BRIDGE_ENDPOINT = process.platform === 'win32'
|
||||
? 'tcp://127.0.0.1:18765'
|
||||
: 'ipc:///tmp/hermes-agent-bridge.sock'
|
||||
function resolveDefaultAgentBridgeEndpoint(): string {
|
||||
if (process.env.VITEST) {
|
||||
return process.platform === 'win32'
|
||||
? `tcp://127.0.0.1:${28000 + (process.pid % 10000)}`
|
||||
: `ipc://${join(tmpdir(), `hermes-agent-bridge-test-${process.pid}.sock`)}`
|
||||
}
|
||||
return process.platform === 'win32'
|
||||
? 'tcp://127.0.0.1:18765'
|
||||
: 'ipc:///tmp/hermes-agent-bridge.sock'
|
||||
}
|
||||
|
||||
export const DEFAULT_AGENT_BRIDGE_ENDPOINT = resolveDefaultAgentBridgeEndpoint()
|
||||
export const DEFAULT_AGENT_BRIDGE_TIMEOUT_MS = 120000
|
||||
|
||||
function envPositiveInt(name: string): number | undefined {
|
||||
@@ -26,6 +36,7 @@ export interface AgentBridgeOptions {
|
||||
|
||||
export interface AgentBridgeRequestOptions {
|
||||
timeoutMs?: number
|
||||
serialize?: boolean
|
||||
}
|
||||
|
||||
export interface AgentBridgeChatOptions {
|
||||
@@ -33,6 +44,9 @@ export interface AgentBridgeChatOptions {
|
||||
storage_message?: AgentBridgeMessage
|
||||
model?: string
|
||||
provider?: string
|
||||
source?: string
|
||||
wait?: boolean
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export type AgentBridgeMessage =
|
||||
@@ -298,6 +312,10 @@ export class AgentBridgeClient {
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.serialize) {
|
||||
return run()
|
||||
}
|
||||
|
||||
const next = this.lock.then(run, run)
|
||||
this.lock = next.catch(() => undefined)
|
||||
return next
|
||||
@@ -325,6 +343,9 @@ export class AgentBridgeClient {
|
||||
...(profile ? { profile } : {}),
|
||||
...(options.model ? { model: options.model } : {}),
|
||||
...(options.provider ? { provider: options.provider } : {}),
|
||||
...(options.source ? { source: options.source } : {}),
|
||||
...(options.wait ? { wait: true } : {}),
|
||||
...(options.timeout ? { timeout: options.timeout } : {}),
|
||||
...(options.force_compress ? { force_compress: true } : {}),
|
||||
})
|
||||
}
|
||||
@@ -383,12 +404,22 @@ export class AgentBridgeClient {
|
||||
return this.request<AgentBridgeRunResult>({ action: 'get_result', run_id: runId }, options)
|
||||
}
|
||||
|
||||
interrupt(sessionId: string, message?: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'interrupt', session_id: sessionId, message })
|
||||
interrupt(sessionId: string, message?: string, profile?: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({
|
||||
action: 'interrupt',
|
||||
session_id: sessionId,
|
||||
message,
|
||||
...(profile ? { profile } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
steer(sessionId: string, text: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'steer', session_id: sessionId, text })
|
||||
steer(sessionId: string, text: string, profile?: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({
|
||||
action: 'steer',
|
||||
session_id: sessionId,
|
||||
text,
|
||||
...(profile ? { profile } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
approvalRespond(approvalId: string, choice: string): Promise<AgentBridgeResponse> {
|
||||
@@ -407,15 +438,27 @@ export class AgentBridgeClient {
|
||||
}
|
||||
|
||||
destroyAll(): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'destroy_all' })
|
||||
return this.request({ action: 'destroy_all' }, { serialize: true })
|
||||
}
|
||||
|
||||
getHistory(sessionId: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'get_history', session_id: sessionId })
|
||||
destroyProfile(profile: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'destroy_profile', profile }, { serialize: true })
|
||||
}
|
||||
|
||||
destroy(sessionId: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'destroy', session_id: sessionId })
|
||||
getHistory(sessionId: string, profile?: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({
|
||||
action: 'get_history',
|
||||
session_id: sessionId,
|
||||
...(profile ? { profile } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
destroy(sessionId: string, profile?: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({
|
||||
action: 'destroy',
|
||||
session_id: sessionId,
|
||||
...(profile ? { profile } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
list(): Promise<AgentBridgeResponse> {
|
||||
@@ -423,7 +466,7 @@ export class AgentBridgeClient {
|
||||
}
|
||||
|
||||
shutdown(): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'shutdown' })
|
||||
return this.request({ action: 'shutdown' }, { serialize: true })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,13 +11,16 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
@@ -174,6 +177,11 @@ def _base_hermes_home() -> Path:
|
||||
return _normalize_base_home(_discover_hermes_home(os.environ.get("HERMES_AGENT_BRIDGE_BASE_HOME") or DEFAULT_HERMES_HOME))
|
||||
|
||||
|
||||
def _worker_profile() -> str | None:
|
||||
raw = os.environ.get("HERMES_AGENT_BRIDGE_WORKER_PROFILE", "").strip()
|
||||
return raw or None
|
||||
|
||||
|
||||
def _profile_home(profile: str | None) -> Path:
|
||||
base = _base_hermes_home()
|
||||
if not profile or profile == "default":
|
||||
@@ -319,8 +327,20 @@ def _restore_profile_dotenv(snapshot: dict[str, str | None]) -> None:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def _set_worker_profile_env(profile: str | None) -> None:
|
||||
profile_home = _profile_home(profile)
|
||||
os.environ["HERMES_HOME"] = str(profile_home)
|
||||
os.environ["HERMES_AGENT_BRIDGE_WORKER_PROFILE"] = profile or "default"
|
||||
values = _read_dotenv(profile_home / ".env")
|
||||
for key, value in values.items():
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _profile_env(profile: str | None):
|
||||
if _worker_profile():
|
||||
yield
|
||||
return
|
||||
original = _apply_profile_env(profile)
|
||||
env_snapshot = _apply_profile_dotenv(profile)
|
||||
try:
|
||||
@@ -832,6 +852,7 @@ class AgentPool:
|
||||
storage_message: Any | None,
|
||||
conversation_history: list[dict[str, Any]] | None,
|
||||
profile: str | None,
|
||||
source: str | None = None,
|
||||
) -> bool:
|
||||
persist_message = storage_message if storage_message is not None else message
|
||||
user_content = str(persist_message) if not isinstance(persist_message, dict) else str(persist_message.get("content", persist_message))
|
||||
@@ -848,7 +869,7 @@ class AgentPool:
|
||||
if hasattr(db, "create_session"):
|
||||
db.create_session(
|
||||
session_id=session.session_id,
|
||||
source=_bridge_platform(),
|
||||
source=source or _bridge_platform(),
|
||||
model=session.config.get("model"),
|
||||
)
|
||||
|
||||
@@ -958,6 +979,7 @@ class AgentPool:
|
||||
force_compress: bool = False,
|
||||
model: str | None = None,
|
||||
provider: str | None = None,
|
||||
source: str | None = None,
|
||||
) -> RunRecord:
|
||||
session = self.get_or_create(session_id, profile=profile, model=model, provider=provider)
|
||||
with session.lock:
|
||||
@@ -973,14 +995,14 @@ class AgentPool:
|
||||
|
||||
thread = threading.Thread(
|
||||
target=self._run_chat,
|
||||
args=(session, record, message, storage_message, instructions, conversation_history, profile, force_compress),
|
||||
args=(session, record, message, storage_message, instructions, conversation_history, profile, force_compress, source),
|
||||
daemon=True,
|
||||
name=f"hermes-bridge-run-{run_id[:8]}",
|
||||
)
|
||||
thread.start()
|
||||
return record
|
||||
|
||||
def _run_chat(self, session: AgentSession, record: RunRecord, message: Any, storage_message: Any | None = None, instructions: str | None = None, conversation_history: list[dict[str, Any]] | None = None, profile: str | None = None, force_compress: bool = False) -> None:
|
||||
def _run_chat(self, session: AgentSession, record: RunRecord, message: Any, storage_message: Any | None = None, instructions: str | None = None, conversation_history: list[dict[str, Any]] | None = None, profile: str | None = None, force_compress: bool = False, source: str | None = None) -> None:
|
||||
with self._run_lock:
|
||||
with _profile_env(profile):
|
||||
def stream_callback(delta: str) -> None:
|
||||
@@ -1004,7 +1026,7 @@ class AgentPool:
|
||||
os.environ["HERMES_EXEC_ASK"] = "1"
|
||||
except Exception:
|
||||
previous_approval_callback = None
|
||||
self._prepersist_user_message(session, message, storage_message, conversation_history, profile)
|
||||
self._prepersist_user_message(session, message, storage_message, conversation_history, profile, source)
|
||||
db_count_after_prepersist = self._session_db_message_count(session.session_id, profile)
|
||||
if force_compress:
|
||||
compress = getattr(session.agent, "_compress_context", None)
|
||||
@@ -1265,7 +1287,13 @@ class BridgeServer:
|
||||
raise ValueError("action is required")
|
||||
|
||||
if action == "ping":
|
||||
return {"pong": True, "time": time.time(), "agent_root": str(_agent_root())}
|
||||
return {
|
||||
"pong": True,
|
||||
"time": time.time(),
|
||||
"agent_root": str(_agent_root()),
|
||||
"profile": _worker_profile() or "default",
|
||||
"hermes_home": str(_hermes_home()),
|
||||
}
|
||||
|
||||
if action == "chat":
|
||||
session_id = str(req.get("session_id") or "").strip() or uuid.uuid4().hex
|
||||
@@ -1276,6 +1304,7 @@ class BridgeServer:
|
||||
profile = req.get("profile")
|
||||
model = req.get("model")
|
||||
provider = req.get("provider")
|
||||
source = req.get("source")
|
||||
record = self.pool.start_chat(
|
||||
session_id,
|
||||
message,
|
||||
@@ -1286,6 +1315,7 @@ class BridgeServer:
|
||||
bool(req.get("force_compress")),
|
||||
model,
|
||||
provider,
|
||||
source,
|
||||
)
|
||||
if req.get("wait"):
|
||||
timeout = float(req.get("timeout", 0) or 0)
|
||||
@@ -1355,50 +1385,13 @@ class BridgeServer:
|
||||
raise ValueError(f"unknown action: {action}")
|
||||
|
||||
def _make_server_socket(self) -> socket.socket:
|
||||
if self.endpoint.startswith("ipc://"):
|
||||
if not hasattr(socket, "AF_UNIX"):
|
||||
raise RuntimeError("ipc:// endpoints require Unix domain socket support; use tcp://host:port on this platform")
|
||||
sock_path = Path(self.endpoint.removeprefix("ipc://"))
|
||||
sock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
sock_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(str(sock_path))
|
||||
return server
|
||||
|
||||
parsed = urlparse(self.endpoint)
|
||||
if parsed.scheme != "tcp":
|
||||
raise RuntimeError(f"unsupported endpoint scheme: {self.endpoint}")
|
||||
host = parsed.hostname or "127.0.0.1"
|
||||
port = int(parsed.port or 0)
|
||||
if port <= 0:
|
||||
raise RuntimeError(f"tcp endpoint requires a port: {self.endpoint}")
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind((host, port))
|
||||
return server
|
||||
return _make_listen_socket(self.endpoint)
|
||||
|
||||
def _read_request(self, conn: socket.socket) -> dict[str, Any]:
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
chunk = conn.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
if b"\n" in chunk:
|
||||
break
|
||||
if not chunks:
|
||||
raise RuntimeError("empty request")
|
||||
line = b"".join(chunks).split(b"\n", 1)[0].strip()
|
||||
if not line:
|
||||
raise RuntimeError("empty request")
|
||||
return json.loads(line.decode("utf-8"))
|
||||
return _read_json_request(conn)
|
||||
|
||||
def _write_response(self, conn: socket.socket, resp: dict[str, Any]) -> None:
|
||||
payload = json.dumps(resp, ensure_ascii=False, default=str) + "\n"
|
||||
conn.sendall(payload.encode("utf-8"))
|
||||
_write_json_response(conn, resp)
|
||||
|
||||
def _gc_idle_sessions(self) -> None:
|
||||
"""Destroy sessions idle longer than IDLE_TIMEOUT_SECONDS."""
|
||||
@@ -1458,16 +1451,530 @@ class BridgeServer:
|
||||
pass
|
||||
|
||||
|
||||
class WorkerProcess:
|
||||
STARTUP_TIMEOUT_SECONDS = 120
|
||||
REQUEST_TIMEOUT_SECONDS = 120
|
||||
|
||||
def __init__(self, profile: str, endpoint: str, agent_root: str | None, hermes_home: str | None) -> None:
|
||||
self.profile = profile or "default"
|
||||
self.endpoint = endpoint
|
||||
self.agent_root = agent_root
|
||||
self.hermes_home = hermes_home
|
||||
self.process: subprocess.Popen[str] | None = None
|
||||
self.last_used_at = time.time()
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self.process is not None and self.process.poll() is None
|
||||
|
||||
def start(self) -> None:
|
||||
with self._lock:
|
||||
if self.running:
|
||||
return
|
||||
args = [
|
||||
sys.executable,
|
||||
str(Path(__file__).resolve()),
|
||||
"--endpoint",
|
||||
self.endpoint,
|
||||
"--worker-profile",
|
||||
self.profile,
|
||||
]
|
||||
if self.agent_root:
|
||||
args.extend(["--agent-root", self.agent_root])
|
||||
if self.hermes_home:
|
||||
args.extend(["--hermes-home", self.hermes_home])
|
||||
|
||||
env = {
|
||||
**os.environ,
|
||||
"HERMES_AGENT_BRIDGE_ENDPOINT": self.endpoint,
|
||||
"HERMES_AGENT_BRIDGE_WORKER_PROFILE": self.profile,
|
||||
}
|
||||
self.process = subprocess.Popen(
|
||||
args,
|
||||
env=env,
|
||||
cwd=os.getcwd(),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
self._pipe_stderr()
|
||||
self._wait_ready()
|
||||
|
||||
def _pipe_stderr(self) -> None:
|
||||
proc = self.process
|
||||
if proc is None or proc.stderr is None:
|
||||
return
|
||||
|
||||
def run() -> None:
|
||||
assert proc.stderr is not None
|
||||
for line in proc.stderr:
|
||||
text = line.rstrip()
|
||||
if text:
|
||||
print(f"[hermes-bridge-worker:{self.profile}] {text}", file=sys.stderr, flush=True)
|
||||
|
||||
threading.Thread(target=run, daemon=True, name=f"hermes-bridge-worker-stderr-{self.profile}").start()
|
||||
|
||||
def _wait_ready(self) -> None:
|
||||
proc = self.process
|
||||
if proc is None or proc.stdout is None:
|
||||
raise RuntimeError(f"profile worker {self.profile} did not start")
|
||||
lines: queue.Queue[str | None] = queue.Queue()
|
||||
ready_event = threading.Event()
|
||||
|
||||
def read_stdout() -> None:
|
||||
assert proc.stdout is not None
|
||||
try:
|
||||
for line in proc.stdout:
|
||||
if ready_event.is_set():
|
||||
text = line.rstrip()
|
||||
if text:
|
||||
print(f"[hermes-bridge-worker:{self.profile}] {text}", file=sys.stderr, flush=True)
|
||||
else:
|
||||
lines.put(line)
|
||||
finally:
|
||||
lines.put(None)
|
||||
|
||||
threading.Thread(target=read_stdout, daemon=True, name=f"hermes-bridge-worker-stdout-{self.profile}").start()
|
||||
deadline = time.time() + self.STARTUP_TIMEOUT_SECONDS
|
||||
while time.time() < deadline:
|
||||
if proc.poll() is not None:
|
||||
raise RuntimeError(f"profile worker {self.profile} exited before ready")
|
||||
try:
|
||||
line = lines.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if line is None:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
text = line.strip()
|
||||
if text:
|
||||
print(f"[hermes-bridge-worker:{self.profile}] {text}", file=sys.stderr, flush=True)
|
||||
try:
|
||||
data = json.loads(text)
|
||||
if data.get("event") == "ready":
|
||||
ready_event.set()
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
self.stop()
|
||||
raise RuntimeError(f"profile worker {self.profile} did not become ready within {self.STARTUP_TIMEOUT_SECONDS}s")
|
||||
|
||||
def stop(self) -> None:
|
||||
with self._lock:
|
||||
proc = self.process
|
||||
self.process = None
|
||||
if proc is None:
|
||||
return
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=3)
|
||||
if self.endpoint.startswith("ipc://"):
|
||||
try:
|
||||
Path(self.endpoint.removeprefix("ipc://")).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def request(self, req: dict[str, Any]) -> dict[str, Any]:
|
||||
self.start()
|
||||
self.last_used_at = time.time()
|
||||
return _send_bridge_request(self.endpoint, req, self.REQUEST_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
def _worker_endpoint(profile: str) -> str:
|
||||
safe = hashlib.sha256(profile.encode("utf-8")).hexdigest()[:16]
|
||||
if os.name == "nt":
|
||||
port_base = int(os.environ.get("HERMES_AGENT_BRIDGE_WORKER_PORT_BASE", "18780"))
|
||||
return f"tcp://127.0.0.1:{port_base + int(safe[:4], 16) % 1000}"
|
||||
root = Path(tempfile.gettempdir()) / "hermes-agent-bridge-workers"
|
||||
return f"ipc://{root / f'{safe}.sock'}"
|
||||
|
||||
|
||||
def _connect_bridge_socket(endpoint: str, timeout: float) -> socket.socket:
|
||||
if endpoint.startswith("ipc://"):
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
sock.connect(endpoint.removeprefix("ipc://"))
|
||||
return sock
|
||||
parsed = urlparse(endpoint)
|
||||
if parsed.scheme != "tcp":
|
||||
raise RuntimeError(f"unsupported endpoint scheme: {endpoint}")
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
sock.connect((parsed.hostname or "127.0.0.1", int(parsed.port or 0)))
|
||||
return sock
|
||||
|
||||
|
||||
def _send_bridge_request(endpoint: str, req: dict[str, Any], timeout: float) -> dict[str, Any]:
|
||||
sock = _connect_bridge_socket(endpoint, timeout)
|
||||
try:
|
||||
sock.sendall((json.dumps(req, ensure_ascii=False, default=str) + "\n").encode("utf-8"))
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
if b"\n" in chunk:
|
||||
break
|
||||
line = b"".join(chunks).split(b"\n", 1)[0].strip()
|
||||
if not line:
|
||||
raise RuntimeError("worker closed without a response")
|
||||
resp = json.loads(line.decode("utf-8"))
|
||||
if not resp.get("ok"):
|
||||
raise RuntimeError(str(resp.get("error") or "worker request failed"))
|
||||
return resp
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _make_listen_socket(endpoint: str) -> socket.socket:
|
||||
if endpoint.startswith("ipc://"):
|
||||
if not hasattr(socket, "AF_UNIX"):
|
||||
raise RuntimeError("ipc:// endpoints require Unix domain socket support; use tcp://host:port on this platform")
|
||||
sock_path = Path(endpoint.removeprefix("ipc://"))
|
||||
sock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
sock_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(str(sock_path))
|
||||
return server
|
||||
|
||||
parsed = urlparse(endpoint)
|
||||
if parsed.scheme != "tcp":
|
||||
raise RuntimeError(f"unsupported endpoint scheme: {endpoint}")
|
||||
host = parsed.hostname or "127.0.0.1"
|
||||
port = int(parsed.port or 0)
|
||||
if port <= 0:
|
||||
raise RuntimeError(f"tcp endpoint requires a port: {endpoint}")
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind((host, port))
|
||||
return server
|
||||
|
||||
|
||||
def _read_json_request(conn: socket.socket) -> dict[str, Any]:
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
chunk = conn.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
if b"\n" in chunk:
|
||||
break
|
||||
if not chunks:
|
||||
raise RuntimeError("empty request")
|
||||
line = b"".join(chunks).split(b"\n", 1)[0].strip()
|
||||
if not line:
|
||||
raise RuntimeError("empty request")
|
||||
return json.loads(line.decode("utf-8"))
|
||||
|
||||
|
||||
def _write_json_response(conn: socket.socket, resp: dict[str, Any]) -> None:
|
||||
payload = json.dumps(resp, ensure_ascii=False, default=str) + "\n"
|
||||
conn.sendall(payload.encode("utf-8"))
|
||||
|
||||
|
||||
class BridgeBroker:
|
||||
IDLE_TIMEOUT_SECONDS = 30 * 60
|
||||
GC_INTERVAL_SECONDS = 60
|
||||
|
||||
def __init__(self, endpoint: str, agent_root: str | None = None, hermes_home: str | None = None) -> None:
|
||||
self.endpoint = endpoint
|
||||
self.agent_root = agent_root
|
||||
self.hermes_home = hermes_home
|
||||
self._workers: dict[str, WorkerProcess] = {}
|
||||
self._run_profile: dict[str, str] = {}
|
||||
self._session_profile: dict[str, str] = {}
|
||||
self._approval_profile: dict[str, str] = {}
|
||||
self._compression_profile: dict[str, str] = {}
|
||||
self._lock = threading.RLock()
|
||||
self._stop = threading.Event()
|
||||
self._last_gc = time.time()
|
||||
|
||||
def _normalize_profile(self, value: Any) -> str:
|
||||
profile = str(value or "").strip()
|
||||
return profile or "default"
|
||||
|
||||
def _worker_for_profile(self, profile: str) -> WorkerProcess:
|
||||
profile = self._normalize_profile(profile)
|
||||
with self._lock:
|
||||
worker = self._workers.get(profile)
|
||||
if worker is None:
|
||||
worker = WorkerProcess(profile, _worker_endpoint(profile), self.agent_root, self.hermes_home)
|
||||
self._workers[profile] = worker
|
||||
return worker
|
||||
|
||||
def _profile_for_run(self, run_id: str) -> str:
|
||||
with self._lock:
|
||||
profile = self._run_profile.get(run_id)
|
||||
if not profile:
|
||||
raise KeyError(f"unknown run: {run_id}")
|
||||
return profile
|
||||
|
||||
def _profile_for_session(self, session_id: str, fallback_profile: Any = None) -> str:
|
||||
with self._lock:
|
||||
profile = self._session_profile.get(session_id)
|
||||
if not profile:
|
||||
fallback = self._normalize_profile(fallback_profile)
|
||||
if fallback_profile is not None and fallback:
|
||||
return fallback
|
||||
raise KeyError(f"unknown session: {session_id}")
|
||||
return profile
|
||||
|
||||
def _record_response_routes(self, profile: str, resp: dict[str, Any]) -> None:
|
||||
run_id = str(resp.get("run_id") or "")
|
||||
session_id = str(resp.get("session_id") or "")
|
||||
with self._lock:
|
||||
if run_id:
|
||||
self._run_profile[run_id] = profile
|
||||
if session_id:
|
||||
self._session_profile[session_id] = profile
|
||||
for event in resp.get("events") or []:
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
approval_id = str(event.get("approval_id") or "")
|
||||
if approval_id:
|
||||
self._approval_profile[approval_id] = profile
|
||||
request_id = str(event.get("request_id") or "")
|
||||
if event.get("event") == "bridge.compression.requested" and request_id:
|
||||
self._compression_profile[request_id] = profile
|
||||
if event.get("event") in {"bridge.compression.completed", "bridge.compression.failed"} and request_id:
|
||||
self._compression_profile.pop(request_id, None)
|
||||
|
||||
def _forward(self, profile: str, req: dict[str, Any]) -> dict[str, Any]:
|
||||
worker = self._worker_for_profile(profile)
|
||||
forwarded = dict(req)
|
||||
forwarded["profile"] = profile
|
||||
resp = worker.request(forwarded)
|
||||
self._record_response_routes(profile, resp)
|
||||
return resp
|
||||
|
||||
def handle(self, req: dict[str, Any]) -> dict[str, Any]:
|
||||
action = str(req.get("action") or "").strip()
|
||||
if not action:
|
||||
raise ValueError("action is required")
|
||||
|
||||
if action == "ping":
|
||||
with self._lock:
|
||||
workers = {profile: worker.running for profile, worker in self._workers.items()}
|
||||
return {"pong": True, "time": time.time(), "mode": "broker", "workers": workers}
|
||||
|
||||
if action == "worker_ping":
|
||||
profile = self._normalize_profile(req.get("profile"))
|
||||
resp = self._forward(profile, {"action": "ping"})
|
||||
resp["worker_profile"] = profile
|
||||
return resp
|
||||
|
||||
if action == "chat":
|
||||
profile = self._normalize_profile(req.get("profile"))
|
||||
return self._forward(profile, req)
|
||||
|
||||
if action in {"get_result", "get_output"}:
|
||||
profile = self._profile_for_run(str(req.get("run_id") or ""))
|
||||
return self._forward(profile, req)
|
||||
|
||||
if action in {"interrupt", "steer", "get_history", "destroy"}:
|
||||
session_id = str(req.get("session_id") or "")
|
||||
profile = self._profile_for_session(session_id, req.get("profile"))
|
||||
resp = self._forward(profile, req)
|
||||
if action == "destroy":
|
||||
with self._lock:
|
||||
self._session_profile.pop(session_id, None)
|
||||
return resp
|
||||
|
||||
if action == "approval_respond":
|
||||
approval_id = str(req.get("approval_id") or "").strip()
|
||||
if not approval_id:
|
||||
raise ValueError("approval_id is required")
|
||||
with self._lock:
|
||||
profile = self._approval_profile.get(approval_id)
|
||||
if not profile:
|
||||
raise KeyError(f"unknown approval request: {approval_id}")
|
||||
return self._forward(profile, req)
|
||||
|
||||
if action == "compression_respond":
|
||||
request_id = str(req.get("request_id") or "").strip()
|
||||
if not request_id:
|
||||
raise ValueError("request_id is required")
|
||||
with self._lock:
|
||||
profile = self._compression_profile.get(request_id)
|
||||
if not profile:
|
||||
raise KeyError(f"unknown compression request: {request_id}")
|
||||
return self._forward(profile, req)
|
||||
|
||||
if action == "destroy_all":
|
||||
with self._lock:
|
||||
workers = list(self._workers.values())
|
||||
self._run_profile.clear()
|
||||
self._session_profile.clear()
|
||||
self._approval_profile.clear()
|
||||
self._compression_profile.clear()
|
||||
destroyed = 0
|
||||
for worker in workers:
|
||||
if not worker.running:
|
||||
worker.stop()
|
||||
continue
|
||||
try:
|
||||
resp = worker.request({"action": "destroy_all"})
|
||||
destroyed += int(resp.get("destroyed") or 0)
|
||||
except Exception:
|
||||
pass
|
||||
return {"destroyed": destroyed}
|
||||
|
||||
if action == "destroy_profile":
|
||||
profile = self._normalize_profile(req.get("profile"))
|
||||
with self._lock:
|
||||
worker = self._workers.get(profile)
|
||||
self._run_profile = {key: value for key, value in self._run_profile.items() if value != profile}
|
||||
self._session_profile = {key: value for key, value in self._session_profile.items() if value != profile}
|
||||
self._approval_profile = {key: value for key, value in self._approval_profile.items() if value != profile}
|
||||
self._compression_profile = {key: value for key, value in self._compression_profile.items() if value != profile}
|
||||
|
||||
if worker is None or not worker.running:
|
||||
if worker is not None:
|
||||
worker.stop()
|
||||
return {"profile": profile, "destroyed": 0}
|
||||
|
||||
try:
|
||||
resp = worker.request({"action": "destroy_all"})
|
||||
return {"profile": profile, "destroyed": int(resp.get("destroyed") or 0)}
|
||||
except Exception:
|
||||
return {"profile": profile, "destroyed": 0}
|
||||
|
||||
if action == "list":
|
||||
sessions: list[Any] = []
|
||||
with self._lock:
|
||||
workers = list(self._workers.items())
|
||||
for profile, worker in workers:
|
||||
if not worker.running:
|
||||
continue
|
||||
try:
|
||||
resp = worker.request({"action": "list"})
|
||||
for session in resp.get("sessions") or []:
|
||||
if isinstance(session, dict):
|
||||
session.setdefault("profile", profile)
|
||||
sessions.append(session)
|
||||
except Exception:
|
||||
pass
|
||||
return {"sessions": sessions}
|
||||
|
||||
if action == "shutdown":
|
||||
self._stop.set()
|
||||
with self._lock:
|
||||
workers = list(self._workers.values())
|
||||
for worker in workers:
|
||||
if not worker.running:
|
||||
worker.stop()
|
||||
continue
|
||||
try:
|
||||
worker.request({"action": "shutdown"})
|
||||
except Exception:
|
||||
worker.stop()
|
||||
return {"status": "shutting_down"}
|
||||
|
||||
raise ValueError(f"unknown action: {action}")
|
||||
|
||||
def _make_server_socket(self) -> socket.socket:
|
||||
return _make_listen_socket(self.endpoint)
|
||||
|
||||
def _read_request(self, conn: socket.socket) -> dict[str, Any]:
|
||||
return _read_json_request(conn)
|
||||
|
||||
def _write_response(self, conn: socket.socket, resp: dict[str, Any]) -> None:
|
||||
_write_json_response(conn, resp)
|
||||
|
||||
def _gc_idle_workers(self) -> None:
|
||||
now = time.time()
|
||||
if now - self._last_gc < self.GC_INTERVAL_SECONDS:
|
||||
return
|
||||
self._last_gc = now
|
||||
with self._lock:
|
||||
idle = [
|
||||
profile for profile, worker in self._workers.items()
|
||||
if worker.running and now - worker.last_used_at > self.IDLE_TIMEOUT_SECONDS
|
||||
]
|
||||
for profile in idle:
|
||||
with self._lock:
|
||||
worker = self._workers.pop(profile, None)
|
||||
if worker:
|
||||
worker.stop()
|
||||
|
||||
def serve_forever(self) -> None:
|
||||
server = self._make_server_socket()
|
||||
server.listen(64)
|
||||
server.settimeout(0.2)
|
||||
print(json.dumps({"event": "ready", "endpoint": self.endpoint, "mode": "broker"}), flush=True)
|
||||
|
||||
while not self._stop.is_set():
|
||||
conn: socket.socket | None = None
|
||||
try:
|
||||
try:
|
||||
conn, _addr = server.accept()
|
||||
except socket.timeout:
|
||||
self._gc_idle_workers()
|
||||
continue
|
||||
try:
|
||||
req = self._read_request(conn)
|
||||
data = self.handle(req)
|
||||
resp = {"ok": True, **_jsonable(data)}
|
||||
except Exception as exc:
|
||||
resp = {
|
||||
"ok": False,
|
||||
"error": str(exc),
|
||||
"error_type": exc.__class__.__name__,
|
||||
}
|
||||
self._write_response(conn, resp)
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except Exception as exc:
|
||||
print(f"[hermes-bridge-broker] server loop error: {exc}", file=sys.stderr, flush=True)
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
with self._lock:
|
||||
workers = list(self._workers.values())
|
||||
self._workers.clear()
|
||||
for worker in workers:
|
||||
worker.stop()
|
||||
server.close()
|
||||
if self.endpoint.startswith("ipc://"):
|
||||
try:
|
||||
Path(self.endpoint.removeprefix("ipc://")).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Hermes AIAgent in-process bridge")
|
||||
parser.add_argument("--endpoint", default=os.environ.get("HERMES_AGENT_BRIDGE_ENDPOINT", DEFAULT_ENDPOINT))
|
||||
parser.add_argument("--agent-root", default=os.environ.get("HERMES_AGENT_ROOT", DEFAULT_AGENT_ROOT))
|
||||
parser.add_argument("--hermes-home", default=os.environ.get("HERMES_HOME", DEFAULT_HERMES_HOME))
|
||||
parser.add_argument("--worker-profile", default=os.environ.get("HERMES_AGENT_BRIDGE_WORKER_PROFILE"))
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
_set_path_env(args.agent_root, args.hermes_home)
|
||||
_ensure_agent_imports()
|
||||
BridgeServer(args.endpoint).serve_forever()
|
||||
if args.worker_profile:
|
||||
_set_worker_profile_env(str(args.worker_profile or "default"))
|
||||
BridgeServer(args.endpoint).serve_forever()
|
||||
else:
|
||||
BridgeBroker(args.endpoint, args.agent_root, args.hermes_home).serve_forever()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ export class ContextEngine {
|
||||
// Under threshold — return summary + new messages directly
|
||||
if (totalTokens <= config.triggerTokens) {
|
||||
logger.debug(`[ContextEngine] [Path A] UNDER threshold — return summary + ${newMessages.length} verbatim msgs directly`)
|
||||
const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId)
|
||||
const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId, input.agentName)
|
||||
this.logHistory('Path A (no compress)', history)
|
||||
return { conversationHistory: history, instructions, meta }
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export class ContextEngine {
|
||||
meta.summaryTokenEstimate = this.countTokens(result.summary)
|
||||
logger.debug(`[ContextEngine] [Path A] incremental compression DONE in ${elapsed}ms, newSummaryLen=${result.summary.length}, newLastMsgId=${lastMsg.id}`)
|
||||
logger.debug(`[ContextEngine] [Path A] NEW SUMMARY (${result.summary.length} chars): ${result.summary.slice(0, 300)}`)
|
||||
const history = this.buildHistory(result.summary, newMessages, input.agentSocketId)
|
||||
const history = this.buildHistory(result.summary, newMessages, input.agentSocketId, input.agentName)
|
||||
this.logHistory('Path A (after incremental compress)', history)
|
||||
if (result.sessionId) this.sessionCleaner?.(result.sessionId)
|
||||
return { conversationHistory: history, instructions, meta }
|
||||
@@ -163,7 +163,7 @@ export class ContextEngine {
|
||||
|
||||
// Compression failed — degrade
|
||||
logger.warn(`[ContextEngine] [Path A] incremental compression FAILED (${elapsed}ms) — degrading to summary + trimmed verbatim`)
|
||||
const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId)
|
||||
const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId, input.agentName)
|
||||
this.trimToBudget(history, summaryTokens, config.maxHistoryTokens)
|
||||
return { conversationHistory: history, instructions, meta }
|
||||
}
|
||||
@@ -177,7 +177,7 @@ export class ContextEngine {
|
||||
// Under threshold — pass all messages verbatim
|
||||
if (totalTokens <= config.triggerTokens) {
|
||||
logger.debug(`[ContextEngine] [Path B] UNDER threshold — return all ${total} msgs verbatim`)
|
||||
const history = messages.map(m => this.mapToHistory(m, input.agentSocketId))
|
||||
const history = messages.map(m => this.mapToHistory(m, input.agentSocketId, input.agentName))
|
||||
this.logHistory('Path B (no compress)', history)
|
||||
return { conversationHistory: history, instructions, meta }
|
||||
}
|
||||
@@ -209,7 +209,7 @@ export class ContextEngine {
|
||||
meta.summaryTokenEstimate = this.countTokens(result.summary)
|
||||
logger.debug(`[ContextEngine] [Path B] full compression DONE in ${elapsed}ms, summaryLen=${result.summary.length}, compressed=${toCompress.length} msgs, keptTail=${tail.length} msgs, savedLastMsgId=${lastCompressedMsg.id}`)
|
||||
logger.debug(`[ContextEngine] [Path B] COMPRESSED SUMMARY (${result.summary.length} chars): ${result.summary.slice(0, 300)}`)
|
||||
const history = this.buildHistory(result.summary, tail, input.agentSocketId)
|
||||
const history = this.buildHistory(result.summary, tail, input.agentSocketId, input.agentName)
|
||||
this.logHistory('Path B (after full compress)', history)
|
||||
if (result.sessionId) this.sessionCleaner?.(result.sessionId)
|
||||
return { conversationHistory: history, instructions, meta }
|
||||
@@ -217,7 +217,7 @@ export class ContextEngine {
|
||||
|
||||
// Compression failed — degrade
|
||||
logger.warn(`[ContextEngine] [Path B] full compression FAILED (${elapsed}ms) — degrading to trimmed verbatim`)
|
||||
const history = messages.map(m => this.mapToHistory(m, input.agentSocketId))
|
||||
const history = messages.map(m => this.mapToHistory(m, input.agentSocketId, input.agentName))
|
||||
this.trimToBudget(history, 0, config.maxHistoryTokens)
|
||||
meta.verbatimCount = history.length
|
||||
return { conversationHistory: history, instructions, meta }
|
||||
@@ -265,6 +265,7 @@ export class ContextEngine {
|
||||
summary: string,
|
||||
messages: StoredMessage[],
|
||||
agentSocketId: string,
|
||||
agentName: string,
|
||||
): Array<{ role: 'user' | 'assistant'; content: string }> {
|
||||
const history: Array<{ role: 'user' | 'assistant'; content: string }> = []
|
||||
|
||||
@@ -275,7 +276,7 @@ export class ContextEngine {
|
||||
)
|
||||
}
|
||||
|
||||
history.push(...messages.map(m => this.mapToHistory(m, agentSocketId)))
|
||||
history.push(...messages.map(m => this.mapToHistory(m, agentSocketId, agentName)))
|
||||
return history
|
||||
}
|
||||
|
||||
@@ -314,11 +315,51 @@ export class ContextEngine {
|
||||
private mapToHistory(
|
||||
msg: StoredMessage,
|
||||
agentSocketId: string,
|
||||
agentName: string,
|
||||
): { role: 'user' | 'assistant'; content: string } {
|
||||
if (msg.senderId === agentSocketId) {
|
||||
return { role: 'assistant', content: msg.content }
|
||||
const senderName = msg.senderName || 'unknown'
|
||||
const isOwnAgent = msg.senderId === agentSocketId || senderName === agentName
|
||||
|
||||
if (msg.role === 'tool') {
|
||||
const label = msg.tool_name ? `Tool result: ${msg.tool_name}` : 'Tool result'
|
||||
return { role: 'user', content: `[${senderName}] [${label}]\n${msg.content || ''}` }
|
||||
}
|
||||
return { role: 'user', content: `[${msg.senderName}]: ${msg.content}` }
|
||||
|
||||
if (msg.role === 'assistant' && msg.tool_calls?.length) {
|
||||
const toolsInfo = msg.tool_calls.map(tc => {
|
||||
const name = tc.function?.name || 'unknown'
|
||||
let args = tc.function?.arguments || '{}'
|
||||
if (args.length > 4000) args = `${args.slice(0, 4000)}...`
|
||||
return `[Calling tool: ${name} with arguments: ${args}]`
|
||||
}).join('\n')
|
||||
const content = msg.content?.trim()
|
||||
return {
|
||||
role: isOwnAgent ? 'assistant' : 'user',
|
||||
content: content
|
||||
? `${this.formatAttributedContent(senderName, content)}\n${this.formatAttributionPrefix(senderName, content)}${toolsInfo}`
|
||||
: `${this.formatAttributionPrefix(senderName, content)}${toolsInfo}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
role: isOwnAgent ? 'assistant' : 'user',
|
||||
content: this.formatAttributedContent(senderName, msg.content || ''),
|
||||
}
|
||||
}
|
||||
|
||||
private formatAttributedContent(senderName: string, content: string): string {
|
||||
return `${this.formatAttributionPrefix(senderName)}${this.stripMentions(content)}`
|
||||
}
|
||||
|
||||
private formatAttributionPrefix(senderName: string, _content?: string): string {
|
||||
return `[${senderName}]: `
|
||||
}
|
||||
|
||||
private stripMentions(content: string): string {
|
||||
return String(content || '')
|
||||
.replace(/@([^\s@]+)/g, '')
|
||||
.replace(/[ \t]{2,}/g, ' ')
|
||||
.replace(/^\s+/, '')
|
||||
}
|
||||
|
||||
private trimToBudget(
|
||||
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
} from './prompt'
|
||||
import { updateUsage } from '../../../db/hermes/usage-store'
|
||||
import { logger } from '../../logger'
|
||||
import { AgentBridgeClient, type AgentBridgeRunResult } from '../agent-bridge'
|
||||
|
||||
/**
|
||||
* Calls Hermes /v1/responses to produce LLM-generated summaries.
|
||||
* The context engine owns history assembly; Responses storage/chaining is not used.
|
||||
* Calls the local bridge to produce LLM-generated summaries.
|
||||
* The context engine owns history assembly; gateway storage/chaining is not used.
|
||||
*/
|
||||
export class GatewaySummarizer implements GatewayCaller {
|
||||
private timeoutMs: number
|
||||
@@ -19,8 +20,8 @@ export class GatewaySummarizer implements GatewayCaller {
|
||||
}
|
||||
|
||||
async summarize(
|
||||
upstream: string,
|
||||
apiKey: string | null,
|
||||
_upstream: string,
|
||||
_apiKey: string | null,
|
||||
systemPrompt: string,
|
||||
messages: StoredMessage[],
|
||||
roomId: string,
|
||||
@@ -29,7 +30,7 @@ export class GatewaySummarizer implements GatewayCaller {
|
||||
): Promise<{ summary: string; sessionId: string }> {
|
||||
const history: Array<{ role: string; content: string }> = messages.map(m => ({
|
||||
role: 'user',
|
||||
content: `[${m.senderName}]: ${m.content}`,
|
||||
content: summarizeMessageForPrompt(m),
|
||||
}))
|
||||
|
||||
if (previousSummary) {
|
||||
@@ -43,132 +44,67 @@ export class GatewaySummarizer implements GatewayCaller {
|
||||
? buildIncrementalUpdatePrompt()
|
||||
: buildFullSummaryPrompt()
|
||||
|
||||
const res = await fetch(`${upstream.replace(/\/$/, '')}/v1/responses`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
input: userPrompt,
|
||||
const bridge = new AgentBridgeClient({ timeoutMs: this.timeoutMs + 15_000 })
|
||||
const sessionId = `gc_compress_${roomId}_${profile}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
.slice(0, 160)
|
||||
|
||||
try {
|
||||
const result = await bridge.request<AgentBridgeRunResult>({
|
||||
action: 'chat',
|
||||
session_id: sessionId,
|
||||
message: userPrompt,
|
||||
instructions: systemPrompt || buildSummarizationSystemPrompt(),
|
||||
conversation_history: history,
|
||||
stream: true,
|
||||
store: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
})
|
||||
profile,
|
||||
source: 'api_server',
|
||||
wait: true,
|
||||
timeout: Math.ceil(this.timeoutMs / 1000),
|
||||
}, { timeoutMs: this.timeoutMs + 15_000 })
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Summarization response failed: ${res.status}`)
|
||||
}
|
||||
if (!res.body) {
|
||||
throw new Error('Summarization response stream missing')
|
||||
}
|
||||
|
||||
let output = ''
|
||||
for await (const frame of readSseFrames(res.body)) {
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = JSON.parse(frame.data)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const eventType = parsed.type || frame.event || parsed.event
|
||||
|
||||
if (eventType === 'response.output_text.delta' && parsed.delta) {
|
||||
output += parsed.delta
|
||||
continue
|
||||
if (result.status === 'error') {
|
||||
throw new Error(result.error || 'Summarization bridge run failed')
|
||||
}
|
||||
|
||||
if (eventType === 'response.completed') {
|
||||
const response = parsed.response || parsed
|
||||
const finalText = extractResponseText(response)
|
||||
if (!output && finalText) output = finalText
|
||||
const payload = result.result as any
|
||||
const output = String(payload?.final_response || result.output || '').trim()
|
||||
if (!output) throw new Error('Empty summarization response')
|
||||
|
||||
const usage = response.usage || {}
|
||||
const usage = payload?.usage || payload?.response?.usage
|
||||
if (usage) {
|
||||
updateUsage(roomId, {
|
||||
inputTokens: usage.input_tokens ?? usage.inputTokens ?? 0,
|
||||
outputTokens: usage.output_tokens ?? usage.outputTokens ?? 0,
|
||||
cacheReadTokens: usage.cache_read_tokens ?? usage.cacheReadTokens ?? 0,
|
||||
cacheWriteTokens: usage.cache_write_tokens ?? usage.cacheWriteTokens ?? 0,
|
||||
reasoningTokens: usage.reasoning_tokens ?? usage.reasoningTokens ?? 0,
|
||||
model: response.model || '',
|
||||
model: payload?.model || payload?.response?.model || '',
|
||||
profile,
|
||||
})
|
||||
logger.debug(`[GatewaySummarizer] Recorded response usage for compression room ${roomId} (profile=${profile}): input=${usage.input_tokens ?? 0}, output=${usage.output_tokens ?? 0}`)
|
||||
|
||||
if (!output || output.trim() === '') {
|
||||
throw new Error('Empty summarization response')
|
||||
}
|
||||
return { summary: output.trim(), sessionId: '' }
|
||||
}
|
||||
|
||||
if (eventType === 'response.failed') {
|
||||
throw new Error(parsed.error?.message || parsed.error || 'Summarization response failed')
|
||||
}
|
||||
logger.debug(`[GatewaySummarizer] Bridge compression completed for room ${roomId} (profile=${profile})`)
|
||||
return { summary: output, sessionId }
|
||||
} finally {
|
||||
await bridge.destroy(sessionId, profile).catch(() => undefined)
|
||||
}
|
||||
|
||||
throw new Error('Summarization response stream ended without a terminal event')
|
||||
}
|
||||
}
|
||||
|
||||
async function* readSseFrames(stream: ReadableStream<Uint8Array>): AsyncGenerator<{ event?: string; data: string }> {
|
||||
const decoder = new TextDecoder()
|
||||
const reader = stream.getReader()
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
let boundary = buffer.indexOf('\n\n')
|
||||
while (boundary >= 0) {
|
||||
const raw = buffer.slice(0, boundary)
|
||||
buffer = buffer.slice(boundary + 2)
|
||||
const frame = parseSseFrame(raw)
|
||||
if (frame?.data) yield frame
|
||||
boundary = buffer.indexOf('\n\n')
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode()
|
||||
const frame = parseSseFrame(buffer)
|
||||
if (frame?.data) yield frame
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
function summarizeMessageForPrompt(message: StoredMessage): string {
|
||||
if (message.role === 'tool') {
|
||||
const label = message.tool_name ? `Tool result: ${message.tool_name}` : 'Tool result'
|
||||
return `[${label}]\n${message.content || ''}`
|
||||
}
|
||||
}
|
||||
|
||||
function parseSseFrame(raw: string): { event?: string; data: string } | null {
|
||||
let event: string | undefined
|
||||
const data: string[] = []
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
if (!line || line.startsWith(':')) continue
|
||||
if (line.startsWith('event:')) {
|
||||
event = line.slice(6).trim()
|
||||
} else if (line.startsWith('data:')) {
|
||||
data.push(line.slice(5).trimStart())
|
||||
}
|
||||
if (message.role === 'assistant' && message.tool_calls?.length) {
|
||||
const toolsInfo = message.tool_calls.map(tc => {
|
||||
const name = tc.function?.name || 'tool'
|
||||
const args = tc.function?.arguments || '{}'
|
||||
return `${name}(${args})`
|
||||
}).join(', ')
|
||||
const content = message.content?.trim()
|
||||
return `[${message.senderName}]: ${content ? `${content}\n` : ''}[Tool calls: ${toolsInfo}]`
|
||||
}
|
||||
if (data.length === 0) return null
|
||||
return { event, data: data.join('\n') }
|
||||
}
|
||||
|
||||
function extractResponseText(response: any): string {
|
||||
const output = Array.isArray(response?.output) ? response.output : []
|
||||
const parts: string[] = []
|
||||
for (const item of output) {
|
||||
if (item.type !== 'message') continue
|
||||
const content = Array.isArray(item.content) ? item.content : []
|
||||
for (const part of content) {
|
||||
if (part.type === 'output_text' || part.type === 'text') {
|
||||
parts.push(part.text || '')
|
||||
}
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) return parts.join('')
|
||||
return typeof response?.output_text === 'string' ? response.output_text : ''
|
||||
return `[${message.senderName}]: ${message.content}`
|
||||
}
|
||||
|
||||
@@ -52,15 +52,23 @@ export function buildAgentInstructions(params: AgentInstructionsParams): string
|
||||
${memberSection}
|
||||
|
||||
规则:
|
||||
- 有人用 @${params.agentName} 提及你时才需要回复,重点回应提及你的人。
|
||||
- 禁止@自己。
|
||||
- 当你收到群聊任务时,说明系统已经判断你需要回复;请直接回应当前消息,不要因为消息里同时提及其他成员而拒绝回复或输出空回复。
|
||||
- 重点回应提及你的人。
|
||||
- 回答简洁、对群聊有帮助。
|
||||
- 不要假装是人类,需要时明确表明自己是 AI。
|
||||
- 对话历史中包含多个人的消息,每条消息前标有发送者名字。
|
||||
- 对话开头可能包含之前的对话摘要,用于提供更早的上下文。
|
||||
- 回复最新一条提及你的消息。
|
||||
- 如果需要其他 agent 协作或明确回复某个人,使用 @名字 来提及对方。
|
||||
- 自行判断对话是否已经结束——如果问题已解决、达成共识、或对方只是陈述不需要回复,则不要再 @任何人,直接结束回复,避免产生无意义的循环对话。`
|
||||
- 不要假装是人类,需要时明确表明自己是 AI。
|
||||
- 对话历史中包含多个人的消息,每条消息前标有发送者名字。
|
||||
- 历史消息里的"[发送者]: ..."只是系统添加的归属标记,用来帮助你理解谁说了这句话;不要在你的回复中复述或模仿这种方括号前缀。
|
||||
- 回复时使用自然语言即可;如果需要点名某人,只使用 @名字,不要输出"[${params.agentName}]:"这类格式。
|
||||
- 对话开头可能包含之前的对话摘要,用于提供更早的上下文。
|
||||
- 回复最新一条提及你的消息。
|
||||
- 群聊系统支持 agent 之间通过 @名字 接力:当你在回复中写出 @某个成员,系统会把消息路由给对应成员。
|
||||
- 如果用户明确要求你叫、让、请某个 agent 执行任务,不要自己代办,不要说你无法指挥其他 agent;请直接用 @名字 转交任务,并简短说明你已转交。
|
||||
- 如果需要其他 agent 协作或明确回复某个人,使用 @名字 来提及对方,并把需要对方执行的任务写清楚。
|
||||
- 不要主动 @ 任何人,除非最新消息明确要求你转交、邀请、询问某个具体成员。
|
||||
- 如果只是回答提问,直接回答,不要在结尾 @ 其他成员继续接力。
|
||||
- 不要为了活跃气氛、征求补充、让别人也看看而 @ 其他 agent 或用户。
|
||||
- 只有在确实需要对方执行动作、提供信息、确认决策时,才可以 @名字。
|
||||
- 自行判断对话是否已经结束——如果问题已解决、达成共识、或对方只是陈述不需要回复,则不要再 @任何人,直接结束回复,避免产生无意义的循环对话。`
|
||||
|
||||
return getSystemPrompt(basePrompt)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ export interface StoredMessage {
|
||||
senderName: string
|
||||
content: string
|
||||
timestamp: number
|
||||
role?: string
|
||||
tool_call_id?: string | null
|
||||
tool_calls?: Array<{ id?: string; type?: string; function?: { name?: string; arguments?: string } }> | null
|
||||
tool_name?: string | null
|
||||
finish_reason?: string | null
|
||||
}
|
||||
|
||||
// ─── Compression Config ────────────────────────────────────
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { readFile, stat as fsStat, readdir, mkdir, rm, rename, copyFile as fsCopyFile, writeFile as fsWriteFile } from 'fs/promises'
|
||||
import { resolve, normalize, isAbsolute, basename } from 'path'
|
||||
import { resolve, normalize, isAbsolute, basename, join } from 'path'
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import YAML from 'js-yaml'
|
||||
import { config } from '../../config'
|
||||
import { getActiveProfileDir, getActiveEnvPath } from './hermes-profile'
|
||||
import { isPathWithin, relativePathFromBase } from './hermes-path'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const execOpts = { windowsHide: true }
|
||||
@@ -90,11 +91,7 @@ export function validatePath(filePath: string): string {
|
||||
* Check if a path is inside the upload directory.
|
||||
*/
|
||||
export function isInUploadDir(filePath: string): boolean {
|
||||
const normalized = normalize(resolve(filePath))
|
||||
const uploadNormalized = normalize(resolve(config.uploadDir))
|
||||
return normalized.startsWith(uploadNormalized + '/')
|
||||
|| normalized.startsWith(uploadNormalized + '\\')
|
||||
|| normalized === uploadNormalized
|
||||
return isPathWithin(filePath, config.uploadDir)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,7 +117,7 @@ export function resolveHermesPath(relativePath: string): string {
|
||||
throw Object.assign(new Error('Invalid file path'), { code: 'invalid_path' })
|
||||
}
|
||||
const resolved = resolve(homeDir, normalized)
|
||||
if (!resolved.startsWith(homeDir)) {
|
||||
if (!isPathWithin(resolved, homeDir)) {
|
||||
throw Object.assign(new Error('Path traversal detected'), { code: 'invalid_path' })
|
||||
}
|
||||
return resolved
|
||||
@@ -160,9 +157,7 @@ export class LocalFileProvider implements FileProvider {
|
||||
try {
|
||||
const fullPath = resolve(p, entry.name)
|
||||
const s = await fsStat(fullPath)
|
||||
const relPath = fullPath.startsWith(homeDir)
|
||||
? fullPath.slice(homeDir.length + 1)
|
||||
: entry.name
|
||||
const relPath = relativePathFromBase(fullPath, homeDir) ?? entry.name
|
||||
results.push({
|
||||
name: entry.name,
|
||||
path: relPath,
|
||||
@@ -181,9 +176,7 @@ export class LocalFileProvider implements FileProvider {
|
||||
const p = validatePath(filePath)
|
||||
const homeDir = getActiveProfileDir()
|
||||
const s = await fsStat(p)
|
||||
const relPath = p.startsWith(homeDir)
|
||||
? p.slice(homeDir.length + 1)
|
||||
: basename(p)
|
||||
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
|
||||
return {
|
||||
name: basename(p),
|
||||
path: relPath || basename(p),
|
||||
@@ -291,7 +284,7 @@ export class DockerFileProvider implements FileProvider {
|
||||
// Node.js supports encoding: 'buffer' but @types/node doesn't type it correctly
|
||||
const { stdout } = await execFileAsync('docker', [
|
||||
'exec', this.containerName, 'cat', p,
|
||||
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any })
|
||||
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any, ...execOpts })
|
||||
return stdout as unknown as Buffer
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) {
|
||||
@@ -309,7 +302,7 @@ export class DockerFileProvider implements FileProvider {
|
||||
try {
|
||||
await execFileAsync('docker', [
|
||||
'exec', this.containerName, 'test', '-f', p,
|
||||
], { timeout: 5000 })
|
||||
], { timeout: 5000, ...execOpts })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
@@ -321,9 +314,9 @@ export class DockerFileProvider implements FileProvider {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', [
|
||||
'exec', this.containerName, 'ls', '-la', '--time-style=+%Y-%m-%dT%H:%M:%S', p,
|
||||
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT })
|
||||
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
const homeDir = getActiveProfileDir()
|
||||
const relParent = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : ''
|
||||
const relParent = relativePathFromBase(p, homeDir) ?? ''
|
||||
return parseLsOutput(stdout, relParent)
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
@@ -338,9 +331,9 @@ export class DockerFileProvider implements FileProvider {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', [
|
||||
'exec', this.containerName, 'stat', '-c', '%n|%F|%s|%Y', p,
|
||||
], { timeout: BACKEND_TIMEOUT })
|
||||
], { timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
const homeDir = getActiveProfileDir()
|
||||
const relPath = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : basename(p)
|
||||
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
|
||||
return parseStatOutput(stdout, relPath)
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
@@ -354,7 +347,7 @@ export class DockerFileProvider implements FileProvider {
|
||||
try {
|
||||
await execFileAsync('docker', [
|
||||
'exec', '-i', this.containerName, 'sh', '-c', `cat > '${p.replace(/'/g, "'\\''")}'`,
|
||||
], { timeout: BACKEND_TIMEOUT, input: content } as any)
|
||||
], { timeout: BACKEND_TIMEOUT, input: content, ...execOpts } as any)
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
|
||||
@@ -364,7 +357,7 @@ export class DockerFileProvider implements FileProvider {
|
||||
async deleteFile(filePath: string): Promise<void> {
|
||||
const p = validatePath(filePath)
|
||||
try {
|
||||
await execFileAsync('docker', ['exec', this.containerName, 'rm', p], { timeout: BACKEND_TIMEOUT })
|
||||
await execFileAsync('docker', ['exec', this.containerName, 'rm', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
|
||||
@@ -374,7 +367,7 @@ export class DockerFileProvider implements FileProvider {
|
||||
async deleteDir(dirPath: string): Promise<void> {
|
||||
const p = validatePath(dirPath)
|
||||
try {
|
||||
await execFileAsync('docker', ['exec', this.containerName, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT })
|
||||
await execFileAsync('docker', ['exec', this.containerName, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
|
||||
@@ -385,7 +378,7 @@ export class DockerFileProvider implements FileProvider {
|
||||
const op = validatePath(oldPath)
|
||||
const np = validatePath(newPath)
|
||||
try {
|
||||
await execFileAsync('docker', ['exec', this.containerName, 'mv', op, np], { timeout: BACKEND_TIMEOUT })
|
||||
await execFileAsync('docker', ['exec', this.containerName, 'mv', op, np], { timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
|
||||
@@ -395,7 +388,7 @@ export class DockerFileProvider implements FileProvider {
|
||||
async mkDir(dirPath: string): Promise<void> {
|
||||
const p = validatePath(dirPath)
|
||||
try {
|
||||
await execFileAsync('docker', ['exec', this.containerName, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT })
|
||||
await execFileAsync('docker', ['exec', this.containerName, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
|
||||
@@ -406,7 +399,7 @@ export class DockerFileProvider implements FileProvider {
|
||||
const sp = validatePath(srcPath)
|
||||
const dp = validatePath(destPath)
|
||||
try {
|
||||
await execFileAsync('docker', ['exec', this.containerName, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT })
|
||||
await execFileAsync('docker', ['exec', this.containerName, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
|
||||
@@ -451,7 +444,7 @@ export class SSHFileProvider implements FileProvider {
|
||||
// Pass a single quoted command string to prevent shell injection on remote
|
||||
const { stdout } = await execFileAsync('ssh', [
|
||||
...this.sshArgs(), `cat ${this.shellEscape(p)}`,
|
||||
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any })
|
||||
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any, ...execOpts })
|
||||
return stdout as unknown as Buffer
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) {
|
||||
@@ -469,7 +462,7 @@ export class SSHFileProvider implements FileProvider {
|
||||
try {
|
||||
await execFileAsync('ssh', [
|
||||
...this.sshArgs(), `test -f ${this.shellEscape(p)}`,
|
||||
], { timeout: 5000 })
|
||||
], { timeout: 5000, ...execOpts })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
@@ -481,9 +474,9 @@ export class SSHFileProvider implements FileProvider {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('ssh', [
|
||||
...this.sshArgs(), `ls -la --time-style=+%Y-%m-%dT%H:%M:%S ${this.shellEscape(p)}`,
|
||||
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT })
|
||||
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
const homeDir = getActiveProfileDir()
|
||||
const relParent = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : ''
|
||||
const relParent = relativePathFromBase(p, homeDir) ?? ''
|
||||
return parseLsOutput(stdout, relParent)
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
@@ -498,9 +491,9 @@ export class SSHFileProvider implements FileProvider {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('ssh', [
|
||||
...this.sshArgs(), `stat -c '%n|%F|%s|%Y' ${this.shellEscape(p)}`,
|
||||
], { timeout: BACKEND_TIMEOUT })
|
||||
], { timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
const homeDir = getActiveProfileDir()
|
||||
const relPath = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : basename(p)
|
||||
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
|
||||
return parseStatOutput(stdout, relPath)
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
@@ -514,7 +507,7 @@ export class SSHFileProvider implements FileProvider {
|
||||
try {
|
||||
await execFileAsync('ssh', [
|
||||
...this.sshArgs(), `cat > ${this.shellEscape(p)}`,
|
||||
], { timeout: BACKEND_TIMEOUT, input: content } as any)
|
||||
], { timeout: BACKEND_TIMEOUT, input: content, ...execOpts } as any)
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
|
||||
@@ -524,7 +517,7 @@ export class SSHFileProvider implements FileProvider {
|
||||
async deleteFile(filePath: string): Promise<void> {
|
||||
const p = validatePath(filePath)
|
||||
try {
|
||||
await execFileAsync('ssh', [...this.sshArgs(), `rm ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT })
|
||||
await execFileAsync('ssh', [...this.sshArgs(), `rm ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
|
||||
@@ -534,7 +527,7 @@ export class SSHFileProvider implements FileProvider {
|
||||
async deleteDir(dirPath: string): Promise<void> {
|
||||
const p = validatePath(dirPath)
|
||||
try {
|
||||
await execFileAsync('ssh', [...this.sshArgs(), `rm -rf ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT })
|
||||
await execFileAsync('ssh', [...this.sshArgs(), `rm -rf ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
|
||||
@@ -545,7 +538,7 @@ export class SSHFileProvider implements FileProvider {
|
||||
const op = validatePath(oldPath)
|
||||
const np = validatePath(newPath)
|
||||
try {
|
||||
await execFileAsync('ssh', [...this.sshArgs(), `mv ${this.shellEscape(op)} ${this.shellEscape(np)}`], { timeout: BACKEND_TIMEOUT })
|
||||
await execFileAsync('ssh', [...this.sshArgs(), `mv ${this.shellEscape(op)} ${this.shellEscape(np)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
|
||||
@@ -555,7 +548,7 @@ export class SSHFileProvider implements FileProvider {
|
||||
async mkDir(dirPath: string): Promise<void> {
|
||||
const p = validatePath(dirPath)
|
||||
try {
|
||||
await execFileAsync('ssh', [...this.sshArgs(), `mkdir -p ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT })
|
||||
await execFileAsync('ssh', [...this.sshArgs(), `mkdir -p ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
|
||||
@@ -566,7 +559,7 @@ export class SSHFileProvider implements FileProvider {
|
||||
const sp = validatePath(srcPath)
|
||||
const dp = validatePath(destPath)
|
||||
try {
|
||||
await execFileAsync('ssh', [...this.sshArgs(), `cp ${this.shellEscape(sp)} ${this.shellEscape(dp)}`], { timeout: BACKEND_TIMEOUT })
|
||||
await execFileAsync('ssh', [...this.sshArgs(), `cp ${this.shellEscape(sp)} ${this.shellEscape(dp)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
|
||||
@@ -590,7 +583,7 @@ export class SingularityFileProvider implements FileProvider {
|
||||
// Node.js supports encoding: 'buffer' but @types/node doesn't type it correctly
|
||||
const { stdout } = await execFileAsync('singularity', [
|
||||
'exec', this.imagePath, 'cat', p,
|
||||
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any })
|
||||
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any, ...execOpts })
|
||||
return stdout as unknown as Buffer
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) {
|
||||
@@ -608,7 +601,7 @@ export class SingularityFileProvider implements FileProvider {
|
||||
try {
|
||||
await execFileAsync('singularity', [
|
||||
'exec', this.imagePath, 'test', '-f', p,
|
||||
], { timeout: 5000 })
|
||||
], { timeout: 5000, ...execOpts })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
@@ -620,9 +613,9 @@ export class SingularityFileProvider implements FileProvider {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('singularity', [
|
||||
'exec', this.imagePath, 'ls', '-la', '--time-style=+%Y-%m-%dT%H:%M:%S', p,
|
||||
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT })
|
||||
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
const homeDir = getActiveProfileDir()
|
||||
const relParent = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : ''
|
||||
const relParent = relativePathFromBase(p, homeDir) ?? ''
|
||||
return parseLsOutput(stdout, relParent)
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
@@ -637,9 +630,9 @@ export class SingularityFileProvider implements FileProvider {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('singularity', [
|
||||
'exec', this.imagePath, 'stat', '-c', '%n|%F|%s|%Y', p,
|
||||
], { timeout: BACKEND_TIMEOUT })
|
||||
], { timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
const homeDir = getActiveProfileDir()
|
||||
const relPath = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : basename(p)
|
||||
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
|
||||
return parseStatOutput(stdout, relPath)
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
@@ -653,7 +646,7 @@ export class SingularityFileProvider implements FileProvider {
|
||||
try {
|
||||
await execFileAsync('singularity', [
|
||||
'exec', this.imagePath, 'sh', '-c', `cat > '${p.replace(/'/g, "'\\''")}'`,
|
||||
], { timeout: BACKEND_TIMEOUT, input: content } as any)
|
||||
], { timeout: BACKEND_TIMEOUT, input: content, ...execOpts } as any)
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
|
||||
@@ -663,7 +656,7 @@ export class SingularityFileProvider implements FileProvider {
|
||||
async deleteFile(filePath: string): Promise<void> {
|
||||
const p = validatePath(filePath)
|
||||
try {
|
||||
await execFileAsync('singularity', ['exec', this.imagePath, 'rm', p], { timeout: BACKEND_TIMEOUT })
|
||||
await execFileAsync('singularity', ['exec', this.imagePath, 'rm', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
|
||||
@@ -673,7 +666,7 @@ export class SingularityFileProvider implements FileProvider {
|
||||
async deleteDir(dirPath: string): Promise<void> {
|
||||
const p = validatePath(dirPath)
|
||||
try {
|
||||
await execFileAsync('singularity', ['exec', this.imagePath, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT })
|
||||
await execFileAsync('singularity', ['exec', this.imagePath, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
|
||||
@@ -684,7 +677,7 @@ export class SingularityFileProvider implements FileProvider {
|
||||
const op = validatePath(oldPath)
|
||||
const np = validatePath(newPath)
|
||||
try {
|
||||
await execFileAsync('singularity', ['exec', this.imagePath, 'mv', op, np], { timeout: BACKEND_TIMEOUT })
|
||||
await execFileAsync('singularity', ['exec', this.imagePath, 'mv', op, np], { timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
|
||||
@@ -694,7 +687,7 @@ export class SingularityFileProvider implements FileProvider {
|
||||
async mkDir(dirPath: string): Promise<void> {
|
||||
const p = validatePath(dirPath)
|
||||
try {
|
||||
await execFileAsync('singularity', ['exec', this.imagePath, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT })
|
||||
await execFileAsync('singularity', ['exec', this.imagePath, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
|
||||
@@ -705,7 +698,7 @@ export class SingularityFileProvider implements FileProvider {
|
||||
const sp = validatePath(srcPath)
|
||||
const dp = validatePath(destPath)
|
||||
try {
|
||||
await execFileAsync('singularity', ['exec', this.imagePath, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT })
|
||||
await execFileAsync('singularity', ['exec', this.imagePath, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT, ...execOpts })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
|
||||
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
|
||||
@@ -720,7 +713,7 @@ export class SingularityFileProvider implements FileProvider {
|
||||
*/
|
||||
export function getTerminalConfig(): TerminalConfig {
|
||||
try {
|
||||
const configPath = `${getActiveProfileDir()}/config.yaml`
|
||||
const configPath = join(getActiveProfileDir(), 'config.yaml')
|
||||
if (!existsSync(configPath)) return { backend: 'local' }
|
||||
const raw = readFileSync(configPath, 'utf-8')
|
||||
const doc = YAML.load(raw, { json: true }) as any
|
||||
@@ -777,7 +770,7 @@ async function resolveDockerContainer(cfg: TerminalConfig): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', [
|
||||
'ps', '-q', '--filter', `ancestor=${cfg.docker_image}`, '--latest',
|
||||
], { timeout: 5000 })
|
||||
], { timeout: 5000, ...execOpts })
|
||||
const id = stdout.trim()
|
||||
if (id) return id
|
||||
} catch { }
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { stripLegacyApiServerGatewayConfig } from '../config-helpers'
|
||||
import { logger } from '../logger'
|
||||
import { safeFileStore } from '../safe-file-store'
|
||||
import { getProfileDir, listProfileNamesFromDisk } from './hermes-profile'
|
||||
import { startGatewayRunManaged } from './gateway-runner'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
function resolveHermesBin(): string {
|
||||
return process.env.HERMES_BIN?.trim() || 'hermes'
|
||||
}
|
||||
|
||||
function isDockerRuntime(): boolean {
|
||||
return existsSync('/.dockerenv')
|
||||
}
|
||||
|
||||
function isTermuxRuntime(): boolean {
|
||||
const prefix = process.env.PREFIX || ''
|
||||
return !!process.env.TERMUX_VERSION ||
|
||||
prefix.includes('/com.termux/') ||
|
||||
existsSync('/data/data/com.termux/files/usr')
|
||||
}
|
||||
|
||||
export function gatewayStatusLooksRunning(output: string): boolean {
|
||||
const text = output.toLowerCase()
|
||||
if (text.includes('gateway is not running') || text.includes('not running')) return false
|
||||
return text.includes('gateway is running') || text.includes('running')
|
||||
}
|
||||
|
||||
export function gatewayStatusLooksRuntimeLocked(output: string): boolean {
|
||||
const text = output.toLowerCase()
|
||||
return text.includes('runtime lock is already held')
|
||||
|| text.includes('gateway runtime lock is already held')
|
||||
|| text.includes('already held by another instance')
|
||||
}
|
||||
|
||||
export async function isGatewayRunningForProfile(hermesBin: string, profileDir: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(hermesBin, ['gateway', 'status'], {
|
||||
timeout: 10000,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME: profileDir,
|
||||
},
|
||||
})
|
||||
return gatewayStatusLooksRunning(`${stdout}\n${stderr}`)
|
||||
} catch (err: any) {
|
||||
const output = `${err?.stdout || ''}\n${err?.stderr || ''}\n${err?.message || ''}`
|
||||
if (gatewayStatusLooksRuntimeLocked(output)) {
|
||||
logger.info({ profileDir }, 'Hermes gateway status reported runtime lock held; treating gateway as already running')
|
||||
return true
|
||||
}
|
||||
if (output.trim()) {
|
||||
logger.warn({ err, profileDir }, 'Hermes gateway status failed; treating as not running')
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function startGatewayForProfile(hermesBin: string, profile: string, profileDir: string): Promise<void> {
|
||||
if (isDockerRuntime() || isTermuxRuntime()) {
|
||||
const result = startGatewayRunManaged(hermesBin, { profileDir })
|
||||
logger.info(
|
||||
'[gateway-autostart] gateway started via background run profile=%s home=%s pid=%s',
|
||||
profile,
|
||||
profileDir,
|
||||
result.pid || 'unknown',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await execFileAsync(hermesBin, ['gateway', 'start'], {
|
||||
timeout: 30000,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME: profileDir,
|
||||
},
|
||||
})
|
||||
logger.info('[gateway-autostart] gateway started via Hermes CLI service profile=%s home=%s', profile, profileDir)
|
||||
} catch (err) {
|
||||
logger.warn(err, '[gateway-autostart] Hermes CLI gateway start failed; falling back to background run profile=%s home=%s', profile, profileDir)
|
||||
const result = startGatewayRunManaged(hermesBin, { profileDir })
|
||||
logger.info(
|
||||
'[gateway-autostart] gateway started via fallback background run profile=%s home=%s pid=%s',
|
||||
profile,
|
||||
profileDir,
|
||||
result.pid || 'unknown',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearApiServerForProfile(profileDir: string): Promise<void> {
|
||||
const configPath = join(profileDir, 'config.yaml')
|
||||
try {
|
||||
await safeFileStore.updateYaml(configPath, (config) => {
|
||||
const result = stripLegacyApiServerGatewayConfig(config)
|
||||
return { data: result.config, result: undefined, write: result.changed }
|
||||
}, { backup: true })
|
||||
} catch (err) {
|
||||
logger.warn(err, 'Failed to clear legacy api_server gateway config before gateway startup: %s', profileDir)
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureProfileGatewaysRunning(): Promise<void> {
|
||||
const hermesBin = resolveHermesBin()
|
||||
const profiles = listProfileNamesFromDisk()
|
||||
for (const profile of profiles) {
|
||||
const profileDir = getProfileDir(profile)
|
||||
const running = await isGatewayRunningForProfile(hermesBin, profileDir)
|
||||
if (running) {
|
||||
logger.info('[gateway-autostart] gateway already running profile=%s home=%s', profile, profileDir)
|
||||
continue
|
||||
}
|
||||
|
||||
await clearApiServerForProfile(profileDir)
|
||||
await startGatewayForProfile(hermesBin, profile, profileDir)
|
||||
}
|
||||
}
|
||||
@@ -14,13 +14,13 @@
|
||||
* - 否 → 标记为 stopped
|
||||
*
|
||||
* detectStatus 只做只读检测:不会认领未知端口上的进程,也不会探测实际监听端口后回写
|
||||
* config.yaml。端口修正发生在启动前的 resolvePort 阶段。
|
||||
* config.yaml。
|
||||
*
|
||||
* 端口分配流程(resolvePort,启动前调用):
|
||||
* ① 读取配置端口
|
||||
* ② 如果内存记录或 PID 文件对应的配置端口仍健康运行,复用该端口
|
||||
* ③ 收集本轮已分配端口、其他已管理网关端口、Web UI 端口
|
||||
* ④ 从 8642 起递增查找空闲端口,并写入 config.yaml
|
||||
* ④ 从 8642 起递增查找空闲端口,仅返回本次运行使用的端口,不再回写 config.yaml
|
||||
*
|
||||
* 启动模式:
|
||||
* - 所有平台统一使用 `hermes gateway run --replace`
|
||||
@@ -36,7 +36,6 @@ import { createServer } from 'net'
|
||||
import yaml from 'js-yaml'
|
||||
import { logger } from '../logger'
|
||||
import { detectHermesHome, getHermesBin } from './hermes-path'
|
||||
import { safeFileStore } from '../safe-file-store'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -334,53 +333,6 @@ export class GatewayManager {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 配置写入
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* 将端口和主机写入 profile 的 config.yaml
|
||||
* 写入完整结构:
|
||||
* platforms:
|
||||
* api_server:
|
||||
* enabled: true
|
||||
* key: ''
|
||||
* cors_origins: '*'
|
||||
* extra:
|
||||
* port: <port>
|
||||
* host: <host>
|
||||
* 同时清理旧的顶层 port/host(避免 Hermes 读取错误)
|
||||
*/
|
||||
private async writeProfilePort(name: string, port: number, host: string): Promise<void> {
|
||||
const configPath = join(this.profileDir(name), 'config.yaml')
|
||||
try {
|
||||
await safeFileStore.updateYaml(configPath, (cfg) => {
|
||||
// 确保 platforms.api_server 结构存在(不会影响其他位置的 platforms)
|
||||
if (!cfg.platforms) cfg.platforms = {}
|
||||
if (!cfg.platforms.api_server) cfg.platforms.api_server = {}
|
||||
if (!cfg.platforms.api_server.extra) cfg.platforms.api_server.extra = {}
|
||||
|
||||
cfg.platforms.api_server.enabled = true
|
||||
cfg.platforms.api_server.key = ''
|
||||
cfg.platforms.api_server.cors_origins = '*'
|
||||
cfg.platforms.api_server.extra.port = port
|
||||
cfg.platforms.api_server.extra.host = host
|
||||
|
||||
// 清理旧的顶层 port/host,Hermes 只从 extra 读取
|
||||
if (cfg.platforms.api_server.port !== undefined) {
|
||||
delete cfg.platforms.api_server.port
|
||||
}
|
||||
if (cfg.platforms.api_server.host !== undefined) {
|
||||
delete cfg.platforms.api_server.host
|
||||
}
|
||||
return cfg
|
||||
})
|
||||
logger.debug('Updated %s: api_server.extra.port = %d', configPath, port)
|
||||
} catch (err) {
|
||||
logger.error(err, 'Failed to write config for profile "%s"', name)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 端口分配
|
||||
// ============================
|
||||
@@ -392,7 +344,6 @@ export class GatewayManager {
|
||||
* 1. 当前 profile 已经健康运行 → 直接使用运行端口
|
||||
* 2. 未运行 → 从 8642 开始找空闲端口
|
||||
* 3. 检查已管理 profile / 本轮已分配端口 / 系统 TCP 占用
|
||||
* 4. 先写入 config.yaml,再启动 gateway
|
||||
*/
|
||||
private async resolvePort(name: string): Promise<{ port: number; host: string }> {
|
||||
const { port: configuredPort, host } = this.readProfilePort(name)
|
||||
@@ -437,8 +388,6 @@ export class GatewayManager {
|
||||
} else {
|
||||
logger.debug('Assigning port %d for profile "%s"', port, name)
|
||||
}
|
||||
await this.writeProfilePort(name, port, host)
|
||||
|
||||
this.allocatedPorts.add(port)
|
||||
return { port, host }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { spawn } from 'child_process'
|
||||
import { getActiveProfileDir } from './hermes-profile'
|
||||
|
||||
export function startGatewayRunManaged(
|
||||
hermesBin: string,
|
||||
opts: { profileDir?: string } = {},
|
||||
): { pid: number | null; reused: boolean } {
|
||||
const profileDir = opts.profileDir || getActiveProfileDir()
|
||||
const child = spawn(hermesBin, ['gateway', 'run', '--replace'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME: profileDir,
|
||||
},
|
||||
})
|
||||
child.unref()
|
||||
|
||||
const pid = child.pid ?? null
|
||||
return { pid, reused: false }
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
import { getToken } from '../../../services/auth'
|
||||
import type { GatewayManager } from '../gateway-manager'
|
||||
import { logger } from '../../../services/logger'
|
||||
import { updateUsage } from '../../../db/hermes/usage-store'
|
||||
import { AgentBridgeClient, type AgentBridgeMessage, type AgentBridgeOutput } from '../agent-bridge'
|
||||
import { convertContentBlocksForAgent, isContentBlockArray } from '../run-chat/content-blocks'
|
||||
import type { ContentBlock } from '../run-chat/types'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────
|
||||
|
||||
@@ -22,6 +24,15 @@ interface MessageData {
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
type MentionMessage = {
|
||||
content: string
|
||||
senderName: string
|
||||
senderId: string
|
||||
timestamp: number
|
||||
input?: string | ContentBlock[]
|
||||
mentionDepth?: number
|
||||
}
|
||||
|
||||
interface MemberData {
|
||||
id: string
|
||||
name: string
|
||||
@@ -55,9 +66,10 @@ class AgentClient {
|
||||
private joinedRooms = new Set<string>()
|
||||
private handlers: AgentEventHandler
|
||||
private _reconnecting = false
|
||||
private gatewayManager: GatewayManager | null = null
|
||||
private contextEngine: any = null
|
||||
private storage: any = null
|
||||
private pendingToolCallIds = new Map<string, string[]>()
|
||||
private pendingToolBaseIds = new Map<string, string>()
|
||||
|
||||
constructor(config: AgentConfig, handlers: AgentEventHandler = {}) {
|
||||
this.agentId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
||||
@@ -75,10 +87,6 @@ class AgentClient {
|
||||
return this.socket?.id
|
||||
}
|
||||
|
||||
setGatewayManager(manager: GatewayManager): void {
|
||||
this.gatewayManager = manager
|
||||
}
|
||||
|
||||
setContextEngine(engine: any): void {
|
||||
this.contextEngine = engine
|
||||
}
|
||||
@@ -146,10 +154,10 @@ class AgentClient {
|
||||
})
|
||||
}
|
||||
|
||||
sendMessage(roomId: string, content: string): Promise<string> {
|
||||
sendMessage(roomId: string, content: string, messageId?: string, extra?: Record<string, unknown>): Promise<string> {
|
||||
this.ensureConnected()
|
||||
return new Promise((resolve, reject) => {
|
||||
this.socket!.emit('message', { roomId, content }, (res: { id?: string; error?: string }) => {
|
||||
this.socket!.emit('message', { roomId, content, id: messageId, ...extra }, (res: { id?: string; error?: string }) => {
|
||||
if (res.error) {
|
||||
reject(new Error(res.error))
|
||||
} else {
|
||||
@@ -174,6 +182,52 @@ class AgentClient {
|
||||
this.socket!.emit('context_status', { roomId, agentName: this.name, status })
|
||||
}
|
||||
|
||||
emitApprovalRequested(roomId: string, payload: Record<string, unknown>): void {
|
||||
this.ensureConnected()
|
||||
this.socket!.emit('approval.requested', { roomId, agentName: this.name, ...payload })
|
||||
}
|
||||
|
||||
emitApprovalResolved(roomId: string, payload: Record<string, unknown>): void {
|
||||
this.ensureConnected()
|
||||
this.socket!.emit('approval.resolved', { roomId, agentName: this.name, ...payload })
|
||||
}
|
||||
|
||||
async interrupt(roomId: string): Promise<void> {
|
||||
const sessionSeed = String(this.storage?.getRoom?.(roomId)?.sessionSeed || '0')
|
||||
const sessionId = groupBridgeSessionId(roomId, this.profile, this.name, sessionSeed)
|
||||
await new AgentBridgeClient().interrupt(sessionId, 'Interrupted by group chat user', this.profile)
|
||||
this.stopTyping(roomId)
|
||||
this.emitContextStatus(roomId, 'ready')
|
||||
}
|
||||
|
||||
emitMessageStreamStart(roomId: string, messageId: string): void {
|
||||
this.ensureConnected()
|
||||
this.socket!.emit('message_stream_start', {
|
||||
roomId,
|
||||
id: messageId,
|
||||
senderId: this.socket?.id || this.agentId,
|
||||
senderName: this.name,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
emitMessageStreamDelta(roomId: string, messageId: string, delta: string): void {
|
||||
if (!delta) return
|
||||
this.ensureConnected()
|
||||
this.socket!.emit('message_stream_delta', { roomId, id: messageId, delta })
|
||||
}
|
||||
|
||||
emitMessageReasoningDelta(roomId: string, messageId: string, delta: string): void {
|
||||
if (!delta) return
|
||||
this.ensureConnected()
|
||||
this.socket!.emit('message_reasoning_delta', { roomId, id: messageId, delta })
|
||||
}
|
||||
|
||||
emitMessageStreamEnd(roomId: string, messageId: string): void {
|
||||
this.ensureConnected()
|
||||
this.socket!.emit('message_stream_end', { roomId, id: messageId })
|
||||
}
|
||||
|
||||
getJoinedRooms(): string[] {
|
||||
return Array.from(this.joinedRooms)
|
||||
}
|
||||
@@ -193,23 +247,10 @@ class AgentClient {
|
||||
*/
|
||||
async replyToMention(
|
||||
roomId: string,
|
||||
msg: { content: string; senderName: string; senderId: string; timestamp: number },
|
||||
msg: MentionMessage,
|
||||
onStatus?: (status: 'compressing' | 'replying' | 'ready') => void,
|
||||
): Promise<void> {
|
||||
logger.debug(`[AgentClients] ${this.name} mentioned by ${msg.senderName}: "${msg.content.slice(0, 50)}"`)
|
||||
if (!this.gatewayManager) {
|
||||
logger.debug(`[AgentClients] ${this.name}: gatewayManager is null, skipping`)
|
||||
return
|
||||
}
|
||||
|
||||
const upstream = this.gatewayManager.getUpstream(this.profile)
|
||||
const apiKey = this.gatewayManager.getApiKey(this.profile)
|
||||
logger.debug(`[AgentClients] ${this.name}: upstream=${upstream}, profile=${this.profile}`)
|
||||
if (!upstream) {
|
||||
logger.error(`[AgentClients] ${this.name}: no gateway upstream for profile "${this.profile}"`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Notify room that agent is typing
|
||||
this.startTyping(roomId)
|
||||
@@ -244,8 +285,8 @@ class AgentClient {
|
||||
roomName: roomId,
|
||||
memberNames,
|
||||
members,
|
||||
upstream,
|
||||
apiKey,
|
||||
upstream: '',
|
||||
apiKey: null,
|
||||
currentMessage: msg,
|
||||
compression,
|
||||
profile: this.profile,
|
||||
@@ -261,86 +302,101 @@ class AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Strip @mention from input — agent already knows it was mentioned
|
||||
const input = msg.content.replace(new RegExp(`@${this.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi'), '').trim() || msg.content
|
||||
const responseRes = await fetch(`${upstream.replace(/\/$/, '')}/v1/responses`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
||||
// Keep the original mentions visible and add an explicit routing note.
|
||||
// When a user mentions multiple agents, stripping only this agent's
|
||||
// name can make the remaining input look like it was meant for
|
||||
// someone else.
|
||||
const routedPrefix = `群聊系统:这条消息已经提及你(${this.name}),请直接回复;即使消息同时提及其他成员,也不要因此输出空回复。`
|
||||
const ownMentionPattern = new RegExp(`@${this.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi')
|
||||
const rawInput = msg.input || msg.content
|
||||
const input = isContentBlockArray(rawInput)
|
||||
? rawInput.map((block) => {
|
||||
if (block.type !== 'text') return block
|
||||
const text = String(block.text || msg.content).replace(ownMentionPattern, '').trim()
|
||||
return { ...block, text: `${routedPrefix}\n\n原始消息:${text || msg.content}` }
|
||||
})
|
||||
: `${routedPrefix}\n\n原始消息:${msg.content.replace(ownMentionPattern, '').trim() || msg.content}`
|
||||
const bridgeInput: AgentBridgeMessage = isContentBlockArray(input)
|
||||
? await convertContentBlocksForAgent(input)
|
||||
: input
|
||||
const bridge = new AgentBridgeClient()
|
||||
const sessionSeed = String(this.storage?.getRoom?.(roomId)?.sessionSeed || '0')
|
||||
const sessionId = groupBridgeSessionId(roomId, this.profile, this.name, sessionSeed)
|
||||
const runMessageId = groupMessageId(roomId, this.profile, this.name)
|
||||
let partIndex = 0
|
||||
let streamMessageId = groupMessagePartId(runMessageId, partIndex)
|
||||
let currentContent = ''
|
||||
let totalContent = ''
|
||||
let reasoningContent = ''
|
||||
const flushedAssistantParts = new Set<string>()
|
||||
let lastChunk: AgentBridgeOutput | null = null
|
||||
const started = await bridge.chat(
|
||||
sessionId,
|
||||
bridgeInput,
|
||||
conversationHistory,
|
||||
instructions,
|
||||
this.profile,
|
||||
{
|
||||
source: 'api_server',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
input,
|
||||
...(conversationHistory.length > 0 ? { conversation_history: conversationHistory } : {}),
|
||||
...(instructions ? { instructions } : {}),
|
||||
stream: true,
|
||||
store: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
})
|
||||
)
|
||||
|
||||
if (!responseRes.ok) {
|
||||
const text = await responseRes.text().catch(() => '')
|
||||
logger.error(`[AgentClients] ${this.name}: gateway response failed (${responseRes.status}): ${text}`)
|
||||
this.stopTyping(roomId)
|
||||
onStatus?.('ready')
|
||||
return
|
||||
}
|
||||
|
||||
if (!responseRes.body) {
|
||||
logger.error(`[AgentClients] ${this.name}: gateway response stream missing`)
|
||||
this.stopTyping(roomId)
|
||||
onStatus?.('ready')
|
||||
return
|
||||
}
|
||||
|
||||
let fullContent = ''
|
||||
for await (const frame of readSseFrames(responseRes.body)) {
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = JSON.parse(frame.data)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const eventType = parsed.type || frame.event || parsed.event
|
||||
logger.debug(`[AgentClients] ${this.name}: event=${eventType}`)
|
||||
|
||||
if (eventType === 'response.output_text.delta' && parsed.delta) {
|
||||
fullContent += parsed.delta
|
||||
continue
|
||||
}
|
||||
|
||||
if (eventType === 'response.completed') {
|
||||
const response = parsed.response || parsed
|
||||
const finalText = extractResponseText(response)
|
||||
if (!fullContent && finalText) fullContent = finalText
|
||||
const usage = response.usage || {}
|
||||
updateUsage(roomId, {
|
||||
inputTokens: usage.input_tokens ?? usage.inputTokens ?? 0,
|
||||
outputTokens: usage.output_tokens ?? usage.outputTokens ?? 0,
|
||||
cacheReadTokens: usage.cache_read_tokens ?? usage.cacheReadTokens ?? 0,
|
||||
cacheWriteTokens: usage.cache_write_tokens ?? usage.cacheWriteTokens ?? 0,
|
||||
reasoningTokens: usage.reasoning_tokens ?? usage.reasoningTokens ?? 0,
|
||||
model: response.model || '',
|
||||
profile: this.profile,
|
||||
})
|
||||
logger.debug(`[AgentClients] ${this.name}: response completed, content length=${fullContent.length}`)
|
||||
if (fullContent) {
|
||||
this.stopTyping(roomId)
|
||||
this.sendMessage(roomId, fullContent)
|
||||
this.emitMessageStreamStart(roomId, streamMessageId)
|
||||
for await (const chunk of bridge.streamOutput(started.run_id, { timeoutMs: 120000 })) {
|
||||
lastChunk = chunk
|
||||
reasoningContent += await this.recordBridgeEvents(roomId, chunk, () => streamMessageId, async () => {
|
||||
const toolBaseId = streamMessageId
|
||||
if (currentContent.trim()) {
|
||||
await this.sendMessage(roomId, currentContent, streamMessageId, {
|
||||
role: 'assistant',
|
||||
mentionDepth: nextMentionDepth(msg),
|
||||
reasoning: reasoningContent || null,
|
||||
reasoning_content: reasoningContent || null,
|
||||
})
|
||||
flushedAssistantParts.add(streamMessageId)
|
||||
currentContent = ''
|
||||
}
|
||||
onStatus?.('ready')
|
||||
return
|
||||
}
|
||||
|
||||
if (eventType === 'response.failed') {
|
||||
logger.error(`[AgentClients] ${this.name}: response failed`)
|
||||
this.stopTyping(roomId)
|
||||
onStatus?.('ready')
|
||||
return
|
||||
this.emitMessageStreamEnd(roomId, toolBaseId)
|
||||
partIndex += 1
|
||||
streamMessageId = groupMessagePartId(runMessageId, partIndex)
|
||||
this.emitMessageStreamStart(roomId, streamMessageId)
|
||||
return toolBaseId
|
||||
})
|
||||
if (chunk.delta) {
|
||||
currentContent += chunk.delta
|
||||
totalContent += chunk.delta
|
||||
this.emitMessageStreamDelta(roomId, streamMessageId, chunk.delta)
|
||||
}
|
||||
}
|
||||
logger.warn(`[AgentClients] ${this.name}: response stream ended without terminal event`)
|
||||
|
||||
if (lastChunk?.status === 'error') {
|
||||
logger.error(`[AgentClients] ${this.name}: bridge response failed: ${lastChunk.error || 'unknown error'}`)
|
||||
this.emitMessageStreamEnd(roomId, streamMessageId)
|
||||
this.stopTyping(roomId)
|
||||
onStatus?.('ready')
|
||||
return
|
||||
}
|
||||
|
||||
if (!totalContent) {
|
||||
currentContent = extractBridgeFinalText(lastChunk)
|
||||
totalContent = currentContent
|
||||
}
|
||||
recordBridgeUsage(roomId, this.profile, lastChunk?.result)
|
||||
logger.debug(`[AgentClients] ${this.name}: bridge response completed, content length=${totalContent.length}`)
|
||||
if (currentContent) {
|
||||
this.stopTyping(roomId)
|
||||
await this.sendMessage(roomId, currentContent, streamMessageId, {
|
||||
role: 'assistant',
|
||||
mentionDepth: nextMentionDepth(msg),
|
||||
reasoning: reasoningContent || null,
|
||||
reasoning_content: reasoningContent || null,
|
||||
})
|
||||
this.emitMessageStreamEnd(roomId, streamMessageId)
|
||||
onStatus?.('ready')
|
||||
return
|
||||
}
|
||||
logger.warn(`[AgentClients] ${this.name}: bridge response completed without content`)
|
||||
this.emitMessageStreamEnd(roomId, streamMessageId)
|
||||
this.stopTyping(roomId)
|
||||
onStatus?.('ready')
|
||||
} catch (err: any) {
|
||||
@@ -350,6 +406,132 @@ class AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async recordBridgeEvents(
|
||||
roomId: string,
|
||||
chunk: AgentBridgeOutput,
|
||||
getCurrentMessageId: () => string,
|
||||
beforeToolStarted: () => Promise<string>,
|
||||
): Promise<string> {
|
||||
let reasoning = ''
|
||||
for (const ev of chunk.events || []) {
|
||||
const eventType = String((ev as any)?.event || '')
|
||||
if (eventType === 'tool.started') {
|
||||
const toolBaseId = await beforeToolStarted()
|
||||
this.recordToolStarted(roomId, ev as Record<string, unknown>, toolBaseId)
|
||||
} else if (eventType === 'tool.completed') {
|
||||
this.recordToolCompleted(roomId, ev as Record<string, unknown>)
|
||||
} else if (eventType === 'approval.requested') {
|
||||
this.emitApprovalRequested(roomId, {
|
||||
event: 'approval.requested',
|
||||
approval_id: (ev as any).approval_id,
|
||||
command: (ev as any).command,
|
||||
description: (ev as any).description,
|
||||
choices: Array.isArray((ev as any).choices) ? (ev as any).choices : undefined,
|
||||
allow_permanent: (ev as any).allow_permanent,
|
||||
})
|
||||
} else if (eventType === 'approval.resolved') {
|
||||
this.emitApprovalResolved(roomId, {
|
||||
event: 'approval.resolved',
|
||||
approval_id: (ev as any).approval_id,
|
||||
choice: (ev as any).choice,
|
||||
})
|
||||
} else if (eventType === 'reasoning.delta' || eventType === 'thinking.delta') {
|
||||
const text = String((ev as any)?.text || '')
|
||||
reasoning += text
|
||||
this.emitMessageReasoningDelta(roomId, getCurrentMessageId(), text)
|
||||
}
|
||||
}
|
||||
return reasoning
|
||||
}
|
||||
|
||||
private recordToolStarted(roomId: string, ev: Record<string, unknown>, runMessageId: string): void {
|
||||
const toolName = String(ev.tool_name || ev.tool || ev.name || '')
|
||||
const toolCallId = groupToolCallId(ev.tool_call_id, toolName, this.nextToolIndex(roomId, toolName))
|
||||
this.trackPendingToolCall(roomId, toolName, toolCallId)
|
||||
this.pendingToolBaseIds.set(toolCallId, runMessageId)
|
||||
const timestamp = Date.now()
|
||||
const rawArgs = ev.args ?? ev.arguments ?? ev.input ?? {}
|
||||
const args = normalizeToolArgs(rawArgs)
|
||||
const toolCall = {
|
||||
id: toolCallId,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolName,
|
||||
arguments: JSON.stringify(args),
|
||||
},
|
||||
}
|
||||
const msg: MessageData & Record<string, any> = {
|
||||
id: `${runMessageId}_toolcall_${safeId(toolCallId)}`,
|
||||
roomId,
|
||||
senderId: this.socket?.id || this.agentId,
|
||||
senderName: this.name,
|
||||
content: '',
|
||||
timestamp,
|
||||
role: 'assistant',
|
||||
tool_calls: [toolCall],
|
||||
finish_reason: 'tool_calls',
|
||||
}
|
||||
this.sendMessage(roomId, '', msg.id, {
|
||||
role: 'assistant',
|
||||
tool_calls: msg.tool_calls,
|
||||
finish_reason: 'tool_calls',
|
||||
timestamp,
|
||||
}).catch((err: any) => logger.warn(`[AgentClients] failed to record tool call: ${err.message}`))
|
||||
}
|
||||
|
||||
private recordToolCompleted(roomId: string, ev: Record<string, unknown>): void {
|
||||
const toolName = String(ev.tool_name || ev.tool || ev.name || '')
|
||||
const rawId = String(ev.tool_call_id || '').trim()
|
||||
const toolCallId = rawId || this.takePendingToolCall(roomId, toolName) || groupToolCallId(null, toolName, this.nextToolIndex(roomId, toolName))
|
||||
const runMessageId = this.pendingToolBaseIds.get(toolCallId) || groupMessagePartId(groupMessageId(roomId, this.profile, this.name), 0)
|
||||
this.pendingToolBaseIds.delete(toolCallId)
|
||||
const output = bridgeToolOutput(ev)
|
||||
const timestamp = Date.now()
|
||||
const msg: MessageData & Record<string, any> = {
|
||||
id: `${runMessageId}_toolresult_${safeId(toolCallId)}_${Date.now()}`,
|
||||
roomId,
|
||||
senderId: this.socket?.id || this.agentId,
|
||||
senderName: this.name,
|
||||
content: output,
|
||||
timestamp,
|
||||
role: 'tool',
|
||||
tool_call_id: toolCallId,
|
||||
tool_name: toolName || null,
|
||||
}
|
||||
this.sendMessage(roomId, output, msg.id, {
|
||||
role: 'tool',
|
||||
tool_call_id: toolCallId,
|
||||
tool_name: toolName || null,
|
||||
timestamp,
|
||||
}).catch((err: any) => logger.warn(`[AgentClients] failed to record tool result: ${err.message}`))
|
||||
}
|
||||
|
||||
private pendingToolKey(roomId: string, toolName: string): string {
|
||||
return `${roomId}::${toolName || 'tool'}`
|
||||
}
|
||||
|
||||
private trackPendingToolCall(roomId: string, toolName: string, toolCallId: string): void {
|
||||
const key = this.pendingToolKey(roomId, toolName)
|
||||
const list = this.pendingToolCallIds.get(key) || []
|
||||
list.push(toolCallId)
|
||||
this.pendingToolCallIds.set(key, list)
|
||||
}
|
||||
|
||||
private takePendingToolCall(roomId: string, toolName: string): string | undefined {
|
||||
const key = this.pendingToolKey(roomId, toolName)
|
||||
const list = this.pendingToolCallIds.get(key)
|
||||
if (!list?.length) return undefined
|
||||
const id = list.shift()
|
||||
if (list.length) this.pendingToolCallIds.set(key, list)
|
||||
else this.pendingToolCallIds.delete(key)
|
||||
return id
|
||||
}
|
||||
|
||||
private nextToolIndex(roomId: string, toolName: string): number {
|
||||
const key = this.pendingToolKey(roomId, toolName)
|
||||
return (this.pendingToolCallIds.get(key)?.length || 0) + 1
|
||||
}
|
||||
|
||||
private bindEvents(): void {
|
||||
const s = this.socket!
|
||||
|
||||
@@ -387,77 +569,79 @@ class AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
async function* readSseFrames(stream: ReadableStream<Uint8Array>): AsyncGenerator<{ event?: string; data: string }> {
|
||||
const decoder = new TextDecoder()
|
||||
const reader = stream.getReader()
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
let boundary = buffer.indexOf('\n\n')
|
||||
while (boundary >= 0) {
|
||||
const raw = buffer.slice(0, boundary)
|
||||
buffer = buffer.slice(boundary + 2)
|
||||
const frame = parseSseFrame(raw)
|
||||
if (frame?.data) yield frame
|
||||
boundary = buffer.indexOf('\n\n')
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode()
|
||||
const frame = parseSseFrame(buffer)
|
||||
if (frame?.data) yield frame
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
function groupBridgeSessionId(roomId: string, profile: string, name: string, sessionSeed: string): string {
|
||||
const raw = `gc_${roomId}_${profile}_${name}_${sessionSeed || '0'}`
|
||||
return raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 120)
|
||||
}
|
||||
|
||||
function parseSseFrame(raw: string): { event?: string; data: string } | null {
|
||||
let event: string | undefined
|
||||
const data: string[] = []
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
if (!line || line.startsWith(':')) continue
|
||||
if (line.startsWith('event:')) {
|
||||
event = line.slice(6).trim()
|
||||
} else if (line.startsWith('data:')) {
|
||||
data.push(line.slice(5).trimStart())
|
||||
}
|
||||
}
|
||||
if (data.length === 0) return null
|
||||
return { event, data: data.join('\n') }
|
||||
function groupMessageId(roomId: string, profile: string, name: string): string {
|
||||
const raw = `gcmsg_${safeId(roomId)}_${safeId(profile)}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
return raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 160)
|
||||
}
|
||||
|
||||
function extractResponseText(response: any): string {
|
||||
const output = Array.isArray(response?.output) ? response.output : []
|
||||
const parts: string[] = []
|
||||
for (const item of output) {
|
||||
if (item.type !== 'message') continue
|
||||
const content = Array.isArray(item.content) ? item.content : []
|
||||
for (const part of content) {
|
||||
if (part.type === 'output_text' || part.type === 'text') {
|
||||
parts.push(part.text || '')
|
||||
}
|
||||
function groupMessagePartId(runMessageId: string, partIndex: number): string {
|
||||
return `${safeId(runMessageId)}_part_${partIndex}`
|
||||
}
|
||||
|
||||
function groupToolCallId(rawToolCallId: unknown, toolName: string, index: number): string {
|
||||
const raw = String(rawToolCallId || '').trim()
|
||||
if (raw) return raw
|
||||
return `cli_${safeId(toolName || 'tool')}_${Date.now()}_${index}`
|
||||
}
|
||||
|
||||
function safeId(value: string): string {
|
||||
return String(value || 'item').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80)
|
||||
}
|
||||
|
||||
function bridgeToolOutput(ev: Record<string, unknown>): string {
|
||||
const value = ev.result ?? ev.output ?? ev.result_preview ?? ev.preview ?? ''
|
||||
return typeof value === 'string' ? value : JSON.stringify(value ?? '')
|
||||
}
|
||||
|
||||
function normalizeToolArgs(value: unknown): Record<string, unknown> {
|
||||
if (!value) return {}
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : { value }
|
||||
} catch {
|
||||
return { value }
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) return parts.join('')
|
||||
return typeof response?.output_text === 'string' ? response.output_text : ''
|
||||
return typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : { value }
|
||||
}
|
||||
|
||||
function extractBridgeFinalText(chunk: AgentBridgeOutput | null): string {
|
||||
const result = chunk?.result as any
|
||||
const output = result?.final_response || chunk?.output || ''
|
||||
return typeof output === 'string' ? output.trim() : ''
|
||||
}
|
||||
|
||||
function recordBridgeUsage(roomId: string, profile: string, result: unknown): void {
|
||||
const payload = result as any
|
||||
const usage = payload?.usage || payload?.response?.usage
|
||||
if (!usage) return
|
||||
updateUsage(roomId, {
|
||||
inputTokens: usage.input_tokens ?? usage.inputTokens ?? 0,
|
||||
outputTokens: usage.output_tokens ?? usage.outputTokens ?? 0,
|
||||
cacheReadTokens: usage.cache_read_tokens ?? usage.cacheReadTokens ?? 0,
|
||||
cacheWriteTokens: usage.cache_write_tokens ?? usage.cacheWriteTokens ?? 0,
|
||||
reasoningTokens: usage.reasoning_tokens ?? usage.reasoningTokens ?? 0,
|
||||
model: payload?.model || payload?.response?.model || '',
|
||||
profile,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── AgentClients (roomId -> agents) ──────────────────────────
|
||||
|
||||
export class AgentClients {
|
||||
private rooms = new Map<string, Map<string, AgentClient>>()
|
||||
private _gatewayManager: GatewayManager | null = null
|
||||
private _contextEngine: any = null
|
||||
private _storage: any = null
|
||||
|
||||
// Per-room processing lock + mention queue
|
||||
private _processingRooms = new Set<string>()
|
||||
private _mentionQueue = new Map<string, Array<{ agent: AgentClient; msg: { content: string; senderName: string; senderId: string; timestamp: number } }>>()
|
||||
private _mentionQueue = new Map<string, Array<{ agent: AgentClient; msg: MentionMessage }>>()
|
||||
|
||||
/**
|
||||
* Create an agent client and connect it to the server.
|
||||
@@ -468,7 +652,6 @@ export class AgentClients {
|
||||
await client.connect(port)
|
||||
|
||||
// Auto-apply stored references (fixes propagation for agents created after set*)
|
||||
if (this._gatewayManager) client.setGatewayManager(this._gatewayManager)
|
||||
if (this._contextEngine) client.setContextEngine(this._contextEngine)
|
||||
if (this._storage) client.setStorage(this._storage)
|
||||
|
||||
@@ -557,6 +740,13 @@ export class AgentClients {
|
||||
return Promise.all(agents.map((agent) => agent.sendMessage(roomId, content)))
|
||||
}
|
||||
|
||||
async interruptAgent(roomId: string, agentName: string): Promise<void> {
|
||||
const agent = this.getAgents(roomId).find(a => a.name === agentName)
|
||||
if (!agent) throw new Error(`Agent "${agentName}" not found in room "${roomId}"`)
|
||||
this._mentionQueue.delete(`${roomId}:${agent.name}`)
|
||||
await agent.interrupt(roomId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect all agents in a room.
|
||||
*/
|
||||
@@ -576,7 +766,12 @@ export class AgentClients {
|
||||
|
||||
resetRoomContext(roomId: string): void {
|
||||
this._mentionQueue.delete(roomId)
|
||||
this._processingRooms.delete(roomId)
|
||||
for (const key of Array.from(this._mentionQueue.keys())) {
|
||||
if (key.startsWith(`${roomId}:`)) this._mentionQueue.delete(key)
|
||||
}
|
||||
for (const key of Array.from(this._processingRooms)) {
|
||||
if (key.startsWith(`${roomId}:`)) this._processingRooms.delete(key)
|
||||
}
|
||||
if (this._contextEngine) {
|
||||
try { this._contextEngine.invalidateRoom(roomId) } catch { /* ignore */ }
|
||||
}
|
||||
@@ -593,16 +788,6 @@ export class AgentClients {
|
||||
logger.info('[AgentClients] All agents disconnected')
|
||||
}
|
||||
|
||||
/**
|
||||
* Set gateway manager for all existing and future agents.
|
||||
*/
|
||||
setGatewayManager(manager: GatewayManager): void {
|
||||
this._gatewayManager = manager
|
||||
this.rooms.forEach((room) => {
|
||||
room.forEach((client) => client.setGatewayManager(manager))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Set context engine for all existing and future agents.
|
||||
*/
|
||||
@@ -628,13 +813,14 @@ export class AgentClients {
|
||||
* Server-side: parse @mentions and forward to matching agents directly.
|
||||
* If the room is already processing (compressing/replying), queue the mention.
|
||||
*/
|
||||
async processMentions(roomId: string, msg: { content: string; senderName: string; senderId: string; timestamp: number }): Promise<void> {
|
||||
if (!this._gatewayManager) return
|
||||
|
||||
const content = msg.content.toLowerCase()
|
||||
async processMentions(roomId: string, msg: MentionMessage): Promise<void> {
|
||||
const agents = this.getAgents(roomId)
|
||||
const senderName = msg.senderName.toLowerCase()
|
||||
|
||||
const mentioned = agents.filter(a => content.includes(`@${a.name.toLowerCase()}`))
|
||||
const mentioned = agents.filter(a => (
|
||||
a.name.toLowerCase() !== senderName &&
|
||||
isAgentMentioned(msg.content, a.name)
|
||||
))
|
||||
if (mentioned.length === 0) return
|
||||
|
||||
logger.debug(`[AgentClients] ${mentioned.map(a => a.name).join(', ')} mentioned by ${msg.senderName}`)
|
||||
@@ -652,7 +838,7 @@ export class AgentClients {
|
||||
private async _processAgentMention(
|
||||
roomId: string,
|
||||
agent: AgentClient,
|
||||
msg: { content: string; senderName: string; senderId: string; timestamp: number },
|
||||
msg: MentionMessage,
|
||||
): Promise<void> {
|
||||
const agentKey = `${roomId}:${agent.name}`
|
||||
if (this._processingRooms.has(agentKey)) {
|
||||
@@ -693,9 +879,16 @@ export class AgentClients {
|
||||
|
||||
// Process the last queued mention only (most recent, discards stale intermediate ones)
|
||||
const last = queue[queue.length - 1]
|
||||
this._processingRooms.add(agentKey)
|
||||
this._processAgentMention(roomId, last.agent, last.msg).catch((err) => {
|
||||
logger.error(`[AgentClients] error processing queued mention: ${err.message}`)
|
||||
})
|
||||
await this._processAgentMention(roomId, last.agent, last.msg)
|
||||
}
|
||||
}
|
||||
|
||||
function nextMentionDepth(msg: MentionMessage): number {
|
||||
return Math.max(0, msg.mentionDepth || 0) + 1
|
||||
}
|
||||
|
||||
function isAgentMentioned(content: string, agentName: string): boolean {
|
||||
const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const pattern = new RegExp(`@${escaped}(?=$|\\s|[.,!?;:,。!?;:])`, 'i')
|
||||
return pattern.test(content)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { getDb } from '../../../db'
|
||||
import { AgentClients } from './agent-clients'
|
||||
import { ContextEngine } from '../context-engine/compressor'
|
||||
import { SessionDeleter } from '../session-deleter'
|
||||
import { countTokens, SUMMARY_PREFIX } from '../../../lib/context-compressor'
|
||||
import { AgentBridgeClient } from '../agent-bridge'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────
|
||||
|
||||
@@ -16,6 +18,43 @@ 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
|
||||
mentionDepth?: number
|
||||
}
|
||||
|
||||
function contentToStorageString(content: unknown): string {
|
||||
if (typeof content === 'string') return content
|
||||
return JSON.stringify(content ?? '')
|
||||
}
|
||||
|
||||
function contentToText(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
const trimmed = content.trim()
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
try {
|
||||
return contentToText(JSON.parse(trimmed))
|
||||
} catch {
|
||||
return content
|
||||
}
|
||||
}
|
||||
return content
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content.map((block: any) => {
|
||||
if (block?.type === 'text') return block.text || ''
|
||||
if (block?.type === 'image') return `[Image: ${block.name || block.path || ''}]`
|
||||
if (block?.type === 'file') return `[File: ${block.name || block.path || ''}]`
|
||||
return ''
|
||||
}).filter(Boolean).join('\n')
|
||||
}
|
||||
return content == null ? '' : String(content)
|
||||
}
|
||||
|
||||
interface RoomAgent {
|
||||
@@ -64,6 +103,64 @@ export interface PendingSessionDeleteDrainResult {
|
||||
failed: Array<{ sessionId: string; error: string }>
|
||||
}
|
||||
|
||||
function parseJsonArray(value: unknown): any[] | null {
|
||||
if (value == null || value === '') return null
|
||||
if (Array.isArray(value)) return value
|
||||
if (typeof value !== 'string') return null
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return Array.isArray(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMessageRole(role: unknown): string {
|
||||
const value = String(role || '').trim()
|
||||
return ['user', 'assistant', 'tool', 'command'].includes(value) ? value : 'user'
|
||||
}
|
||||
|
||||
function normalizeMentionDepth(depth: unknown): number {
|
||||
const value = Number(depth)
|
||||
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0
|
||||
}
|
||||
|
||||
function groupRunOrder(id: string): { baseId: string; phase: number } {
|
||||
const value = String(id || '')
|
||||
const partMatch = value.match(/^(.*)_part_(\d+)(?:_(toolcall|toolresult)_.+)?$/)
|
||||
if (partMatch) {
|
||||
const part = Number(partMatch[2] || 0)
|
||||
const kind = partMatch[3] || 'assistant'
|
||||
const offset = kind === 'toolcall' ? 1 : kind === 'toolresult' ? 2 : 0
|
||||
return { baseId: partMatch[1], phase: part * 3 + offset }
|
||||
}
|
||||
const toolIdx = value.indexOf('_toolcall_')
|
||||
if (toolIdx >= 0) return { baseId: value.slice(0, toolIdx), phase: 0 }
|
||||
const resultIdx = value.indexOf('_toolresult_')
|
||||
if (resultIdx >= 0) return { baseId: value.slice(0, resultIdx), phase: 1 }
|
||||
return { baseId: value, phase: 2 }
|
||||
}
|
||||
|
||||
function sortGroupMessages<T extends { id: string; timestamp: number }>(messages: T[]): T[] {
|
||||
const baseMinTimestamp = new Map<string, number>()
|
||||
for (const msg of messages) {
|
||||
const { baseId } = groupRunOrder(msg.id)
|
||||
const existing = baseMinTimestamp.get(baseId)
|
||||
if (existing == null || msg.timestamp < existing) baseMinTimestamp.set(baseId, msg.timestamp)
|
||||
}
|
||||
return [...messages].sort((a, b) => {
|
||||
const ao = groupRunOrder(a.id)
|
||||
const bo = groupRunOrder(b.id)
|
||||
const at = baseMinTimestamp.get(ao.baseId) ?? a.timestamp
|
||||
const bt = baseMinTimestamp.get(bo.baseId) ?? b.timestamp
|
||||
if (at !== bt) return at - bt
|
||||
if (ao.baseId !== bo.baseId) return ao.baseId.localeCompare(bo.baseId)
|
||||
if (ao.phase !== bo.phase) return ao.phase - bo.phase
|
||||
if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
|
||||
class ChatStorage {
|
||||
private db() { return getDb() }
|
||||
|
||||
@@ -175,16 +272,16 @@ class ChatStorage {
|
||||
|
||||
// ─── Rooms ────────────────────────────────────────────────
|
||||
|
||||
getRoom(roomId: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number } | undefined {
|
||||
return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens FROM gc_rooms WHERE id = ?').get(roomId) as any
|
||||
getRoom(roomId: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number; sessionSeed: string } | undefined {
|
||||
return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms WHERE id = ?').get(roomId) as any
|
||||
}
|
||||
|
||||
getRoomByInviteCode(code: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number } | undefined {
|
||||
return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens FROM gc_rooms WHERE inviteCode = ?').get(code) as any
|
||||
getRoomByInviteCode(code: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number; sessionSeed: string } | undefined {
|
||||
return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms WHERE inviteCode = ?').get(code) as any
|
||||
}
|
||||
|
||||
getAllRooms(): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number }[] {
|
||||
return (this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens FROM gc_rooms ORDER BY id').all() || []) as any[]
|
||||
getAllRooms(): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number; sessionSeed: string }[] {
|
||||
return (this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms ORDER BY id').all() || []) as any[]
|
||||
}
|
||||
|
||||
saveRoom(id: string, name: string, inviteCode?: string, config?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }): void {
|
||||
@@ -212,25 +309,132 @@ class ChatStorage {
|
||||
this.db()?.prepare('UPDATE gc_rooms SET totalTokens = ? WHERE id = ?').run(tokens, roomId)
|
||||
}
|
||||
|
||||
rotateRoomSessionSeed(roomId: string): string {
|
||||
const seed = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
|
||||
this.db()?.prepare('UPDATE gc_rooms SET sessionSeed = ? WHERE id = ?').run(seed, roomId)
|
||||
return seed
|
||||
}
|
||||
|
||||
estimateTokens(text: string): number {
|
||||
const cjk = (text.match(/[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff00-\uffef]/g) || []).length
|
||||
const other = text.length - cjk
|
||||
return Math.ceil(cjk * 1.5 + other / 4)
|
||||
}
|
||||
|
||||
private contentToUsageText(content: unknown): string {
|
||||
if (typeof content === 'string') return content
|
||||
if (!content) return ''
|
||||
if (Array.isArray(content)) {
|
||||
return content.map((block: any) => {
|
||||
if (typeof block?.text === 'string') return block.text
|
||||
if (typeof block?.type === 'string') return `[${block.type}]`
|
||||
return String(block || '')
|
||||
}).join('\n')
|
||||
}
|
||||
return String(content)
|
||||
}
|
||||
|
||||
private estimateUsageTokensFromMessages(messages: ChatMessage[]): { inputTokens: number; outputTokens: number } {
|
||||
const inputTokens = messages
|
||||
.filter(m => (m.role || 'user') === 'user')
|
||||
.reduce((sum, m) => sum + countTokens(this.contentToUsageText(m.content)), 0)
|
||||
const outputTokens = messages
|
||||
.filter(m => m.role === 'assistant' || m.role === 'tool')
|
||||
.reduce((sum, m) => sum + countTokens(this.contentToUsageText(m.content)) + countTokens(String(m.tool_calls || '')), 0)
|
||||
return { inputTokens, outputTokens }
|
||||
}
|
||||
|
||||
private estimateRoomTotalTokens(roomId: string, messages: ChatMessage[]): number {
|
||||
const snapshot = this.getContextSnapshot(roomId)
|
||||
if (snapshot && messages.length) {
|
||||
const snapshotIdx = messages.findIndex(m => m.id === snapshot.lastMessageId)
|
||||
const newMessages = snapshotIdx >= 0
|
||||
? messages.slice(snapshotIdx + 1)
|
||||
: messages.filter(m => m.timestamp > snapshot.lastMessageTimestamp)
|
||||
const newUsage = this.estimateUsageTokensFromMessages(newMessages)
|
||||
return countTokens(SUMMARY_PREFIX + snapshot.summary) + newUsage.inputTokens + newUsage.outputTokens
|
||||
}
|
||||
const usage = this.estimateUsageTokensFromMessages(messages)
|
||||
return usage.inputTokens + usage.outputTokens
|
||||
}
|
||||
|
||||
// ─── Messages ─────────────────────────────────────────────
|
||||
|
||||
getMessages(roomId: string, limit = 500): ChatMessage[] {
|
||||
const rows = (this.db()?.prepare(
|
||||
'SELECT id, roomId, senderId, senderName, content, timestamp FROM gc_messages WHERE roomId = ? ORDER BY timestamp DESC LIMIT ?'
|
||||
'SELECT id, roomId, senderId, senderName, content, timestamp, role, tool_call_id, tool_calls, tool_name, finish_reason, reasoning, reasoning_details, reasoning_content FROM gc_messages WHERE roomId = ? ORDER BY timestamp DESC LIMIT ?'
|
||||
).all(roomId, limit) || []) as any[]
|
||||
return rows.reverse()
|
||||
return sortGroupMessages(rows.map(row => ({
|
||||
...row,
|
||||
tool_calls: parseJsonArray(row.tool_calls),
|
||||
})))
|
||||
}
|
||||
|
||||
getMessage(messageId: string): ChatMessage | null {
|
||||
const row = this.db()?.prepare(
|
||||
'SELECT id, roomId, senderId, senderName, content, timestamp, role, tool_call_id, tool_calls, tool_name, finish_reason, reasoning, reasoning_details, reasoning_content FROM gc_messages WHERE id = ?'
|
||||
).get(messageId) as any
|
||||
if (!row) return null
|
||||
return {
|
||||
...row,
|
||||
tool_calls: parseJsonArray(row.tool_calls),
|
||||
}
|
||||
}
|
||||
|
||||
addMessage(msg: ChatMessage): void {
|
||||
this.upsertMessage(msg)
|
||||
}
|
||||
|
||||
upsertMessage(msg: ChatMessage): void {
|
||||
const toolCallsJson = msg.tool_calls ? JSON.stringify(msg.tool_calls) : null
|
||||
this.db()?.prepare(
|
||||
'INSERT INTO gc_messages (id, roomId, senderId, senderName, content, timestamp) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(msg.id, msg.roomId, msg.senderId, msg.senderName, msg.content, msg.timestamp)
|
||||
`INSERT INTO gc_messages (id, roomId, senderId, senderName, content, timestamp, role, tool_call_id, tool_calls, tool_name, finish_reason, reasoning, reasoning_details, reasoning_content)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
+ ` ON CONFLICT(id) DO UPDATE SET
|
||||
roomId = excluded.roomId,
|
||||
senderId = excluded.senderId,
|
||||
senderName = excluded.senderName,
|
||||
content = excluded.content,
|
||||
timestamp = excluded.timestamp,
|
||||
role = excluded.role,
|
||||
tool_call_id = excluded.tool_call_id,
|
||||
tool_calls = excluded.tool_calls,
|
||||
tool_name = excluded.tool_name,
|
||||
finish_reason = excluded.finish_reason,
|
||||
reasoning = excluded.reasoning,
|
||||
reasoning_details = excluded.reasoning_details,
|
||||
reasoning_content = excluded.reasoning_content`
|
||||
).run(
|
||||
msg.id, msg.roomId, msg.senderId, msg.senderName, msg.content, msg.timestamp,
|
||||
msg.role || 'user',
|
||||
msg.tool_call_id ?? null,
|
||||
toolCallsJson,
|
||||
msg.tool_name ?? null,
|
||||
msg.finish_reason ?? null,
|
||||
msg.reasoning ?? null,
|
||||
msg.reasoning_details ?? null,
|
||||
msg.reasoning_content ?? null,
|
||||
)
|
||||
}
|
||||
|
||||
saveMessageAndRefreshRoom(msg: ChatMessage, options: { preserveExistingTimestamp?: boolean } = {}): { message: ChatMessage; totalTokens: number } {
|
||||
const db = this.db()
|
||||
if (!db) return { message: msg, totalTokens: 0 }
|
||||
db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const existing = this.getMessage(msg.id)
|
||||
const message = existing && options.preserveExistingTimestamp ? { ...msg, timestamp: existing.timestamp } : msg
|
||||
this.upsertMessage(message)
|
||||
this.pruneMessages(msg.roomId)
|
||||
const messages = this.getMessages(msg.roomId)
|
||||
const totalTokens = this.estimateRoomTotalTokens(msg.roomId, messages)
|
||||
this.updateRoomTotalTokens(msg.roomId, totalTokens)
|
||||
db.exec('COMMIT')
|
||||
return { message, totalTokens }
|
||||
} catch (err) {
|
||||
try { db.exec('ROLLBACK') } catch { /* ignore */ }
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
clearRoomContext(roomId: string): void {
|
||||
@@ -238,7 +442,7 @@ class ChatStorage {
|
||||
if (!db) return
|
||||
db.prepare('DELETE FROM gc_messages WHERE roomId = ?').run(roomId)
|
||||
db.prepare('DELETE FROM gc_context_snapshots WHERE roomId = ?').run(roomId)
|
||||
db.prepare('UPDATE gc_rooms SET totalTokens = 0 WHERE id = ?').run(roomId)
|
||||
db.prepare('UPDATE gc_rooms SET totalTokens = 0, sessionSeed = ? WHERE id = ?').run(`${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, roomId)
|
||||
}
|
||||
|
||||
pruneMessages(roomId: string, keep = 500): void {
|
||||
@@ -419,13 +623,6 @@ export class GroupChatServer {
|
||||
/** roomId -> (agentName -> { agentName, status }) */
|
||||
private contextStatusState = new Map<string, Map<string, { agentName: string; status: string }>>()
|
||||
|
||||
setGatewayManager(manager: any): void {
|
||||
this.agentClients.setGatewayManager(manager)
|
||||
if (this._contextEngine && manager) {
|
||||
this._contextEngine.setUpstream(manager.getUpstream(''), manager.getApiKey(''))
|
||||
}
|
||||
}
|
||||
|
||||
constructor(httpServers: HttpServer | HttpServer[]) {
|
||||
this.storage = new ChatStorage()
|
||||
this.storage.init()
|
||||
@@ -569,10 +766,18 @@ export class GroupChatServer {
|
||||
logger.debug(`[GroupChat] Connected: ${userName} (socket=${socket.id}, user=${userId})`)
|
||||
|
||||
socket.on('join', (data: { roomId?: string; name?: string }, ack?: (response?: unknown) => void) => this.handleJoin(socket, data, ack))
|
||||
socket.on('message', (data: { roomId?: string; content: string }, ack?: (response?: unknown) => void) => this.handleMessage(socket, data, ack))
|
||||
socket.on('message', (data: Partial<ChatMessage> & { roomId?: string; content: string | Array<Record<string, unknown>>; id?: string; mentionDepth?: number }, ack?: (response?: unknown) => void) => this.handleMessage(socket, data, ack))
|
||||
socket.on('message_stream_start', (data: { roomId?: string; id?: string; senderId?: string; senderName?: string; timestamp?: number }) => this.handleMessageStreamStart(socket, data))
|
||||
socket.on('message_stream_delta', (data: { roomId?: string; id?: string; delta?: string }) => this.handleMessageStreamDelta(socket, data))
|
||||
socket.on('message_reasoning_delta', (data: { roomId?: string; id?: string; delta?: string }) => this.handleMessageReasoningDelta(socket, data))
|
||||
socket.on('message_stream_end', (data: { roomId?: string; id?: string }) => this.handleMessageStreamEnd(socket, data))
|
||||
socket.on('typing', (data: { roomId?: string }) => this.handleTyping(socket, data))
|
||||
socket.on('stop_typing', (data: { roomId?: string }) => this.handleStopTyping(socket, data))
|
||||
socket.on('context_status', (data: { roomId?: string; agentName?: string; status?: string }) => this.handleContextStatus(socket, data))
|
||||
socket.on('interrupt_agent', (data: { roomId?: string; agentName?: string }, ack?: (response?: unknown) => void) => this.handleInterruptAgent(socket, data, ack))
|
||||
socket.on('approval.requested', (data: { roomId?: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean }) => this.handleApprovalRequested(socket, data))
|
||||
socket.on('approval.resolved', (data: { roomId?: string; agentName?: string; approval_id?: string; choice?: string }) => this.handleApprovalResolved(socket, data))
|
||||
socket.on('approval.respond', (data: { roomId?: string; approval_id?: string; choice?: string }, ack?: (response?: unknown) => void) => this.handleApprovalRespond(socket, data, ack))
|
||||
socket.on('disconnect', () => this.handleDisconnect(socket))
|
||||
}
|
||||
|
||||
@@ -581,14 +786,18 @@ export class GroupChatServer {
|
||||
private handleJoin(socket: Socket, data: { roomId?: string; name?: string; description?: string }, ack?: (res: any) => void): void {
|
||||
const socketId = socket.id
|
||||
const userId = this.socketUserMap.get(socketId) || socketId
|
||||
const userInfo = this.userInfoMap.get(userId) || { name: `User-${userId.slice(0, 6)}`, description: '' }
|
||||
const userName = data.name || userInfo.name
|
||||
const description = data.description || userInfo.description
|
||||
const roomId = data.roomId || 'general'
|
||||
const existingMember = this.storage.getMemberByUserId(roomId, userId)
|
||||
const userInfo = this.userInfoMap.get(userId) || {
|
||||
name: existingMember?.name || `User-${userId.slice(0, 6)}`,
|
||||
description: existingMember?.description || '',
|
||||
}
|
||||
const userName = data.name || existingMember?.name || userInfo.name
|
||||
const description = data.description || existingMember?.description || userInfo.description
|
||||
|
||||
// Update stored user info
|
||||
this.userInfoMap.set(userId, { name: userName, description })
|
||||
|
||||
const roomId = data.roomId || 'general'
|
||||
let room = this.rooms.get(roomId)
|
||||
if (!room) {
|
||||
room = new ChatRoom(roomId)
|
||||
@@ -628,7 +837,7 @@ export class GroupChatServer {
|
||||
logger.debug(`[GroupChat] ${userName} (user=${userId}) joined room: ${roomId}`)
|
||||
}
|
||||
|
||||
private handleMessage(socket: Socket, data: { roomId?: string; content: string }, ack?: (res: any) => void): void {
|
||||
private handleMessage(socket: Socket, data: Partial<ChatMessage> & { roomId?: string; content: string | Array<Record<string, unknown>>; id?: string; mentionDepth?: number }, ack?: (res: any) => void): void {
|
||||
const socketId = socket.id
|
||||
const roomId = data.roomId || 'general'
|
||||
const room = this.rooms.get(roomId)
|
||||
@@ -643,37 +852,105 @@ export class GroupChatServer {
|
||||
const userName = member?.name || `User-${socketId.slice(0, 6)}`
|
||||
|
||||
const msg: ChatMessage = {
|
||||
id: this.generateId(),
|
||||
id: this.normalizeClientMessageId(data.id) || this.generateId(),
|
||||
roomId,
|
||||
senderId: userId,
|
||||
senderName: userName,
|
||||
content: data.content,
|
||||
timestamp: Date.now(),
|
||||
content: contentToStorageString(data.content),
|
||||
timestamp: this.normalizeMessageTimestamp(data.timestamp, data.role),
|
||||
role: normalizeMessageRole(data.role),
|
||||
tool_call_id: data.tool_call_id ?? null,
|
||||
tool_calls: Array.isArray(data.tool_calls) ? data.tool_calls : null,
|
||||
tool_name: data.tool_name ?? null,
|
||||
finish_reason: data.finish_reason ?? null,
|
||||
reasoning: data.reasoning ?? null,
|
||||
reasoning_details: data.reasoning_details ?? null,
|
||||
reasoning_content: data.reasoning_content ?? null,
|
||||
}
|
||||
|
||||
this.storage.addMessage(msg)
|
||||
this.storage.pruneMessages(roomId)
|
||||
const saved = this.storage.saveMessageAndRefreshRoom(msg)
|
||||
const savedMsg = saved.message
|
||||
const totalTokens = saved.totalTokens
|
||||
|
||||
// Recalculate total tokens for the room
|
||||
const messages = this.storage.getMessages(roomId)
|
||||
const totalTokens = this.storage.estimateTokens(messages.map(m => m.content + m.senderName).join(''))
|
||||
this.storage.updateRoomTotalTokens(roomId, totalTokens)
|
||||
|
||||
this.nsp.to(roomId).emit('message', msg)
|
||||
this.nsp.to(roomId).emit('message', savedMsg)
|
||||
this.nsp.to(roomId).emit('room_updated', { roomId, totalTokens })
|
||||
ack?.({ id: msg.id })
|
||||
ack?.({ id: savedMsg.id })
|
||||
|
||||
// Server-side @mention routing — parse mentions and invoke agents directly
|
||||
this.agentClients.processMentions(roomId, {
|
||||
content: msg.content,
|
||||
senderName: msg.senderName,
|
||||
senderId: msg.senderId,
|
||||
timestamp: msg.timestamp,
|
||||
}).catch((err) => {
|
||||
logger.error(`[GroupChat] processMentions error: ${err.message}`)
|
||||
const mentionDepth = normalizeMentionDepth(data.mentionDepth)
|
||||
const shouldRouteMentions =
|
||||
savedMsg.role === 'user' ||
|
||||
(savedMsg.role === 'assistant' && mentionDepth < 2)
|
||||
|
||||
if (shouldRouteMentions) {
|
||||
// Server-side @mention routing — parse user mentions and invoke agents directly.
|
||||
this.agentClients.processMentions(roomId, {
|
||||
content: contentToText(savedMsg.content),
|
||||
input: Array.isArray(data.content) ? data.content : undefined,
|
||||
senderName: savedMsg.senderName,
|
||||
senderId: savedMsg.senderId,
|
||||
timestamp: savedMsg.timestamp,
|
||||
mentionDepth,
|
||||
}).catch((err) => {
|
||||
logger.error(`[GroupChat] processMentions error: ${err.message}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessageStreamStart(socket: Socket, data: { roomId?: string; id?: string; senderId?: string; senderName?: string; timestamp?: number }): void {
|
||||
const roomId = data.roomId || 'general'
|
||||
const room = this.rooms.get(roomId)
|
||||
if (!room || !room.hasOnlineMember(socket.id)) return
|
||||
const id = this.normalizeClientMessageId(data.id)
|
||||
if (!id) return
|
||||
|
||||
const member = room.getOnlineMemberBySocketId(socket.id)
|
||||
this.nsp.to(roomId).emit('message_stream_start', {
|
||||
id,
|
||||
roomId,
|
||||
senderId: data.senderId || member?.userId || socket.id,
|
||||
senderName: data.senderName || member?.name || `User-${socket.id.slice(0, 6)}`,
|
||||
content: '',
|
||||
timestamp: data.timestamp || Date.now(),
|
||||
role: 'assistant',
|
||||
finish_reason: 'streaming',
|
||||
})
|
||||
}
|
||||
|
||||
private handleMessageStreamDelta(socket: Socket, data: { roomId?: string; id?: string; delta?: string }): void {
|
||||
const roomId = data.roomId || 'general'
|
||||
const room = this.rooms.get(roomId)
|
||||
if (!room || !room.hasOnlineMember(socket.id)) return
|
||||
const id = this.normalizeClientMessageId(data.id)
|
||||
if (!id || !data.delta) return
|
||||
this.nsp.to(roomId).emit('message_stream_delta', {
|
||||
roomId,
|
||||
id,
|
||||
delta: String(data.delta),
|
||||
})
|
||||
}
|
||||
|
||||
private handleMessageReasoningDelta(socket: Socket, data: { roomId?: string; id?: string; delta?: string }): void {
|
||||
const roomId = data.roomId || 'general'
|
||||
const room = this.rooms.get(roomId)
|
||||
if (!room || !room.hasOnlineMember(socket.id)) return
|
||||
const id = this.normalizeClientMessageId(data.id)
|
||||
if (!id || !data.delta) return
|
||||
this.nsp.to(roomId).emit('message_reasoning_delta', {
|
||||
roomId,
|
||||
id,
|
||||
delta: String(data.delta),
|
||||
})
|
||||
}
|
||||
|
||||
private handleMessageStreamEnd(socket: Socket, data: { roomId?: string; id?: string }): void {
|
||||
const roomId = data.roomId || 'general'
|
||||
const room = this.rooms.get(roomId)
|
||||
if (!room || !room.hasOnlineMember(socket.id)) return
|
||||
const id = this.normalizeClientMessageId(data.id)
|
||||
if (!id) return
|
||||
this.nsp.to(roomId).emit('message_stream_end', { roomId, id })
|
||||
}
|
||||
|
||||
private handleTyping(socket: Socket, data: { roomId?: string }): void {
|
||||
const roomId = data.roomId || 'general'
|
||||
const userId = this.socketUserMap.get(socket.id) || socket.id
|
||||
@@ -749,6 +1026,75 @@ export class GroupChatServer {
|
||||
})
|
||||
}
|
||||
|
||||
private async handleInterruptAgent(socket: Socket, data: { roomId?: string; agentName?: string }, ack?: (response?: unknown) => void): Promise<void> {
|
||||
const roomId = data.roomId
|
||||
const agentName = data.agentName
|
||||
if (!roomId || !agentName) {
|
||||
ack?.({ error: 'roomId and agentName are required' })
|
||||
return
|
||||
}
|
||||
const room = this.rooms.get(roomId)
|
||||
if (!room?.hasOnlineMember(socket.id)) {
|
||||
ack?.({ error: 'Not in room' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.agentClients.interruptAgent(roomId, agentName)
|
||||
this.nsp.to(roomId).emit('context_status', { roomId, agentName, status: 'ready' })
|
||||
ack?.({ ok: true })
|
||||
} catch (err: any) {
|
||||
logger.warn(`[GroupChat] failed to interrupt agent ${agentName} in room ${roomId}: ${err.message}`)
|
||||
ack?.({ error: err.message || 'interrupt failed' })
|
||||
}
|
||||
}
|
||||
|
||||
private handleApprovalRequested(socket: Socket, data: { roomId?: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean }): void {
|
||||
const roomId = data.roomId
|
||||
if (!roomId || !data.approval_id) return
|
||||
this.nsp.to(roomId).emit('approval.requested', {
|
||||
event: 'approval.requested',
|
||||
roomId,
|
||||
agentName: data.agentName || '',
|
||||
approval_id: data.approval_id,
|
||||
command: data.command || '',
|
||||
description: data.description || '',
|
||||
choices: Array.isArray(data.choices) ? data.choices : ['once', 'session', 'deny'],
|
||||
allow_permanent: Boolean(data.allow_permanent),
|
||||
})
|
||||
}
|
||||
|
||||
private handleApprovalResolved(socket: Socket, data: { roomId?: string; agentName?: string; approval_id?: string; choice?: string }): void {
|
||||
const roomId = data.roomId
|
||||
if (!roomId || !data.approval_id) return
|
||||
this.nsp.to(roomId).emit('approval.resolved', {
|
||||
event: 'approval.resolved',
|
||||
roomId,
|
||||
agentName: data.agentName || '',
|
||||
approval_id: data.approval_id,
|
||||
choice: data.choice || '',
|
||||
})
|
||||
}
|
||||
|
||||
private async handleApprovalRespond(socket: Socket, data: { roomId?: string; approval_id?: string; choice?: string }, ack?: (response?: unknown) => void): Promise<void> {
|
||||
const roomId = data.roomId
|
||||
if (!roomId || !data.approval_id) {
|
||||
ack?.({ error: 'roomId and approval_id are required' })
|
||||
return
|
||||
}
|
||||
const room = this.rooms.get(roomId)
|
||||
if (!room?.hasOnlineMember(socket.id)) {
|
||||
ack?.({ error: 'Not in room' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await new AgentBridgeClient().approvalRespond(data.approval_id, data.choice || 'deny')
|
||||
ack?.({ ok: true, resolved: Boolean((result as any)?.resolved) })
|
||||
} catch (err: any) {
|
||||
logger.warn(`[GroupChat] failed to respond approval ${data.approval_id}: ${err.message}`)
|
||||
ack?.({ error: err.message || 'approval response failed' })
|
||||
}
|
||||
}
|
||||
|
||||
private handleDisconnect(socket: Socket): void {
|
||||
const socketId = socket.id
|
||||
const userId = this.socketUserMap.get(socketId)
|
||||
@@ -804,4 +1150,19 @@ export class GroupChatServer {
|
||||
private generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
||||
}
|
||||
|
||||
private normalizeClientMessageId(id?: string): string | null {
|
||||
const cleaned = String(id || '').trim()
|
||||
if (!cleaned || cleaned.length > 160) return null
|
||||
return /^[a-zA-Z0-9_-]+$/.test(cleaned) ? cleaned : null
|
||||
}
|
||||
|
||||
private normalizeMessageTimestamp(timestamp?: unknown, role?: unknown): number {
|
||||
const normalizedRole = normalizeMessageRole(role)
|
||||
if (normalizedRole !== 'user') {
|
||||
const value = Number(timestamp)
|
||||
if (Number.isFinite(value) && value > 0) return value
|
||||
}
|
||||
return Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { execFile, spawn } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { existsSync, readFileSync, unlinkSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { logger } from '../logger'
|
||||
import { stripLegacyApiServerGatewayConfig, updateConfigYaml } from '../config-helpers'
|
||||
import { getActiveProfileDir, getProfileDir } from './hermes-profile'
|
||||
import { startGatewayRunManaged } from './gateway-runner'
|
||||
import { isGatewayRunningForProfile } from './gateway-autostart'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const execOpts = { windowsHide: true }
|
||||
const isDocker = existsSync('/.dockerenv')
|
||||
const isTermux = !!process.env.TERMUX_VERSION ||
|
||||
(process.env.PREFIX || '').includes('/com.termux/') ||
|
||||
existsSync('/data/data/com.termux/files/usr')
|
||||
|
||||
/**
|
||||
* 解析 Hermes CLI 二进制路径
|
||||
@@ -18,6 +26,156 @@ function resolveHermesBin(): string {
|
||||
|
||||
const HERMES_BIN = resolveHermesBin()
|
||||
|
||||
async function waitForGatewayRunning(profileDir: string, timeoutMs = 15000): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (await isGatewayRunningForProfile(HERMES_BIN, profileDir)) return true
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function stopGatewayForActiveProfile(): Promise<void> {
|
||||
try {
|
||||
await execFileAsync(HERMES_BIN, ['gateway', 'stop'], {
|
||||
timeout: 30000,
|
||||
...activeGatewayExecOpts(),
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn(err, 'hermes gateway stop before restart failed; continuing with run --replace')
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (err: any) {
|
||||
return err?.code === 'EPERM'
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonPid(path: string): number | null {
|
||||
if (!existsSync(path)) return null
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(path, 'utf-8'))
|
||||
const pid = typeof data?.pid === 'number' ? data.pid : parseInt(String(data?.pid || ''), 10)
|
||||
return Number.isFinite(pid) && pid > 0 ? pid : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readGatewayLockPid(profileDir: string): number | null {
|
||||
return readJsonPid(join(profileDir, 'gateway.lock'))
|
||||
}
|
||||
|
||||
function readGatewayStatePid(profileDir: string): number | null {
|
||||
const pid = readJsonPid(join(profileDir, 'gateway.pid'))
|
||||
if (pid) return pid
|
||||
const statePath = join(profileDir, 'gateway_state.json')
|
||||
if (!existsSync(statePath)) return null
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(statePath, 'utf-8'))
|
||||
const state = data?.gateway_state
|
||||
const statePid = typeof data?.pid === 'number' ? data.pid : parseInt(String(data?.pid || ''), 10)
|
||||
return statePid && Number.isFinite(statePid) && statePid > 0 && (state === 'running' || state === 'starting')
|
||||
? statePid
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function killWindowsPid(pid: number): Promise<void> {
|
||||
if (!pid || process.platform !== 'win32') return
|
||||
try {
|
||||
await execFileAsync('taskkill', ['/PID', String(pid), '/T', '/F'], {
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn(err, 'Failed to taskkill gateway PID %d; falling back to process.kill', pid)
|
||||
try { process.kill(pid) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupStaleGatewayLock(profileDir: string, allowMalformedDelete = false): boolean {
|
||||
const lockPath = join(profileDir, 'gateway.lock')
|
||||
if (!existsSync(lockPath)) return true
|
||||
try {
|
||||
const lockData = JSON.parse(readFileSync(lockPath, 'utf-8'))
|
||||
const pid = Number(lockData?.pid)
|
||||
if (Number.isFinite(pid) && pid > 0 && isProcessAlive(pid)) return false
|
||||
unlinkSync(lockPath)
|
||||
return true
|
||||
} catch {
|
||||
if (!allowMalformedDelete) return false
|
||||
try {
|
||||
unlinkSync(lockPath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForGatewayLockReleased(profileDir: string, timeoutMs = 15000, allowMalformedDelete = false): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (cleanupStaleGatewayLock(profileDir, allowMalformedDelete)) return true
|
||||
await sleep(500)
|
||||
}
|
||||
return cleanupStaleGatewayLock(profileDir, allowMalformedDelete)
|
||||
}
|
||||
|
||||
async function forceReleaseWindowsGatewayLock(profileDir: string): Promise<void> {
|
||||
if (process.platform !== 'win32') return
|
||||
const pids = new Set<number>()
|
||||
const lockPid = readGatewayLockPid(profileDir)
|
||||
const statePid = readGatewayStatePid(profileDir)
|
||||
if (lockPid) pids.add(lockPid)
|
||||
if (statePid) pids.add(statePid)
|
||||
|
||||
for (const pid of pids) {
|
||||
if (isProcessAlive(pid)) {
|
||||
logger.warn('Gateway lock is still held by PID %d; force killing Windows process tree', pid)
|
||||
await killWindowsPid(pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForGatewayLockReleasedAfterStop(profileDir: string, timeoutMs = 15000): Promise<boolean> {
|
||||
if (await waitForGatewayLockReleased(profileDir, timeoutMs)) return true
|
||||
await forceReleaseWindowsGatewayLock(profileDir)
|
||||
return waitForGatewayLockReleased(profileDir, 5000, true)
|
||||
}
|
||||
|
||||
function activeGatewayExecOpts() {
|
||||
return {
|
||||
...execOpts,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME: getActiveProfileDir(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function clearLegacyApiServerGatewayConfig(): Promise<void> {
|
||||
try {
|
||||
await updateConfigYaml((config) => {
|
||||
const result = stripLegacyApiServerGatewayConfig(config)
|
||||
return { data: result.config, result: undefined, write: result.changed }
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn(err, 'Failed to clear legacy api_server gateway config before restart')
|
||||
}
|
||||
}
|
||||
|
||||
export interface HermesSession {
|
||||
id: string
|
||||
source: string
|
||||
@@ -210,6 +368,26 @@ export async function deleteSession(id: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session from a specific Hermes profile.
|
||||
*/
|
||||
export async function deleteSessionForProfile(id: string, profile: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync(HERMES_BIN, ['sessions', 'delete', id, '--yes'], {
|
||||
timeout: 10000,
|
||||
...execOpts,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME: getProfileDir(profile),
|
||||
},
|
||||
})
|
||||
return true
|
||||
} catch (err: any) {
|
||||
logger.error({ err, sessionId: id, profile }, 'Hermes CLI: profile session delete failed')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a session title via Hermes CLI
|
||||
*/
|
||||
@@ -255,7 +433,7 @@ export async function startGateway(): Promise<string> {
|
||||
|
||||
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'start'], {
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
...activeGatewayExecOpts(),
|
||||
})
|
||||
return stdout || stderr
|
||||
}
|
||||
@@ -269,22 +447,49 @@ export async function startGatewayBackground(): Promise<number | null> {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME: getActiveProfileDir(),
|
||||
},
|
||||
})
|
||||
child.unref()
|
||||
return child.pid ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart Hermes gateway (stop then start)
|
||||
* Restart Hermes gateway through Hermes CLI, falling back to detached
|
||||
* `gateway run` when the environment does not support `gateway restart`.
|
||||
*/
|
||||
export async function restartGateway(): Promise<string> {
|
||||
try {
|
||||
await stopGateway()
|
||||
} catch (err) {
|
||||
// Ignore stop errors, gateway might not be running
|
||||
await clearLegacyApiServerGatewayConfig()
|
||||
const profileDir = getActiveProfileDir()
|
||||
if (isDocker || isTermux || process.platform === 'win32') {
|
||||
await stopGatewayForActiveProfile()
|
||||
const lockReleased = await waitForGatewayLockReleasedAfterStop(profileDir)
|
||||
if (!lockReleased) throw new Error('Gateway stopped but runtime lock is still held by another process')
|
||||
const result = startGatewayRunManaged(HERMES_BIN, { profileDir })
|
||||
const ready = await waitForGatewayRunning(profileDir)
|
||||
if (!ready) throw new Error(`Gateway run replace triggered but gateway did not report running within timeout${result.pid ? ` (PID: ${result.pid})` : ''}`)
|
||||
return result.pid ? `Gateway run replaced (PID: ${result.pid})` : 'Gateway run replaced'
|
||||
}
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'restart'], {
|
||||
timeout: 30000,
|
||||
...activeGatewayExecOpts(),
|
||||
})
|
||||
const ready = await waitForGatewayRunning(profileDir)
|
||||
if (!ready) throw new Error('Hermes gateway restart completed but gateway did not report running within timeout')
|
||||
return stdout || stderr
|
||||
} catch (err: any) {
|
||||
logger.warn(err, 'hermes gateway restart failed; falling back to gateway run')
|
||||
await stopGatewayForActiveProfile()
|
||||
const lockReleased = await waitForGatewayLockReleasedAfterStop(profileDir)
|
||||
if (!lockReleased) throw new Error('Gateway restart failed and runtime lock is still held by another process')
|
||||
const result = startGatewayRunManaged(HERMES_BIN, { profileDir })
|
||||
const ready = await waitForGatewayRunning(profileDir)
|
||||
if (!ready) throw new Error(`Gateway run fallback triggered but gateway did not report running within timeout${result.pid ? ` (PID: ${result.pid})` : ''}`)
|
||||
return result.pid ? `Gateway run started (PID: ${result.pid})` : 'Gateway run started'
|
||||
}
|
||||
const result = await startGateway()
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,7 +498,7 @@ export async function restartGateway(): Promise<string> {
|
||||
export async function stopGateway(): Promise<string> {
|
||||
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'stop'], {
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
...activeGatewayExecOpts(),
|
||||
})
|
||||
return stdout || stderr
|
||||
}
|
||||
@@ -363,7 +568,6 @@ export interface HermesProfile {
|
||||
name: string
|
||||
active: boolean
|
||||
model: string
|
||||
gateway: string
|
||||
alias: string
|
||||
}
|
||||
|
||||
@@ -372,7 +576,6 @@ export interface HermesProfileDetail {
|
||||
path: string
|
||||
model: string
|
||||
provider: string
|
||||
gateway: string
|
||||
skills: number
|
||||
hasEnv: boolean
|
||||
hasSoulMd: boolean
|
||||
@@ -403,7 +606,6 @@ export async function listProfiles(): Promise<HermesProfile[]> {
|
||||
name: match[2],
|
||||
active: !!match[1],
|
||||
model: match[3],
|
||||
gateway: match[4],
|
||||
alias: match[5].trim() === '—' ? '' : match[5].trim(),
|
||||
})
|
||||
}
|
||||
@@ -443,7 +645,6 @@ export async function getProfile(name: string): Promise<HermesProfileDetail> {
|
||||
path: result.path || '',
|
||||
model,
|
||||
provider: providerMatch ? providerMatch[1] : '',
|
||||
gateway: result.gateway || '',
|
||||
skills: parseInt(result.skills || '0', 10),
|
||||
hasEnv: result['.env'] === 'exists',
|
||||
hasSoulMd: result['soul.md'] === 'exists',
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - 用户自定义: HERMES_HOME 环境变量
|
||||
*/
|
||||
|
||||
import { basename, dirname, resolve, join } from 'path'
|
||||
import { basename, dirname, isAbsolute, relative, resolve, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
/**
|
||||
@@ -62,3 +62,20 @@ export function getHermesBin(customBin?: string): string {
|
||||
if (process.env.HERMES_BIN?.trim()) return process.env.HERMES_BIN.trim()
|
||||
return 'hermes'
|
||||
}
|
||||
|
||||
function comparablePath(path: string): string {
|
||||
return process.platform === 'win32' ? path.toLowerCase() : path
|
||||
}
|
||||
|
||||
export function isPathWithin(targetPath: string, basePath: string): boolean {
|
||||
const base = resolve(basePath)
|
||||
const target = resolve(targetPath)
|
||||
const rel = relative(comparablePath(base), comparablePath(target))
|
||||
return rel === '' || (!!rel && !rel.startsWith('..') && !isAbsolute(rel))
|
||||
}
|
||||
|
||||
export function relativePathFromBase(targetPath: string, basePath: string): string | null {
|
||||
if (!isPathWithin(targetPath, basePath)) return null
|
||||
const rel = relative(resolve(basePath), resolve(targetPath))
|
||||
return rel.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { join } from 'path'
|
||||
import { readFileSync, existsSync } from 'fs'
|
||||
import { readFileSync, existsSync, readdirSync } from 'fs'
|
||||
import { detectHermesRootHome } from './hermes-path'
|
||||
|
||||
export function getHermesBaseDir(): string {
|
||||
@@ -69,3 +69,21 @@ export function getProfileDir(name: string): string {
|
||||
const dir = join(hermesBase, 'profiles', name)
|
||||
return existsSync(dir) ? dir : hermesBase
|
||||
}
|
||||
|
||||
export function listProfileNamesFromDisk(): string[] {
|
||||
const hermesBase = getHermesBaseDir()
|
||||
const names = new Set<string>(['default'])
|
||||
const profilesDir = join(hermesBase, 'profiles')
|
||||
try {
|
||||
for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && entry.name.trim()) {
|
||||
names.add(entry.name)
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return [...names].sort((a, b) => {
|
||||
if (a === 'default') return -1
|
||||
if (b === 'default') return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,12 @@ const HERMES_BASE = detectHermesHome()
|
||||
const MODELS_DEV_CACHE = resolve(HERMES_BASE, 'models_dev_cache.json')
|
||||
const DEFAULT_CONTEXT_LENGTH = 200_000
|
||||
|
||||
export interface ModelContextLengthOptions {
|
||||
profile?: string
|
||||
model?: string | null
|
||||
provider?: string | null
|
||||
}
|
||||
|
||||
interface ModelLimit {
|
||||
context?: number
|
||||
output?: number
|
||||
@@ -351,15 +357,19 @@ function lookupContextFromDatabase(modelName: string, provider: string | null):
|
||||
}
|
||||
}
|
||||
|
||||
export function getModelContextLength(profile?: string): number {
|
||||
export function getModelContextLength(input?: string | ModelContextLengthOptions): number {
|
||||
const options: ModelContextLengthOptions = typeof input === 'string'
|
||||
? { profile: input }
|
||||
: input || {}
|
||||
const profile = options.profile
|
||||
const profileDir = getProfileDir(profile)
|
||||
const config = loadConfig(profileDir)
|
||||
if (!config) return DEFAULT_CONTEXT_LENGTH
|
||||
|
||||
const model = getDefaultModel(config)
|
||||
const model = String(options.model || '').trim() || getDefaultModel(config)
|
||||
if (!model) return DEFAULT_CONTEXT_LENGTH
|
||||
|
||||
const provider = getDefaultProvider(config)
|
||||
const provider = String(options.provider || '').trim() || getDefaultProvider(config)
|
||||
|
||||
// 0. Database model_context table (highest priority)
|
||||
const dbCtx = lookupContextFromDatabase(model, provider)
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function handleAbort(
|
||||
|
||||
if (state.source === 'cli') {
|
||||
try {
|
||||
await bridge.interrupt(sessionId, 'Aborted by user')
|
||||
await bridge.interrupt(sessionId, 'Aborted by user', state.profile)
|
||||
} catch (err) {
|
||||
logger.warn(err, '[chat-run-socket][abort] failed to interrupt CLI bridge for session %s', sessionId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
export interface BridgeDeltaFilterState {
|
||||
bridgePendingToolCallMarkup?: string
|
||||
}
|
||||
|
||||
const TOOL_CALL_MARKER = '[Calling tool:'
|
||||
const MAX_PENDING_TOOL_MARKUP_LENGTH = 100_000
|
||||
|
||||
function findToolMarkupEnd(text: string, start: number): number {
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let escaped = false
|
||||
|
||||
for (let i = start; i < text.length; i += 1) {
|
||||
const ch = text[i]
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
} else if (ch === '\\') {
|
||||
escaped = true
|
||||
} else if (ch === '"') {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
inString = true
|
||||
continue
|
||||
}
|
||||
if (ch === '[') {
|
||||
depth += 1
|
||||
continue
|
||||
}
|
||||
if (ch === ']') {
|
||||
depth -= 1
|
||||
if (depth === 0) return i + 1
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function trailingMarkerPrefixLength(text: string): number {
|
||||
const max = Math.min(text.length, TOOL_CALL_MARKER.length - 1)
|
||||
for (let len = max; len > 0; len -= 1) {
|
||||
if (TOOL_CALL_MARKER.startsWith(text.slice(text.length - len))) return len
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export function filterBridgeToolCallMarkupDelta(
|
||||
state: BridgeDeltaFilterState,
|
||||
delta: string,
|
||||
): string {
|
||||
if (!delta) return ''
|
||||
|
||||
const text = `${state.bridgePendingToolCallMarkup || ''}${delta}`
|
||||
state.bridgePendingToolCallMarkup = ''
|
||||
|
||||
let out = ''
|
||||
let idx = 0
|
||||
while (idx < text.length) {
|
||||
const markerIdx = text.indexOf(TOOL_CALL_MARKER, idx)
|
||||
if (markerIdx < 0) {
|
||||
const rest = text.slice(idx)
|
||||
const pendingPrefixLength = trailingMarkerPrefixLength(rest)
|
||||
if (pendingPrefixLength > 0) {
|
||||
out += rest.slice(0, rest.length - pendingPrefixLength)
|
||||
state.bridgePendingToolCallMarkup = rest.slice(rest.length - pendingPrefixLength)
|
||||
} else {
|
||||
out += rest
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
out += text.slice(idx, markerIdx)
|
||||
const end = findToolMarkupEnd(text, markerIdx)
|
||||
if (end < 0) {
|
||||
state.bridgePendingToolCallMarkup = text.slice(markerIdx)
|
||||
if (state.bridgePendingToolCallMarkup.length > MAX_PENDING_TOOL_MARKUP_LENGTH) {
|
||||
state.bridgePendingToolCallMarkup = ''
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
idx = end
|
||||
if (text[idx] === '\r' && text[idx + 1] === '\n') {
|
||||
idx += 2
|
||||
} else if (text[idx] === '\n') {
|
||||
idx += 1
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import {
|
||||
getSessionDetail,
|
||||
getSession,
|
||||
} from '../../../db/hermes/session-store'
|
||||
import { getCompressionSnapshot } from '../../../db/hermes/compression-snapshot'
|
||||
import { ChatContextCompressor, SUMMARY_PREFIX } from '../../../lib/context-compressor'
|
||||
@@ -96,12 +97,17 @@ export async function buildCompressedHistory(
|
||||
apiKey: string | undefined,
|
||||
emit: (event: string, payload: any) => void,
|
||||
sessionMap: Map<string, SessionState>,
|
||||
modelContext: { model?: string | null; provider?: string | null } = {},
|
||||
): Promise<ChatMessage[]> {
|
||||
try {
|
||||
let history = await buildDbHistory(sessionId, { excludeLastUser: true })
|
||||
if (history.length === 0) return []
|
||||
|
||||
const contextLength = getModelContextLength(profile)
|
||||
const contextLength = getModelContextLength({
|
||||
profile,
|
||||
model: modelContext.model,
|
||||
provider: modelContext.provider,
|
||||
})
|
||||
const triggerTokens = Math.floor(contextLength / 2)
|
||||
const cState = getOrCreateSession(sessionMap, sessionId)
|
||||
const assembledTokens = await calcAndUpdateUsage(sessionId, cState, emit)
|
||||
@@ -118,13 +124,13 @@ export async function buildCompressedHistory(
|
||||
...newMessages,
|
||||
] as ChatMessage[]
|
||||
} else {
|
||||
history = await compressHistory(history, newMessages, sessionId, upstream, apiKey, cState, totalTokens, emit, sessionMap)
|
||||
history = await compressHistory(history, newMessages, sessionId, upstream, apiKey, cState, totalTokens, emit, sessionMap, modelContext)
|
||||
}
|
||||
} else if (history.length > 4) {
|
||||
if (totalTokens <= triggerTokens && history.length <= 150) {
|
||||
logger.info('[context-compress] session=%s: %d messages, ~%d tokens — under threshold, skip', sessionId, history.length, totalTokens)
|
||||
} else {
|
||||
history = await compressHistory(history, null, sessionId, upstream, apiKey, cState, totalTokens, emit, sessionMap)
|
||||
history = await compressHistory(history, null, sessionId, upstream, apiKey, cState, totalTokens, emit, sessionMap, modelContext)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +151,7 @@ export async function compressHistory(
|
||||
totalTokens: number,
|
||||
emit: (event: string, payload: any) => void,
|
||||
sessionMap: Map<string, SessionState>,
|
||||
modelContext: { model?: string | null; provider?: string | null } = {},
|
||||
): Promise<ChatMessage[]> {
|
||||
const msgCount = newMessagesOnly ? newMessagesOnly.length : history.length
|
||||
pushState(sessionMap, sessionId, 'compression.started', {
|
||||
@@ -155,7 +162,12 @@ export async function compressHistory(
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await compressor.compress(history, upstream, apiKey, sessionId)
|
||||
const session = getSession(sessionId)
|
||||
const result = await compressor.compress(history, upstream, apiKey, sessionId, {
|
||||
profile: session?.profile,
|
||||
model: modelContext.model || session?.model,
|
||||
provider: modelContext.provider || session?.provider,
|
||||
})
|
||||
const afterTokens = await calcAndUpdateUsage(sessionId, cState, emit)
|
||||
const compressedMeta = {
|
||||
event: 'compression.completed' as const,
|
||||
@@ -211,8 +223,6 @@ export async function forceCompressBridgeHistory(
|
||||
sessionId: string,
|
||||
profile: string,
|
||||
_messages: ChatMessage[],
|
||||
getUpstream: (profile: string) => string,
|
||||
getApiKey: (profile: string) => string | undefined,
|
||||
): Promise<BridgeCompressionResult> {
|
||||
const history = await buildDbHistory(sessionId, { excludeLastUser: true })
|
||||
|
||||
@@ -231,8 +241,9 @@ export async function forceCompressBridgeHistory(
|
||||
}
|
||||
}
|
||||
|
||||
const upstream = getUpstream(profile).replace(/\/$/, '')
|
||||
const apiKey = getApiKey(profile) || undefined
|
||||
const upstream = ''
|
||||
const apiKey = undefined
|
||||
const session = getSession(sessionId)
|
||||
const beforeUsage = estimateSnapshotAwareHistoryUsage(sessionId, history)
|
||||
const totalTokens = beforeUsage.tokenCount
|
||||
bridgeLogger.info({
|
||||
@@ -245,7 +256,11 @@ export async function forceCompressBridgeHistory(
|
||||
snapshotAware: true,
|
||||
}, '[chat-run-socket] bridge forced compression started')
|
||||
|
||||
const result = await compressor.compress(history, upstream, apiKey, sessionId, profile)
|
||||
const result = await compressor.compress(history, upstream, apiKey, sessionId, {
|
||||
profile: session?.profile || profile,
|
||||
model: session?.model,
|
||||
provider: session?.provider,
|
||||
})
|
||||
const compressedMessages = result.messages.map(m => {
|
||||
const msg: any = { role: m.role, content: m.content }
|
||||
if (m.reasoning_content) msg.reasoning_content = m.reasoning_content
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ContentBlock } from './types'
|
||||
|
||||
type ResponseContentPart = { type: string; text?: string; image_url?: string }
|
||||
type AgentContentPart = { type: string; text?: string; image_url?: { url: string } }
|
||||
|
||||
/**
|
||||
* Convert ContentBlock[] to string for display/storage
|
||||
*/
|
||||
@@ -29,22 +32,16 @@ export function isContentBlockArray(input: any): input is ContentBlock[] {
|
||||
/**
|
||||
* Convert ContentBlock[] to multimodal format for /v1/responses API.
|
||||
*/
|
||||
export async function convertContentBlocks(blocks: ContentBlock[]): Promise<Array<{ type: string; text?: string; image_url?: string }>> {
|
||||
const parts: Array<{ type: string; text?: string; image_url?: string }> = []
|
||||
const fs = await import('fs/promises')
|
||||
const path = await import('path')
|
||||
|
||||
export async function convertContentBlocks(blocks: ContentBlock[]): Promise<ResponseContentPart[]> {
|
||||
const parts: ResponseContentPart[] = []
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text') {
|
||||
parts.push({ type: 'input_text', text: block.text })
|
||||
} else if (block.type === 'image') {
|
||||
try {
|
||||
const buf = await fs.readFile(block.path)
|
||||
const ext = path.extname(block.path).toLowerCase().replace('.', '')
|
||||
const mime = ext === 'jpg' ? 'jpeg' : ext || 'png'
|
||||
const base64 = buf.toString('base64')
|
||||
parts.push({ type: 'input_image', image_url: `data:image/${mime};base64,${base64}` })
|
||||
} catch {
|
||||
const dataUri = await imageBlockToDataUri(block)
|
||||
if (dataUri) {
|
||||
parts.push({ type: 'input_image', image_url: dataUri })
|
||||
} else {
|
||||
parts.push({ type: 'input_text', text: `[Image: ${block.path}]` })
|
||||
}
|
||||
} else if (block.type === 'file') {
|
||||
@@ -59,15 +56,42 @@ export async function convertContentBlocks(blocks: ContentBlock[]): Promise<Arra
|
||||
* Convert ContentBlock[] to the normalized multimodal shape Hermes agent
|
||||
* receives after /v1/responses input normalization.
|
||||
*/
|
||||
export async function convertContentBlocksForAgent(blocks: ContentBlock[]): Promise<Array<{ type: string; text?: string; image_url?: { url: string } }>> {
|
||||
const responseParts = await convertContentBlocks(blocks)
|
||||
return responseParts.map((part) => {
|
||||
if (part.type === 'input_text') {
|
||||
return { type: 'text', text: part.text || '' }
|
||||
export async function convertContentBlocksForAgent(blocks: ContentBlock[]): Promise<AgentContentPart[]> {
|
||||
const parts: AgentContentPart[] = []
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text') {
|
||||
parts.push({ type: 'text', text: block.text || '' })
|
||||
} else if (block.type === 'image') {
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: `[Attached image: ${block.name || block.path}]\nLocal image path for tools: ${block.path}`,
|
||||
})
|
||||
const dataUri = await imageBlockToDataUri(block)
|
||||
if (dataUri) {
|
||||
parts.push({ type: 'image_url', image_url: { url: dataUri } })
|
||||
}
|
||||
} else if (block.type === 'file') {
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: `[Attached file: ${block.name || block.path}]\nLocal file path for tools: ${block.path}`,
|
||||
})
|
||||
}
|
||||
if (part.type === 'input_image') {
|
||||
return { type: 'image_url', image_url: { url: part.image_url || '' } }
|
||||
}
|
||||
return { type: 'text', text: part.text || '' }
|
||||
})
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
async function imageBlockToDataUri(block: Extract<ContentBlock, { type: 'image' }>): Promise<string | null> {
|
||||
try {
|
||||
const fs = await import('fs/promises')
|
||||
const path = await import('path')
|
||||
const buf = await fs.readFile(block.path)
|
||||
const ext = path.extname(block.path).toLowerCase().replace('.', '')
|
||||
const mimeFromExt = ext === 'jpg' ? 'jpeg' : ext || 'png'
|
||||
const mime = block.media_type?.startsWith('image/')
|
||||
? block.media_type.slice('image/'.length)
|
||||
: mimeFromExt
|
||||
return `data:image/${mime};base64,${buf.toString('base64')}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,15 +25,8 @@ import { countTokens, SUMMARY_PREFIX } from '../../../lib/context-compressor'
|
||||
import { getCompressionSnapshot } from '../../../db/hermes/compression-snapshot'
|
||||
import type { ContentBlock, SessionState, ChatRunSource } from './types'
|
||||
|
||||
export function resolveRunSource(source?: string, sessionId?: string): ChatRunSource {
|
||||
const normalized = String(source || '').trim()
|
||||
if (normalized === 'cli') return 'cli'
|
||||
if (normalized === 'api_server') return 'api_server'
|
||||
if (sessionId) {
|
||||
const existing = getSession(sessionId)
|
||||
if (existing?.source === 'cli') return 'cli'
|
||||
}
|
||||
return 'api_server'
|
||||
export function resolveRunSource(_source?: string, _sessionId?: string): ChatRunSource {
|
||||
return 'cli'
|
||||
}
|
||||
|
||||
export async function loadSessionStateFromDb(sid: string, _sessionMap: Map<string, SessionState>): Promise<SessionState> {
|
||||
@@ -78,7 +71,6 @@ export async function handleApiRun(
|
||||
data: { input: string | ContentBlock[]; session_id?: string; model?: string; provider?: string; instructions?: string; source?: string },
|
||||
profile: string,
|
||||
sessionMap: Map<string, SessionState>,
|
||||
gatewayManager: any,
|
||||
skipUserMessage = false,
|
||||
dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => void,
|
||||
) {
|
||||
@@ -96,8 +88,8 @@ export async function handleApiRun(
|
||||
}
|
||||
}
|
||||
|
||||
const upstream = gatewayManager.getUpstream(profile).replace(/\/$/, '')
|
||||
const apiKey = gatewayManager.getApiKey(profile) || undefined
|
||||
const upstream = ''
|
||||
const apiKey = undefined
|
||||
|
||||
const runMarker = session_id
|
||||
? `resp_run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
||||
@@ -179,7 +171,11 @@ export async function handleApiRun(
|
||||
if (model) body.model = model
|
||||
body.instructions = fullInstructions
|
||||
if (session_id) {
|
||||
const compressed = await buildCompressedHistory(session_id, profile, upstream, apiKey, emit, sessionMap)
|
||||
const sessionRow = getSession(session_id)
|
||||
const compressed = await buildCompressedHistory(session_id, profile, upstream, apiKey, emit, sessionMap, {
|
||||
model: sessionRow?.model || model,
|
||||
provider: sessionRow?.provider || provider,
|
||||
})
|
||||
if (compressed.length > 0) {
|
||||
body.conversation_history = compressed
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { getSession, createSession, addMessage, updateSession, updateSessionStat
|
||||
import { updateUsage } from '../../../db/hermes/usage-store'
|
||||
import { logger, bridgeLogger } from '../../logger'
|
||||
import { AgentBridgeClient, type AgentBridgeMessage, type AgentBridgeOutput } from '../agent-bridge'
|
||||
import { readConfigYaml } from '../../config-helpers'
|
||||
import { contentBlocksToString, convertContentBlocksForAgent, extractTextForPreview, isContentBlockArray } from './content-blocks'
|
||||
import { buildCompressedHistory } from './compression'
|
||||
import { pushState, replaceState } from './compression'
|
||||
@@ -24,43 +23,19 @@ import {
|
||||
import { forceCompressBridgeHistory } from './compression'
|
||||
import { summarizeToolArguments } from './response-utils'
|
||||
import { buildDbHistory } from './compression'
|
||||
import { convertHistoryFormat } from './message-format'
|
||||
import type { ContentBlock, SessionState } from './types'
|
||||
import type { ChatMessage } from '../../../lib/context-compressor'
|
||||
import { resolveBridgeRunModelConfig, type RunModelGroup } from './model-config'
|
||||
import { filterBridgeToolCallMarkupDelta } from './bridge-delta'
|
||||
|
||||
const BRIDGE_USAGE_FLUSH_DELAY_MS = 200
|
||||
|
||||
type RunModelGroup = { provider: string; models: string[] }
|
||||
|
||||
async function resolveDefaultModelConfig(): Promise<{ model: string; provider: string }> {
|
||||
try {
|
||||
const config = await readConfigYaml()
|
||||
const modelConfig = config?.model
|
||||
const model = typeof modelConfig === 'string'
|
||||
? modelConfig.trim()
|
||||
: String(modelConfig?.default || '').trim()
|
||||
const provider = typeof modelConfig === 'object'
|
||||
? String(modelConfig?.provider || '').trim()
|
||||
: ''
|
||||
return { model, provider }
|
||||
} catch {
|
||||
return { model: '', provider: '' }
|
||||
}
|
||||
}
|
||||
|
||||
function hasModelInGroups(groups: RunModelGroup[] | undefined, provider: string, model: string): boolean {
|
||||
if (!groups?.length || !provider || !model) return false
|
||||
const group = groups.find(item => item.provider === provider)
|
||||
return Array.isArray(group?.models) && group.models.includes(model)
|
||||
}
|
||||
|
||||
export async function handleBridgeRun(
|
||||
nsp: ReturnType<Server['of']>,
|
||||
socket: Socket,
|
||||
data: { input: string | ContentBlock[]; session_id?: string; model?: string; provider?: string; model_groups?: RunModelGroup[]; instructions?: string; source?: string },
|
||||
profile: string,
|
||||
sessionMap: Map<string, SessionState>,
|
||||
gatewayManager: any,
|
||||
bridge: AgentBridgeClient,
|
||||
_skipUserMessage = false,
|
||||
loadSessionStateFromDbFn: (sid: string, sessionMap: Map<string, SessionState>) => Promise<SessionState>,
|
||||
@@ -78,14 +53,14 @@ export async function handleBridgeRun(
|
||||
const sessionRow = getSession(session_id)
|
||||
const sessionModel = sessionRow?.model || ''
|
||||
const sessionProvider = sessionRow?.provider || ''
|
||||
const hasGroups = Array.isArray(data.model_groups) && data.model_groups.length > 0
|
||||
const sessionModelAvailable = hasGroups && hasModelInGroups(data.model_groups, sessionProvider, sessionModel)
|
||||
const shouldUseDefault = !sessionModel || !sessionProvider || !sessionModelAvailable
|
||||
const defaultModelConfig = shouldUseDefault
|
||||
? await resolveDefaultModelConfig()
|
||||
: { model: '', provider: '' }
|
||||
const resolvedModel = shouldUseDefault ? defaultModelConfig.model : sessionModel
|
||||
const resolvedProvider = shouldUseDefault ? defaultModelConfig.provider : sessionProvider
|
||||
const { model: resolvedModel, provider: resolvedProvider } = await resolveBridgeRunModelConfig({
|
||||
profile,
|
||||
sessionModel,
|
||||
sessionProvider,
|
||||
requestedModel: data.model,
|
||||
requestedProvider: data.provider,
|
||||
modelGroups: data.model_groups,
|
||||
})
|
||||
if (sessionRow) {
|
||||
const updates: { model?: string; provider?: string } = {}
|
||||
if (resolvedModel && sessionRow.model !== resolvedModel) updates.model = resolvedModel
|
||||
@@ -117,6 +92,7 @@ export async function handleBridgeRun(
|
||||
state.bridgeOutput = ''
|
||||
state.bridgePendingAssistantContent = ''
|
||||
state.bridgePendingReasoningContent = ''
|
||||
state.bridgePendingToolCallMarkup = ''
|
||||
state.bridgeToolCounter = 0
|
||||
state.bridgePendingTools = []
|
||||
state.responseRun = undefined
|
||||
@@ -154,12 +130,13 @@ export async function handleBridgeRun(
|
||||
|
||||
const history = await buildCompressedHistory(
|
||||
session_id, profile,
|
||||
gatewayManager.getUpstream(profile).replace(/\/$/, ''),
|
||||
gatewayManager.getApiKey(profile) || undefined,
|
||||
'',
|
||||
undefined,
|
||||
emit,
|
||||
sessionMap,
|
||||
{ model: resolvedModel, provider: resolvedProvider },
|
||||
)
|
||||
const bridgeHistory = history.length > 0 ? convertHistoryFormat(history) : history
|
||||
const bridgeHistory = history
|
||||
|
||||
try {
|
||||
const bridgeInput = isContentBlockArray(input)
|
||||
@@ -207,7 +184,7 @@ export async function handleBridgeRun(
|
||||
})
|
||||
|
||||
for await (const chunk of bridge.streamOutput(started.run_id)) {
|
||||
await applyBridgeChunkAsync(nsp, socket, state, session_id, runMarker, chunk, emit, profile, sessionMap, gatewayManager, bridge, dequeueNextQueuedRun)
|
||||
await applyBridgeChunkAsync(nsp, socket, state, session_id, runMarker, chunk, emit, profile, sessionMap, bridge, dequeueNextQueuedRun)
|
||||
if (chunk.done) break
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -220,6 +197,7 @@ export async function handleBridgeRun(
|
||||
state.runId = undefined
|
||||
state.activeRunMarker = undefined
|
||||
state.events = []
|
||||
state.bridgePendingToolCallMarkup = undefined
|
||||
flushBridgePendingToDb(state, session_id)
|
||||
updateSessionStats(session_id)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
@@ -244,7 +222,6 @@ async function applyBridgeChunkAsync(
|
||||
emit: (event: string, payload: any) => void,
|
||||
profile: string,
|
||||
sessionMap: Map<string, SessionState>,
|
||||
gatewayManager: any,
|
||||
bridge: AgentBridgeClient,
|
||||
dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => void,
|
||||
): Promise<void> {
|
||||
@@ -357,8 +334,6 @@ async function applyBridgeChunkAsync(
|
||||
sessionId,
|
||||
profile,
|
||||
ev.messages as ChatMessage[],
|
||||
(p: string) => gatewayManager.getUpstream(p),
|
||||
(p: string) => gatewayManager.getApiKey(p),
|
||||
)
|
||||
state.bridgeCompressionResults = state.bridgeCompressionResults || {}
|
||||
state.bridgeCompressionResults[String(ev.request_id)] = compressed
|
||||
@@ -421,30 +396,33 @@ async function applyBridgeChunkAsync(
|
||||
}
|
||||
|
||||
if (chunk.delta) {
|
||||
state.bridgeOutput = (state.bridgeOutput || '') + chunk.delta
|
||||
state.bridgePendingAssistantContent = (state.bridgePendingAssistantContent || '') + chunk.delta
|
||||
const last = [...state.messages].reverse().find(m => m.runMarker === runMarker)
|
||||
if (last?.role === 'assistant' && last.finish_reason == null) {
|
||||
last.content += chunk.delta
|
||||
syncBridgeReasoningToMessage(last, state.bridgePendingReasoningContent)
|
||||
} else {
|
||||
state.messages.push({
|
||||
id: state.messages.length + 1,
|
||||
session_id: sessionId,
|
||||
runMarker,
|
||||
role: 'assistant',
|
||||
content: chunk.delta,
|
||||
reasoning: state.bridgePendingReasoningContent || null,
|
||||
reasoning_content: state.bridgePendingReasoningContent || null,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
const delta = filterBridgeToolCallMarkupDelta(state, chunk.delta)
|
||||
if (delta) {
|
||||
state.bridgeOutput = (state.bridgeOutput || '') + delta
|
||||
state.bridgePendingAssistantContent = (state.bridgePendingAssistantContent || '') + delta
|
||||
const last = [...state.messages].reverse().find(m => m.runMarker === runMarker)
|
||||
if (last?.role === 'assistant' && last.finish_reason == null) {
|
||||
last.content += delta
|
||||
syncBridgeReasoningToMessage(last, state.bridgePendingReasoningContent)
|
||||
} else {
|
||||
state.messages.push({
|
||||
id: state.messages.length + 1,
|
||||
session_id: sessionId,
|
||||
runMarker,
|
||||
role: 'assistant',
|
||||
content: delta,
|
||||
reasoning: state.bridgePendingReasoningContent || null,
|
||||
reasoning_content: state.bridgePendingReasoningContent || null,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
}
|
||||
emit('message.delta', {
|
||||
event: 'message.delta',
|
||||
run_id: chunk.run_id,
|
||||
delta,
|
||||
output: state.bridgeOutput,
|
||||
})
|
||||
}
|
||||
emit('message.delta', {
|
||||
event: 'message.delta',
|
||||
run_id: chunk.run_id,
|
||||
delta: chunk.delta,
|
||||
output: state.bridgeOutput,
|
||||
})
|
||||
}
|
||||
|
||||
if (!chunk.done) return
|
||||
@@ -459,6 +437,7 @@ async function applyBridgeChunkAsync(
|
||||
}
|
||||
|
||||
flushBridgePendingToDb(state, sessionId)
|
||||
state.bridgePendingToolCallMarkup = undefined
|
||||
updateSessionStats(sessionId)
|
||||
await delay(BRIDGE_USAGE_FLUSH_DELAY_MS)
|
||||
const usage = await calcAndUpdateUsage(sessionId, state, emit)
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { Server, Socket } from 'socket.io'
|
||||
import { logger } from '../../logger'
|
||||
import { getSystemPrompt } from '../../../lib/llm-prompt'
|
||||
import { getSession } from '../../../db/hermes/session-store'
|
||||
import { getActiveProfileName } from '../hermes-profile'
|
||||
import { getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../hermes-profile'
|
||||
import { AgentBridgeClient } from '../agent-bridge'
|
||||
import { handleApiRun, resolveRunSource, loadSessionStateFromDb } from './handle-api-run'
|
||||
import { handleBridgeRun } from './handle-bridge-run'
|
||||
@@ -25,14 +25,12 @@ export type { ContentBlock } from './types'
|
||||
|
||||
export class ChatRunSocket {
|
||||
private nsp: ReturnType<Server['of']>
|
||||
private gatewayManager: any
|
||||
private bridge = new AgentBridgeClient()
|
||||
/** sessionId → session state (messages, working status, events, run tracking) */
|
||||
private sessionMap = new Map<string, SessionState>()
|
||||
|
||||
constructor(io: Server, gatewayManager: any) {
|
||||
constructor(io: Server) {
|
||||
this.nsp = io.of('/chat-run')
|
||||
this.gatewayManager = gatewayManager
|
||||
}
|
||||
|
||||
init() {
|
||||
@@ -60,6 +58,17 @@ export class ChatRunSocket {
|
||||
private onConnection(socket: Socket) {
|
||||
const socketProfile = (socket.handshake.query?.profile as string) || 'default'
|
||||
const currentProfile = () => getActiveProfileName() || socketProfile || 'default'
|
||||
const profileExists = (profile: string) => {
|
||||
if (!profile || profile === 'default') return true
|
||||
return listProfileNamesFromDisk().includes(profile)
|
||||
}
|
||||
const resolveRunProfile = (sessionId?: string, requested?: string) => {
|
||||
const requestedProfile = typeof requested === 'string' ? requested.trim() : ''
|
||||
if (requestedProfile && profileExists(requestedProfile)) return requestedProfile
|
||||
if (!sessionId) return currentProfile()
|
||||
const storedProfile = getSession(sessionId)?.profile || ''
|
||||
return storedProfile && profileExists(storedProfile) ? storedProfile : currentProfile()
|
||||
}
|
||||
|
||||
socket.on('run', async (data: {
|
||||
input: string | ContentBlock[]
|
||||
@@ -70,7 +79,9 @@ export class ChatRunSocket {
|
||||
model_groups?: Array<{ provider: string; models: string[] }>
|
||||
queue_id?: string
|
||||
source?: string
|
||||
profile?: string
|
||||
}) => {
|
||||
const runProfile = resolveRunProfile(data.session_id, data.profile)
|
||||
if (data.session_id) {
|
||||
const state = getOrCreateSession(this.sessionMap, data.session_id)
|
||||
const source = resolveRunSource(data.source, data.session_id)
|
||||
@@ -82,8 +93,7 @@ export class ChatRunSocket {
|
||||
socket,
|
||||
sessionMap: this.sessionMap,
|
||||
bridge: this.bridge,
|
||||
gatewayManager: this.gatewayManager,
|
||||
profile: currentProfile(),
|
||||
profile: runProfile,
|
||||
model: data.model,
|
||||
instructions: data.instructions,
|
||||
runQueuedItem: this.runQueuedItem.bind(this),
|
||||
@@ -107,7 +117,7 @@ export class ChatRunSocket {
|
||||
provider: data.provider,
|
||||
model_groups: data.model_groups,
|
||||
instructions: data.instructions,
|
||||
profile: currentProfile(),
|
||||
profile: runProfile,
|
||||
source,
|
||||
})
|
||||
this.nsp.to(`session:${data.session_id}`).emit('run.queued', {
|
||||
@@ -119,11 +129,11 @@ export class ChatRunSocket {
|
||||
return
|
||||
}
|
||||
state.isWorking = true
|
||||
state.profile = currentProfile()
|
||||
state.profile = runProfile
|
||||
state.source = source
|
||||
}
|
||||
try {
|
||||
await this.handleRun(socket, data, currentProfile())
|
||||
await this.handleRun(socket, data, runProfile)
|
||||
} catch (err) {
|
||||
if (data.session_id) {
|
||||
const state = this.sessionMap.get(data.session_id)
|
||||
@@ -224,7 +234,7 @@ export class ChatRunSocket {
|
||||
|
||||
await handleBridgeRun(
|
||||
this.nsp, socket, { ...data, instructions: fullInstructions }, profile,
|
||||
this.sessionMap, this.gatewayManager, this.bridge,
|
||||
this.sessionMap, this.bridge,
|
||||
skipUserMessage,
|
||||
loadSessionStateFromDb,
|
||||
this.dequeueNextQueuedRun.bind(this),
|
||||
@@ -234,7 +244,7 @@ export class ChatRunSocket {
|
||||
|
||||
await handleApiRun(
|
||||
this.nsp, socket, data, profile,
|
||||
this.sessionMap, this.gatewayManager,
|
||||
this.sessionMap,
|
||||
skipUserMessage,
|
||||
this.dequeueNextQueuedRun.bind(this),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readConfigYamlForProfile } from '../../config-helpers'
|
||||
|
||||
export type RunModelGroup = { provider: string; models: string[] }
|
||||
|
||||
async function resolveDefaultModelConfig(profile: string): Promise<{ model: string; provider: string }> {
|
||||
try {
|
||||
const config = await readConfigYamlForProfile(profile)
|
||||
const modelConfig = config?.model
|
||||
const model = typeof modelConfig === 'string'
|
||||
? modelConfig.trim()
|
||||
: String(modelConfig?.default || '').trim()
|
||||
const provider = typeof modelConfig === 'object'
|
||||
? String(modelConfig?.provider || '').trim()
|
||||
: ''
|
||||
return { model, provider }
|
||||
} catch {
|
||||
return { model: '', provider: '' }
|
||||
}
|
||||
}
|
||||
|
||||
function hasModelInGroups(groups: RunModelGroup[] | undefined, provider: string, model: string): boolean {
|
||||
if (!groups?.length || !provider || !model) return false
|
||||
const group = groups.find(item => item.provider === provider)
|
||||
return Array.isArray(group?.models) && group.models.includes(model)
|
||||
}
|
||||
|
||||
export async function resolveBridgeRunModelConfig(options: {
|
||||
profile: string
|
||||
sessionModel?: string | null
|
||||
sessionProvider?: string | null
|
||||
requestedModel?: string | null
|
||||
requestedProvider?: string | null
|
||||
modelGroups?: RunModelGroup[]
|
||||
}): Promise<{ model: string; provider: string }> {
|
||||
const sessionModel = String(options.sessionModel || '').trim()
|
||||
const sessionProvider = String(options.sessionProvider || '').trim()
|
||||
const requestedModel = String(options.requestedModel || '').trim()
|
||||
const requestedProvider = String(options.requestedProvider || '').trim()
|
||||
const candidateModel = sessionModel || requestedModel
|
||||
const candidateProvider = sessionProvider || requestedProvider
|
||||
const hasGroups = Array.isArray(options.modelGroups) && options.modelGroups.length > 0
|
||||
const candidateAvailable = hasGroups && hasModelInGroups(options.modelGroups, candidateProvider, candidateModel)
|
||||
const shouldUseDefault = !candidateModel || !candidateProvider || !candidateAvailable
|
||||
return shouldUseDefault
|
||||
? resolveDefaultModelConfig(options.profile)
|
||||
: { model: candidateModel, provider: candidateProvider }
|
||||
}
|
||||
@@ -30,7 +30,6 @@ interface SessionCommandContext {
|
||||
socket: Socket
|
||||
sessionMap: Map<string, SessionState>
|
||||
bridge: AgentBridgeClient
|
||||
gatewayManager: any
|
||||
profile: string
|
||||
model?: string
|
||||
instructions?: string
|
||||
@@ -243,8 +242,6 @@ export async function handleSessionCommand(
|
||||
sessionId,
|
||||
ctx.profile,
|
||||
[],
|
||||
(profile: string) => ctx.gatewayManager.getUpstream(profile),
|
||||
(profile: string) => ctx.gatewayManager.getApiKey(profile),
|
||||
)
|
||||
state.bridgeCompressionResults = state.bridgeCompressionResults || {}
|
||||
await calcAndUpdateUsage(sessionId, state, emit)
|
||||
@@ -312,11 +309,11 @@ export async function handleSessionCommand(
|
||||
try {
|
||||
if (wasWorking) {
|
||||
flushBridgePendingToDb(state, sessionId)
|
||||
await ctx.bridge.interrupt(sessionId, 'Destroyed by user').catch((err) => {
|
||||
await ctx.bridge.interrupt(sessionId, 'Destroyed by user', state.profile).catch((err) => {
|
||||
logger.warn(err, '[chat-run-socket] /destroy interrupt failed for session %s', sessionId)
|
||||
})
|
||||
}
|
||||
await ctx.bridge.destroy(sessionId).catch((err) => {
|
||||
await ctx.bridge.destroy(sessionId, state.profile).catch((err) => {
|
||||
bridgeReachable = false
|
||||
bridgeError = err instanceof Error ? err.message : String(err)
|
||||
logger.warn(err, '[chat-run-socket] /destroy bridge unavailable for session %s', sessionId)
|
||||
@@ -337,6 +334,7 @@ export async function handleSessionCommand(
|
||||
state.queue = []
|
||||
state.bridgePendingAssistantContent = undefined
|
||||
state.bridgePendingReasoningContent = undefined
|
||||
state.bridgePendingToolCallMarkup = undefined
|
||||
state.bridgeOutput = undefined
|
||||
state.bridgePendingTools = undefined
|
||||
state.bridgeCompressionResults = undefined
|
||||
@@ -366,6 +364,7 @@ export async function handleSessionCommand(
|
||||
function clearTransientRunState(state: SessionState) {
|
||||
state.events = []
|
||||
state.bridgePendingTools = undefined
|
||||
state.bridgePendingToolCallMarkup = undefined
|
||||
state.bridgeCompressionResults = undefined
|
||||
state.responseRun = undefined
|
||||
state.activeRunMarker = undefined
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface SessionState {
|
||||
source?: ChatRunSource
|
||||
bridgePendingAssistantContent?: string
|
||||
bridgePendingReasoningContent?: string
|
||||
bridgePendingToolCallMarkup?: string
|
||||
bridgeOutput?: string
|
||||
bridgeToolCounter?: number
|
||||
bridgePendingTools?: Array<{
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { copyFile, mkdir, readdir, rm, stat } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { join, resolve } from 'path'
|
||||
import { getActiveProfileDir } from './hermes-profile'
|
||||
import { logger } from '../logger'
|
||||
|
||||
export interface SkillInjectionResult {
|
||||
sourceDir: string
|
||||
targetDir: string
|
||||
injected: string[]
|
||||
updated: string[]
|
||||
skipped: string[]
|
||||
}
|
||||
|
||||
export class HermesSkillInjector {
|
||||
constructor(
|
||||
private readonly sourceDir = HermesSkillInjector.resolveSourceDir(),
|
||||
private readonly targetDir = join(getActiveProfileDir(), 'skills'),
|
||||
) {}
|
||||
|
||||
static resolveSourceDir(env: NodeJS.ProcessEnv = process.env, baseDir = __dirname): string {
|
||||
const override = env.HERMES_WEB_UI_SKILLS_DIR?.trim()
|
||||
if (override) return resolve(override)
|
||||
|
||||
const candidates = [
|
||||
// Production bundle: dist/server/index.js with dist/skills copied by build.
|
||||
resolve(baseDir, '../skills'),
|
||||
// Development/test: packages/server/src/services/hermes -> packages/skills.
|
||||
resolve(baseDir, '../../../../skills'),
|
||||
// Running from repository root without bundling.
|
||||
resolve(process.cwd(), 'packages/skills'),
|
||||
]
|
||||
|
||||
return candidates.find(candidate => existsSync(candidate)) || candidates[0]
|
||||
}
|
||||
|
||||
async injectMissingSkills(): Promise<SkillInjectionResult> {
|
||||
const result: SkillInjectionResult = {
|
||||
sourceDir: this.sourceDir,
|
||||
targetDir: this.targetDir,
|
||||
injected: [],
|
||||
updated: [],
|
||||
skipped: [],
|
||||
}
|
||||
|
||||
if (!await this.isDirectory(this.sourceDir)) {
|
||||
logger.debug('[skill-injector] no bundled skills directory at %s', this.sourceDir)
|
||||
return result
|
||||
}
|
||||
|
||||
await mkdir(this.targetDir, { recursive: true })
|
||||
const entries = await readdir(this.sourceDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
||||
const sourceSkillDir = join(this.sourceDir, entry.name)
|
||||
const targetSkillDir = join(this.targetDir, entry.name)
|
||||
const existed = existsSync(targetSkillDir)
|
||||
if (existsSync(targetSkillDir)) {
|
||||
await rm(targetSkillDir, { recursive: true, force: true })
|
||||
}
|
||||
await this.copyDir(sourceSkillDir, targetSkillDir)
|
||||
if (existed) result.updated.push(entry.name)
|
||||
else result.injected.push(entry.name)
|
||||
}
|
||||
|
||||
if (result.injected.length > 0 || result.updated.length > 0) {
|
||||
logger.info({
|
||||
injected: result.injected,
|
||||
updated: result.updated,
|
||||
targetDir: this.targetDir,
|
||||
}, '[skill-injector] synced bundled skills')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private async isDirectory(path: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(path)).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async copyDir(sourceDir: string, targetDir: string): Promise<void> {
|
||||
await mkdir(targetDir, { recursive: true })
|
||||
const entries = await readdir(sourceDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const sourcePath = join(sourceDir, entry.name)
|
||||
const targetPath = join(targetDir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await this.copyDir(sourcePath, targetPath)
|
||||
} else if (entry.isFile()) {
|
||||
await copyFile(sourcePath, targetPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import pino from 'pino'
|
||||
import { resolve } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { join, resolve } from 'path'
|
||||
import { mkdirSync, statSync, truncateSync, openSync, readSync, closeSync, writeFileSync } from 'fs'
|
||||
import { config } from '../config'
|
||||
|
||||
const MAX_LOG_SIZE = 3 * 1024 * 1024 // 3MB
|
||||
const CHECK_INTERVAL = 60_000 // Check every minute
|
||||
|
||||
const logDir = resolve(config.appHome, 'logs')
|
||||
const logDir = process.env.VITEST
|
||||
? resolve(tmpdir(), 'hermes-web-ui-test-logs', String(process.pid))
|
||||
: resolve(config.appHome, 'logs')
|
||||
mkdirSync(logDir, { recursive: true })
|
||||
|
||||
const logFile = resolve(logDir, 'server.log')
|
||||
|
||||
@@ -1,31 +1,5 @@
|
||||
import { logger } from './logger'
|
||||
import { closeDb } from '../db'
|
||||
import { getGatewayManagerInstance } from './gateway-bootstrap'
|
||||
|
||||
function shouldStopGatewaysOnShutdown(signal: string): boolean {
|
||||
// nodemon may use SIGTERM on Windows restarts, so dev mode opts out via env.
|
||||
// Production keeps stopping owned gateways by default.
|
||||
const override = process.env.HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN?.trim()
|
||||
|
||||
console.log(`[shutdown] Signal: ${signal}, HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN: ${override}`)
|
||||
|
||||
// Explicit '0' or 'false' means dev mode: never stop gateways
|
||||
if (override === '0' || override === 'false') {
|
||||
console.log('[shutdown] Dev mode detected: NOT stopping gateways')
|
||||
return false
|
||||
}
|
||||
|
||||
// Explicit '1' or 'true' means always stop gateways
|
||||
if (override === '1' || override === 'true') {
|
||||
console.log('[shutdown] Explicit gateway shutdown enabled: stopping gateways')
|
||||
return true
|
||||
}
|
||||
|
||||
// Default behavior: only stop gateways on explicit termination, not on reload
|
||||
const shouldStop = signal !== 'SIGUSR2'
|
||||
console.log(`[shutdown] Default behavior: ${shouldStop ? 'STOPPING' : 'NOT stopping'} gateways (signal: ${signal})`)
|
||||
return shouldStop
|
||||
}
|
||||
|
||||
export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?: any, agentBridgeManager?: any): void {
|
||||
let isShuttingDown = false
|
||||
@@ -39,25 +13,8 @@ export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?:
|
||||
|
||||
logger.info('Shutting down (%s)...', signal)
|
||||
console.log(`[shutdown] Received signal: ${signal}`)
|
||||
console.log(`[shutdown] HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN = ${process.env.HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN}`)
|
||||
console.log(`[shutdown] shouldStopGatewaysOnShutdown = ${shouldStopGatewaysOnShutdown(signal)}`)
|
||||
|
||||
try {
|
||||
if (shouldStopGatewaysOnShutdown(signal)) {
|
||||
// Stop gateway processes owned by this Web UI instance first.
|
||||
try {
|
||||
const gatewayManager = getGatewayManagerInstance()
|
||||
if (gatewayManager) {
|
||||
await gatewayManager.stopAll()
|
||||
logger.info('All gateways stopped')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(err, 'Failed to stop gateways (non-fatal)')
|
||||
}
|
||||
} else {
|
||||
logger.info('Skipping gateway shutdown for %s', signal)
|
||||
}
|
||||
|
||||
if (agentBridgeManager) {
|
||||
try {
|
||||
await agentBridgeManager.stop()
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
name: grok-image-to-video
|
||||
description: "Animate a local image into a short mp4 video through Hermes Web UI using xAI Grok Imagine."
|
||||
version: 1.0.0
|
||||
author: Ekko
|
||||
license: MIT
|
||||
platforms: [linux, macos, windows]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [xAI, Grok, image-to-video, video-generation, media]
|
||||
prerequisites:
|
||||
commands: [curl]
|
||||
---
|
||||
|
||||
# Grok Image To Video
|
||||
|
||||
Use this skill when the user wants to animate a local image into a short video with xAI Grok Imagine.
|
||||
|
||||
## Workflow
|
||||
|
||||
Call the local Hermes Web UI media endpoint. Pass a local image path; the server will check for xAI credentials, read the file, convert it to a base64 data URI, call xAI, poll until completion, and optionally save the generated mp4.
|
||||
|
||||
Endpoint:
|
||||
|
||||
```bash
|
||||
POST <Hermes Web UI base URL>/api/hermes/media/grok-image-to-video
|
||||
```
|
||||
|
||||
Resolve the Hermes Web UI base URL in this order:
|
||||
|
||||
1. `HERMES_WEB_UI_URL` environment variable, if set.
|
||||
2. `http://127.0.0.1:${PORT}`, if `PORT` is set.
|
||||
3. `http://127.0.0.1:8648` for local development.
|
||||
|
||||
When Hermes Web UI is running from the provided Docker Compose setup, the default external URL is `http://127.0.0.1:6060`.
|
||||
|
||||
Authentication:
|
||||
|
||||
The endpoint is protected by Hermes Web UI auth. Always send the Web UI bearer token.
|
||||
|
||||
Resolve the token in this order:
|
||||
|
||||
1. `AUTH_TOKEN` environment variable, if set.
|
||||
2. `${HERMES_WEB_UI_HOME}/.token`, if `HERMES_WEB_UI_HOME` is set.
|
||||
3. `${HERMES_WEBUI_STATE_DIR}/.token`, if `HERMES_WEBUI_STATE_DIR` is set.
|
||||
4. `~/.hermes-web-ui/.token`.
|
||||
|
||||
Required JSON fields:
|
||||
|
||||
- `image_path`: local path to a png, jpeg, or webp image.
|
||||
- `prompt`: motion and style instructions for the generated video.
|
||||
|
||||
Optional JSON fields:
|
||||
|
||||
- `duration`: seconds, 1 to 15. Defaults to 8.
|
||||
- `output_path`: local path where the server should save the mp4. If omitted, the server saves to `${HERMES_WEB_UI_HOME:-~/.hermes-web-ui}/media/<request_id>.mp4` and creates the `media` directory if needed.
|
||||
- `timeout_ms`: maximum wait time. Defaults to 600000.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
TOKEN="${AUTH_TOKEN:-}"
|
||||
if [ -z "$TOKEN" ] && [ -n "${HERMES_WEB_UI_HOME:-}" ] && [ -f "$HERMES_WEB_UI_HOME/.token" ]; then
|
||||
TOKEN="$(cat "$HERMES_WEB_UI_HOME/.token")"
|
||||
fi
|
||||
if [ -z "$TOKEN" ] && [ -n "${HERMES_WEBUI_STATE_DIR:-}" ] && [ -f "$HERMES_WEBUI_STATE_DIR/.token" ]; then
|
||||
TOKEN="$(cat "$HERMES_WEBUI_STATE_DIR/.token")"
|
||||
fi
|
||||
if [ -z "$TOKEN" ] && [ -f "$HOME/.hermes-web-ui/.token" ]; then
|
||||
TOKEN="$(cat "$HOME/.hermes-web-ui/.token")"
|
||||
fi
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "Missing Hermes Web UI token. Check AUTH_TOKEN, HERMES_WEB_UI_HOME, HERMES_WEBUI_STATE_DIR, or ~/.hermes-web-ui/.token." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BASE_URL="${HERMES_WEB_UI_URL:-}"
|
||||
if [ -z "$BASE_URL" ]; then
|
||||
BASE_URL="http://127.0.0.1:${PORT:-8648}"
|
||||
fi
|
||||
BASE_URL="${BASE_URL%/}"
|
||||
|
||||
curl -sS -X POST "$BASE_URL/api/hermes/media/grok-image-to-video" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"image_path": "/absolute/path/to/input.png",
|
||||
"prompt": "Animate the subject with a slow cinematic push-in and subtle natural motion.",
|
||||
"duration": 8,
|
||||
"output_path": "/absolute/path/to/output.mp4"
|
||||
}'
|
||||
```
|
||||
|
||||
If the response has `code: "missing_xai_token"`, tell the user to set `XAI_API_KEY` or complete xAI OAuth login in Hermes Web UI before retrying.
|
||||
|
||||
Return the generated `output_path`.
|
||||
@@ -40,7 +40,7 @@ export default {
|
||||
},
|
||||
profiles: {
|
||||
title: 'Multi-Profile',
|
||||
desc: 'Isolated profiles with independent configs. Clone, import/export profiles, run multiple gateways.',
|
||||
desc: 'Isolated profiles with independent configs. Clone, import/export profiles, and run chats through the agent bridge.',
|
||||
},
|
||||
files: {
|
||||
title: 'File Browser',
|
||||
@@ -52,7 +52,7 @@ export default {
|
||||
},
|
||||
quickInstall: {
|
||||
title: 'One Command',
|
||||
desc: 'Install and start with a single command. Auto-detects config, resolves ports, opens the browser.',
|
||||
desc: 'Install and start with a single command. Initializes Web UI data, starts the bridge, and opens the browser.',
|
||||
},
|
||||
i18n: {
|
||||
title: '8 Languages',
|
||||
@@ -120,7 +120,7 @@ export default {
|
||||
},
|
||||
firstRun: {
|
||||
title: 'First Run',
|
||||
content: 'On first start, Hermes Web UI will automatically generate an auth token, validate configuration files, start the Hermes gateway, and open the dashboard in your browser.',
|
||||
content: 'On first start, Hermes Web UI will automatically generate an auth token, initialize local data, start the Hermes agent bridge, and open the dashboard in your browser.',
|
||||
},
|
||||
login: {
|
||||
title: 'Login',
|
||||
@@ -143,12 +143,12 @@ export default {
|
||||
],
|
||||
},
|
||||
gateway: {
|
||||
title: 'Gateway Management',
|
||||
content: 'The gateway is the Hermes Agent process that handles AI conversations. Hermes Web UI manages the gateway lifecycle — start, stop, and monitor from the Gateways page. Multiple gateways can run with different profiles, and each profile resolves its own gateway host/port from its Hermes config.',
|
||||
title: 'Agent Bridge Runtime',
|
||||
content: 'Chat runs are handled through the Hermes agent bridge, which runs alongside the Web UI server and talks directly to the Hermes Agent runtime. The Web UI no longer starts or manages separate gateway processes.',
|
||||
},
|
||||
profiles: {
|
||||
title: 'Profiles',
|
||||
content: 'Profiles provide isolated configurations for different use cases. Each profile has its own Hermes config, cache, and gateway. Create, clone, import, or export profiles from the Profiles page.',
|
||||
content: 'Profiles provide isolated configurations for different use cases. Each profile has its own Hermes config and cache. Create, clone, import, or export profiles from the Profiles page.',
|
||||
},
|
||||
},
|
||||
features: {
|
||||
@@ -229,14 +229,14 @@ export default {
|
||||
},
|
||||
api: {
|
||||
title: 'API Reference',
|
||||
intro: 'Hermes Web UI provides both a local BFF API and proxies requests to the upstream Hermes gateway.',
|
||||
intro: 'Hermes Web UI provides a local BFF API for the dashboard and Socket.IO endpoints for streaming chat.',
|
||||
local: {
|
||||
title: 'Local BFF Endpoints',
|
||||
content: 'The Koa server handles session management, profile CRUD, config read/write, log access, skill listing, and memory operations. These endpoints call the Hermes CLI directly.',
|
||||
content: 'The Koa server handles session management, profile CRUD, config read/write, log access, skill listing, memory operations, and static assets.',
|
||||
},
|
||||
proxy: {
|
||||
title: 'Gateway Proxy',
|
||||
content: 'Requests to /api/hermes/v1/* are forwarded to the Hermes gateway. This includes AI model interactions, run management, and streaming events.',
|
||||
title: 'Chat Streaming',
|
||||
content: 'Chat runs use the /chat-run Socket.IO namespace and the Hermes agent bridge. Legacy gateway proxy routes are kept only for compatibility where applicable.',
|
||||
},
|
||||
auth: {
|
||||
title: 'Authentication',
|
||||
|
||||
@@ -40,7 +40,7 @@ export default {
|
||||
},
|
||||
profiles: {
|
||||
title: '多配置',
|
||||
desc: '隔离的多配置文件,独立配置。支持克隆、导入/导出、多网关运行。',
|
||||
desc: '隔离的多配置文件,独立配置。支持克隆、导入/导出,并通过 agent bridge 运行聊天。',
|
||||
},
|
||||
files: {
|
||||
title: '文件管理',
|
||||
@@ -52,7 +52,7 @@ export default {
|
||||
},
|
||||
quickInstall: {
|
||||
title: '一键安装',
|
||||
desc: '一条命令安装启动。自动检测配置、解析端口、打开浏览器。',
|
||||
desc: '一条命令安装启动。初始化 Web UI 数据、启动 bridge 并打开浏览器。',
|
||||
},
|
||||
i18n: {
|
||||
title: '8 种语言',
|
||||
@@ -120,7 +120,7 @@ export default {
|
||||
},
|
||||
firstRun: {
|
||||
title: '首次运行',
|
||||
content: '首次启动时,Hermes Web UI 会自动生成认证令牌、验证配置文件、启动 Hermes 网关并在浏览器中打开仪表板。',
|
||||
content: '首次启动时,Hermes Web UI 会自动生成认证令牌、初始化本地数据、启动 Hermes agent bridge 并在浏览器中打开仪表板。',
|
||||
},
|
||||
login: {
|
||||
title: '登录',
|
||||
@@ -143,12 +143,12 @@ export default {
|
||||
],
|
||||
},
|
||||
gateway: {
|
||||
title: '网关管理',
|
||||
content: '网关是处理 AI 对话的 Hermes Agent 进程。Hermes Web UI 管理网关生命周期——在网关页面启动、停止和监控。不同配置可运行多个网关,且每个 profile 都会从各自的 Hermes 配置中解析网关 host/port。',
|
||||
title: 'Agent Bridge 运行时',
|
||||
content: '聊天运行通过 Hermes agent bridge 处理。它随 Web UI 服务一起运行,并直接连接 Hermes Agent runtime。Web UI 不再启动或管理独立的 gateway 进程。',
|
||||
},
|
||||
profiles: {
|
||||
title: '配置文件',
|
||||
content: '配置文件为不同场景提供隔离的配置。每个配置文件拥有独立的 Hermes 配置、缓存和网关。可在配置页面创建、克隆、导入或导出配置文件。',
|
||||
content: '配置文件为不同场景提供隔离的配置。每个配置文件拥有独立的 Hermes 配置和缓存。可在配置页面创建、克隆、导入或导出配置文件。',
|
||||
},
|
||||
},
|
||||
features: {
|
||||
@@ -229,14 +229,14 @@ export default {
|
||||
},
|
||||
api: {
|
||||
title: 'API 参考',
|
||||
intro: 'Hermes Web UI 提供本地 BFF API 并代理请求到上游 Hermes 网关。',
|
||||
intro: 'Hermes Web UI 提供本地 BFF API,并通过 Socket.IO 端点进行聊天流式通信。',
|
||||
local: {
|
||||
title: '本地 BFF 端点',
|
||||
content: 'Koa 服务器处理会话管理、配置文件 CRUD、配置读写、日志访问、技能列表和记忆操作。这些端点直接调用 Hermes CLI。',
|
||||
content: 'Koa 服务器处理会话管理、配置文件 CRUD、配置读写、日志访问、技能列表、记忆操作和静态资源。',
|
||||
},
|
||||
proxy: {
|
||||
title: '网关代理',
|
||||
content: '对 /api/hermes/v1/* 的请求会转发到 Hermes 网关。包括 AI 模型交互、运行管理和流式事件。',
|
||||
title: '聊天流式通信',
|
||||
content: '聊天运行使用 /chat-run Socket.IO 命名空间和 Hermes agent bridge。旧 gateway proxy 路由仅在兼容场景下保留。',
|
||||
},
|
||||
auth: {
|
||||
title: '认证',
|
||||
|
||||
Reference in New Issue
Block a user