[codex] fix media skill profile auth and run events (#965)
* fix media skill profile auth and run events * test bridge run profile context
This commit is contained in:
@@ -91,6 +91,7 @@ const sessionEventHandlers = new Map<string, {
|
||||
onReasoningAvailable: (event: RunEvent) => void
|
||||
onToolStarted: (event: RunEvent) => void
|
||||
onToolCompleted: (event: RunEvent) => void
|
||||
onSubagentEvent?: (event: RunEvent) => void
|
||||
onRunStarted: (event: RunEvent) => void
|
||||
onRunCompleted: (event: RunEvent) => void
|
||||
onRunFailed: (event: RunEvent) => void
|
||||
@@ -187,6 +188,16 @@ function globalToolCompletedHandler(event: RunEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
function globalSubagentEventHandler(event: RunEvent): void {
|
||||
const sid = event.session_id
|
||||
if (!sid) return
|
||||
|
||||
const handlers = sessionEventHandlers.get(sid)
|
||||
if (handlers?.onSubagentEvent) {
|
||||
handlers.onSubagentEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global run.started event handler
|
||||
*/
|
||||
@@ -376,6 +387,7 @@ export function registerSessionHandlers(
|
||||
onReasoningAvailable: (event: RunEvent) => void
|
||||
onToolStarted: (event: RunEvent) => void
|
||||
onToolCompleted: (event: RunEvent) => void
|
||||
onSubagentEvent?: (event: RunEvent) => void
|
||||
onRunStarted: (event: RunEvent) => void
|
||||
onRunCompleted: (event: RunEvent) => void
|
||||
onRunFailed: (event: RunEvent) => void
|
||||
@@ -485,6 +497,10 @@ export function connectChatRun(requestedProfile?: string | null): Socket {
|
||||
// Tool events
|
||||
chatRunSocket.on('tool.started', globalToolStartedHandler)
|
||||
chatRunSocket.on('tool.completed', globalToolCompletedHandler)
|
||||
chatRunSocket.on('subagent.start', globalSubagentEventHandler)
|
||||
chatRunSocket.on('subagent.tool', globalSubagentEventHandler)
|
||||
chatRunSocket.on('subagent.progress', globalSubagentEventHandler)
|
||||
chatRunSocket.on('subagent.complete', globalSubagentEventHandler)
|
||||
|
||||
// Run lifecycle events
|
||||
chatRunSocket.on('run.started', globalRunStartedHandler)
|
||||
@@ -622,6 +638,10 @@ export function startRunViaSocket(
|
||||
if (closed) return
|
||||
onEvent(evt)
|
||||
},
|
||||
onSubagentEvent: (evt: RunEvent) => {
|
||||
if (closed) return
|
||||
onEvent(evt)
|
||||
},
|
||||
onRunStarted: (evt: RunEvent) => {
|
||||
if (closed) return
|
||||
onEvent(evt)
|
||||
|
||||
@@ -667,6 +667,8 @@ export const useChatStore = defineStore('chat', () => {
|
||||
toolResult: output,
|
||||
})
|
||||
}
|
||||
} else if (String(e.event || '').startsWith('subagent.')) {
|
||||
handleSubagentEvent(sessionId, e as RunEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -757,6 +759,71 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubagentEvent(sessionId: string, evt: RunEvent) {
|
||||
const eventName = String(evt.event || '')
|
||||
if (!eventName.startsWith('subagent.')) return
|
||||
|
||||
const subagentId = String((evt as any).subagent_id || `${(evt as any).task_index ?? 0}`)
|
||||
const toolCallId = `subagent:${evt.run_id || 'run'}:${subagentId}`
|
||||
const taskIndex = Number((evt as any).task_index ?? 0)
|
||||
const taskCount = Number((evt as any).task_count ?? 1)
|
||||
const label = `${taskIndex + 1}/${Math.max(1, taskCount || 1)}`
|
||||
const toolName = String((evt as any).tool || (evt as any).name || '')
|
||||
const toolCount = Number((evt as any).tool_count || 0)
|
||||
const goal = String((evt as any).goal || '').trim()
|
||||
const text = String(evt.text || evt.preview || '').trim()
|
||||
const summary = String((evt as any).summary || '').trim()
|
||||
const duration = Number((evt as any).duration_seconds ?? (evt as any).duration)
|
||||
|
||||
let preview = text || summary || goal
|
||||
if (eventName === 'subagent.start') {
|
||||
preview = `subagent ${label} started${goal ? `: ${goal}` : ''}`
|
||||
} else if (eventName === 'subagent.tool') {
|
||||
const prefix = `subagent ${label}${toolCount ? ` turn ${toolCount}` : ''}`
|
||||
preview = `${prefix}${toolName ? `: ${toolName}` : ''}${text ? ` - ${text}` : ''}`
|
||||
} else if (eventName === 'subagent.progress') {
|
||||
preview = `subagent ${label}: ${text || 'working'}`
|
||||
} else if (eventName === 'subagent.complete') {
|
||||
const status = String((evt as any).status || 'completed')
|
||||
preview = `subagent ${label} ${status}${summary ? `: ${summary}` : ''}`
|
||||
}
|
||||
|
||||
const msgs = getSessionMsgs(sessionId)
|
||||
const existing = msgs.find(m => m.role === 'tool' && m.toolCallId === toolCallId)
|
||||
const toolStatus = eventName === 'subagent.complete'
|
||||
? ((evt as any).status && String((evt as any).status) !== 'completed' ? 'error' : 'done')
|
||||
: 'running'
|
||||
const update: Partial<Message> = {
|
||||
toolName: 'delegate_task',
|
||||
toolCallId,
|
||||
toolPreview: preview.slice(0, 220),
|
||||
toolStatus,
|
||||
toolDuration: Number.isFinite(duration) ? duration : undefined,
|
||||
toolResult: eventName === 'subagent.complete'
|
||||
? JSON.stringify({
|
||||
status: (evt as any).status || 'completed',
|
||||
summary: summary || text,
|
||||
api_calls: (evt as any).api_calls,
|
||||
input_tokens: (evt as any).input_tokens,
|
||||
output_tokens: (evt as any).output_tokens,
|
||||
}, null, 2)
|
||||
: undefined,
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
updateMessage(sessionId, existing.id, update)
|
||||
return
|
||||
}
|
||||
|
||||
addMessage(sessionId, {
|
||||
id: uid(),
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
...update,
|
||||
})
|
||||
}
|
||||
|
||||
function addAgentErrorMessage(sessionId: string, error?: string | null) {
|
||||
const content = error ? `Error: ${error}` : 'Run failed'
|
||||
const msgs = getSessionMsgs(sessionId)
|
||||
@@ -1383,6 +1450,15 @@ export const useChatStore = defineStore('chat', () => {
|
||||
break
|
||||
}
|
||||
|
||||
case 'subagent.start':
|
||||
case 'subagent.tool':
|
||||
case 'subagent.progress':
|
||||
case 'subagent.complete': {
|
||||
runHadToolActivity = true
|
||||
handleSubagentEvent(sid, evt)
|
||||
break
|
||||
}
|
||||
|
||||
case 'approval.requested': {
|
||||
setPendingApproval(evt)
|
||||
break
|
||||
@@ -1824,6 +1900,15 @@ export const useChatStore = defineStore('chat', () => {
|
||||
break
|
||||
}
|
||||
|
||||
case 'subagent.start':
|
||||
case 'subagent.tool':
|
||||
case 'subagent.progress':
|
||||
case 'subagent.complete': {
|
||||
runHadToolActivity = true
|
||||
handleSubagentEvent(sid, evt)
|
||||
break
|
||||
}
|
||||
|
||||
case 'approval.requested': {
|
||||
setPendingApproval(evt)
|
||||
break
|
||||
@@ -1971,6 +2056,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
onReasoningAvailable: (evt) => handleEvent(evt),
|
||||
onToolStarted: (evt) => handleEvent(evt),
|
||||
onToolCompleted: (evt) => handleEvent(evt),
|
||||
onSubagentEvent: (evt) => handleEvent(evt),
|
||||
onRunStarted: (evt) => handleEvent(evt),
|
||||
onRunCompleted: (evt) => handleEvent(evt),
|
||||
onRunFailed: (evt) => handleEvent(evt),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile'
|
||||
import { config } from '../../config'
|
||||
import { readConfigYaml } from '../../services/config-helpers'
|
||||
import { readConfigYamlForProfile } from '../../services/config-helpers'
|
||||
|
||||
const XAI_VIDEO_GENERATIONS_URL = 'https://api.x.ai/v1/videos/generations'
|
||||
const XAI_VIDEO_STATUS_URL = 'https://api.x.ai/v1/videos'
|
||||
@@ -28,6 +28,42 @@ type FunCodexProvider = {
|
||||
model: string
|
||||
}
|
||||
|
||||
function requestedProfileName(ctx: Context): string {
|
||||
const headerProfile = ctx.get('x-hermes-profile')
|
||||
const queryProfile = typeof ctx.query.profile === 'string' ? ctx.query.profile : ''
|
||||
const body = ctx.request.body as { profile?: unknown } | undefined
|
||||
const bodyProfile = typeof body?.profile === 'string' ? body.profile : ''
|
||||
return (ctx.state.profile?.name || headerProfile || queryProfile || bodyProfile || '').trim()
|
||||
}
|
||||
|
||||
function resolveMediaProfile(ctx: Context): string {
|
||||
let requested = requestedProfileName(ctx)
|
||||
if (!requested && ctx.state.user?.role !== 'super_admin' && !ctx.state.serverTokenAuth) {
|
||||
const profiles = ctx.state.user?.profiles || []
|
||||
if (profiles.length === 1) {
|
||||
requested = profiles[0]
|
||||
} else {
|
||||
const err: any = new Error('Profile is required')
|
||||
err.status = 400
|
||||
err.code = 'profile_required'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const profile = requested || getActiveProfileName() || 'default'
|
||||
if (!listProfileNamesFromDisk().includes(profile)) {
|
||||
const err: any = new Error(`Profile "${profile}" does not exist`)
|
||||
err.status = 404
|
||||
err.code = 'profile_not_found'
|
||||
throw err
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
function authPathForProfile(profile: string): string {
|
||||
return join(getProfileDir(profile), 'auth.json')
|
||||
}
|
||||
|
||||
function readJsonFile(path: string): any {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8'))
|
||||
@@ -43,8 +79,8 @@ function buildApiUrl(baseUrl: string, pathWithV1: string): string {
|
||||
return `${base}${apiPath}`
|
||||
}
|
||||
|
||||
async function resolveFunCodexProvider(): Promise<FunCodexProvider | null> {
|
||||
const hermesConfig = await readConfigYaml()
|
||||
async function resolveFunCodexProvider(profile: string): Promise<FunCodexProvider | null> {
|
||||
const hermesConfig = await readConfigYamlForProfile(profile)
|
||||
const customProviders = Array.isArray(hermesConfig.custom_providers)
|
||||
? hermesConfig.custom_providers as any[]
|
||||
: []
|
||||
@@ -59,11 +95,11 @@ async function resolveFunCodexProvider(): Promise<FunCodexProvider | null> {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveXaiToken(): { token: string; source: string } | null {
|
||||
function resolveXaiToken(profile: string): { 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 auth = readJsonFile(authPathForProfile(profile)) 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' }
|
||||
|
||||
@@ -421,11 +457,20 @@ function saveGeneratedImages(images: string[], requestedOutputPath?: string): st
|
||||
}
|
||||
|
||||
export async function apiKeyImageGenerate(ctx: Context) {
|
||||
const provider = await resolveFunCodexProvider()
|
||||
let profile: string
|
||||
try {
|
||||
profile = resolveMediaProfile(ctx)
|
||||
} catch (err: any) {
|
||||
ctx.status = err.status || 400
|
||||
ctx.body = { error: err.message || String(err), code: err.code || 'invalid_profile' }
|
||||
return
|
||||
}
|
||||
|
||||
const provider = await resolveFunCodexProvider(profile)
|
||||
if (!provider) {
|
||||
ctx.status = 401
|
||||
ctx.body = {
|
||||
error: 'Missing fun-codex provider in active profile config.yaml.',
|
||||
error: `Missing fun-codex provider in profile "${profile}" config.yaml.`,
|
||||
code: 'missing_fun_codex_provider',
|
||||
}
|
||||
return
|
||||
@@ -443,6 +488,7 @@ export async function apiKeyImageGenerate(ctx: Context) {
|
||||
output_paths: outputPaths,
|
||||
provider: APIKEY_IMAGE_PROVIDER,
|
||||
base_url: provider.baseUrl,
|
||||
profile,
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.status = err.status || 500
|
||||
@@ -484,11 +530,20 @@ async function downloadVideo(url: string, outputPath: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function grokImageToVideo(ctx: Context) {
|
||||
const tokenInfo = resolveXaiToken()
|
||||
let profile: string
|
||||
try {
|
||||
profile = resolveMediaProfile(ctx)
|
||||
} catch (err: any) {
|
||||
ctx.status = err.status || 400
|
||||
ctx.body = { error: err.message || String(err), code: err.code || 'invalid_profile' }
|
||||
return
|
||||
}
|
||||
|
||||
const tokenInfo = resolveXaiToken(profile)
|
||||
if (!tokenInfo) {
|
||||
ctx.status = 401
|
||||
ctx.body = {
|
||||
error: 'Missing xAI token. Set XAI_API_KEY or complete xAI OAuth login first.',
|
||||
error: `Missing xAI token for profile "${profile}". Set XAI_API_KEY or complete xAI OAuth login first.`,
|
||||
code: 'missing_xai_token',
|
||||
}
|
||||
return
|
||||
@@ -539,6 +594,7 @@ export async function grokImageToVideo(ctx: Context) {
|
||||
video_url: videoUrl,
|
||||
output_path: outputPath,
|
||||
token_source: tokenInfo.source,
|
||||
profile,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ declare module 'koa' {
|
||||
interface DefaultState {
|
||||
user?: AuthenticatedUser
|
||||
profile?: RequestProfile
|
||||
serverTokenAuth?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +70,19 @@ function requestToken(ctx: Context): string {
|
||||
return typeof ctx.query.token === 'string' ? ctx.query.token.trim() : ''
|
||||
}
|
||||
|
||||
const SERVER_TOKEN_MEDIA_PATHS = new Set([
|
||||
'/api/hermes/media/apikey-image-generate',
|
||||
'/api/hermes/media/grok-image-to-video',
|
||||
])
|
||||
|
||||
async function allowServerTokenForMedia(ctx: Context, token: string): Promise<boolean> {
|
||||
if (!token || !SERVER_TOKEN_MEDIA_PATHS.has(ctx.path)) return false
|
||||
const serverToken = await getToken()
|
||||
if (!serverToken || token !== serverToken) return false
|
||||
ctx.state.serverTokenAuth = true
|
||||
return true
|
||||
}
|
||||
|
||||
export function signUserJwt(user: Pick<UserRecord, 'id' | 'username' | 'role'>, secret: string, now = Date.now()): string {
|
||||
const iat = Math.floor(now / 1000)
|
||||
const payload: JwtPayload = {
|
||||
@@ -149,6 +163,10 @@ export async function requireUserJwt(ctx: Context, next: Next): Promise<void> {
|
||||
const token = requestToken(ctx)
|
||||
const payload = token ? verifyUserJwt(token, secret) : null
|
||||
if (!payload) {
|
||||
if (await allowServerTokenForMedia(ctx, token)) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
ctx.status = 401
|
||||
ctx.body = { error: 'Unauthorized' }
|
||||
return
|
||||
|
||||
@@ -948,7 +948,7 @@ class AgentPool:
|
||||
|
||||
def _tool_progress_callback(self, session_id: str):
|
||||
def callback(event_type, function_name=None, preview=None, function_args=None, **kwargs):
|
||||
if event_type in (None, "tool.started", "tool.completed"):
|
||||
if event_type in (None, "tool.started", "tool.completed") or str(event_type or "").startswith("subagent."):
|
||||
print(
|
||||
"[hermes_bridge] tool_progress_callback "
|
||||
f"session={session_id} event={event_type} tool={function_name} "
|
||||
@@ -964,6 +964,18 @@ class AgentPool:
|
||||
})
|
||||
return
|
||||
|
||||
if str(event_type or "").startswith("subagent."):
|
||||
payload = {
|
||||
"event": str(event_type),
|
||||
"tool_name": str(function_name) if function_name else "",
|
||||
"text": str(preview) if preview is not None else "",
|
||||
"args": _jsonable(function_args) if function_args else {},
|
||||
}
|
||||
for key, value in kwargs.items():
|
||||
payload[str(key)] = _jsonable(value)
|
||||
self._append_event(session_id, payload)
|
||||
return
|
||||
|
||||
if event_type == "_thinking":
|
||||
text = function_name
|
||||
if text:
|
||||
|
||||
@@ -473,6 +473,11 @@ class AgentClient {
|
||||
return { ...block, text: `${routedPrefix}\n\n原始消息:${text || msg.content}` }
|
||||
})
|
||||
: `${routedPrefix}\n\n原始消息:${stripMentionRoutingTokens(msg.content, this.name) || msg.content}`
|
||||
const runContext = [
|
||||
`[Current Hermes profile: ${this.profile}]`,
|
||||
'When calling Hermes Web UI endpoints from tools or skills, include the current Hermes profile as the X-Hermes-Profile header if the endpoint supports profile-scoped behavior.',
|
||||
].join('\n')
|
||||
instructions = instructions ? `${runContext}\n${instructions}` : runContext
|
||||
const bridgeInput: AgentBridgeMessage = isContentBlockArray(input)
|
||||
? await convertContentBlocksForAgent(input)
|
||||
: input
|
||||
|
||||
@@ -127,11 +127,6 @@ export async function markAbortCompleted(
|
||||
}
|
||||
|
||||
state.events = []
|
||||
replaceState(sessionMap, sessionId, 'abort.completed', {
|
||||
event: 'abort.completed',
|
||||
run_id: runId,
|
||||
synced: true,
|
||||
})
|
||||
emitToSession(nsp, socket, sessionId, 'abort.completed', {
|
||||
event: 'abort.completed',
|
||||
run_id: runId,
|
||||
|
||||
@@ -105,6 +105,7 @@ export async function handleApiRun(
|
||||
sessionMap.set(session_id, state)
|
||||
}
|
||||
state.isWorking = true
|
||||
state.events = []
|
||||
state.profile = profile
|
||||
state.source = 'api_server'
|
||||
state.activeRunMarker = runMarker
|
||||
|
||||
@@ -128,10 +128,12 @@ export async function handleBridgeRun(
|
||||
if (resolvedProvider && sessionRow.provider !== resolvedProvider) updates.provider = resolvedProvider
|
||||
if (Object.keys(updates).length > 0) updateSession(session_id, updates)
|
||||
}
|
||||
if (sessionRow?.workspace) {
|
||||
const workspaceCtx = `[Current working directory: ${sessionRow.workspace}]`
|
||||
fullInstructions = `\n${workspaceCtx}\n${fullInstructions}`
|
||||
}
|
||||
const runContext = [
|
||||
`[Current Hermes profile: ${profile}]`,
|
||||
sessionRow?.workspace ? `[Current working directory: ${sessionRow.workspace}]` : '',
|
||||
'When calling Hermes Web UI endpoints from tools or skills, include the current Hermes profile as the X-Hermes-Profile header if the endpoint supports profile-scoped behavior.',
|
||||
].filter(Boolean).join('\n')
|
||||
fullInstructions = `\n${runContext}\n${fullInstructions}`
|
||||
|
||||
const runMarker = `cli_run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
@@ -145,6 +147,7 @@ export async function handleBridgeRun(
|
||||
|
||||
state.isWorking = true
|
||||
state.isAborting = false
|
||||
state.events = []
|
||||
state.profile = profile
|
||||
state.source = 'cli'
|
||||
state.activeRunMarker = runMarker
|
||||
@@ -491,6 +494,38 @@ async function applyBridgeChunkAsync(
|
||||
}
|
||||
pushState(sessionMap, sessionId, 'tool.completed', payload)
|
||||
emit('tool.completed', payload)
|
||||
} else if (evType?.startsWith('subagent.')) {
|
||||
const payload = {
|
||||
event: evType,
|
||||
run_id: chunk.run_id,
|
||||
subagent_id: ev.subagent_id,
|
||||
parent_id: ev.parent_id,
|
||||
depth: ev.depth,
|
||||
task_index: ev.task_index,
|
||||
task_count: ev.task_count,
|
||||
goal: ev.goal,
|
||||
model: ev.model,
|
||||
toolsets: ev.toolsets,
|
||||
tool_count: ev.tool_count,
|
||||
tool: ev.tool_name,
|
||||
name: ev.tool_name,
|
||||
preview: ev.text || ev.summary || ev.tool_preview || '',
|
||||
text: ev.text || '',
|
||||
status: ev.status,
|
||||
summary: ev.summary,
|
||||
duration: ev.duration_seconds,
|
||||
duration_seconds: ev.duration_seconds,
|
||||
input_tokens: ev.input_tokens,
|
||||
output_tokens: ev.output_tokens,
|
||||
reasoning_tokens: ev.reasoning_tokens,
|
||||
api_calls: ev.api_calls,
|
||||
cost_usd: ev.cost_usd,
|
||||
files_read: ev.files_read,
|
||||
files_written: ev.files_written,
|
||||
output_tail: ev.output_tail,
|
||||
}
|
||||
pushState(sessionMap, sessionId, evType, payload)
|
||||
emit(evType, payload)
|
||||
} else if (evType === 'turn.boundary') {
|
||||
flushBridgePendingToDb(state, sessionId, runMarker)
|
||||
} else if (evType === 'reasoning.delta' || evType === 'thinking.delta') {
|
||||
|
||||
@@ -168,6 +168,7 @@ export class ChatRunSocket {
|
||||
logger.info('[chat-run-socket] queued run for session %s (queue: %d)', data.session_id, state.queue.length)
|
||||
return
|
||||
}
|
||||
state.events = []
|
||||
state.isWorking = true
|
||||
state.profile = runProfile
|
||||
state.source = source
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: apikey-image-gen
|
||||
description: "Generate or edit images through Hermes Web UI using the active profile's fun-codex provider from config.yaml."
|
||||
description: "Generate or edit images through Hermes Web UI using the selected/requested profile's fun-codex provider from config.yaml."
|
||||
version: 1.0.0
|
||||
author: Ekko
|
||||
license: MIT
|
||||
@@ -16,7 +16,9 @@ prerequisites:
|
||||
|
||||
Use this skill when the user wants to generate an image, generate an image from a reference image, or edit an existing image.
|
||||
|
||||
Always call Hermes Web UI's media endpoint. Do not call `api.apikey.fun` directly, and do not ask the user for an API key. The server reads the active profile's `config.yaml` and uses the `custom_providers` entry named `fun-codex`:
|
||||
Always call Hermes Web UI's media endpoint. Do not call `api.apikey.fun` directly, and do not ask the user for an API key. The server reads the selected/requested profile's `config.yaml` and uses the `custom_providers` entry named `fun-codex`:
|
||||
|
||||
Do not use any built-in image generation tool as a fallback. If the Hermes Web UI endpoint returns `401`, `403`, connection failure, or any other error, stop and report the Hermes Web UI error to the user.
|
||||
|
||||
```yaml
|
||||
custom_providers:
|
||||
@@ -43,7 +45,7 @@ When Hermes Web UI is running from Docker Compose, the default external URL is `
|
||||
|
||||
Authentication:
|
||||
|
||||
Send the Hermes Web UI bearer token.
|
||||
Send the Hermes Web UI server bearer token. This token is accepted only by Hermes Web UI media generation endpoints for agent skills; it is not a general Web UI login token.
|
||||
|
||||
Resolve the token in this order:
|
||||
|
||||
@@ -52,6 +54,20 @@ Resolve the token in this order:
|
||||
3. `${HERMES_WEBUI_STATE_DIR}/.token`, if `HERMES_WEBUI_STATE_DIR` is set.
|
||||
4. `~/.hermes-web-ui/.token`.
|
||||
|
||||
Profile selection:
|
||||
|
||||
Use the current Hermes profile from the run instructions by sending `X-Hermes-Profile`.
|
||||
|
||||
If the run instructions include `[Current Hermes profile: <name>]`, include:
|
||||
|
||||
```bash
|
||||
-H "X-Hermes-Profile: <name>"
|
||||
```
|
||||
|
||||
Replace `<name>` with the exact profile name from the run instructions. Never send a placeholder value such as `<name>` or `<current-hermes-profile>`.
|
||||
|
||||
If no current profile is provided, omit the header and let the server fall back to the current Hermes active profile.
|
||||
|
||||
## Modes
|
||||
|
||||
### Text To Image
|
||||
@@ -163,4 +179,4 @@ Successful responses include:
|
||||
}
|
||||
```
|
||||
|
||||
If the response code is `missing_fun_codex_provider`, tell the user to configure `fun-codex` in the active profile's `config.yaml`.
|
||||
If the response code is `missing_fun_codex_provider`, tell the user to configure `fun-codex` in the selected/requested profile's `config.yaml`.
|
||||
|
||||
@@ -16,6 +16,8 @@ prerequisites:
|
||||
|
||||
Use this skill when the user wants to animate a local image into a short video with xAI Grok Imagine.
|
||||
|
||||
Do not use any built-in image or video generation tool as a fallback. If the Hermes Web UI endpoint returns `401`, `403`, connection failure, or any other error, stop and report the Hermes Web UI error to the user.
|
||||
|
||||
## 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.
|
||||
@@ -36,7 +38,7 @@ When Hermes Web UI is running from the provided Docker Compose setup, the defaul
|
||||
|
||||
Authentication:
|
||||
|
||||
The endpoint is protected by Hermes Web UI auth. Always send the Web UI bearer token.
|
||||
The endpoint is protected by Hermes Web UI auth. Always send the Hermes Web UI server bearer token. This token is accepted only by Hermes Web UI media generation endpoints for agent skills; it is not a general Web UI login token.
|
||||
|
||||
Resolve the token in this order:
|
||||
|
||||
@@ -45,6 +47,20 @@ Resolve the token in this order:
|
||||
3. `${HERMES_WEBUI_STATE_DIR}/.token`, if `HERMES_WEBUI_STATE_DIR` is set.
|
||||
4. `~/.hermes-web-ui/.token`.
|
||||
|
||||
Profile selection:
|
||||
|
||||
Use the current Hermes profile from the run instructions by sending `X-Hermes-Profile`.
|
||||
|
||||
If the run instructions include `[Current Hermes profile: <name>]`, include:
|
||||
|
||||
```bash
|
||||
-H "X-Hermes-Profile: <name>"
|
||||
```
|
||||
|
||||
Replace `<name>` with the exact profile name from the run instructions. Never send a placeholder value such as `<name>` or `<current-hermes-profile>`.
|
||||
|
||||
If no current profile is provided, omit the header and let the server fall back to the current Hermes active profile.
|
||||
|
||||
Required JSON fields:
|
||||
|
||||
- `image_path`: local path to a png, jpeg, or webp image.
|
||||
|
||||
@@ -40,11 +40,11 @@ export default {
|
||||
},
|
||||
profiles: {
|
||||
title: 'Multi-Profile',
|
||||
desc: 'Isolated profiles with independent configs. Clone, import/export profiles, and run chats through the agent bridge.',
|
||||
desc: 'Account-authorized Hermes profiles with isolated config, models, uploads, jobs, usage, memory, skills, plugins, and providers.',
|
||||
},
|
||||
files: {
|
||||
title: 'File Browser',
|
||||
desc: 'Manage files across local, Docker, SSH, and Singularity backends with upload, preview, and edit.',
|
||||
desc: 'Manage files across local, Docker, SSH, and Singularity backends with profile-scoped upload plus path-based download, preview, and edit.',
|
||||
},
|
||||
terminal: {
|
||||
title: 'Web Terminal',
|
||||
@@ -124,7 +124,7 @@ export default {
|
||||
},
|
||||
login: {
|
||||
title: 'Login',
|
||||
content: 'The auto-generated token is stored in ~/.hermes-web-ui/.token. You can also set up username/password login from the Settings page after your first login.',
|
||||
content: 'The auto-generated token is stored in ~/.hermes-web-ui/.token. Username/password login is available with bootstrap credentials admin / 123456 on first use, and the app prompts users to change default credentials after login.',
|
||||
},
|
||||
},
|
||||
configuration: {
|
||||
@@ -137,18 +137,18 @@ export default {
|
||||
['AUTH_TOKEN', 'Custom auth token (overrides auto-generated)'],
|
||||
['PORT', 'Server listen port (default: 8648)'],
|
||||
['BIND_HOST', 'Server bind host (default: 0.0.0.0). Set :: explicitly to enable IPv6 listening.'],
|
||||
['UPLOAD_DIR', 'Custom upload directory path'],
|
||||
['UPLOAD_DIR', 'Custom upload root. Uploaded files are stored below profile-scoped subdirectories.'],
|
||||
['CORS_ORIGINS', 'CORS origin config (default: *)'],
|
||||
['HERMES_BIN', 'Custom path to hermes CLI binary'],
|
||||
],
|
||||
},
|
||||
gateway: {
|
||||
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.',
|
||||
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. Switching the frontend Hermes Profile changes later request context only; it does not restart the bridge or clear other running tasks.',
|
||||
},
|
||||
profiles: {
|
||||
title: 'Profiles',
|
||||
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.',
|
||||
content: 'Profiles provide isolated configurations for different use cases. Super administrators can manage every profile, while regular administrators only see and use profiles assigned to their account. Create, clone, import, export, or switch Hermes profiles from the Profiles page.',
|
||||
},
|
||||
},
|
||||
features: {
|
||||
@@ -156,7 +156,7 @@ export default {
|
||||
intro: 'Explore the core features of Hermes Web UI.',
|
||||
chat: {
|
||||
title: 'AI Chat',
|
||||
content: 'Real-time chat streaming over Socket.IO /chat-run. Supports multi-session management, Markdown rendering with syntax highlighting, tool call inspection, file upload/download, and Ctrl+K search across the Web UI local session database.',
|
||||
content: 'Real-time chat streaming over Socket.IO /chat-run. Supports multi-session management, Markdown rendering with syntax highlighting, tool call inspection, profile-scoped upload, path-based download, and Ctrl+K search across the Web UI local session database.',
|
||||
},
|
||||
kanban: {
|
||||
title: 'Kanban Board',
|
||||
@@ -184,7 +184,7 @@ export default {
|
||||
},
|
||||
files: {
|
||||
title: 'File Browser',
|
||||
content: 'Browse and manage files on remote backends including local, Docker, SSH, and Singularity. Upload, download, rename, move, delete files, and preview content with syntax highlighting.',
|
||||
content: 'Browse and manage files on remote backends including local, Docker, SSH, and Singularity. Uploads are stored under the selected/requested profile, while downloads resolve real paths so agent-generated artifacts outside the upload directory still work.',
|
||||
},
|
||||
analytics: {
|
||||
title: 'Usage Analytics',
|
||||
@@ -232,7 +232,7 @@ export default {
|
||||
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, memory operations, and static assets.',
|
||||
content: 'The Koa server handles session management, profile CRUD, account/profile authorization, config read/write, log access, skill listing, memory operations, and static assets.',
|
||||
},
|
||||
proxy: {
|
||||
title: 'Chat Streaming',
|
||||
@@ -240,7 +240,7 @@ export default {
|
||||
},
|
||||
auth: {
|
||||
title: 'Authentication',
|
||||
content: 'All API endpoints require a Bearer token via the Authorization header. The token is auto-generated on first run and stored in ~/.hermes-web-ui/.token. Optional username/password login can be configured from the Settings page.',
|
||||
content: 'API endpoints require authenticated access. The token is auto-generated on first run and stored in ~/.hermes-web-ui/.token. Username/password login uses account records; super administrators manage users and profile bindings, while regular administrators manage their own account details.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -40,11 +40,11 @@ export default {
|
||||
},
|
||||
profiles: {
|
||||
title: '多配置',
|
||||
desc: '隔离的多配置文件,独立配置。支持克隆、导入/导出,并通过 agent bridge 运行聊天。',
|
||||
desc: '按账号授权的 Hermes Profile,隔离配置、模型、上传、任务、用量、记忆、技能、插件和 Provider。',
|
||||
},
|
||||
files: {
|
||||
title: '文件管理',
|
||||
desc: '跨本地、Docker、SSH 和 Singularity 管理文件,支持上传、预览和编辑。',
|
||||
desc: '跨本地、Docker、SSH 和 Singularity 管理文件,支持按 Profile 上传、按路径下载、预览和编辑。',
|
||||
},
|
||||
terminal: {
|
||||
title: 'Web 终端',
|
||||
@@ -124,7 +124,7 @@ export default {
|
||||
},
|
||||
login: {
|
||||
title: '登录',
|
||||
content: '自动生成的令牌存储在 ~/.hermes-web-ui/.token。首次登录后可在设置页面配置用户名/密码登录。',
|
||||
content: '自动生成的令牌存储在 ~/.hermes-web-ui/.token。首次使用可通过默认登录名 admin / 默认密码 123456 登录;登录后系统会提示尽快修改默认账户和密码。',
|
||||
},
|
||||
},
|
||||
configuration: {
|
||||
@@ -137,18 +137,18 @@ export default {
|
||||
['AUTH_TOKEN', '自定义认证令牌(覆盖自动生成的令牌)'],
|
||||
['PORT', '服务器监听端口(默认:8648)'],
|
||||
['BIND_HOST', '服务器绑定地址(默认:0.0.0.0)。如需 IPv6,请显式设置为 ::。'],
|
||||
['UPLOAD_DIR', '自定义上传目录路径'],
|
||||
['UPLOAD_DIR', '自定义上传根目录。文件会保存在按 Profile 隔离的子目录下'],
|
||||
['CORS_ORIGINS', 'CORS 来源配置(默认:*)'],
|
||||
['HERMES_BIN', '自定义 hermes CLI 二进制路径'],
|
||||
],
|
||||
},
|
||||
gateway: {
|
||||
title: 'Agent Bridge 运行时',
|
||||
content: '聊天运行通过 Hermes agent bridge 处理。它随 Web UI 服务一起运行,并直接连接 Hermes Agent runtime。Web UI 不再启动或管理独立的 gateway 进程。',
|
||||
content: '聊天运行通过 Hermes agent bridge 处理。它随 Web UI 服务一起运行,并直接连接 Hermes Agent runtime。前端切换 Hermes Profile 只影响后续请求上下文,不会重启 bridge 或清理其他正在运行的任务。',
|
||||
},
|
||||
profiles: {
|
||||
title: '配置文件',
|
||||
content: '配置文件为不同场景提供隔离的配置。每个配置文件拥有独立的 Hermes 配置和缓存。可在配置页面创建、克隆、导入或导出配置文件。',
|
||||
content: 'Profile 为不同场景提供隔离配置。超级管理员可以管理全部 Profile;普通管理员只能查看和使用分配给自己的 Profile。可在 Profile 页面创建、克隆、导入、导出或切换 Hermes Profile。',
|
||||
},
|
||||
},
|
||||
features: {
|
||||
@@ -156,7 +156,7 @@ export default {
|
||||
intro: '探索 Hermes Web UI 的核心功能。',
|
||||
chat: {
|
||||
title: 'AI 聊天',
|
||||
content: '通过 Socket.IO /chat-run 实时流式聊天。支持多会话管理、Markdown 渲染与语法高亮、工具调用检查、文件上传/下载,以及 Ctrl+K 搜索 Web UI 本地会话库。',
|
||||
content: '通过 Socket.IO /chat-run 实时流式聊天。支持多会话管理、Markdown 渲染与语法高亮、工具调用检查、按 Profile 上传、按路径下载,以及 Ctrl+K 搜索 Web UI 本地会话库。',
|
||||
},
|
||||
kanban: {
|
||||
title: '看板管理',
|
||||
@@ -184,7 +184,7 @@ export default {
|
||||
},
|
||||
files: {
|
||||
title: '文件管理',
|
||||
content: '浏览和管理本地、Docker、SSH 和 Singularity 等远程后端上的文件。支持上传、下载、重命名、移动、删除文件以及带语法高亮的内容预览。',
|
||||
content: '浏览和管理本地、Docker、SSH 和 Singularity 等远程后端上的文件。上传保存到当前选择/请求的 Profile;下载按真实路径解析,因此上传目录外的 Agent 产物也可以下载。',
|
||||
},
|
||||
analytics: {
|
||||
title: '用量分析',
|
||||
@@ -232,7 +232,7 @@ export default {
|
||||
intro: 'Hermes Web UI 提供本地 BFF API,并通过 Socket.IO 端点进行聊天流式通信。',
|
||||
local: {
|
||||
title: '本地 BFF 端点',
|
||||
content: 'Koa 服务器处理会话管理、配置文件 CRUD、配置读写、日志访问、技能列表、记忆操作和静态资源。',
|
||||
content: 'Koa 服务器处理会话管理、Profile CRUD、账号/Profile 鉴权、配置读写、日志访问、技能列表、记忆操作和静态资源。',
|
||||
},
|
||||
proxy: {
|
||||
title: '聊天流式通信',
|
||||
@@ -240,7 +240,7 @@ export default {
|
||||
},
|
||||
auth: {
|
||||
title: '认证',
|
||||
content: '所有 API 端点需要通过 Authorization 头提供 Bearer 令牌。令牌在首次运行时自动生成并存储在 ~/.hermes-web-ui/.token。可在设置页面配置可选的用户名/密码登录。',
|
||||
content: 'API 端点需要经过认证访问。令牌在首次运行时自动生成并存储在 ~/.hermes-web-ui/.token。用户名/密码登录使用账户记录;超级管理员管理用户和 Profile 绑定,普通管理员管理自己的账户信息。',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user