chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
export const CHAT_INPUT_MOBILE_BREAKPOINT = 768
|
||||
export const MIN_CHAT_INPUT_HEIGHT = 48
|
||||
export const MAX_CHAT_INPUT_HEIGHT = 400
|
||||
|
||||
export function clampChatInputHeight(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
|
||||
const numericValue = Number(value)
|
||||
if (!Number.isFinite(numericValue)) return null
|
||||
|
||||
return Math.min(MAX_CHAT_INPUT_HEIGHT, Math.max(MIN_CHAT_INPUT_HEIGHT, Math.round(numericValue)))
|
||||
}
|
||||
|
||||
export function isMobileChatInputViewport(width: number): boolean {
|
||||
return width <= CHAT_INPUT_MOBILE_BREAKPOINT
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function isSameLocalDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear()
|
||||
&& a.getMonth() === b.getMonth()
|
||||
&& a.getDate() === b.getDate()
|
||||
}
|
||||
|
||||
export function formatChatTimestamp(
|
||||
timestamp: number | string | Date,
|
||||
options: { now?: Date; locale?: string | string[] } = {},
|
||||
): string {
|
||||
const date = timestamp instanceof Date ? timestamp : new Date(timestamp)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
const now = options.now || new Date()
|
||||
const locale = options.locale
|
||||
const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' }
|
||||
|
||||
if (isSameLocalDay(date, now)) {
|
||||
return date.toLocaleTimeString(locale, timeOptions)
|
||||
}
|
||||
|
||||
const dateOptions: Intl.DateTimeFormatOptions = date.getFullYear() === now.getFullYear()
|
||||
? { month: '2-digit', day: '2-digit', ...timeOptions }
|
||||
: { year: 'numeric', month: '2-digit', day: '2-digit', ...timeOptions }
|
||||
|
||||
return date.toLocaleString(locale, dateOptions)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copy text to clipboard with a fallback for non-secure contexts (HTTP, non-localhost).
|
||||
* `navigator.clipboard` is only available in secure contexts; intranet HTTP deployments
|
||||
* must fall back to the legacy `document.execCommand('copy')` flow via a hidden textarea.
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// fall through to legacy fallback
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') return false
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.top = '0'
|
||||
textarea.style.left = '0'
|
||||
textarea.style.width = '1px'
|
||||
textarea.style.height = '1px'
|
||||
textarea.style.padding = '0'
|
||||
textarea.style.border = 'none'
|
||||
textarea.style.outline = 'none'
|
||||
textarea.style.boxShadow = 'none'
|
||||
textarea.style.background = 'transparent'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
|
||||
let ok = false
|
||||
try {
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
textarea.setSelectionRange(0, text.length)
|
||||
ok = document.execCommand('copy')
|
||||
} catch {
|
||||
ok = false
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
interface CompletionNotificationPayload {
|
||||
title: string
|
||||
body?: string
|
||||
icon?: string
|
||||
tag?: string
|
||||
}
|
||||
|
||||
interface HermesDesktopBridge {
|
||||
isDesktop?: boolean
|
||||
notifyCompletion?: (payload: CompletionNotificationPayload) => Promise<boolean>
|
||||
}
|
||||
|
||||
export interface CompletionNotificationPermissionResult {
|
||||
granted: boolean
|
||||
reason?: 'unsupported' | 'insecure' | 'denied'
|
||||
}
|
||||
|
||||
type WindowWithHermesDesktop = Window & typeof globalThis & {
|
||||
hermesDesktop?: HermesDesktopBridge
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, fallback: T): Promise<T> {
|
||||
return new Promise<T>((resolve) => {
|
||||
const timer = window.setTimeout(() => resolve(fallback), timeoutMs)
|
||||
promise.then(
|
||||
value => {
|
||||
window.clearTimeout(timer)
|
||||
resolve(value)
|
||||
},
|
||||
() => {
|
||||
window.clearTimeout(timer)
|
||||
resolve(fallback)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function desktopBridge(): HermesDesktopBridge | undefined {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
return (window as WindowWithHermesDesktop).hermesDesktop
|
||||
}
|
||||
|
||||
function supportsBrowserNotification(): boolean {
|
||||
return typeof window !== 'undefined' && 'Notification' in window
|
||||
}
|
||||
|
||||
function isBrowserNotificationSecureContext(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
return window.isSecureContext
|
||||
}
|
||||
|
||||
function browserNotificationOptions(payload: CompletionNotificationPayload): NotificationOptions {
|
||||
return {
|
||||
body: payload.body,
|
||||
icon: payload.icon ? new URL(payload.icon, window.location.origin).href : undefined,
|
||||
tag: payload.tag,
|
||||
}
|
||||
}
|
||||
|
||||
async function showServiceWorkerNotification(payload: CompletionNotificationPayload): Promise<boolean> {
|
||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return false
|
||||
|
||||
try {
|
||||
const registration = await withTimeout(
|
||||
navigator.serviceWorker.register('/notification-sw.js'),
|
||||
3000,
|
||||
null,
|
||||
)
|
||||
if (!registration) return false
|
||||
await withTimeout(navigator.serviceWorker.ready, 3000, registration)
|
||||
await registration.showNotification(payload.title, browserNotificationOptions(payload))
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn('Failed to show service worker notification:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isDesktopNotificationRuntime(): boolean {
|
||||
return desktopBridge()?.isDesktop === true
|
||||
}
|
||||
|
||||
export async function requestCompletionNotificationPermission(): Promise<CompletionNotificationPermissionResult> {
|
||||
if (isDesktopNotificationRuntime()) return { granted: true }
|
||||
if (!supportsBrowserNotification()) return { granted: false, reason: 'unsupported' }
|
||||
if (!isBrowserNotificationSecureContext()) return { granted: false, reason: 'insecure' }
|
||||
if (Notification.permission === 'granted') return { granted: true }
|
||||
if (Notification.permission === 'denied') return { granted: false, reason: 'denied' }
|
||||
|
||||
try {
|
||||
const permission = await withTimeout(
|
||||
Notification.requestPermission(),
|
||||
5000,
|
||||
'default' as NotificationPermission,
|
||||
)
|
||||
return permission === 'granted'
|
||||
? { granted: true }
|
||||
: { granted: false, reason: permission === 'denied' ? 'denied' : 'unsupported' }
|
||||
} catch {
|
||||
return { granted: false, reason: 'unsupported' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function showCompletionNotification(payload: CompletionNotificationPayload): Promise<boolean> {
|
||||
const bridge = desktopBridge()
|
||||
if (bridge?.isDesktop && bridge.notifyCompletion) {
|
||||
try {
|
||||
return await bridge.notifyCompletion(payload)
|
||||
} catch (err) {
|
||||
console.warn('Failed to show desktop completion notification:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (!supportsBrowserNotification() || !isBrowserNotificationSecureContext() || Notification.permission !== 'granted') {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
if (await showServiceWorkerNotification(payload)) return true
|
||||
|
||||
const notification = new Notification(payload.title, browserNotificationOptions(payload))
|
||||
notification.onclick = () => {
|
||||
window.focus()
|
||||
notification.close()
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn('Failed to show browser completion notification:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
type AudioContextConstructor = typeof AudioContext
|
||||
|
||||
type WindowWithWebkitAudio = Window & typeof globalThis & {
|
||||
webkitAudioContext?: AudioContextConstructor
|
||||
}
|
||||
|
||||
let audioContext: AudioContext | null = null
|
||||
|
||||
function getAudioContext(): AudioContext | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
const AudioContextCtor = window.AudioContext || (window as WindowWithWebkitAudio).webkitAudioContext
|
||||
if (!AudioContextCtor) return null
|
||||
|
||||
if (!audioContext) {
|
||||
audioContext = new AudioContextCtor()
|
||||
}
|
||||
|
||||
return audioContext
|
||||
}
|
||||
|
||||
export function primeCompletionSound(): void {
|
||||
const ctx = getAudioContext()
|
||||
if (!ctx || ctx.state !== 'suspended') return
|
||||
|
||||
void ctx.resume().catch(() => {
|
||||
// Browser autoplay policy may still reject until a user gesture. Ignore; the
|
||||
// next send action will try again.
|
||||
})
|
||||
}
|
||||
|
||||
export async function playCompletionSound(): Promise<boolean> {
|
||||
const ctx = getAudioContext()
|
||||
if (!ctx) return false
|
||||
|
||||
try {
|
||||
if (ctx.state === 'suspended') {
|
||||
await ctx.resume()
|
||||
}
|
||||
|
||||
const now = ctx.currentTime
|
||||
const duration = 0.16
|
||||
const oscillator = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
|
||||
oscillator.type = 'sine'
|
||||
oscillator.frequency.setValueAtTime(880, now)
|
||||
oscillator.frequency.exponentialRampToValueAtTime(660, now + duration)
|
||||
|
||||
gain.gain.setValueAtTime(0.0001, now)
|
||||
gain.gain.exponentialRampToValueAtTime(0.18, now + 0.015)
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration)
|
||||
|
||||
oscillator.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
oscillator.start(now)
|
||||
oscillator.stop(now + duration)
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn('Failed to play completion sound:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetCompletionSoundForTests(): void {
|
||||
audioContext = null
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export interface DesktopWindowBounds {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface DesktopPetWindowState {
|
||||
bounds: DesktopWindowBounds
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
export interface HermesDesktopBridge {
|
||||
getToken: () => Promise<string>
|
||||
ensureAuth?: () => Promise<boolean>
|
||||
retryBootstrap: (source?: 'cf' | 'github') => Promise<void>
|
||||
notifyCompletion: (payload: { title: string; body?: string; icon?: string; tag?: string }) => Promise<boolean>
|
||||
getWindowState: () => Promise<{ isMaximized: boolean }>
|
||||
windowControl: (action: 'minimize' | 'toggle-maximize' | 'close') => Promise<{ isMaximized: boolean }>
|
||||
getPetWindowState?: () => Promise<DesktopPetWindowState>
|
||||
setPetWindowBounds?: (bounds: DesktopWindowBounds) => Promise<DesktopPetWindowState>
|
||||
setPetWindowVisible?: (visible: boolean) => Promise<DesktopPetWindowState>
|
||||
onPetWindowRefresh?: (callback: () => void) => () => void
|
||||
platform: string
|
||||
isDesktop: boolean
|
||||
windowKind?: 'main' | 'pet'
|
||||
}
|
||||
|
||||
export type WindowWithHermesDesktop = Window & typeof globalThis & {
|
||||
hermesDesktop?: HermesDesktopBridge
|
||||
}
|
||||
|
||||
export function desktopBridge(): HermesDesktopBridge | undefined {
|
||||
return (window as WindowWithHermesDesktop).hermesDesktop
|
||||
}
|
||||
|
||||
export function isDesktopShell(): boolean {
|
||||
return desktopBridge()?.isDesktop === true
|
||||
}
|
||||
|
||||
export function isDesktopPetWindow(): boolean {
|
||||
return desktopBridge()?.windowKind === 'pet'
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { FileEntry } from '@/api/hermes/files'
|
||||
|
||||
export function getClipboardPathForEntry(entry: FileEntry): string {
|
||||
return entry.absolutePath || entry.path
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
export type BridgeSessionCommandName =
|
||||
| 'usage'
|
||||
| 'status'
|
||||
| 'abort'
|
||||
| 'queue'
|
||||
| 'skill'
|
||||
| 'learn'
|
||||
| 'plan'
|
||||
| 'moa'
|
||||
| 'goal'
|
||||
| 'subgoal'
|
||||
| 'clear'
|
||||
| 'title'
|
||||
| 'compress'
|
||||
| 'fork'
|
||||
| 'steer'
|
||||
| 'destroy'
|
||||
| 'reload-mcp'
|
||||
| 'reload-skills'
|
||||
|
||||
export interface BridgeSessionCommandDefinition {
|
||||
key: string
|
||||
name: BridgeSessionCommandName
|
||||
descriptionKey: string
|
||||
argsKey?: string
|
||||
args?: string
|
||||
insertText?: string
|
||||
opensSkillPicker?: boolean
|
||||
}
|
||||
|
||||
export const BRIDGE_SESSION_COMMAND_DEFINITIONS: BridgeSessionCommandDefinition[] = [
|
||||
{ key: 'command:usage', name: 'usage', args: '', descriptionKey: 'chat.slashCommands.usage' },
|
||||
{ key: 'command:status', name: 'status', args: '', descriptionKey: 'chat.slashCommands.status' },
|
||||
{ key: 'command:abort', name: 'abort', args: '', descriptionKey: 'chat.slashCommands.abort' },
|
||||
{ key: 'command:queue', name: 'queue', argsKey: 'chat.slashCommandArgs.message', descriptionKey: 'chat.slashCommands.queue' },
|
||||
{ key: 'command:skill', name: 'skill', args: '', descriptionKey: 'skills.title', opensSkillPicker: true },
|
||||
{ key: 'command:learn', name: 'learn', argsKey: 'chat.slashCommandArgs.text', descriptionKey: 'chat.slashCommands.learn' },
|
||||
{ key: 'command:plan', name: 'plan', argsKey: 'chat.slashCommandArgs.text', descriptionKey: 'chat.slashCommands.plan' },
|
||||
{ key: 'command:moa', name: 'moa', argsKey: 'chat.slashCommandArgs.text', descriptionKey: 'chat.slashCommands.moa' },
|
||||
{ key: 'command:goal', name: 'goal', argsKey: 'chat.slashCommandArgs.text', descriptionKey: 'chat.slashCommands.goal' },
|
||||
{ key: 'command:goal-status', name: 'goal', args: 'status', insertText: 'goal status', descriptionKey: 'chat.slashCommands.goalStatus' },
|
||||
{ key: 'command:goal-pause', name: 'goal', args: 'pause', insertText: 'goal pause', descriptionKey: 'chat.slashCommands.goalPause' },
|
||||
{ key: 'command:goal-resume', name: 'goal', args: 'resume', insertText: 'goal resume', descriptionKey: 'chat.slashCommands.goalResume' },
|
||||
{ key: 'command:goal-done', name: 'goal', args: 'done', insertText: 'goal done', descriptionKey: 'chat.slashCommands.goalDone' },
|
||||
{ key: 'command:goal-clear', name: 'goal', args: 'clear', insertText: 'goal clear', descriptionKey: 'chat.slashCommands.goalClear' },
|
||||
{ key: 'command:subgoal', name: 'subgoal', argsKey: 'chat.slashCommandArgs.text', descriptionKey: 'chat.slashCommands.subgoal' },
|
||||
{ key: 'command:clear', name: 'clear', args: '', descriptionKey: 'chat.slashCommands.clear' },
|
||||
{ key: 'command:clear-history', name: 'clear', args: '--history', insertText: 'clear --history', descriptionKey: 'chat.slashCommands.clearHistory' },
|
||||
{ key: 'command:title', name: 'title', argsKey: 'chat.slashCommandArgs.title', descriptionKey: 'chat.slashCommands.title' },
|
||||
{ key: 'command:compress', name: 'compress', args: '', descriptionKey: 'chat.slashCommands.compress' },
|
||||
{ key: 'command:fork', name: 'fork', argsKey: 'chat.slashCommandArgs.title', descriptionKey: 'chat.slashCommands.fork' },
|
||||
{ key: 'command:steer', name: 'steer', argsKey: 'chat.slashCommandArgs.text', descriptionKey: 'chat.slashCommands.steer' },
|
||||
{ key: 'command:destroy', name: 'destroy', args: '', descriptionKey: 'chat.slashCommands.destroy' },
|
||||
{ key: 'command:reload-mcp', name: 'reload-mcp', args: '', descriptionKey: 'chat.slashCommands.reloadMcp' },
|
||||
{ key: 'command:reload-skills', name: 'reload-skills', args: '', descriptionKey: 'chat.slashCommands.reloadSkills' },
|
||||
]
|
||||
|
||||
export const BRIDGE_SESSION_COMMAND_NAMES = Array.from(
|
||||
new Set(BRIDGE_SESSION_COMMAND_DEFINITIONS.map(command => command.name)),
|
||||
)
|
||||
|
||||
const BRIDGE_SESSION_COMMAND_ALIASES = new Map<string, BridgeSessionCommandName>([
|
||||
['reload_skills', 'reload-skills'],
|
||||
])
|
||||
|
||||
export function normalizeBridgeSessionCommandName(name: string): BridgeSessionCommandName | null {
|
||||
const normalized = name.trim().toLowerCase()
|
||||
if (!normalized) return null
|
||||
const alias = BRIDGE_SESSION_COMMAND_ALIASES.get(normalized)
|
||||
if (alias) return alias
|
||||
return (BRIDGE_SESSION_COMMAND_NAMES as string[]).includes(normalized)
|
||||
? normalized as BridgeSessionCommandName
|
||||
: null
|
||||
}
|
||||
|
||||
export function readBridgeSessionCommandName(input: string): BridgeSessionCommandName | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed.startsWith('/')) return null
|
||||
const match = trimmed.match(/^\/([a-zA-Z][\w-]*)(?:\s|$)/)
|
||||
if (!match) return null
|
||||
return normalizeBridgeSessionCommandName(match[1])
|
||||
}
|
||||
|
||||
export function isKnownBridgeSessionCommand(input: string): boolean {
|
||||
return readBridgeSessionCommandName(input) !== null
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const DEFAULT_KANBAN_ASSIGNEE = 'default'
|
||||
|
||||
export interface KanbanAssigneeSummary {
|
||||
name: string
|
||||
counts?: Record<string, number> | null
|
||||
}
|
||||
|
||||
export function assigneeTaskTotal(assignee: KanbanAssigneeSummary): number {
|
||||
return Object.values(assignee.counts || {}).reduce((sum, count) => sum + count, 0)
|
||||
}
|
||||
|
||||
export function withDefaultAssignee<T extends KanbanAssigneeSummary>(
|
||||
assignees: T[],
|
||||
byAssignee: Record<string, number> = {},
|
||||
): KanbanAssigneeSummary[] {
|
||||
const defaultCount = byAssignee[DEFAULT_KANBAN_ASSIGNEE] || 0
|
||||
return assignees.map(assignee => {
|
||||
if (assignee.name !== DEFAULT_KANBAN_ASSIGNEE || assignee.counts) return assignee
|
||||
return { ...assignee, counts: { total: defaultCount } }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SkillInfo, SkillTarget, SkillsData } from '@/api/hermes/skills'
|
||||
import type { WorkflowSelectOption } from '@/components/hermes/workflow/types'
|
||||
|
||||
export function workflowAgentToSkillTarget(agent: string): SkillTarget {
|
||||
if (agent === 'claude-code') return 'claude'
|
||||
if (agent === 'codex') return 'codex'
|
||||
return 'hermes'
|
||||
}
|
||||
|
||||
export function buildWorkflowSkillOptions(data: SkillsData): WorkflowSelectOption[] {
|
||||
const byName = new Map<string, SkillInfo>()
|
||||
for (const category of data.categories || []) {
|
||||
for (const skill of category.skills || []) {
|
||||
if (skill.enabled === false) continue
|
||||
if (!byName.has(skill.name)) byName.set(skill.name, skill)
|
||||
}
|
||||
}
|
||||
return [...byName.values()]
|
||||
.map(skill => ({ label: skill.name, value: skill.name }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
function errorMessageText(error: unknown): string {
|
||||
if (typeof error === 'string') return error.trim()
|
||||
if (error == null) return ''
|
||||
if (typeof error !== 'object') return String(error).trim()
|
||||
|
||||
if (Array.isArray(error)) {
|
||||
return error.map(errorMessageText).filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
const record = error as Record<string, unknown>
|
||||
for (const key of ['message', 'error', 'detail', 'description', 'code']) {
|
||||
const text = errorMessageText(record[key])
|
||||
if (text) return text
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(error)
|
||||
} catch {
|
||||
return String(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function responseErrorMessage(
|
||||
response: Response,
|
||||
fallbackPrefix = 'Request failed',
|
||||
): Promise<string> {
|
||||
let detail = ''
|
||||
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
detail = errorMessageText(await response.clone().json())
|
||||
} catch {
|
||||
detail = ''
|
||||
}
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
try {
|
||||
detail = (await response.clone().text()).trim()
|
||||
} catch {
|
||||
detail = ''
|
||||
}
|
||||
}
|
||||
|
||||
const base = `${fallbackPrefix}: ${response.status}`
|
||||
return detail ? `${base} - ${detail}` : base
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type ApiKeyFunPresetProvider = 'fun-codex' | 'fun-claude'
|
||||
|
||||
export function isApiKeyFunBaseUrl(baseUrl: string): boolean {
|
||||
const value = baseUrl.trim()
|
||||
if (!value) return false
|
||||
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
const hostname = parsed.hostname.toLowerCase()
|
||||
return hostname === 'apikey.fun' || hostname.endsWith('.apikey.fun')
|
||||
} catch {
|
||||
// Fall back to a string match so partially typed values still route on submit.
|
||||
return /^(?:https?:\/\/)?(?:[a-z0-9-]+\.)*apikey\.fun(?:\/|$)/i.test(value)
|
||||
}
|
||||
}
|
||||
|
||||
export function inferApiKeyFunPresetProvider(model: string): ApiKeyFunPresetProvider | null {
|
||||
const normalized = model.trim().toLowerCase()
|
||||
if (!normalized) return null
|
||||
if (normalized.startsWith('claude')) return 'fun-claude'
|
||||
if (normalized.startsWith('gpt') || normalized.includes('codex')) return 'fun-codex'
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
export interface ParsedThinking {
|
||||
segments: string[]
|
||||
pending: string | null
|
||||
body: string
|
||||
hasThinking: boolean
|
||||
}
|
||||
|
||||
export interface ParseOptions {
|
||||
streaming: boolean
|
||||
}
|
||||
|
||||
const TAG_RE = /<(think|thinking|reasoning)>([\s\S]*?)<\/\1>/gi
|
||||
|
||||
const PLACEHOLDER_PREFIX = '\u0000THKCODE'
|
||||
const PLACEHOLDER_SUFFIX = '\u0000'
|
||||
|
||||
const FENCED_RE = /(^|\n)( {0,3})(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n\2\3[ \t]*(?=\n|$)/g
|
||||
const INLINE_CODE_RE = /`[^`\n]*`/g
|
||||
|
||||
function protectCodeBlocks(input: string): { masked: string; blocks: string[] } {
|
||||
const blocks: string[] = []
|
||||
let masked = input.replace(FENCED_RE, (m) => {
|
||||
blocks.push(m)
|
||||
return `${PLACEHOLDER_PREFIX}${blocks.length - 1}${PLACEHOLDER_SUFFIX}`
|
||||
})
|
||||
masked = masked.replace(INLINE_CODE_RE, (m) => {
|
||||
blocks.push(m)
|
||||
return `${PLACEHOLDER_PREFIX}${blocks.length - 1}${PLACEHOLDER_SUFFIX}`
|
||||
})
|
||||
return { masked, blocks }
|
||||
}
|
||||
|
||||
function restoreCodeBlocks(text: string, blocks: string[]): string {
|
||||
if (blocks.length === 0) return text
|
||||
|
||||
const placeholderRe = new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, 'g')
|
||||
let restored = text
|
||||
|
||||
for (let i = 0; i < blocks.length; i += 1) {
|
||||
const next = restored.replace(
|
||||
placeholderRe,
|
||||
(_, idx) => blocks[Number(idx)] ?? '',
|
||||
)
|
||||
if (next === restored) break
|
||||
restored = next
|
||||
}
|
||||
|
||||
return restored
|
||||
}
|
||||
|
||||
export function parseThinking(content: string, opts: ParseOptions): ParsedThinking {
|
||||
const { masked, blocks } = protectCodeBlocks(content)
|
||||
|
||||
const segments: string[] = []
|
||||
let pending: string | null = null
|
||||
let body = ''
|
||||
let lastIndex = 0
|
||||
|
||||
TAG_RE.lastIndex = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = TAG_RE.exec(masked)) !== null) {
|
||||
body += masked.slice(lastIndex, m.index)
|
||||
segments.push(m[2])
|
||||
lastIndex = m.index + m[0].length
|
||||
}
|
||||
const rest = masked.slice(lastIndex)
|
||||
|
||||
const openRe = /<(think|thinking|reasoning)>([\s\S]*)$/i
|
||||
const openMatch = rest.match(openRe)
|
||||
if (openMatch) {
|
||||
body += rest.slice(0, openMatch.index)
|
||||
if (opts.streaming) {
|
||||
pending = openMatch[2]
|
||||
} else {
|
||||
body += rest.slice(openMatch.index!)
|
||||
}
|
||||
} else {
|
||||
body += rest
|
||||
}
|
||||
|
||||
return {
|
||||
segments: segments.map(s => restoreCodeBlocks(s, blocks)),
|
||||
pending: pending === null ? null : restoreCodeBlocks(pending, blocks),
|
||||
body: restoreCodeBlocks(body, blocks),
|
||||
hasThinking: segments.length > 0 || pending !== null,
|
||||
}
|
||||
}
|
||||
|
||||
export function countThinkingChars(parsed: ParsedThinking): number {
|
||||
const len = (s: string) => [...s].length
|
||||
return parsed.segments.reduce((a, s) => a + len(s), 0) + len(parsed.pending || '')
|
||||
}
|
||||
|
||||
export interface ThinkingBoundary {
|
||||
startedAtBoundary: boolean
|
||||
endedAtBoundary: boolean
|
||||
}
|
||||
|
||||
const ANY_OPEN_RE = /<(think|thinking|reasoning)>/i
|
||||
const ANY_CLOSE_RE = /<\/(think|thinking|reasoning)>/i
|
||||
|
||||
export function detectThinkingBoundary(prev: string, next: string): ThinkingBoundary {
|
||||
const prevMasked = protectCodeBlocks(prev).masked
|
||||
const nextMasked = protectCodeBlocks(next).masked
|
||||
return {
|
||||
startedAtBoundary: !ANY_OPEN_RE.test(prevMasked) && ANY_OPEN_RE.test(nextMasked),
|
||||
endedAtBoundary: !ANY_CLOSE_RE.test(prevMasked) && ANY_CLOSE_RE.test(nextMasked),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 语速倍率 → Edge TTS rate 字符串
|
||||
* 1.0 → "0%", 1.2 → "+20%", 0.5 → "-50%"
|
||||
*/
|
||||
export function speedToEdgeRate(speed: number): string {
|
||||
const percent = Math.round((speed - 1) * 100)
|
||||
return percent >= 0 ? `+${percent}%` : `${percent}%`
|
||||
}
|
||||
|
||||
/**
|
||||
* Hz 偏移值 → Edge TTS pitch 字符串
|
||||
* 0 → "0Hz", 12 → "+12Hz", -8 → "-8Hz"
|
||||
*/
|
||||
export function hzToEdgePitch(hz: number): string {
|
||||
return hz >= 0 ? `+${hz}Hz` : `${hz}Hz`
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export type VoiceDialogueEventType =
|
||||
| 'session.started'
|
||||
| 'session.closed'
|
||||
| 'session.error'
|
||||
| 'capture.started'
|
||||
| 'capture.stopped'
|
||||
| 'capture.cancelled'
|
||||
| 'transcript.delta'
|
||||
| 'transcript.done'
|
||||
| 'turn.started'
|
||||
| 'turn.ended'
|
||||
| 'turn.cancelled'
|
||||
| 'output.audio.started'
|
||||
| 'output.audio.done'
|
||||
| 'latency.metrics'
|
||||
|
||||
export interface VoiceDialogueEvent<TPayload = unknown> {
|
||||
id: string
|
||||
seq: number
|
||||
sessionId: string
|
||||
type: VoiceDialogueEventType
|
||||
captureId?: string
|
||||
turnId?: string
|
||||
timestamp: string
|
||||
final?: boolean
|
||||
payload?: TPayload
|
||||
}
|
||||
|
||||
export interface CreateVoiceDialogueEventOptions<TPayload = unknown> {
|
||||
captureId?: string
|
||||
turnId?: string
|
||||
timestamp?: string
|
||||
final?: boolean
|
||||
payload?: TPayload
|
||||
}
|
||||
|
||||
export function createVoiceEventSequencer(sessionId: string) {
|
||||
let seq = 0
|
||||
|
||||
return function nextEvent<TPayload = unknown>(
|
||||
type: VoiceDialogueEventType,
|
||||
options: CreateVoiceDialogueEventOptions<TPayload> = {},
|
||||
): VoiceDialogueEvent<TPayload> {
|
||||
seq += 1
|
||||
|
||||
return {
|
||||
id: `${sessionId}:${seq}`,
|
||||
seq,
|
||||
sessionId,
|
||||
type,
|
||||
captureId: options.captureId,
|
||||
turnId: options.turnId,
|
||||
timestamp: options.timestamp ?? new Date().toISOString(),
|
||||
final: options.final,
|
||||
payload: options.payload,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user