mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-21 03:39:54 +00:00
chore: uptick
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import type { Unstable_TriggerAdapter } from '@assistant-ui/core'
|
||||
import { ComposerPrimitive } from '@assistant-ui/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { COMPLETION_DRAWER_CLASS } from './constants'
|
||||
|
||||
export const COMPLETION_DRAWER_ROW_CLASS =
|
||||
'flex w-full min-w-0 items-baseline gap-2 rounded-md px-2.5 py-1 text-left text-xs transition-colors hover:bg-accent/70 data-highlighted:bg-accent'
|
||||
|
||||
export function ComposerCompletionDrawer({
|
||||
adapter,
|
||||
ariaLabel,
|
||||
char,
|
||||
children
|
||||
}: {
|
||||
adapter: Unstable_TriggerAdapter
|
||||
ariaLabel: string
|
||||
char: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<ComposerPrimitive.Unstable_TriggerPopover
|
||||
adapter={adapter}
|
||||
aria-label={ariaLabel}
|
||||
char={char}
|
||||
className={COMPLETION_DRAWER_CLASS}
|
||||
data-slot="composer-completion-drawer"
|
||||
>
|
||||
{children}
|
||||
</ComposerPrimitive.Unstable_TriggerPopover>
|
||||
)
|
||||
}
|
||||
|
||||
export function CompletionDrawerEmpty({
|
||||
children,
|
||||
title
|
||||
}: {
|
||||
children?: ReactNode
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<div className="px-3 py-3 text-sm text-muted-foreground">
|
||||
<p>{title}</p>
|
||||
{children && <p className="mt-1 text-xs text-muted-foreground/80">{children}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Unstable_IconComponent } from '@assistant-ui/react'
|
||||
import { FileText, FolderOpen, ImageIcon, Link, type LucideIcon } from 'lucide-react'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
@@ -28,15 +27,8 @@ export const ATTACHMENT_ICON: Record<ComposerAttachment['kind'], LucideIcon> = {
|
||||
file: FileText
|
||||
}
|
||||
|
||||
export const DIRECTIVE_ICONS: Record<string, Unstable_IconComponent> = {
|
||||
file: FileText,
|
||||
folder: FolderOpen,
|
||||
image: ImageIcon,
|
||||
url: Link
|
||||
}
|
||||
|
||||
export const DIRECTIVE_POPOVER_CLASS =
|
||||
'absolute bottom-24 left-1/2 z-50 w-[min(calc(100vw-1.5rem),26rem)] max-h-[min(24rem,calc(100vh-8rem))] -translate-x-1/2 overflow-y-auto overscroll-contain rounded-2xl border border-border/60 bg-popover/95 p-1.5 text-popover-foreground shadow-2xl backdrop-blur-md ring-1 ring-black/5'
|
||||
export const COMPLETION_DRAWER_CLASS =
|
||||
'absolute inset-x-0 bottom-[calc(100%-0.0rem)] z-50 max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain rounded-t-(--composer-active-radius) rounded-b-none border-x border-t border-b-0 border-ring/45 bg-popover/96 p-1.5 pb-3 text-popover-foreground shadow-[0_-1rem_2.25rem_-1.75rem_color-mix(in_srgb,var(--dt-foreground)_34%,transparent),0_-0.3125rem_0.875rem_-0.6875rem_color-mix(in_srgb,var(--dt-foreground)_22%,transparent)] backdrop-blur-md'
|
||||
|
||||
export const PROMPT_SNIPPETS = [
|
||||
{
|
||||
|
||||
@@ -1,103 +1,56 @@
|
||||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import {
|
||||
ComposerPrimitive,
|
||||
type Unstable_IconComponent,
|
||||
type Unstable_MentionCategory,
|
||||
type Unstable_MentionDirective
|
||||
} from '@assistant-ui/react'
|
||||
import { FileText } from 'lucide-react'
|
||||
import { ComposerPrimitive, type Unstable_MentionDirective } from '@assistant-ui/react'
|
||||
|
||||
import { DIRECTIVE_POPOVER_CLASS } from './constants'
|
||||
import type { ContextSuggestion } from './types'
|
||||
import {
|
||||
ComposerCompletionDrawer,
|
||||
CompletionDrawerEmpty,
|
||||
COMPLETION_DRAWER_ROW_CLASS
|
||||
} from './completion-drawer'
|
||||
|
||||
export function DirectivePopover({
|
||||
adapter,
|
||||
directive,
|
||||
fallbackIcon: Fallback,
|
||||
iconMap
|
||||
loading = false
|
||||
}: {
|
||||
adapter: Unstable_TriggerAdapter
|
||||
directive: Unstable_MentionDirective
|
||||
fallbackIcon: Unstable_IconComponent
|
||||
iconMap: Record<string, Unstable_IconComponent>
|
||||
loading?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ComposerPrimitive.Unstable_TriggerPopover adapter={adapter} char="@" className={DIRECTIVE_POPOVER_CLASS}>
|
||||
<ComposerCompletionDrawer adapter={adapter} ariaLabel="Reference suggestions" char="@">
|
||||
<ComposerPrimitive.Unstable_TriggerPopover.Directive {...directive} />
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
{items => (
|
||||
<div className="grid gap-0.5">
|
||||
<div className="px-2 pb-1 pt-0.5 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/80">
|
||||
Reference a file
|
||||
</div>
|
||||
<div className="grid gap-0.5 pt-0.5">
|
||||
{items.length === 0 ? (
|
||||
<div className="px-3 py-3 text-sm text-muted-foreground">
|
||||
<p>No file suggestions yet.</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/80">
|
||||
Keep typing to filter, or click <span className="font-medium text-foreground/80">+</span> to attach
|
||||
files, folders, or a URL.
|
||||
</p>
|
||||
</div>
|
||||
<CompletionDrawerEmpty title={loading ? 'Looking up...' : 'No matches.'}>
|
||||
Try <span className="font-mono text-foreground/80">@</span> for shortcuts, or paths like{' '}
|
||||
<span className="font-mono text-foreground/80">@~/Desktop</span> /{' '}
|
||||
<span className="font-mono text-foreground/80">@./src</span>.
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map((item, index) => {
|
||||
const Icon = directiveIcon(item, iconMap, Fallback)
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItem
|
||||
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent/70 data-highlighted:bg-accent"
|
||||
index={index}
|
||||
item={item}
|
||||
key={`${item.type}:${item.id}`}
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground/80" />
|
||||
<span className="grid min-w-0 flex-1 gap-0.5">
|
||||
<span className="truncate font-medium text-foreground">{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="truncate text-[0.72rem] text-muted-foreground/85">{item.description}</span>
|
||||
)}
|
||||
</span>
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItem>
|
||||
)
|
||||
})
|
||||
items.map((item, index) => <DirectiveRow index={index} item={item} key={item.id} />)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
</ComposerPrimitive.Unstable_TriggerPopover>
|
||||
</ComposerCompletionDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
export function buildMentionCategories(suggestions: ContextSuggestion[] | undefined): Unstable_MentionCategory[] {
|
||||
const items: Unstable_TriggerItem[] = []
|
||||
function DirectiveRow({ index, item }: { index: number; item: Unstable_TriggerItem }) {
|
||||
const metadata = item.metadata as { display?: string; meta?: string } | undefined
|
||||
const display = metadata?.display || item.label
|
||||
const description = metadata?.meta || item.description
|
||||
|
||||
for (const s of suggestions ?? []) {
|
||||
const match = s.text.match(/^@(file|folder|url|image):(.+)$/)
|
||||
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
|
||||
const [, type, id] = match
|
||||
|
||||
items.push({
|
||||
id,
|
||||
type,
|
||||
label: s.display || id,
|
||||
description: s.meta,
|
||||
metadata: { icon: type }
|
||||
})
|
||||
}
|
||||
|
||||
return [{ id: 'context', label: 'References', items }]
|
||||
}
|
||||
|
||||
function directiveIcon(
|
||||
item: Unstable_TriggerItem,
|
||||
iconMap: Record<string, Unstable_IconComponent>,
|
||||
fallback: Unstable_IconComponent
|
||||
): Unstable_IconComponent {
|
||||
const meta = item.metadata as Record<string, unknown> | undefined
|
||||
const key = typeof meta?.icon === 'string' ? meta.icon : item.type
|
||||
|
||||
return iconMap[key] ?? iconMap[item.type] ?? fallback ?? FileText
|
||||
return (
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItem
|
||||
className={COMPLETION_DRAWER_ROW_CLASS}
|
||||
index={index}
|
||||
item={item}
|
||||
>
|
||||
<span className="shrink-0 truncate font-mono font-medium leading-5 text-foreground">{display}</span>
|
||||
{description && <span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItem>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { COMPLETION_DRAWER_CLASS } from './constants'
|
||||
|
||||
const COMMON_COMMANDS: [string, string][] = [
|
||||
['/help', 'full list of commands + hotkeys'],
|
||||
['/clear', 'start a new session'],
|
||||
['/resume', 'resume a prior session'],
|
||||
['/details', 'control transcript detail level'],
|
||||
['/copy', 'copy selection or last assistant message'],
|
||||
['/quit', 'exit hermes']
|
||||
]
|
||||
|
||||
const HOTKEYS: [string, string][] = [
|
||||
['@', 'reference files, folders, urls, git'],
|
||||
['/', 'slash command palette'],
|
||||
['?', 'this quick help (delete to dismiss)'],
|
||||
['Enter', 'send · Shift+Enter for newline'],
|
||||
['Cmd/Ctrl+K', 'send next queued turn'],
|
||||
['Cmd/Ctrl+L', 'redraw'],
|
||||
['Esc', 'close popover · cancel run'],
|
||||
['↑ / ↓', 'cycle popover / history']
|
||||
]
|
||||
|
||||
export function HelpHint() {
|
||||
return (
|
||||
<div className={COMPLETION_DRAWER_CLASS} data-slot="composer-completion-drawer" data-state="open" role="dialog">
|
||||
<Section title="Common commands">
|
||||
{COMMON_COMMANDS.map(([key, desc]) => (
|
||||
<Row description={desc} key={key} keyLabel={key} mono />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="Hotkeys">
|
||||
{HOTKEYS.map(([key, desc]) => (
|
||||
<Row description={desc} key={key} keyLabel={key} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<p className="px-2.5 py-1 text-xs text-muted-foreground/80">
|
||||
<span className="font-mono text-foreground/80">/help</span> opens the full panel · backspace dismisses
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ children, title }: { children: React.ReactNode; title: string }) {
|
||||
return (
|
||||
<div className="grid gap-0.5 pt-0.5">
|
||||
<p className="px-2.5 pb-0.5 pt-1 text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground/75">
|
||||
{title}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ description, keyLabel, mono = false }: { description: string; keyLabel: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-baseline gap-2 rounded-md px-2.5 py-1 text-xs">
|
||||
<span
|
||||
className={mono ? 'shrink-0 truncate font-mono font-medium text-foreground/85' : 'shrink-0 truncate text-foreground/85'}
|
||||
>
|
||||
{keyLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground/80">{description}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
|
||||
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
|
||||
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
|
||||
|
||||
const KIND_RE = /^@(file|folder|url|image|tool|git):(.*)$/
|
||||
|
||||
interface AtItemMetadata extends Record<string, string> {
|
||||
icon: string
|
||||
display: string
|
||||
meta: string
|
||||
/** Raw `text` field from the gateway, e.g. `@file:src/main.tsx` or `@diff`. */
|
||||
rawText: string
|
||||
/** Just the value portion (after `@kind:`), or empty for simple refs. */
|
||||
insertId: string
|
||||
}
|
||||
|
||||
function textValue(value: unknown, fallback = ''): string {
|
||||
return typeof value === 'string' ? value : fallback
|
||||
}
|
||||
|
||||
/** Parse the gateway's `text` field (`@file:src/foo.ts`, `@diff`, `@folder:`) into popover-ready data. */
|
||||
function classify(entry: CompletionEntry): {
|
||||
type: string
|
||||
insertId: string
|
||||
display: string
|
||||
meta: string
|
||||
} {
|
||||
const match = KIND_RE.exec(entry.text)
|
||||
|
||||
if (match) {
|
||||
const [, kind, rest] = match
|
||||
|
||||
return {
|
||||
type: kind,
|
||||
insertId: rest,
|
||||
display: textValue(entry.display, rest || `@${kind}:`),
|
||||
meta: textValue(entry.meta)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'simple',
|
||||
insertId: entry.text,
|
||||
display: textValue(entry.display, entry.text),
|
||||
meta: textValue(entry.meta)
|
||||
}
|
||||
}
|
||||
|
||||
/** Live `@` completions backed by the gateway's `complete.path` RPC. */
|
||||
export function useAtCompletions(options: {
|
||||
gateway: HermesGateway | null
|
||||
sessionId: string | null
|
||||
cwd: string | null
|
||||
}): { adapter: Unstable_TriggerAdapter; loading: boolean } {
|
||||
const { gateway, sessionId, cwd } = options
|
||||
const enabled = Boolean(gateway)
|
||||
|
||||
const fetcher = useCallback(
|
||||
async (query: string): Promise<CompletionPayload> => {
|
||||
if (!gateway) {
|
||||
return { items: [], query }
|
||||
}
|
||||
|
||||
const word = `@${query}`
|
||||
const params: Record<string, unknown> = { word }
|
||||
|
||||
if (sessionId) {
|
||||
params.session_id = sessionId
|
||||
}
|
||||
|
||||
if (cwd) {
|
||||
params.cwd = cwd
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.path', params)
|
||||
|
||||
return { items: result.items ?? [], query }
|
||||
} catch {
|
||||
return { items: [], query }
|
||||
}
|
||||
},
|
||||
[gateway, sessionId, cwd]
|
||||
)
|
||||
|
||||
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
|
||||
const classified = classify(entry)
|
||||
|
||||
const metadata: AtItemMetadata = {
|
||||
icon: classified.type,
|
||||
display: classified.display,
|
||||
meta: classified.meta,
|
||||
rawText: entry.text,
|
||||
insertId: classified.insertId
|
||||
}
|
||||
|
||||
return {
|
||||
// Unique id keyed on the gateway's full `text` so two entries that share
|
||||
// a basename (e.g. multiple `index.ts`) don't collide in keyboard nav.
|
||||
id: `${entry.text}|${index}`,
|
||||
type: classified.type,
|
||||
label: classified.display,
|
||||
...(classified.meta ? { description: classified.meta } : {}),
|
||||
metadata
|
||||
}
|
||||
}, [])
|
||||
|
||||
return useLiveCompletionAdapter({ enabled, fetcher, toItem })
|
||||
}
|
||||
|
||||
/** Re-export `classify` for use by the formatter (insertion side). */
|
||||
export { classify }
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
export interface CompletionEntry {
|
||||
text: string
|
||||
display?: unknown
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
export interface CompletionPayload {
|
||||
items: CompletionEntry[]
|
||||
query: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives an assistant-ui `Unstable_TriggerAdapter` from an async RPC call.
|
||||
*
|
||||
* Mirrors the TUI's `useCompletion` flow: each query change schedules a
|
||||
* debounced fetch (default 60ms) and the adapter synchronously returns the
|
||||
* most recent items while the user keeps typing. When the fetch resolves we
|
||||
* store the new items + the query they belong to, which causes a re-render
|
||||
* with a fresh adapter instance — `Unstable_TriggerPopover` then re-runs its
|
||||
* `search()` and shows the latest results.
|
||||
*/
|
||||
export function useLiveCompletionAdapter(options: {
|
||||
enabled: boolean
|
||||
debounceMs?: number
|
||||
fetcher: (query: string) => Promise<CompletionPayload>
|
||||
toItem: (entry: CompletionEntry, index: number) => Unstable_TriggerItem
|
||||
}): { adapter: Unstable_TriggerAdapter; loading: boolean } {
|
||||
const { enabled, debounceMs = 60, fetcher, toItem } = options
|
||||
|
||||
const [state, setState] = useState<{ query: string; items: Unstable_TriggerItem[] }>({
|
||||
query: '\u0000',
|
||||
items: []
|
||||
})
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const tokenRef = useRef(0)
|
||||
const timerRef = useRef<number | null>(null)
|
||||
const pendingQueryRef = useRef<string | null>(null)
|
||||
|
||||
const cancelTimer = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => cancelTimer(), [cancelTimer])
|
||||
|
||||
const scheduleFetch = useCallback(
|
||||
(query: string) => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingQueryRef.current === query) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingQueryRef.current = query
|
||||
cancelTimer()
|
||||
const token = ++tokenRef.current
|
||||
setLoading(true)
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
timerRef.current = null
|
||||
|
||||
fetcher(query)
|
||||
.then(payload => {
|
||||
if (token !== tokenRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setState({
|
||||
query: payload.query,
|
||||
items: payload.items.map((entry, index) => toItem(entry, index))
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
if (token !== tokenRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setState({ query, items: [] })
|
||||
})
|
||||
.finally(() => {
|
||||
if (token === tokenRef.current) {
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
}, debounceMs)
|
||||
},
|
||||
[cancelTimer, debounceMs, enabled, fetcher, toItem]
|
||||
)
|
||||
|
||||
const adapter = useMemo<Unstable_TriggerAdapter>(
|
||||
() => ({
|
||||
categories: () => [],
|
||||
categoryItems: () => [],
|
||||
search: (query: string) => {
|
||||
if (query !== state.query) {
|
||||
scheduleFetch(query)
|
||||
}
|
||||
|
||||
return state.items
|
||||
}
|
||||
}),
|
||||
[scheduleFetch, state]
|
||||
)
|
||||
|
||||
return { adapter, loading }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
|
||||
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
|
||||
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
|
||||
|
||||
const PICKER_OWNED = new Set(['/model', '/provider', 'model', 'provider'])
|
||||
|
||||
interface SlashItemMetadata extends Record<string, string> {
|
||||
command: string
|
||||
display: string
|
||||
meta: string
|
||||
}
|
||||
|
||||
interface CommandsCatalogResponse {
|
||||
pairs?: [string, string][]
|
||||
}
|
||||
|
||||
function textValue(value: unknown, fallback = ''): string {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(part => (Array.isArray(part) ? String(part[1] ?? '') : typeof part === 'string' ? part : ''))
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function commandText(value: string): string {
|
||||
return value.startsWith('/') ? value : `/${value}`
|
||||
}
|
||||
|
||||
/** Live `/` completions backed by the gateway's `complete.slash` RPC. */
|
||||
export function useSlashCompletions(options: {
|
||||
gateway: HermesGateway | null
|
||||
}): { adapter: Unstable_TriggerAdapter; loading: boolean } {
|
||||
const { gateway } = options
|
||||
const enabled = Boolean(gateway)
|
||||
|
||||
const fetcher = useCallback(
|
||||
async (query: string): Promise<CompletionPayload> => {
|
||||
if (!gateway) {
|
||||
return { items: [], query }
|
||||
}
|
||||
|
||||
const text = `/${query}`
|
||||
|
||||
// Model/provider have a dedicated picker; suppress slash completions for them once typed.
|
||||
if (text.startsWith('/model') || text.startsWith('/provider')) {
|
||||
return { items: [], query }
|
||||
}
|
||||
|
||||
try {
|
||||
if (!query) {
|
||||
const catalog = await gateway.request<CommandsCatalogResponse>('commands.catalog')
|
||||
|
||||
const items = (catalog.pairs ?? [])
|
||||
.map(([command, meta]) => ({
|
||||
text: command,
|
||||
display: command,
|
||||
meta
|
||||
}))
|
||||
.filter(item => !PICKER_OWNED.has(item.text))
|
||||
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text })
|
||||
const items = (result.items ?? []).filter(item => !PICKER_OWNED.has(item.text))
|
||||
|
||||
return { items, query }
|
||||
} catch {
|
||||
return { items: [], query }
|
||||
}
|
||||
},
|
||||
[gateway]
|
||||
)
|
||||
|
||||
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
|
||||
const command = commandText(entry.text)
|
||||
const display = textValue(entry.display, commandText(entry.text))
|
||||
const meta = textValue(entry.meta)
|
||||
|
||||
const metadata: SlashItemMetadata = {
|
||||
command,
|
||||
display,
|
||||
meta
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${entry.text}|${index}`,
|
||||
type: 'slash',
|
||||
label: display.startsWith('/') ? display.slice(1) : display,
|
||||
...(meta ? { description: meta } : {}),
|
||||
metadata
|
||||
}
|
||||
}, [])
|
||||
|
||||
return useLiveCompletionAdapter({ enabled, fetcher, toItem })
|
||||
}
|
||||
@@ -85,13 +85,11 @@ export function useVoiceConversation({
|
||||
}
|
||||
|
||||
const appendSpeechText = (text: string) => {
|
||||
const cleaned = text
|
||||
|
||||
if (!cleaned) {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
speechBufferRef.current = `${speechBufferRef.current} ${cleaned}`.trim()
|
||||
speechBufferRef.current = `${speechBufferRef.current} ${text}`.trim()
|
||||
}
|
||||
|
||||
const takeSpeechChunk = (force = false): string | null => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { ComposerPrimitive, unstable_useMentionAdapter, useAui, useAuiState } from '@assistant-ui/react'
|
||||
import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import LiquidGlass from 'liquid-glass-react'
|
||||
import { FileText } from 'lucide-react'
|
||||
import { type ClipboardEvent, type CSSProperties, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { type ClipboardEvent, type CSSProperties, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
ASK_PLACEHOLDERS,
|
||||
COMPOSER_BACKDROP_STYLE,
|
||||
DEFAULT_MAX_RECORDING_SECONDS,
|
||||
DIRECTIVE_ICONS,
|
||||
EDGE_NEWLINES_RE,
|
||||
EXPAND_HEIGHT_PX,
|
||||
NARROW_VIEWPORT,
|
||||
@@ -26,10 +24,14 @@ import {
|
||||
} from './constants'
|
||||
import { ContextMenu } from './context-menu'
|
||||
import { ComposerControls } from './controls'
|
||||
import { buildMentionCategories, DirectivePopover } from './directive-popover'
|
||||
import { DirectivePopover } from './directive-popover'
|
||||
import { HelpHint } from './help-hint'
|
||||
import { useAtCompletions } from './hooks/use-at-completions'
|
||||
import { useComposerGlassTweaks } from './hooks/use-composer-glass-tweaks'
|
||||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||
import { useVoiceConversation } from './hooks/use-voice-conversation'
|
||||
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
||||
import { SlashPopover } from './slash-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
import { UrlDialog } from './url-dialog'
|
||||
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
|
||||
@@ -40,9 +42,12 @@ function trimPastedEdgeNewlines(text: string): string {
|
||||
|
||||
export function ChatBar({
|
||||
busy,
|
||||
cwd,
|
||||
disabled,
|
||||
focusKey,
|
||||
gateway,
|
||||
maxRecordingSeconds = DEFAULT_MAX_RECORDING_SECONDS,
|
||||
sessionId,
|
||||
state,
|
||||
onCancel,
|
||||
onAddUrl,
|
||||
@@ -76,19 +81,13 @@ export function ChatBar({
|
||||
() => ASK_PLACEHOLDERS[Math.floor(Math.random() * ASK_PLACEHOLDERS.length)] || 'Ask anything'
|
||||
)
|
||||
|
||||
const mentionCategories = useMemo(() => buildMentionCategories(state.tools.suggestions), [state.tools.suggestions])
|
||||
|
||||
const mention = unstable_useMentionAdapter({
|
||||
categories: mentionCategories,
|
||||
includeModelContextTools: false,
|
||||
formatter: hermesDirectiveFormatter,
|
||||
iconMap: DIRECTIVE_ICONS,
|
||||
fallbackIcon: FileText
|
||||
})
|
||||
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
|
||||
const slash = useSlashCompletions({ gateway: gateway ?? null })
|
||||
|
||||
const stacked = expanded || stack
|
||||
const hasComposerPayload = draft.trim().length > 0 || attachments.length > 0
|
||||
const canSubmit = busy || hasComposerPayload
|
||||
const showHelpHint = draft === '?'
|
||||
|
||||
const glassTweaks = useComposerGlassTweaks()
|
||||
|
||||
@@ -344,20 +343,27 @@ export function ChatBar({
|
||||
return (
|
||||
<>
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
|
||||
<DirectivePopover
|
||||
adapter={mention.adapter}
|
||||
directive={mention.directive}
|
||||
fallbackIcon={mention.fallbackIcon ?? FileText}
|
||||
iconMap={mention.iconMap ?? DIRECTIVE_ICONS}
|
||||
/>
|
||||
<ComposerPrimitive.Root
|
||||
className={cn(SHELL, 'group/composer pb-8 pt-2')}
|
||||
data-slot="composer-root"
|
||||
style={
|
||||
{
|
||||
'--composer-active-radius': `${glassTweaks.liquid.cornerRadius}px`
|
||||
} as CSSProperties
|
||||
}
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
submitDraft()
|
||||
}}
|
||||
ref={composerRef}
|
||||
>
|
||||
{showHelpHint && <HelpHint />}
|
||||
<DirectivePopover
|
||||
adapter={at.adapter}
|
||||
directive={{ formatter: hermesDirectiveFormatter }}
|
||||
loading={at.loading}
|
||||
/>
|
||||
<SlashPopover adapter={slash.adapter} loading={slash.loading} />
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 top-0"
|
||||
style={{ background: glassTweaks.fadeBackground }}
|
||||
@@ -375,6 +381,7 @@ export function ChatBar({
|
||||
ref={glassShellRef}
|
||||
style={
|
||||
{
|
||||
'--composer-active-radius': `${glassTweaks.liquid.cornerRadius}px`,
|
||||
'--composer-glass-radius': `${glassTweaks.liquid.cornerRadius}px`
|
||||
} as CSSProperties
|
||||
}
|
||||
@@ -403,7 +410,14 @@ export function ChatBar({
|
||||
? 'opacity-60 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
|
||||
: 'opacity-100'
|
||||
)}
|
||||
style={{ ...COMPOSER_BACKDROP_STYLE, borderRadius: `${glassTweaks.liquid.cornerRadius}px` }}
|
||||
data-slot="composer-surface"
|
||||
style={
|
||||
{
|
||||
...COMPOSER_BACKDROP_STYLE,
|
||||
'--composer-active-radius': `${glassTweaks.liquid.cornerRadius}px`,
|
||||
borderRadius: `${glassTweaks.liquid.cornerRadius}px`
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<VoiceActivity state={voiceActivityState} />
|
||||
<VoicePlaybackActivity />
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Unstable_DirectiveFormatter, Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { ComposerPrimitive } from '@assistant-ui/react'
|
||||
|
||||
import {
|
||||
ComposerCompletionDrawer,
|
||||
CompletionDrawerEmpty,
|
||||
COMPLETION_DRAWER_ROW_CLASS
|
||||
} from './completion-drawer'
|
||||
|
||||
const slashFormatter: Unstable_DirectiveFormatter = {
|
||||
serialize(item: Unstable_TriggerItem): string {
|
||||
const metadata = item.metadata as { command?: unknown; display?: unknown } | undefined
|
||||
const command = typeof metadata?.command === 'string' ? metadata.command : null
|
||||
|
||||
if (command) {
|
||||
return command
|
||||
}
|
||||
|
||||
return `/${item.label}`
|
||||
},
|
||||
parse() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function SlashPopover({ adapter, loading }: { adapter: Unstable_TriggerAdapter; loading: boolean }) {
|
||||
return (
|
||||
<ComposerCompletionDrawer adapter={adapter} ariaLabel="Slash command suggestions" char="/">
|
||||
<ComposerPrimitive.Unstable_TriggerPopover.Directive formatter={slashFormatter} />
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
{items => (
|
||||
<div className="grid gap-0.5 pt-0.5">
|
||||
{items.length === 0 ? (
|
||||
<CompletionDrawerEmpty title={loading ? 'Looking up...' : 'No matching commands.'}>
|
||||
Try <span className="font-mono text-foreground/80">/help</span> for the full list.
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map((item, index) => {
|
||||
const meta = item.metadata as { command?: string; display?: string; meta?: string } | undefined
|
||||
const display = meta?.display ?? meta?.command ?? `/${item.label}`
|
||||
const description = meta?.meta || item.description
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItem
|
||||
className={COMPLETION_DRAWER_ROW_CLASS}
|
||||
index={index}
|
||||
item={item}
|
||||
key={item.id}
|
||||
>
|
||||
<span className="shrink-0 font-mono font-medium leading-5 text-foreground">{display}</span>
|
||||
{description && (
|
||||
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>
|
||||
)}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItem>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
</ComposerCompletionDrawer>
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,9 @@ export interface ChatBarProps {
|
||||
focusKey?: string | null
|
||||
maxRecordingSeconds?: number
|
||||
state: ChatBarState
|
||||
gateway?: import('@/hermes').HermesGateway | null
|
||||
sessionId?: string | null
|
||||
cwd?: string | null
|
||||
onCancel: () => void
|
||||
onAddContextRef?: (refText: string, label?: string, detail?: string) => void
|
||||
onAddUrl?: (url: string) => void
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { type AudioDataProvider, AudioWave, useCustomAudio } from '@audiowave/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Loader2, Mic, Volume2, VolumeX } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -8,6 +10,15 @@ import { $voicePlayback } from '@/store/voice-playback'
|
||||
|
||||
import type { VoiceActivityState } from './types'
|
||||
|
||||
type BrowserAudioContext = typeof AudioContext
|
||||
|
||||
interface ElementAnalyser {
|
||||
analyser: AnalyserNode
|
||||
context: AudioContext
|
||||
}
|
||||
|
||||
const elementAnalysers = new WeakMap<HTMLAudioElement, ElementAnalyser>()
|
||||
|
||||
function formatElapsed(seconds: number) {
|
||||
const safeSeconds = Math.max(0, Math.floor(seconds))
|
||||
const minutes = Math.floor(safeSeconds / 60)
|
||||
@@ -40,21 +51,80 @@ function VoiceLevelBars({ level, active }: { active: boolean; level: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
function PlaybackBars() {
|
||||
const bars = [820, 940, 760, 880, 700, 980, 790]
|
||||
function PlaybackWaveform({ audioElement }: { audioElement: HTMLAudioElement | null }) {
|
||||
const provider = useMemo<AudioDataProvider>(
|
||||
() => ({
|
||||
onAudioData(callback) {
|
||||
if (!audioElement) {
|
||||
return () => undefined
|
||||
}
|
||||
|
||||
const audioWindow = window as Window & { webkitAudioContext?: BrowserAudioContext }
|
||||
const AudioContextCtor = window.AudioContext || audioWindow.webkitAudioContext
|
||||
|
||||
if (!AudioContextCtor) {
|
||||
return () => undefined
|
||||
}
|
||||
|
||||
let entry = elementAnalysers.get(audioElement)
|
||||
|
||||
if (!entry || entry.context.state === 'closed') {
|
||||
const context = new AudioContextCtor()
|
||||
const source = context.createMediaElementSource(audioElement)
|
||||
const analyser = context.createAnalyser()
|
||||
|
||||
analyser.fftSize = 256
|
||||
analyser.smoothingTimeConstant = 0.72
|
||||
source.connect(analyser)
|
||||
analyser.connect(context.destination)
|
||||
entry = { analyser, context }
|
||||
elementAnalysers.set(audioElement, entry)
|
||||
}
|
||||
|
||||
void entry.context.resume()
|
||||
|
||||
const data = new Uint8Array(entry.analyser.fftSize)
|
||||
let raf = 0
|
||||
|
||||
const tick = () => {
|
||||
entry.analyser.getByteTimeDomainData(data)
|
||||
callback(new Uint8Array(data))
|
||||
raf = window.requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
tick()
|
||||
|
||||
return () => window.cancelAnimationFrame(raf)
|
||||
}
|
||||
}),
|
||||
[audioElement]
|
||||
)
|
||||
|
||||
const { source } = useCustomAudio({
|
||||
provider,
|
||||
status: audioElement ? 'active' : 'idle'
|
||||
})
|
||||
|
||||
return (
|
||||
<div aria-hidden="true" className="flex h-4 items-center gap-0.75">
|
||||
{bars.map((duration, index) => (
|
||||
<span
|
||||
className="voice-wave-bar h-full w-0.5 rounded-full bg-current"
|
||||
key={index}
|
||||
style={{
|
||||
animationDelay: `${index * -110}ms`,
|
||||
animationDuration: `${duration}ms`
|
||||
}}
|
||||
<div aria-hidden="true" className="h-4 w-22 overflow-hidden rounded-full">
|
||||
{source ? (
|
||||
<AudioWave
|
||||
amplitudeMode="adaptive"
|
||||
animateCurrentPick
|
||||
backgroundColor="transparent"
|
||||
barColor="rgb(37 99 235)"
|
||||
barWidth={2}
|
||||
gain={1.8}
|
||||
gap={1}
|
||||
height={16}
|
||||
onlyActive
|
||||
rounded={2}
|
||||
secondaryBarColor="transparent"
|
||||
source={source}
|
||||
speed={2}
|
||||
width="100%"
|
||||
/>
|
||||
))}
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -129,7 +199,7 @@ export function VoicePlaybackActivity() {
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="truncate font-medium text-foreground/85">{title}</span>
|
||||
{!preparing && <PlaybackBars />}
|
||||
{!preparing && <PlaybackWaveform audioElement={playback.audioElement} />}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
$awaitingResponse,
|
||||
$busy,
|
||||
$contextSuggestions,
|
||||
$currentCwd,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$freshDraftReady,
|
||||
@@ -108,6 +109,7 @@ export function ChatView({
|
||||
const awaitingResponse = useStore($awaitingResponse)
|
||||
const busy = useStore($busy)
|
||||
const contextSuggestions = useStore($contextSuggestions)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const freshDraftReady = useStore($freshDraftReady)
|
||||
@@ -225,7 +227,7 @@ export function ChatView({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent">
|
||||
<div className="relative flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent">
|
||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && (
|
||||
@@ -264,8 +266,10 @@ export function ChatView({
|
||||
<Suspense fallback={<ChatBarFallback />}>
|
||||
<ChatBar
|
||||
busy={busy}
|
||||
cwd={currentCwd}
|
||||
disabled={!gatewayOpen}
|
||||
focusKey={activeSessionId}
|
||||
gateway={gateway}
|
||||
maxRecordingSeconds={maxVoiceRecordingSeconds}
|
||||
onAddContextRef={onAddContextRef}
|
||||
onAddUrl={onAddUrl}
|
||||
@@ -277,6 +281,7 @@ export function ChatView({
|
||||
onRemoveAttachment={onRemoveAttachment}
|
||||
onSubmit={onSubmit}
|
||||
onTranscribeAudio={onTranscribeAudio}
|
||||
sessionId={activeSessionId}
|
||||
state={chatBarState}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -48,6 +48,37 @@ interface PromptActionsOptions {
|
||||
) => ClientSessionState
|
||||
}
|
||||
|
||||
interface CommandsCatalogResponse {
|
||||
categories?: Array<{ name: string; pairs: [string, string][] }>
|
||||
pairs?: [string, string][]
|
||||
skill_count?: number
|
||||
warning?: string
|
||||
}
|
||||
|
||||
function renderCommandsCatalog(catalog: CommandsCatalogResponse): string {
|
||||
const sections = catalog.categories?.length
|
||||
? catalog.categories
|
||||
: [{ name: 'Commands', pairs: catalog.pairs ?? [] }]
|
||||
|
||||
const body = sections
|
||||
.filter(section => section.pairs.length > 0)
|
||||
.map(section => {
|
||||
const rows = section.pairs.map(([cmd, desc]) => `${cmd.padEnd(18)} ${desc}`)
|
||||
|
||||
return [`${section.name}:`, ...rows].join('\n')
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
const tail = [
|
||||
catalog.skill_count ? `${catalog.skill_count} skill commands available.` : '',
|
||||
catalog.warning ? `warning: ${catalog.warning}` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
return [body || 'No commands available.', tail].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
export function usePromptActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
@@ -193,6 +224,18 @@ export function usePromptActions({
|
||||
return
|
||||
}
|
||||
|
||||
if (name === 'help' || name === 'commands') {
|
||||
try {
|
||||
const catalog = await requestGateway<CommandsCatalogResponse>('commands.catalog', { session_id: sessionId })
|
||||
|
||||
renderSlashOutput(renderCommandsCatalog(catalog))
|
||||
} catch (err) {
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestGateway<SlashExecResponse>('slash.exec', {
|
||||
session_id: sessionId,
|
||||
|
||||
@@ -84,6 +84,31 @@ export function formatRefValue(value: string): string {
|
||||
|
||||
export const hermesDirectiveFormatter: Unstable_DirectiveFormatter = {
|
||||
serialize(item: Unstable_TriggerItem): string {
|
||||
const metadata = item.metadata as { rawText?: unknown; insertId?: unknown } | undefined
|
||||
const rawText = typeof metadata?.rawText === 'string' ? metadata.rawText : null
|
||||
const insertId = typeof metadata?.insertId === 'string' ? metadata.insertId : null
|
||||
|
||||
// Live-completion items carry the gateway's original `text` field via metadata.
|
||||
if (rawText) {
|
||||
// Palette starters (`@file:` with empty value) — insert verbatim so the
|
||||
// user can keep typing the path inline.
|
||||
if (rawText.endsWith(':') && !insertId) {
|
||||
return rawText
|
||||
}
|
||||
|
||||
// Simple references like `@diff` / `@staged`.
|
||||
if (!insertId) {
|
||||
return rawText
|
||||
}
|
||||
|
||||
// Typed references with a value — quote when needed.
|
||||
const kindMatch = rawText.match(/^@([^:]+):/)
|
||||
const kind = kindMatch?.[1] ?? item.type
|
||||
|
||||
return `@${kind}:${formatRefValue(insertId)}`
|
||||
}
|
||||
|
||||
// Fallback for legacy callers that pass raw `id` strings.
|
||||
if (item.id === `${item.type}:`) {
|
||||
return `@${item.id}`
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function NotificationStack() {
|
||||
return (
|
||||
<div
|
||||
aria-label="Notifications"
|
||||
className="pointer-events-none fixed left-1/2 top-[calc(var(--titlebar-height)+0.75rem)] z-1050 flex w-[min(32rem,calc(100vw-2rem))] -translate-x-1/2 flex-col gap-2"
|
||||
className="pointer-events-none absolute left-1/2 top-[calc(var(--titlebar-height)+0.75rem)] z-1050 flex w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 flex-col gap-2"
|
||||
role="region"
|
||||
>
|
||||
<NotificationItem notification={latest} />
|
||||
|
||||
@@ -11,8 +11,13 @@ import { sanitizeTextForSpeech } from './speech-text'
|
||||
let currentAudio: HTMLAudioElement | null = null
|
||||
let sequence = 0
|
||||
|
||||
function currentState(status: VoicePlaybackState['status'], options?: VoicePlaybackOptions): VoicePlaybackState {
|
||||
function currentState(
|
||||
status: VoicePlaybackState['status'],
|
||||
options?: VoicePlaybackOptions,
|
||||
audioElement: HTMLAudioElement | null = null
|
||||
): VoicePlaybackState {
|
||||
return {
|
||||
audioElement,
|
||||
messageId: options?.messageId ?? null,
|
||||
sequence,
|
||||
source: options?.source ?? null,
|
||||
@@ -35,6 +40,7 @@ export function stopVoicePlayback() {
|
||||
}
|
||||
|
||||
setVoicePlaybackState({
|
||||
audioElement: null,
|
||||
messageId: null,
|
||||
sequence,
|
||||
source: null,
|
||||
@@ -65,7 +71,7 @@ export async function playSpeechText(text: string, options: VoicePlaybackOptions
|
||||
|
||||
const audio = new Audio(response.data_url)
|
||||
currentAudio = audio
|
||||
setVoicePlaybackState(currentState('speaking', options))
|
||||
setVoicePlaybackState(currentState('speaking', options, audio))
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
audio.addEventListener('ended', () => resolve(), { once: true })
|
||||
|
||||
@@ -4,6 +4,7 @@ export type VoicePlaybackSource = 'read-aloud' | 'voice-conversation'
|
||||
export type VoicePlaybackStatus = 'idle' | 'preparing' | 'speaking'
|
||||
|
||||
export interface VoicePlaybackState {
|
||||
audioElement: HTMLAudioElement | null
|
||||
messageId: string | null
|
||||
sequence: number
|
||||
source: VoicePlaybackSource | null
|
||||
@@ -11,6 +12,7 @@ export interface VoicePlaybackState {
|
||||
}
|
||||
|
||||
export const $voicePlayback = atom<VoicePlaybackState>({
|
||||
audioElement: null,
|
||||
messageId: null,
|
||||
sequence: 0,
|
||||
source: null,
|
||||
|
||||
+26
-23
@@ -184,29 +184,6 @@ button {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
@keyframes voice-wave {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.45;
|
||||
transform: scaleY(0.28);
|
||||
}
|
||||
|
||||
35% {
|
||||
opacity: 0.95;
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
62% {
|
||||
opacity: 0.7;
|
||||
transform: scaleY(0.52);
|
||||
}
|
||||
}
|
||||
|
||||
.voice-wave-bar {
|
||||
animation: voice-wave 860ms ease-in-out infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.composer-liquid-shell-wrap {
|
||||
pointer-events: none;
|
||||
border-radius: var(--composer-glass-radius, 20px);
|
||||
@@ -282,6 +259,32 @@ button {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
[data-slot='composer-root']:has([data-slot='composer-completion-drawer'][data-state='open'])
|
||||
[data-slot='composer-surface'] {
|
||||
border-top-left-radius: 0 !important;
|
||||
border-top-right-radius: 0 !important;
|
||||
border-top-color: transparent;
|
||||
box-shadow:
|
||||
0 0.0625rem 0 0.0625rem color-mix(in srgb, var(--dt-ring) 35%, transparent),
|
||||
0 0.5rem 1.5rem color-mix(in srgb, var(--shadow-ink) 6%, transparent);
|
||||
}
|
||||
|
||||
[data-slot='composer-root']:has([data-slot='composer-completion-drawer'][data-state='open'])
|
||||
[data-glass-frame='true'] {
|
||||
border-top-left-radius: 0 !important;
|
||||
border-top-right-radius: 0 !important;
|
||||
}
|
||||
|
||||
[data-slot='composer-root']:has([data-slot='composer-completion-drawer'][data-state='open'])
|
||||
[data-slot='composer-completion-drawer'] {
|
||||
margin-bottom: -0.5rem;
|
||||
border-bottom: 0;
|
||||
box-shadow:
|
||||
0 -0.0625rem 0 0.0625rem color-mix(in srgb, var(--dt-ring) 35%, transparent),
|
||||
0 -1rem 2.25rem -1.75rem color-mix(in srgb, var(--dt-foreground) 34%, transparent),
|
||||
0 -0.3125rem 0.875rem -0.6875rem color-mix(in srgb, var(--dt-foreground) 22%, transparent);
|
||||
}
|
||||
|
||||
.composer-liquid-shell > .glass > .glass__warp {
|
||||
border-radius: var(--composer-glass-radius, 20px) !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user