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,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}` })
|
||||
|
||||
Reference in New Issue
Block a user