updated the UI in chat adding voice and image. In cockpit redesigned it.
This commit is contained in:
parent
b8b231964c
commit
f270a87894
2 changed files with 229 additions and 598 deletions
|
|
@ -1,84 +1,15 @@
|
||||||
import {
|
|
||||||
IconArrowRight,
|
|
||||||
IconBrain,
|
|
||||||
IconMicrophone,
|
|
||||||
IconMicrophoneOff,
|
|
||||||
IconPhoto,
|
|
||||||
IconSearch,
|
|
||||||
IconSettings,
|
|
||||||
IconUpload,
|
|
||||||
} from "@tabler/icons-react"
|
|
||||||
import { Link } from "@tanstack/react-router"
|
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react"
|
import { useMemo } from "react"
|
||||||
import { toast } from "sonner"
|
import { IconArrowRight } from "@tabler/icons-react"
|
||||||
|
|
||||||
import type { ChatAttachment } from "@/store/chat"
|
|
||||||
import { usePicoChat } from "@/hooks/use-pico-chat"
|
import { usePicoChat } from "@/hooks/use-pico-chat"
|
||||||
import { PageHeader } from "@/components/page-header"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
import { MemoryGraph } from "./memory-graph"
|
import { MemoryGraph } from "./memory-graph"
|
||||||
import { useAgentCockpit } from "./use-agent-cockpit"
|
import { useAgentCockpit } from "./use-agent-cockpit"
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024
|
|
||||||
const ALLOWED_IMAGE_TYPES = new Set([
|
|
||||||
"image/jpeg",
|
|
||||||
"image/png",
|
|
||||||
"image/gif",
|
|
||||||
"image/webp",
|
|
||||||
"image/bmp",
|
|
||||||
])
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
SpeechRecognition?: new () => SpeechRecognitionLike
|
|
||||||
webkitSpeechRecognition?: new () => SpeechRecognitionLike
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpeechRecognitionLike {
|
|
||||||
continuous: boolean
|
|
||||||
interimResults: boolean
|
|
||||||
lang: string
|
|
||||||
onresult: ((event: SpeechRecognitionEventLike) => void) | null
|
|
||||||
onend: (() => void) | null
|
|
||||||
onerror: ((event: { error: string }) => void) | null
|
|
||||||
start(): void
|
|
||||||
stop(): void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpeechRecognitionEventLike {
|
|
||||||
results: ArrayLike<ArrayLike<{ transcript: string }>>
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusBadgeVariant(status: string) {
|
|
||||||
switch (status) {
|
|
||||||
case "enabled":
|
|
||||||
case "completed":
|
|
||||||
return "default" as const
|
|
||||||
case "blocked":
|
|
||||||
case "failed":
|
|
||||||
return "destructive" as const
|
|
||||||
case "running":
|
|
||||||
return "secondary" as const
|
|
||||||
default:
|
|
||||||
return "outline" as const
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reasonLabel(reasonCode?: string) {
|
function reasonLabel(reasonCode?: string) {
|
||||||
switch (reasonCode) {
|
switch (reasonCode) {
|
||||||
case "requires_subagent":
|
case "requires_subagent":
|
||||||
|
|
@ -96,289 +27,89 @@ function reasonLabel(reasonCode?: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readFileAsDataUrl(file: File): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = () => {
|
|
||||||
if (typeof reader.result === "string") {
|
|
||||||
resolve(reader.result)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reject(new Error("Failed to read file"))
|
|
||||||
}
|
|
||||||
reader.onerror = () =>
|
|
||||||
reject(reader.error || new Error("Failed to read file"))
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CockpitPage() {
|
export function CockpitPage() {
|
||||||
const { activeSessionId, connectionState, sendMessage } = usePicoChat()
|
const { activeSessionId } = usePicoChat()
|
||||||
const {
|
const {
|
||||||
categoryCounts,
|
|
||||||
groupedTools,
|
groupedTools,
|
||||||
pendingToolName,
|
pendingToolName,
|
||||||
searchQuery,
|
|
||||||
sessionSubagents,
|
sessionSubagents,
|
||||||
sessionMemoryGraph,
|
sessionMemoryGraph,
|
||||||
statusCounts,
|
|
||||||
statusFilter,
|
|
||||||
webSearchConfig,
|
|
||||||
hasMemoryGraphError,
|
|
||||||
hasSubagentsError,
|
|
||||||
hasToolsError,
|
|
||||||
isMemoryGraphLoading,
|
|
||||||
isSubagentsLoading,
|
|
||||||
isToolsLoading,
|
|
||||||
isWebSearchLoading,
|
|
||||||
setSearchQuery,
|
|
||||||
setStatusFilter,
|
|
||||||
toggleTool,
|
toggleTool,
|
||||||
} = useAgentCockpit(activeSessionId)
|
} = useAgentCockpit(activeSessionId)
|
||||||
|
|
||||||
const [prompt, setPrompt] = useState("")
|
|
||||||
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
|
|
||||||
const [isListening, setIsListening] = useState(false)
|
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
|
||||||
const recognitionRef = useRef<SpeechRecognitionLike | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const Recognition =
|
|
||||||
window.SpeechRecognition ?? window.webkitSpeechRecognition
|
|
||||||
if (!Recognition) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const recognition = new Recognition()
|
|
||||||
recognition.continuous = false
|
|
||||||
recognition.interimResults = false
|
|
||||||
recognition.lang = "en-US"
|
|
||||||
recognition.onresult = (event) => {
|
|
||||||
const transcript = event.results[0]?.[0]?.transcript?.trim() ?? ""
|
|
||||||
setPrompt(transcript)
|
|
||||||
if (!transcript) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const sent = sendMessage({ content: transcript })
|
|
||||||
if (!sent) {
|
|
||||||
toast.error("Voice capture worked, but chat is not ready to send.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
recognition.onend = () => setIsListening(false)
|
|
||||||
recognition.onerror = (event) => {
|
|
||||||
setIsListening(false)
|
|
||||||
toast.error(`Voice capture error: ${event.error}`)
|
|
||||||
}
|
|
||||||
recognitionRef.current = recognition
|
|
||||||
}, [sendMessage])
|
|
||||||
|
|
||||||
const filteredToolCount = useMemo(
|
const filteredToolCount = useMemo(
|
||||||
() => groupedTools.reduce((total, [, items]) => total + items.length, 0),
|
() => groupedTools.reduce((total, [, items]) => total + items.length, 0),
|
||||||
[groupedTools],
|
[groupedTools],
|
||||||
)
|
)
|
||||||
|
|
||||||
const currentProviderLabel = useMemo(() => {
|
|
||||||
const current = webSearchConfig?.providers.find((provider) => provider.current)
|
|
||||||
return current?.label ?? webSearchConfig?.provider ?? "Auto"
|
|
||||||
}, [webSearchConfig])
|
|
||||||
|
|
||||||
const handleImageSelection = async (event: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const files = Array.from(event.target.files ?? [])
|
|
||||||
event.target.value = ""
|
|
||||||
if (files.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextAttachments: ChatAttachment[] = []
|
|
||||||
for (const file of files) {
|
|
||||||
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
|
|
||||||
toast.error(`Unsupported image type: ${file.name}`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (file.size > MAX_IMAGE_SIZE_BYTES) {
|
|
||||||
toast.error(`${file.name} exceeds 7 MB.`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const url = await readFileAsDataUrl(file)
|
|
||||||
nextAttachments.push({
|
|
||||||
type: "image",
|
|
||||||
url,
|
|
||||||
filename: file.name,
|
|
||||||
contentType: file.type,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error ? error.message : `Failed to read ${file.name}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setAttachments((current) => [...current, ...nextAttachments])
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSendBridgeMessage = () => {
|
|
||||||
const sent = sendMessage({ content: prompt, attachments })
|
|
||||||
if (!sent) {
|
|
||||||
toast.error("Chat connection is not ready yet.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setPrompt("")
|
|
||||||
setAttachments([])
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleVoice = () => {
|
|
||||||
if (!recognitionRef.current) {
|
|
||||||
toast.error("Voice capture is not supported in this browser.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isListening) {
|
|
||||||
recognitionRef.current.stop()
|
|
||||||
setIsListening(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
recognitionRef.current.start()
|
|
||||||
setIsListening(true)
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error ? error.message : "Unable to start voice capture.",
|
|
||||||
)
|
|
||||||
setIsListening(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden bg-[#07110a] text-[#d7f9df]">
|
<div className="flex h-full flex-col overflow-hidden bg-[#050505] text-[#F2F2F2] selection:bg-[#F27D26] selection:text-black font-sans relative">
|
||||||
<PageHeader title="Agent Cockpit" />
|
{/* Ghost Background Typography */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden pointer-events-none select-none opacity-[0.05]">
|
||||||
|
<span className="absolute -left-20 -top-10 text-[35vw] font-black leading-none uppercase">COCKPIT</span>
|
||||||
|
<span className="absolute -right-20 -bottom-20 text-[25vw] font-black leading-none uppercase">SYSTEM</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto px-4 py-4 sm:px-6 sm:py-6">
|
{/* Header */}
|
||||||
<div className="mx-auto grid w-full max-w-[1500px] gap-4 xl:grid-cols-[260px_minmax(0,1fr)_360px]">
|
<header className="flex justify-between items-start border-b border-white/10 p-6 md:px-12 md:py-8 z-10">
|
||||||
<aside className="space-y-4">
|
<div className="flex flex-col gap-1">
|
||||||
<Card className="border-[#1f4f31] bg-[#09150c] text-[#d7f9df] shadow-none">
|
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-[#F27D26]">Terminal Status</span>
|
||||||
<CardHeader>
|
<span className="text-xs opacity-60 font-mono tracking-tighter">CONNECTED / {dayjs().format('DD.MM.YYYY')}</span>
|
||||||
<CardTitle className="font-mono text-sm uppercase tracking-[0.22em]">
|
</div>
|
||||||
Systems
|
<div className="flex flex-col gap-1 text-right">
|
||||||
</CardTitle>
|
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-[#F27D26]">Runtime Epoch</span>
|
||||||
<CardDescription className="text-[#8bb39a]">
|
<span className="text-xs opacity-60 font-mono tracking-tighter">{dayjs().format('HH:mm')} GMT+1</span>
|
||||||
Filter the active tool surface.
|
</div>
|
||||||
</CardDescription>
|
</header>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
<div className="flex-1 overflow-auto px-6 py-6 md:px-12 md:py-10 z-10">
|
||||||
|
<div className="mx-auto grid w-full max-w-[1600px] gap-12 xl:grid-cols-[1fr_380px]">
|
||||||
|
|
||||||
|
{/* Main Workspace */}
|
||||||
|
<div className="space-y-16">
|
||||||
|
{/* Hero Section */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<IconSearch className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-[#6aa37b]" />
|
<h1 className="text-[12vw] leading-[0.85] font-black tracking-[-0.07em] uppercase m-0 p-0 text-[#F2F2F2]">
|
||||||
<Input
|
AGENT<br/>INTERFACE
|
||||||
value={searchQuery}
|
</h1>
|
||||||
onChange={(event) => setSearchQuery(event.target.value)}
|
<p className="mt-8 text-xl font-light max-w-xl opacity-60 leading-relaxed border-l-2 border-[#F27D26] pl-6">
|
||||||
placeholder="Search tools"
|
Active control node for autonomous agents. Managing tool surfaces, memory networks.
|
||||||
className="border-[#1f4f31] bg-[#050d08] pl-9 text-[#d7f9df] placeholder:text-[#5c8168]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{(["all", "enabled", "disabled", "blocked"] as const).map(
|
|
||||||
(status) => (
|
|
||||||
<button
|
|
||||||
key={status}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setStatusFilter(status)}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left text-sm transition-colors",
|
|
||||||
statusFilter === status
|
|
||||||
? "border-[#4fc267] bg-[#10351b] text-[#effff3]"
|
|
||||||
: "border-[#173621] bg-[#08100b] text-[#8bb39a] hover:border-[#2c6a3e] hover:text-[#d7f9df]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="capitalize">{status}</span>
|
|
||||||
<span className="font-mono text-xs">
|
|
||||||
{statusCounts[status]}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 border-t border-[#173621] pt-4">
|
|
||||||
<p className="font-mono text-[11px] uppercase tracking-[0.22em] text-[#7ca88a]">
|
|
||||||
Categories
|
|
||||||
</p>
|
</p>
|
||||||
{categoryCounts.map(([category, count]) => (
|
|
||||||
<div
|
|
||||||
key={category}
|
|
||||||
className="flex items-center justify-between text-sm text-[#9cc8a8]"
|
|
||||||
>
|
|
||||||
<span className="capitalize">{category}</span>
|
|
||||||
<span className="font-mono text-xs">{count}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="grid gap-12">
|
||||||
<Card className="border-[#215d36] bg-[linear-gradient(180deg,#0a150c_0%,#0a1c10_100%)] text-[#effff3] shadow-none">
|
{/* Tool Grid */}
|
||||||
<CardHeader>
|
<div className="space-y-8">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-end justify-between border-b border-white/10 pb-4">
|
||||||
<div>
|
<div className="flex flex-col gap-1">
|
||||||
<CardTitle className="font-mono text-sm uppercase tracking-[0.24em]">
|
<span className="text-[10px] uppercase tracking-[0.22em] font-bold text-[#F27D26]">Active Modules</span>
|
||||||
Tool Grid
|
<h2 className="text-4xl font-black uppercase tracking-tight">Tool Grid</h2>
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-[#98c7a5]">
|
|
||||||
Real launcher tools, live from PicoClaw.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-6">
|
||||||
<Badge variant="outline" className="border-[#2a6b3d] text-[#9fd8ae]">
|
<span className="text-[10px] uppercase tracking-[0.2em] opacity-40 font-bold">{filteredToolCount} Visible</span>
|
||||||
{filteredToolCount} visible
|
<a href="/agent/tools" className="text-xs font-bold uppercase tracking-widest hover:text-[#F27D26] transition-colors border-b border-white/20 pb-1">
|
||||||
</Badge>
|
Configuration
|
||||||
<Button asChild variant="outline" className="border-[#2a6b3d] bg-transparent text-[#c8f5d2] hover:bg-[#12301a]">
|
</a>
|
||||||
<Link to="/agent/tools">
|
|
||||||
<IconSettings className="size-4" />
|
|
||||||
Full Tools
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{hasToolsError ? (
|
{groupedTools.flatMap(([, items]) =>
|
||||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
items.map((tool) => (
|
||||||
Failed to load tools.
|
<div
|
||||||
</div>
|
|
||||||
) : isToolsLoading ? (
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
|
||||||
<Skeleton key={index} className="h-36 rounded-xl bg-[#112117]" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-5">
|
|
||||||
{groupedTools.map(([category, items]) => (
|
|
||||||
<div key={category} className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between border-b border-[#173621] pb-2">
|
|
||||||
<h2 className="font-mono text-xs uppercase tracking-[0.24em] text-[#8ec49c]">
|
|
||||||
{category}
|
|
||||||
</h2>
|
|
||||||
<span className="font-mono text-xs text-[#5f8f6f]">
|
|
||||||
{items.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
{items.map((tool) => (
|
|
||||||
<Card
|
|
||||||
key={tool.name}
|
key={tool.name}
|
||||||
size="sm"
|
className="group relative border border-white/10 bg-[#0A0A0A] p-4 hover:border-[#F27D26]/50 transition-all duration-300"
|
||||||
className="border-[#173621] bg-[#08100b] text-[#d7f9df] shadow-none"
|
|
||||||
>
|
>
|
||||||
<CardHeader className="gap-2">
|
<div className="flex items-start justify-between gap-3 mb-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="space-y-1 flex-1 min-w-0">
|
||||||
<div className="space-y-2">
|
<h4 className="font-bold text-sm tracking-tight uppercase truncate group-hover:text-[#F27D26] transition-colors">
|
||||||
<CardTitle className="font-mono text-sm">
|
|
||||||
{tool.name}
|
{tool.name}
|
||||||
</CardTitle>
|
</h4>
|
||||||
<Badge
|
<Badge
|
||||||
variant={statusBadgeVariant(tool.status)}
|
className={cn(
|
||||||
className="capitalize"
|
"rounded-none text-[8px] uppercase tracking-widest font-black px-1.5 py-0.5",
|
||||||
|
tool.status === "enabled" ? "bg-[#F27D26] text-black" : "bg-white/10 text-white/40"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{tool.status}
|
{tool.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -386,266 +117,92 @@ export function CockpitPage() {
|
||||||
<Switch
|
<Switch
|
||||||
checked={tool.status !== "disabled"}
|
checked={tool.status !== "disabled"}
|
||||||
disabled={pendingToolName === tool.name}
|
disabled={pendingToolName === tool.name}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) => toggleTool(tool.name, checked)}
|
||||||
toggleTool(tool.name, checked)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="text-[#8fb59b]">
|
<p className="text-xs text-[#F2F2F2]/60 leading-relaxed font-light mb-4 line-clamp-2">
|
||||||
{tool.description}
|
{tool.description}
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between text-xs text-[#739981]">
|
|
||||||
<span className="capitalize">{tool.category}</span>
|
|
||||||
<span className="font-mono">{tool.config_key}</span>
|
|
||||||
</div>
|
|
||||||
{tool.reason_code ? (
|
|
||||||
<div className="rounded-lg border border-amber-400/20 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">
|
|
||||||
{reasonLabel(tool.reason_code)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-[#215d36] bg-[linear-gradient(180deg,#09130b_0%,#071109_100%)] text-[#effff3] shadow-none">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="font-mono text-sm uppercase tracking-[0.24em]">
|
|
||||||
Memory Network
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-[#98c7a5]">
|
|
||||||
Obsidian-style graph built from PicoClaw workspace memory and the active session trail.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{hasMemoryGraphError ? (
|
|
||||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
|
||||||
Failed to load memory graph.
|
|
||||||
</div>
|
|
||||||
) : isMemoryGraphLoading ? (
|
|
||||||
<Skeleton className="h-[420px] rounded-xl bg-[#112117]" />
|
|
||||||
) : sessionMemoryGraph && sessionMemoryGraph.nodes.length > 0 ? (
|
|
||||||
<MemoryGraph
|
|
||||||
nodes={sessionMemoryGraph.nodes}
|
|
||||||
edges={sessionMemoryGraph.edges}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-lg border border-[#173621] bg-[#050d08] px-4 py-3 text-sm text-[#8bb39a]">
|
|
||||||
Memory graph will appear when the session and workspace memory have visible context.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<aside className="space-y-4">
|
|
||||||
<Card className="border-[#1f4f31] bg-[#09150c] text-[#d7f9df] shadow-none">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="font-mono text-sm uppercase tracking-[0.22em]">
|
|
||||||
Session Bridge
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-[#8bb39a]">
|
|
||||||
Voice and images route into the active Pico session.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="rounded-lg border border-[#173621] bg-[#050d08] px-3 py-2 text-xs text-[#9fc6aa]">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="font-mono uppercase tracking-[0.18em]">
|
|
||||||
Session
|
|
||||||
</span>
|
|
||||||
<Badge variant="outline" className="border-[#2a6b3d] text-[#9fd8ae]">
|
|
||||||
{connectionState}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 break-all font-mono text-[11px] text-[#d7f9df]">
|
|
||||||
{activeSessionId}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<div className="flex items-center justify-between text-[8px] font-mono uppercase tracking-widest text-white/30 pt-3 border-t border-white/5">
|
||||||
|
<span className="truncate">{tool.config_key}</span>
|
||||||
<Input
|
{(tool as any).reason_code && (
|
||||||
value={prompt}
|
<span className="text-[#F27D26]/60">{reasonLabel((tool as any).reason_code)}</span>
|
||||||
onChange={(event) => setPrompt(event.target.value)}
|
|
||||||
placeholder="Send a message to the active agent session"
|
|
||||||
className="border-[#1f4f31] bg-[#050d08] text-[#d7f9df] placeholder:text-[#5c8168]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{attachments.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{attachments.map((attachment, index) => (
|
|
||||||
<div
|
|
||||||
key={`${attachment.filename ?? "attachment"}-${index}`}
|
|
||||||
className="flex items-center justify-between rounded-lg border border-[#173621] bg-[#050d08] px-3 py-2 text-xs text-[#bfe7c8]"
|
|
||||||
>
|
|
||||||
<span className="truncate">{attachment.filename ?? "Image"}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-[#7ca88a] hover:text-[#effff3]"
|
|
||||||
onClick={() =>
|
|
||||||
setAttachments((current) =>
|
|
||||||
current.filter((_, itemIndex) => itemIndex !== index),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="border-[#2a6b3d] bg-transparent text-[#d7f9df] hover:bg-[#12301a]"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
<IconPhoto className="size-4" />
|
|
||||||
Add Image
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={isListening ? "destructive" : "outline"}
|
|
||||||
className={cn(
|
|
||||||
"border-[#2a6b3d] text-[#d7f9df] hover:bg-[#12301a]",
|
|
||||||
isListening && "border-red-400/40 bg-red-500/10 text-red-100",
|
|
||||||
)}
|
)}
|
||||||
onClick={toggleVoice}
|
|
||||||
>
|
|
||||||
{isListening ? (
|
|
||||||
<IconMicrophoneOff className="size-4" />
|
|
||||||
) : (
|
|
||||||
<IconMicrophone className="size-4" />
|
|
||||||
)}
|
|
||||||
Voice
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="w-full bg-[#4fc267] text-[#08120b] hover:bg-[#77e58e]"
|
|
||||||
onClick={handleSendBridgeMessage}
|
|
||||||
>
|
|
||||||
<IconUpload className="size-4" />
|
|
||||||
Send To Active Session
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<Button asChild variant="outline" className="border-[#2a6b3d] bg-transparent text-[#d7f9df] hover:bg-[#12301a]">
|
|
||||||
<Link to="/">
|
|
||||||
Open Chat
|
|
||||||
<IconArrowRight className="size-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" className="border-[#2a6b3d] bg-transparent text-[#d7f9df] hover:bg-[#12301a]">
|
|
||||||
<Link to="/agent/hub">
|
|
||||||
Skills Hub
|
|
||||||
<IconArrowRight className="size-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp"
|
|
||||||
className="hidden"
|
|
||||||
multiple
|
|
||||||
onChange={handleImageSelection}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-[#1f4f31] bg-[#09150c] text-[#d7f9df] shadow-none">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="font-mono text-sm uppercase tracking-[0.22em]">
|
|
||||||
Main Agent Subagents
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-[#8bb39a]">
|
|
||||||
Live status for the current Pico session only.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{hasSubagentsError ? (
|
|
||||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
|
||||||
Failed to load subagent status.
|
|
||||||
</div>
|
|
||||||
) : isSubagentsLoading ? (
|
|
||||||
Array.from({ length: 3 }).map((_, index) => (
|
|
||||||
<Skeleton key={index} className="h-20 rounded-xl bg-[#112117]" />
|
|
||||||
))
|
))
|
||||||
) : sessionSubagents.length === 0 ? (
|
)}
|
||||||
<div className="rounded-lg border border-[#173621] bg-[#050d08] px-3 py-3 text-sm text-[#8bb39a]">
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Memory Network */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex items-end justify-between border-b border-white/10 pb-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.22em] font-bold text-[#F27D26]">Relational Map</span>
|
||||||
|
<h2 className="text-4xl font-black uppercase tracking-tight">Memory Network</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-8 border border-white/10 bg-[#0A0A0A] relative overflow-hidden">
|
||||||
|
<MemoryGraph
|
||||||
|
nodes={sessionMemoryGraph?.nodes ?? []}
|
||||||
|
edges={sessionMemoryGraph?.edges ?? []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Sidebar */}
|
||||||
|
<aside className="space-y-12">
|
||||||
|
{/* Subagents */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="border-b border-white/10 pb-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-[#F27D26]">Subagent Manifest</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sessionSubagents.length === 0 ? (
|
||||||
|
<div className="border border-white/10 p-5 bg-[#0A0A0A] text-sm text-white/40">
|
||||||
No subagents have been created in this session yet.
|
No subagents have been created in this session yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
sessionSubagents.map((task) => (
|
sessionSubagents.map((task) => (
|
||||||
<div
|
<div key={task.id} className="group border border-white/10 p-5 bg-[#0A0A0A] hover:border-white/30 transition-all">
|
||||||
key={task.id}
|
<div className="flex justify-between items-start mb-2">
|
||||||
className="rounded-xl border border-[#173621] bg-[#050d08] px-3 py-3"
|
<span className="font-bold text-sm uppercase tracking-tight">{task.label || task.id}</span>
|
||||||
>
|
<span className={cn(
|
||||||
<div className="flex items-center justify-between gap-3">
|
"text-[9px] uppercase font-mono px-1.5 py-0.5",
|
||||||
<div>
|
task.status === "completed" ? "bg-green-500/20 text-green-400" : "bg-[#F27D26]/20 text-[#F27D26]"
|
||||||
<p className="font-mono text-sm text-[#effff3]">
|
)}>
|
||||||
{task.label || task.id}
|
|
||||||
</p>
|
|
||||||
<p className="text-[11px] text-[#7ca88a]">
|
|
||||||
{dayjs(task.created).format("MMM D, HH:mm")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant={statusBadgeVariant(task.status)} className="capitalize">
|
|
||||||
{task.status}
|
{task.status}
|
||||||
</Badge>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] font-mono text-white/30 truncate">
|
||||||
|
{dayjs(task.created).format("HH:mm:ss [UTC]")}
|
||||||
</div>
|
</div>
|
||||||
{task.result ? (
|
|
||||||
<p className="mt-3 text-xs leading-relaxed text-[#9dc2a7]">
|
|
||||||
{task.result}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-[#1f4f31] bg-[#09150c] text-[#d7f9df] shadow-none">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="font-mono text-sm uppercase tracking-[0.22em]">
|
|
||||||
Tool Runtime
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-[#8bb39a]">
|
|
||||||
Quick view of the current search/tool setup.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3 text-sm text-[#a8cfb3]">
|
|
||||||
<div className="flex items-center justify-between rounded-lg border border-[#173621] bg-[#050d08] px-3 py-3">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<IconBrain className="size-4 text-[#71d888]" />
|
|
||||||
Web search provider
|
|
||||||
</span>
|
|
||||||
<span className="font-mono text-xs uppercase text-[#effff3]">
|
|
||||||
{isWebSearchLoading ? "Loading" : currentProviderLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-[#7ca88a]">
|
</div>
|
||||||
The cockpit reuses PicoClaw’s existing Pico session, tool
|
|
||||||
toggles, and media pipeline instead of the source app’s
|
|
||||||
Firebase and Gemini-specific wiring.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="h-20 border-t border-white/10 flex items-center justify-between px-6 md:px-12 z-10 bg-[#050505]">
|
||||||
|
<nav className="flex gap-10 text-[10px] font-bold uppercase tracking-[0.3em]">
|
||||||
|
<a href="#" className="hover:text-[#F27D26] transition-colors">Portfolio</a>
|
||||||
|
<a href="#" className="hover:text-[#F27D26] transition-colors">Documentation</a>
|
||||||
|
<a href="#" className="hover:text-[#F27D26] transition-colors">Gateway</a>
|
||||||
|
</nav>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.2em] opacity-40 font-bold">System Ref: BOLD-UX-01</span>
|
||||||
|
<div className="h-10 w-10 rounded-full border border-white/10 flex items-center justify-center hover:bg-white hover:text-black cursor-pointer transition-all">
|
||||||
|
<IconArrowRight size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { IconArrowUp, IconPhotoPlus, IconX } from "@tabler/icons-react"
|
import { IconArrowUp, IconMicrophone, IconMicrophoneOff, IconPhotoPlus, IconX } from "@tabler/icons-react"
|
||||||
import type { KeyboardEvent } from "react"
|
import { type KeyboardEvent } from "react"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import TextareaAutosize from "react-textarea-autosize"
|
import TextareaAutosize from "react-textarea-autosize"
|
||||||
|
|
||||||
|
|
@ -13,6 +14,28 @@ import {
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import type { ChatAttachment, ContextUsage } from "@/store/chat"
|
import type { ChatAttachment, ContextUsage } from "@/store/chat"
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
SpeechRecognition?: new () => SpeechRecognitionLike
|
||||||
|
webkitSpeechRecognition?: new () => SpeechRecognitionLike
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionLike {
|
||||||
|
continuous: boolean
|
||||||
|
interimResults: boolean
|
||||||
|
lang: string
|
||||||
|
onresult: ((event: SpeechRecognitionEventLike) => void) | null
|
||||||
|
onend: (() => void) | null
|
||||||
|
onerror: ((event: { error: string }) => void) | null
|
||||||
|
start(): void
|
||||||
|
stop(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionEventLike {
|
||||||
|
results: ArrayLike<ArrayLike<{ transcript: string }>>
|
||||||
|
}
|
||||||
|
|
||||||
export type ChatInputDisabledReason =
|
export type ChatInputDisabledReason =
|
||||||
| "gatewayUnknown"
|
| "gatewayUnknown"
|
||||||
| "gatewayStarting"
|
| "gatewayStarting"
|
||||||
|
|
@ -51,6 +74,41 @@ export function ChatComposer({
|
||||||
contextUsage,
|
contextUsage,
|
||||||
}: ChatComposerProps) {
|
}: ChatComposerProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [isListening, setIsListening] = useState(false)
|
||||||
|
const recognitionRef = useRef<SpeechRecognitionLike | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const Recognition =
|
||||||
|
window.SpeechRecognition ?? window.webkitSpeechRecognition
|
||||||
|
if (!Recognition) return
|
||||||
|
const recognition = new Recognition()
|
||||||
|
recognition.continuous = false
|
||||||
|
recognition.interimResults = false
|
||||||
|
recognition.lang = "en-US"
|
||||||
|
recognition.onresult = (event) => {
|
||||||
|
const transcript = event.results[0]?.[0]?.transcript?.trim() ?? ""
|
||||||
|
if (transcript) onInputChange(transcript)
|
||||||
|
}
|
||||||
|
recognition.onend = () => setIsListening(false)
|
||||||
|
recognition.onerror = () => setIsListening(false)
|
||||||
|
recognitionRef.current = recognition
|
||||||
|
}, [onInputChange])
|
||||||
|
|
||||||
|
const toggleVoice = () => {
|
||||||
|
if (!recognitionRef.current) return
|
||||||
|
if (isListening) {
|
||||||
|
recognitionRef.current.stop()
|
||||||
|
setIsListening(false)
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
recognitionRef.current.start()
|
||||||
|
setIsListening(true)
|
||||||
|
} catch {
|
||||||
|
setIsListening(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const canInput = inputDisabledReason === null
|
const canInput = inputDisabledReason === null
|
||||||
const disabledMessage =
|
const disabledMessage =
|
||||||
inputDisabledReason === null
|
inputDisabledReason === null
|
||||||
|
|
@ -124,6 +182,22 @@ export function ChatComposer({
|
||||||
>
|
>
|
||||||
<IconPhotoPlus className="size-4" />
|
<IconPhotoPlus className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 rounded-full",
|
||||||
|
isListening
|
||||||
|
? "text-red-500 bg-red-500/10 hover:bg-red-500/20"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
onClick={toggleVoice}
|
||||||
|
aria-label={isListening ? t("chat.stopListening") : t("chat.startListening")}
|
||||||
|
title={isListening ? t("chat.stopListening") : t("chat.startListening")}
|
||||||
|
>
|
||||||
|
{isListening ? <IconMicrophoneOff className="size-4" /> : <IconMicrophone className="size-4" />}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue