mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
37 Commits
fix/github
...
polecat/ru
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30263a42b8 | ||
|
|
fec626ec0b | ||
|
|
f54eff4543 | ||
|
|
f1ebfa5232 | ||
|
|
b89ea201fa | ||
|
|
4e405681a7 | ||
|
|
b445615d61 | ||
|
|
d68e8905fe | ||
|
|
e89fccd997 | ||
|
|
805ae8e607 | ||
|
|
833baec84d | ||
|
|
7a2a8e09bc | ||
|
|
6f8da5b7fb | ||
|
|
50cbe48558 | ||
|
|
d81b99c8e3 | ||
|
|
86cb03a1fc | ||
|
|
7765d99c73 | ||
|
|
db5e55a174 | ||
|
|
fbae45eb97 | ||
|
|
554fcd7c06 | ||
|
|
eed158eca0 | ||
|
|
d61d6fc8a9 | ||
|
|
d383b5e344 | ||
|
|
ce4bb44083 | ||
|
|
0d56815cba | ||
|
|
c07d3d95d4 | ||
|
|
32530ec418 | ||
|
|
e7105ae50b | ||
|
|
1d42a973ea | ||
|
|
921a797c5b | ||
|
|
d94597bbf9 | ||
|
|
ecc6bac070 | ||
|
|
84e2739663 | ||
|
|
974e7e9b86 | ||
|
|
19e07c086f | ||
|
|
ab354d7dd7 | ||
|
|
0e779fa344 |
53
.github/workflows/eval-weekly.yml
vendored
53
.github/workflows/eval-weekly.yml
vendored
@@ -44,6 +44,19 @@ jobs:
|
||||
working-directory: packages/browseros-agent
|
||||
run: bun install --ignore-scripts
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
working-directory: packages/browseros-agent/apps/eval
|
||||
env:
|
||||
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/legacy/browseros-agent-weekly.json' }}
|
||||
run: |
|
||||
if bun -e "const config = await Bun.file(process.env.EVAL_CONFIG).json(); process.exit(config.agent?.type === 'claude-code' ? 0 : 1)"; then
|
||||
npm install -g @anthropic-ai/claude-code@2.1.119
|
||||
echo "Claude Code CLI installed at $(command -v claude)"
|
||||
claude --version
|
||||
else
|
||||
echo "Eval config does not use Claude Code; skipping Claude Code CLI install"
|
||||
fi
|
||||
|
||||
- name: Install Python eval dependencies
|
||||
# agisdk pinned so silent upstream releases can't shift task definitions
|
||||
# or grader behavior. Bump intentionally with a documented re-baseline.
|
||||
@@ -67,13 +80,11 @@ jobs:
|
||||
env:
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION || 'us-west-2' }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
NOPECHA_API_KEY: ${{ secrets.NOPECHA_API_KEY }}
|
||||
EVAL_R2_ACCOUNT_ID: ${{ secrets.EVAL_R2_ACCOUNT_ID }}
|
||||
EVAL_R2_ACCESS_KEY_ID: ${{ secrets.EVAL_R2_ACCESS_KEY_ID }}
|
||||
EVAL_R2_SECRET_ACCESS_KEY: ${{ secrets.EVAL_R2_SECRET_ACCESS_KEY }}
|
||||
EVAL_R2_BUCKET: ${{ secrets.EVAL_R2_BUCKET }}
|
||||
EVAL_R2_CDN_BASE_URL: ${{ secrets.EVAL_R2_CDN_BASE_URL }}
|
||||
BROWSEROS_BINARY: /usr/bin/browseros
|
||||
WEBARENA_INFINITY_DIR: /tmp/webarena-infinity
|
||||
# OpenClaw container runtime is macOS-only; opt the Linux runner
|
||||
@@ -82,7 +93,35 @@ jobs:
|
||||
EVAL_CONFIG: ${{ github.event.inputs.config || 'configs/legacy/browseros-agent-weekly.json' }}
|
||||
run: |
|
||||
echo "Running eval with config: $EVAL_CONFIG"
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1440x900x24" bun run src/index.ts suite --config "$EVAL_CONFIG" --publish r2
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1440x900x24" bun run src/index.ts suite --config "$EVAL_CONFIG"
|
||||
# Capture the run directory so report.html can be generated before the R2 publish step.
|
||||
SUMMARY_PATH="$(find results -name summary.json -type f -print | sort | tail -n 1)"
|
||||
if [ -z "$SUMMARY_PATH" ]; then
|
||||
echo "No eval run summary found"
|
||||
exit 1
|
||||
fi
|
||||
RUN_DIR="$(dirname "$SUMMARY_PATH")"
|
||||
echo "EVAL_RUN_DIR=$RUN_DIR" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate run analysis report
|
||||
if: success()
|
||||
working-directory: packages/browseros-agent/apps/eval
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
run: |
|
||||
echo "Generating run report for $EVAL_RUN_DIR"
|
||||
bun scripts/generate-report.ts --input "$EVAL_RUN_DIR" --output "$EVAL_RUN_DIR/report.html"
|
||||
|
||||
- name: Publish eval run to R2
|
||||
if: success()
|
||||
working-directory: packages/browseros-agent/apps/eval
|
||||
env:
|
||||
EVAL_R2_ACCOUNT_ID: ${{ secrets.EVAL_R2_ACCOUNT_ID }}
|
||||
EVAL_R2_ACCESS_KEY_ID: ${{ secrets.EVAL_R2_ACCESS_KEY_ID }}
|
||||
EVAL_R2_SECRET_ACCESS_KEY: ${{ secrets.EVAL_R2_SECRET_ACCESS_KEY }}
|
||||
EVAL_R2_BUCKET: ${{ secrets.EVAL_R2_BUCKET }}
|
||||
EVAL_R2_CDN_BASE_URL: ${{ secrets.EVAL_R2_CDN_BASE_URL }}
|
||||
run: bun run src/index.ts publish --run "$EVAL_RUN_DIR" --target r2
|
||||
|
||||
- name: Generate trend report
|
||||
if: success()
|
||||
@@ -97,7 +136,7 @@ jobs:
|
||||
EVAL_R2_CDN_BASE_URL: ${{ secrets.EVAL_R2_CDN_BASE_URL }}
|
||||
run: bun apps/eval/scripts/weekly-report.ts /tmp/eval-report.html
|
||||
|
||||
- name: Upload report as artifact
|
||||
- name: Upload trend report as artifact
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
21
.github/workflows/sync-internal-docs.yml
vendored
21
.github/workflows/sync-internal-docs.yml
vendored
@@ -9,6 +9,9 @@ jobs:
|
||||
sync:
|
||||
name: Bump internal-docs submodule pointer on dev
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Rewrite SSH submodule URL to HTTPS-with-token
|
||||
env:
|
||||
@@ -23,9 +26,9 @@ jobs:
|
||||
ref: dev
|
||||
fetch-depth: 50
|
||||
|
||||
- name: Bump submodule pointer if internal-docs has new commits
|
||||
- name: Open auto-merge PR if internal-docs has new commits
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.INTERNAL_DOCS_SYNC_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
@@ -42,12 +45,18 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BRANCH="bot/sync-internal-docs-$(date -u +%Y%m%d-%H%M%S)"
|
||||
git config user.name "browseros-bot"
|
||||
git config user.email "bot@browseros.ai"
|
||||
git checkout -b "$BRANCH"
|
||||
git add .internal-docs
|
||||
git commit -m "chore: sync internal-docs submodule"
|
||||
git push -u origin "$BRANCH"
|
||||
|
||||
# Rebase onto latest dev to absorb any commits that landed during the run,
|
||||
# then push. set -e takes care of failing the run on rebase conflict.
|
||||
git pull --rebase origin dev
|
||||
git push origin dev
|
||||
PR_URL=$(gh pr create \
|
||||
--base dev \
|
||||
--head "$BRANCH" \
|
||||
--title "chore: sync internal-docs submodule" \
|
||||
--body "Automated bump of the \`.internal-docs\` submodule pointer. Auto-merging.")
|
||||
|
||||
gh pr merge "$PR_URL" --auto --squash --delete-branch
|
||||
|
||||
Submodule .internal-docs updated: 01085a4ef5...590799ae1c
@@ -0,0 +1,40 @@
|
||||
import { cloudSyncSignInLinks } from '@/lib/constants/productUrls'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CloudSyncDisclosureProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CloudSyncDisclosure({ className }: CloudSyncDisclosureProps) {
|
||||
const [termsLink, privacyLink, cloudSyncLink] = cloudSyncSignInLinks
|
||||
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
'text-center text-muted-foreground text-xs leading-relaxed',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
By signing in, you agree to the <DisclosureLink link={termsLink} /> and
|
||||
acknowledge the <DisclosureLink link={privacyLink} />.{' '}
|
||||
<DisclosureLink link={cloudSyncLink} />.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function DisclosureLink({
|
||||
link,
|
||||
}: {
|
||||
link: (typeof cloudSyncSignInLinks)[number]
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -80,6 +80,11 @@ const primarySettingsSections: NavSection[] = [
|
||||
icon: Palette,
|
||||
feature: Feature.CUSTOMIZATION_SUPPORT,
|
||||
},
|
||||
{
|
||||
name: 'Reset Data',
|
||||
to: '/settings/reset-data',
|
||||
icon: RotateCcw,
|
||||
},
|
||||
{
|
||||
name: 'Tool Approvals',
|
||||
to: '/settings/approvals',
|
||||
|
||||
@@ -30,6 +30,7 @@ import { MagicLinkCallback } from './login/MagicLinkCallback'
|
||||
import { MCPSettingsPage } from './mcp-settings/MCPSettingsPage'
|
||||
import { MemoryPage } from './memory/MemoryPage'
|
||||
import { ProfilePage } from './profile/ProfilePage'
|
||||
import { ResetDataPage } from './reset-data/ResetDataPage'
|
||||
import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
|
||||
import { SearchProviderPage } from './search-provider/SearchProviderPage'
|
||||
import { SkillsPage } from './skills/SkillsPage'
|
||||
@@ -143,6 +144,7 @@ export const App: FC = () => {
|
||||
<Route path="chat" element={<LlmHubPage />} />
|
||||
<Route path="mcp" element={<MCPSettingsPage />} />
|
||||
<Route path="customization" element={<CustomizationPage />} />
|
||||
<Route path="reset-data" element={<ResetDataPage />} />
|
||||
<Route path="search" element={<SearchProviderPage />} />
|
||||
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
|
||||
<Route path="usage" element={<UsagePage />} />
|
||||
|
||||
@@ -1,186 +1,44 @@
|
||||
import { ArrowLeft, Bot, Home } from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useRef } from 'react'
|
||||
import { ArrowLeft, PanelRight } from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type {
|
||||
HarnessAgent,
|
||||
HarnessAgentAdapter,
|
||||
} from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
|
||||
import {
|
||||
cancelHarnessTurn,
|
||||
useAgentAdapters,
|
||||
useEnqueueHarnessMessage,
|
||||
useHarnessAgents,
|
||||
useRemoveHarnessQueuedMessage,
|
||||
useUpdateHarnessAgent,
|
||||
} from '@/entrypoints/app/agents/useAgents'
|
||||
import {
|
||||
type AgentEntry,
|
||||
getModelDisplayName,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { type ProducedFilesRailGroup, useAgentOutputs } from '@/lib/agent-files'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AgentRail } from './AgentRail'
|
||||
import { useAgentCommandData } from './agent-command-layout'
|
||||
import {
|
||||
OutputsRail,
|
||||
useOutputsRailOpen,
|
||||
} from './agent-conversation.outputs-rail'
|
||||
import { ClawChat } from './ClawChat'
|
||||
import { ConversationHeader } from './ConversationHeader'
|
||||
import { ConversationInput } from './ConversationInput'
|
||||
import {
|
||||
buildChatHistoryFromClawMessages,
|
||||
filterTurnsPersistedInHistory,
|
||||
flattenHistoryPages,
|
||||
mapHistoryToProducedFilesGroups,
|
||||
selectStripOnlyTurns,
|
||||
} from './claw-chat-types'
|
||||
import { consumePendingInitialMessage } from './pending-initial-message'
|
||||
import { QueuePanel } from './QueuePanel'
|
||||
import { useAgentConversation } from './useAgentConversation'
|
||||
import { useHarnessChatHistory } from './useHarnessChatHistory'
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card px-3 py-1 text-[11px] text-muted-foreground uppercase tracking-[0.18em]">
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 rounded-full',
|
||||
status === 'Working on your request'
|
||||
? 'bg-amber-500'
|
||||
: status === 'Ready'
|
||||
? 'bg-emerald-500'
|
||||
: status === 'Offline'
|
||||
? 'bg-muted-foreground/50'
|
||||
: 'bg-[var(--accent-orange)]',
|
||||
)}
|
||||
/>
|
||||
<span>{status}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentIdentity({
|
||||
name,
|
||||
meta,
|
||||
className,
|
||||
}: {
|
||||
name: string
|
||||
meta: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('min-w-0', className)}>
|
||||
<div className="truncate font-semibold text-[15px] leading-5">{name}</div>
|
||||
<div className="truncate text-muted-foreground text-xs leading-5">
|
||||
{meta}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConversationHeader({
|
||||
agentName,
|
||||
agentMeta,
|
||||
status,
|
||||
backLabel,
|
||||
backTarget,
|
||||
onGoHome,
|
||||
}: {
|
||||
agentName: string
|
||||
agentMeta: string
|
||||
status: string
|
||||
backLabel: string
|
||||
backTarget: 'home' | 'page'
|
||||
onGoHome: () => void
|
||||
}) {
|
||||
const BackIcon = backTarget === 'home' ? Home : ArrowLeft
|
||||
|
||||
return (
|
||||
<div className="flex h-14 items-center justify-between gap-4 border-border/50 border-b px-5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onGoHome}
|
||||
className="size-8 rounded-xl lg:hidden"
|
||||
title={backLabel}
|
||||
>
|
||||
<BackIcon className="size-4" />
|
||||
</Button>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
|
||||
<Bot className="size-4" />
|
||||
</div>
|
||||
<AgentIdentity name={agentName} meta={agentMeta} />
|
||||
</div>
|
||||
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentRailHeader({ onGoHome }: { onGoHome: () => void }) {
|
||||
return (
|
||||
<div className="hidden h-14 items-center border-border/50 border-r border-b bg-background/70 px-4 lg:flex">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onGoHome}
|
||||
className="size-8 rounded-xl"
|
||||
title="Back to home"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="truncate font-semibold text-[15px] leading-5">
|
||||
Agents
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentRailList({
|
||||
activeAgentId,
|
||||
agents,
|
||||
onSelectAgent,
|
||||
}: {
|
||||
activeAgentId: string
|
||||
agents: AgentEntry[]
|
||||
onSelectAgent: (entry: AgentEntry) => void
|
||||
}) {
|
||||
return (
|
||||
<aside className="hidden min-h-0 flex-col border-border/50 border-r bg-background/70 lg:flex">
|
||||
<div className="styled-scrollbar min-h-0 flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||||
{agents.map((entry) => {
|
||||
const active = entry.agentId === activeAgentId
|
||||
const modelName = getAgentEntryMeta(entry)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={entry.agentId}
|
||||
type="button"
|
||||
onClick={() => onSelectAgent(entry)}
|
||||
className={cn(
|
||||
'w-full rounded-2xl border px-3 py-3 text-left transition-all',
|
||||
active
|
||||
? 'border-[var(--accent-orange)]/30 bg-[var(--accent-orange)]/8 shadow-sm'
|
||||
: 'border-transparent bg-transparent hover:border-border/60 hover:bg-card',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-xl',
|
||||
active
|
||||
? 'bg-[var(--accent-orange)]/12 text-[var(--accent-orange)]'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Bot className="size-4" />
|
||||
</div>
|
||||
<AgentIdentity name={entry.name} meta={modelName} />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function getAgentEntryMeta(agent: AgentEntry | undefined): string {
|
||||
if (agent?.source === 'agent-harness') {
|
||||
return getModelDisplayName(agent.model) ?? 'ACP agent'
|
||||
}
|
||||
return getModelDisplayName(agent?.model) ?? 'OpenClaw agent'
|
||||
}
|
||||
|
||||
function AgentConversationController({
|
||||
agentId,
|
||||
initialMessage,
|
||||
@@ -188,6 +46,7 @@ function AgentConversationController({
|
||||
agents,
|
||||
agentPathPrefix,
|
||||
createAgentPath,
|
||||
onOpenOutputsRail,
|
||||
}: {
|
||||
agentId: string
|
||||
initialMessage: string | null
|
||||
@@ -195,6 +54,7 @@ function AgentConversationController({
|
||||
agents: AgentEntry[]
|
||||
agentPathPrefix: string
|
||||
createAgentPath: string
|
||||
onOpenOutputsRail?: ((turnId?: string | null) => void) | null
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const initialMessageSentRef = useRef<string | null>(null)
|
||||
@@ -226,6 +86,15 @@ function AgentConversationController({
|
||||
const harnessAgent = harnessAgents.find((entry) => entry.id === agentId)
|
||||
const queue = harnessAgent?.queue ?? []
|
||||
const activeTurnId = harnessAgent?.activeTurnId ?? null
|
||||
const isOpenClawAgent = harnessAgent?.adapter === 'openclaw'
|
||||
|
||||
// Used to surface produced-files strips on a fresh page load
|
||||
// when there's no optimistic turn to carry the data. Disabled
|
||||
// for non-openclaw adapters since they don't attribute files.
|
||||
const { groups: agentOutputGroups } = useAgentOutputs(
|
||||
agentId,
|
||||
isOpenClawAgent,
|
||||
)
|
||||
|
||||
const { turns, streaming, send } = useAgentConversation(agentId, {
|
||||
runtime: 'agent-harness',
|
||||
@@ -250,6 +119,44 @@ function AgentConversationController({
|
||||
() => filterTurnsPersistedInHistory(turns, historyMessages),
|
||||
[historyMessages, turns],
|
||||
)
|
||||
// Persisted turns that still need to surface their FileCardStrip
|
||||
// — history items don't carry produced-files data, so without
|
||||
// these the strip would vanish on history reload.
|
||||
const stripOnlyTurns = useMemo(
|
||||
() => selectStripOnlyTurns(turns, historyMessages),
|
||||
[historyMessages, turns],
|
||||
)
|
||||
// Two outputs from the per-turn matcher:
|
||||
// - filesByAssistantId → strip rendered directly under the
|
||||
// matching assistant history bubble.
|
||||
// - tailUnmatched → groups with no history pair (orphans);
|
||||
// rendered at the conversation tail.
|
||||
// Both are filtered to exclude turnIds already covered by a
|
||||
// live or strip-only optimistic turn (those carry their own
|
||||
// strip and history hasn't reloaded yet).
|
||||
const { filesByAssistantId, tailStripGroups } = useMemo(() => {
|
||||
if (!isOpenClawAgent) {
|
||||
return {
|
||||
filesByAssistantId: new Map<string, ProducedFilesRailGroup>(),
|
||||
tailStripGroups: [] as ProducedFilesRailGroup[],
|
||||
}
|
||||
}
|
||||
const coveredTurnIds = new Set<string>()
|
||||
for (const turn of turns) {
|
||||
if (turn.turnId) coveredTurnIds.add(turn.turnId)
|
||||
}
|
||||
const eligibleGroups = agentOutputGroups.filter(
|
||||
(group) => !coveredTurnIds.has(group.turnId),
|
||||
)
|
||||
const { byAssistantMessageId, unmatched } = mapHistoryToProducedFilesGroups(
|
||||
historyMessages,
|
||||
eligibleGroups,
|
||||
)
|
||||
return {
|
||||
filesByAssistantId: byAssistantMessageId,
|
||||
tailStripGroups: unmatched,
|
||||
}
|
||||
}, [agentOutputGroups, isOpenClawAgent, historyMessages, turns])
|
||||
onInitialMessageConsumedRef.current = onInitialMessageConsumed
|
||||
|
||||
const disabled = !agent
|
||||
@@ -264,42 +171,73 @@ function AgentConversationController({
|
||||
sendRef.current = send
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled || !historyReady) return
|
||||
|
||||
// Registry-first: when the user submitted at /home with
|
||||
// attachments, the rich payload is here. URL `?q=` may also be
|
||||
// present and is the text-only fallback path; the registry wins
|
||||
// when both exist because it carries the binary attachments
|
||||
// alongside the text.
|
||||
const pending = consumePendingInitialMessage(agentId)
|
||||
if (pending) {
|
||||
// Mark the dedup ref so the text-only branch below doesn't
|
||||
// re-fire on the same render.
|
||||
if (initialMessageKey) {
|
||||
initialMessageSentRef.current = initialMessageKey
|
||||
}
|
||||
onInitialMessageConsumedRef.current()
|
||||
void sendRef.current({
|
||||
text: pending.text,
|
||||
attachments: pending.attachments.map((a) => a.payload),
|
||||
attachmentPreviews: pending.attachments.map((a) => ({
|
||||
id: a.id,
|
||||
kind: a.kind,
|
||||
mediaType: a.mediaType,
|
||||
name: a.name,
|
||||
dataUrl: a.dataUrl,
|
||||
})),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const query = initialMessage?.trim()
|
||||
if (!initialMessageKey) {
|
||||
// Reset is safe even on the post-registry-fire re-run: consume
|
||||
// is destructive, so the registry is already drained — there's
|
||||
// nothing left for a third run to re-send.
|
||||
initialMessageSentRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!query ||
|
||||
initialMessageSentRef.current === initialMessageKey ||
|
||||
disabled ||
|
||||
!historyReady
|
||||
) {
|
||||
if (!query || initialMessageSentRef.current === initialMessageKey) {
|
||||
return
|
||||
}
|
||||
|
||||
initialMessageSentRef.current = initialMessageKey
|
||||
onInitialMessageConsumedRef.current()
|
||||
void sendRef.current({ text: query })
|
||||
}, [disabled, historyReady, initialMessage, initialMessageKey])
|
||||
}, [agentId, disabled, historyReady, initialMessage, initialMessageKey])
|
||||
|
||||
const handleSelectAgent = (entry: AgentEntry) => {
|
||||
navigate(`${agentPathPrefix}/${entry.agentId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<ClawChat
|
||||
agentName={agentName}
|
||||
historyMessages={historyMessages}
|
||||
turns={visibleTurns}
|
||||
stripOnlyTurns={stripOnlyTurns}
|
||||
filesByAssistantId={filesByAssistantId}
|
||||
tailStripGroups={tailStripGroups}
|
||||
streaming={streaming}
|
||||
isInitialLoading={harnessHistoryQuery.isLoading}
|
||||
error={error}
|
||||
hasNextPage={false}
|
||||
isFetchingNextPage={false}
|
||||
onFetchNextPage={() => {}}
|
||||
onOpenOutputsRail={onOpenOutputsRail}
|
||||
onRetry={() => {
|
||||
void harnessHistoryQuery.refetch()
|
||||
}}
|
||||
@@ -368,6 +306,22 @@ interface AgentCommandConversationProps {
|
||||
createAgentPath?: string
|
||||
}
|
||||
|
||||
function inferAdapterFromEntry(
|
||||
entry: AgentEntry | undefined,
|
||||
): HarnessAgentAdapter | 'unknown' {
|
||||
if (!entry) return 'unknown'
|
||||
if (entry.source === 'agent-harness') {
|
||||
// Harness entries don't carry the adapter on AgentEntry; the rail
|
||||
// / header read the harness record directly. This branch only runs
|
||||
// before the harness query resolves, so 'unknown' is correct — the
|
||||
// tile's bot fallback renders until data arrives.
|
||||
return 'unknown'
|
||||
}
|
||||
// OpenClaw-only entries (no harness shadow) are deprecated in
|
||||
// practice but the rail still tolerates them.
|
||||
return 'openclaw'
|
||||
}
|
||||
|
||||
export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
variant = 'command',
|
||||
backPath = '/home',
|
||||
@@ -378,60 +332,191 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const { agents } = useAgentCommandData()
|
||||
const { harnessAgents } = useHarnessAgents()
|
||||
const { adapters } = useAgentAdapters()
|
||||
const updateAgent = useUpdateHarnessAgent()
|
||||
|
||||
const shouldRedirectHome = !agentId
|
||||
const resolvedAgentId = agentId ?? ''
|
||||
const agent = agents.find((entry) => entry.agentId === resolvedAgentId)
|
||||
const agentName = agent?.name || resolvedAgentId || 'Agent'
|
||||
const agentMeta = getAgentEntryMeta(agent)
|
||||
const harnessAgent = harnessAgents.find(
|
||||
(entry) => entry.id === resolvedAgentId,
|
||||
)
|
||||
const entry = agents.find((item) => item.agentId === resolvedAgentId)
|
||||
const fallbackName = entry?.name || resolvedAgentId || 'Agent'
|
||||
const fallbackAdapter = inferAdapterFromEntry(entry)
|
||||
const initialMessage = searchParams.get('q')
|
||||
const isPageVariant = variant === 'page'
|
||||
const backLabel = isPageVariant ? 'Back to agents' : 'Back to home'
|
||||
|
||||
const isOpenClawAgent = harnessAgent?.adapter === 'openclaw'
|
||||
const [outputsRailOpen, setOutputsRailOpen] =
|
||||
useOutputsRailOpen(resolvedAgentId)
|
||||
const railVisible = isOpenClawAgent && outputsRailOpen
|
||||
|
||||
// Deep-link target for the rail. Set when (a) the user clicks
|
||||
// View / +N on an inline file-card strip, or (b) an external nav
|
||||
// arrived with `?outputsTurn=<turnId>`. Cleared by the rail
|
||||
// itself once it has scrolled to + expanded the matching group.
|
||||
const urlOutputsTurn = searchParams.get('outputsTurn')
|
||||
const [focusTurnId, setFocusTurnId] = useState<string | null>(urlOutputsTurn)
|
||||
// If the URL param flips while we're already on this agent, sync.
|
||||
useEffect(() => {
|
||||
if (!urlOutputsTurn) return
|
||||
setFocusTurnId(urlOutputsTurn)
|
||||
if (isOpenClawAgent) setOutputsRailOpen(true)
|
||||
}, [urlOutputsTurn, isOpenClawAgent, setOutputsRailOpen])
|
||||
|
||||
const handleOpenOutputsRail = (turnId?: string | null) => {
|
||||
if (!isOpenClawAgent) return
|
||||
setOutputsRailOpen(true)
|
||||
setFocusTurnId(turnId ?? null)
|
||||
}
|
||||
const handleFocusTurnConsumed = () => {
|
||||
setFocusTurnId(null)
|
||||
if (urlOutputsTurn) {
|
||||
// Drop the URL param so a back-nav doesn't re-trigger the
|
||||
// scroll. `replace: true` keeps history clean.
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.delete('outputsTurn')
|
||||
return next
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const adapterHealth = useMemo<AgentAdapterHealth | null>(() => {
|
||||
const adapterId = harnessAgent?.adapter
|
||||
if (!adapterId) return null
|
||||
const descriptor = adapters.find((item) => item.id === adapterId)
|
||||
if (!descriptor?.health) return null
|
||||
return {
|
||||
healthy: descriptor.health.healthy,
|
||||
reason: descriptor.health.reason,
|
||||
}
|
||||
}, [adapters, harnessAgent?.adapter])
|
||||
|
||||
if (shouldRedirectHome) {
|
||||
return <Navigate to="/home" replace />
|
||||
}
|
||||
|
||||
const handleSelectAgent = (entry: AgentEntry) => {
|
||||
navigate(`${agentPathPrefix}/${entry.agentId}`)
|
||||
const handleSelectHarnessAgent = (target: HarnessAgent) => {
|
||||
navigate(`${agentPathPrefix}/${target.id}`)
|
||||
}
|
||||
|
||||
// Every visible agent runs through the harness now, so per-agent
|
||||
// runtime status doesn't gate chat the way OpenClaw's legacy
|
||||
// gateway lifecycle did. Show "Ready" once the agent record is
|
||||
// resolved from the rail, "Setup" otherwise.
|
||||
const statusCopy = agent ? 'Ready' : 'Setup'
|
||||
const handlePinToggle = (target: HarnessAgent | null, next: boolean) => {
|
||||
if (!target) return
|
||||
updateAgent.mutate({
|
||||
agentId: target.id,
|
||||
patch: { pinned: next },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden bg-background md:pl-[theme(spacing.14)]">
|
||||
<div className="mx-auto grid h-full w-full max-w-[1480px] lg:grid-cols-[288px_minmax(0,1fr)] lg:grid-rows-[3.5rem_minmax(0,1fr)]">
|
||||
<AgentRailHeader onGoHome={() => navigate(backPath)} />
|
||||
<div className="mx-auto flex h-full w-full max-w-[1480px] flex-col">
|
||||
{/* Shared top band — the rail's "Agents" header and the chat
|
||||
header live on one row so they're aligned by construction. */}
|
||||
<div className="flex shrink-0 items-stretch border-border/50 border-b">
|
||||
<div className="hidden min-h-[60px] w-[288px] shrink-0 items-center gap-3 border-border/50 border-r px-4 lg:flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(backPath)}
|
||||
className="size-8 rounded-xl"
|
||||
title="Back to home"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="truncate font-semibold text-[15px] leading-5">
|
||||
Agents
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ConversationHeader
|
||||
agent={harnessAgent ?? null}
|
||||
fallbackName={fallbackName}
|
||||
fallbackAdapter={fallbackAdapter}
|
||||
adapterHealth={adapterHealth}
|
||||
backLabel={backLabel}
|
||||
backTarget={isPageVariant ? 'page' : 'home'}
|
||||
onGoHome={() => navigate(backPath)}
|
||||
onPinToggle={(next) =>
|
||||
handlePinToggle(harnessAgent ?? null, next)
|
||||
}
|
||||
headerExtra={
|
||||
isOpenClawAgent ? (
|
||||
<Button
|
||||
variant={railVisible ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="size-8 rounded-xl"
|
||||
onClick={() => setOutputsRailOpen(!railVisible)}
|
||||
title={railVisible ? 'Hide outputs' : 'Show outputs'}
|
||||
>
|
||||
<PanelRight className="size-4" />
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConversationHeader
|
||||
agentName={agentName}
|
||||
agentMeta={agentMeta}
|
||||
status={statusCopy}
|
||||
backLabel={backLabel}
|
||||
backTarget={isPageVariant ? 'page' : 'home'}
|
||||
onGoHome={() => navigate(backPath)}
|
||||
/>
|
||||
{/* Body grid: rail list + chat (+ outputs rail when an
|
||||
openclaw agent has it open). Columns share the same top
|
||||
edge as the band above so headers can never drift. */}
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)]',
|
||||
railVisible
|
||||
? 'lg:grid-cols-[288px_minmax(0,1fr)_320px]'
|
||||
: 'lg:grid-cols-[288px_minmax(0,1fr)]',
|
||||
)}
|
||||
>
|
||||
<AgentRail
|
||||
agents={harnessAgents}
|
||||
adapters={adapters}
|
||||
activeAgentId={resolvedAgentId}
|
||||
onSelectAgent={handleSelectHarnessAgent}
|
||||
onPinToggle={(target, next) => handlePinToggle(target, next)}
|
||||
/>
|
||||
|
||||
<AgentRailList
|
||||
activeAgentId={resolvedAgentId}
|
||||
agents={agents}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
/>
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<AgentConversationController
|
||||
key={resolvedAgentId}
|
||||
agentId={resolvedAgentId}
|
||||
agents={agents}
|
||||
initialMessage={initialMessage}
|
||||
onInitialMessageConsumed={() => {
|
||||
// Preserve the outputsTurn deep-link if present —
|
||||
// dropping all params would erase the rail focus
|
||||
// before it had a chance to consume.
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams()
|
||||
const turn = prev.get('outputsTurn')
|
||||
if (turn) next.set('outputsTurn', turn)
|
||||
return next
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}}
|
||||
agentPathPrefix={agentPathPrefix}
|
||||
createAgentPath={createAgentPath}
|
||||
onOpenOutputsRail={isOpenClawAgent ? handleOpenOutputsRail : null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AgentConversationController
|
||||
key={resolvedAgentId}
|
||||
agentId={resolvedAgentId}
|
||||
agents={agents}
|
||||
initialMessage={initialMessage}
|
||||
onInitialMessageConsumed={() =>
|
||||
setSearchParams({}, { replace: true })
|
||||
}
|
||||
agentPathPrefix={agentPathPrefix}
|
||||
createAgentPath={createAgentPath}
|
||||
/>
|
||||
{railVisible ? (
|
||||
<OutputsRail
|
||||
agentId={resolvedAgentId}
|
||||
onClose={() => setOutputsRailOpen(false)}
|
||||
focusTurnId={focusTurnId}
|
||||
onFocusTurnConsumed={handleFocusTurnConsumed}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -18,8 +18,12 @@ import { SignInHint } from '@/entrypoints/newtab/index/SignInHint'
|
||||
import { useActiveHint } from '@/entrypoints/newtab/index/useActiveHint'
|
||||
import { AgentCardDock } from './AgentCardDock'
|
||||
import { useAgentCommandData } from './agent-command-layout'
|
||||
import { ConversationInput } from './ConversationInput'
|
||||
import {
|
||||
ConversationInput,
|
||||
type ConversationInputSendInput,
|
||||
} from './ConversationInput'
|
||||
import { orderHomeAgents } from './home-agent-card.helpers'
|
||||
import { setPendingInitialMessage } from './pending-initial-message'
|
||||
|
||||
function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
|
||||
return (
|
||||
@@ -116,8 +120,19 @@ export const AgentCommandHome: FC = () => {
|
||||
}
|
||||
}, [legacyAgents, selectedAgentId])
|
||||
|
||||
const handleSend = (input: { text: string }) => {
|
||||
const handleSend = (input: ConversationInputSendInput) => {
|
||||
if (!selectedAgentId) return
|
||||
// Stash text + attachments in the in-memory registry. Text also
|
||||
// travels in `?q=` so a hard refresh / shareable URL still works
|
||||
// for text-only prompts; attachments are registry-only because a
|
||||
// multi-megabyte dataUrl can't ride a URL search param. The chat
|
||||
// screen prefers the registry when both are present.
|
||||
setPendingInitialMessage({
|
||||
agentId: selectedAgentId,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
navigate(
|
||||
`/home/agents/${selectedAgentId}?q=${encodeURIComponent(input.text)}`,
|
||||
)
|
||||
@@ -147,12 +162,16 @@ export const AgentCommandHome: FC = () => {
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-5 pt-[max(10vh,24px)] text-center">
|
||||
<div className="space-y-3">
|
||||
<h1 className="font-semibold text-[clamp(2rem,4vw,3.25rem)] leading-tight tracking-tight">
|
||||
What should your agent work on next?
|
||||
<h1 className="font-semibold text-[clamp(2.25rem,4.5vw,3.5rem)] leading-[1.08] tracking-[-0.025em] [text-wrap:balance]">
|
||||
What should your agent{' '}
|
||||
<span className="font-medium text-[var(--accent-orange)] italic">
|
||||
work on
|
||||
</span>{' '}
|
||||
next?
|
||||
</h1>
|
||||
<p className="mx-auto max-w-2xl text-muted-foreground text-sm leading-6">
|
||||
Start with a task, continue a thread, or switch to another
|
||||
agent without leaving the new tab.
|
||||
<p className="mx-auto max-w-2xl text-muted-foreground text-sm leading-6 [text-wrap:pretty]">
|
||||
Start a task, continue a thread, or hand off to a different
|
||||
agent — all without leaving this tab.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -167,7 +186,7 @@ export const AgentCommandHome: FC = () => {
|
||||
streaming={false}
|
||||
disabled={!selectedAgentReady}
|
||||
status={selectedAgentStatus}
|
||||
attachmentsEnabled={false}
|
||||
attachmentsEnabled={true}
|
||||
placeholder={
|
||||
selectedAgentReady
|
||||
? `Ask ${selectedAgentName} to handle a task...`
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { type FC, useMemo } from 'react'
|
||||
import type {
|
||||
HarnessAdapterDescriptor,
|
||||
HarnessAgent,
|
||||
HarnessAgentAdapter,
|
||||
} from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
|
||||
import { orderAgentsByPinThenRecency } from '@/entrypoints/app/agents/agents-list-order'
|
||||
import { AgentRailRow } from './AgentRailRow'
|
||||
|
||||
interface AgentRailProps {
|
||||
agents: HarnessAgent[]
|
||||
adapters: HarnessAdapterDescriptor[]
|
||||
activeAgentId: string
|
||||
onSelectAgent: (agent: HarnessAgent) => void
|
||||
onPinToggle: (agent: HarnessAgent, next: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Left-column scrollable list of agents. The "Agents" label + back
|
||||
* button live in the shared top band above (so the rail header and
|
||||
* the chat header sit on a single aligned strip rather than as two
|
||||
* separately-sized headers per column). Sort matches `/agents`:
|
||||
* pinned-first → recency, so the rail doesn't reshuffle as turns
|
||||
* transition every 5 s.
|
||||
*/
|
||||
export const AgentRail: FC<AgentRailProps> = ({
|
||||
agents,
|
||||
adapters,
|
||||
activeAgentId,
|
||||
onSelectAgent,
|
||||
onPinToggle,
|
||||
}) => {
|
||||
const adapterHealth = useMemo(() => {
|
||||
const map = new Map<HarnessAgentAdapter, AgentAdapterHealth>()
|
||||
for (const adapter of adapters) {
|
||||
if (adapter.health) {
|
||||
map.set(adapter.id, {
|
||||
healthy: adapter.health.healthy,
|
||||
reason: adapter.health.reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [adapters])
|
||||
|
||||
const ordered = useMemo(() => orderAgentsByPinThenRecency(agents), [agents])
|
||||
|
||||
return (
|
||||
<aside className="hidden min-h-0 flex-col border-border/50 border-r bg-background/70 lg:flex">
|
||||
<div className="styled-scrollbar min-h-0 flex-1 space-y-1.5 overflow-y-auto px-3 py-3">
|
||||
{ordered.map((agent) => (
|
||||
<AgentRailRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
active={agent.id === activeAgentId}
|
||||
adapterHealth={adapterHealth.get(agent.adapter) ?? null}
|
||||
onSelect={() => onSelectAgent(agent)}
|
||||
onPinToggle={(next) => onPinToggle(agent, next)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { FC } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { adapterLabel } from '@/entrypoints/app/agents/AdapterIcon'
|
||||
import type { HarnessAgent } from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import { AgentSummaryChips } from '@/entrypoints/app/agents/agent-row/AgentSummaryChips'
|
||||
import { AgentTile } from '@/entrypoints/app/agents/agent-row/AgentTile'
|
||||
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
|
||||
import { PinToggle } from '@/entrypoints/app/agents/agent-row/PinToggle'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AgentRailRowProps {
|
||||
agent: HarnessAgent
|
||||
active: boolean
|
||||
adapterHealth: AgentAdapterHealth | null
|
||||
onSelect: () => void
|
||||
onPinToggle: (next: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact rail row for the chat-screen sidebar. Slims `<AgentRowCard>`
|
||||
* down to the essentials that fit a ~280 px rail: tile + name + status
|
||||
* badge + pin star, with the adapter / model / reasoning chips on a
|
||||
* second line. Token totals, sparkline, last-message preview all stay
|
||||
* on the `/agents` page where rows are full-width.
|
||||
*/
|
||||
export const AgentRailRow: FC<AgentRailRowProps> = ({
|
||||
agent,
|
||||
active,
|
||||
adapterHealth,
|
||||
onSelect,
|
||||
onPinToggle,
|
||||
}) => {
|
||||
const status = agent.status ?? 'unknown'
|
||||
const lastUsedAt = agent.lastUsedAt ?? null
|
||||
const pinned = agent.pinned ?? false
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'group w-full rounded-2xl border px-3 py-3 text-left transition-colors',
|
||||
active
|
||||
? 'border-[var(--accent-orange)]/30 bg-[var(--accent-orange)]/8'
|
||||
: 'border-transparent bg-transparent hover:border-border/60 hover:bg-card',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<AgentTile
|
||||
adapter={agent.adapter}
|
||||
status={status}
|
||||
lastUsedAt={lastUsedAt}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-semibold text-[14px] leading-5">
|
||||
{agent.name}
|
||||
</span>
|
||||
{status === 'working' && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 bg-amber-50 px-1.5 text-[10px] text-amber-900 hover:bg-amber-50"
|
||||
>
|
||||
Working
|
||||
</Badge>
|
||||
)}
|
||||
{status === 'asleep' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 px-1.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
Asleep
|
||||
</Badge>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<Badge variant="destructive" className="h-5 px-1.5 text-[10px]">
|
||||
Attention
|
||||
</Badge>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<PinToggle pinned={pinned} onToggle={onPinToggle} />
|
||||
</div>
|
||||
</div>
|
||||
<AgentSummaryChips
|
||||
adapter={agent.adapter}
|
||||
modelLabel={agent.modelId ?? null}
|
||||
reasoningEffort={agent.reasoningEffort ?? null}
|
||||
adapterHealth={adapterHealth}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip-only label helper kept exported in case the tile row needs to
|
||||
* show "Codex agent" or similar in a future state. Inlined fallback for
|
||||
* the rare `unknown` adapter rendering path.
|
||||
*/
|
||||
export function railRowAdapterLabel(agent: HarnessAgent): string {
|
||||
return adapterLabel(agent.adapter)
|
||||
}
|
||||
@@ -27,6 +27,14 @@ interface AgentSelectorProps {
|
||||
onSelectAgent: (agent: AgentEntry) => void
|
||||
onCreateAgent?: () => void
|
||||
status?: string
|
||||
/**
|
||||
* `'pill'` renders the filled-pill variant used by the calm
|
||||
* composer on `/home` — bordered, slightly elevated background,
|
||||
* mono agent name, used as the visual anchor on the left of the
|
||||
* footer chip row. Default `'ghost'` keeps the existing flat
|
||||
* shadcn ghost-button trigger used by the chat surface.
|
||||
*/
|
||||
triggerVariant?: 'ghost' | 'pill'
|
||||
}
|
||||
|
||||
function getStatusDot(status?: string) {
|
||||
@@ -42,31 +50,49 @@ export const AgentSelector: FC<AgentSelectorProps> = ({
|
||||
onSelectAgent,
|
||||
onCreateAgent,
|
||||
status,
|
||||
triggerVariant = 'ghost',
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedAgent = agents.find(
|
||||
(agent) => agent.agentId === selectedAgentId,
|
||||
)
|
||||
|
||||
const triggerNode =
|
||||
triggerVariant === 'pill' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex h-6 max-w-[180px] items-center gap-1.5 rounded-full border border-border bg-accent/40 pr-2 pl-2.5 text-[11.5px] text-foreground transition-colors',
|
||||
'hover:border-border hover:bg-accent/70 data-[state=open]:border-border data-[state=open]:bg-accent/70',
|
||||
)}
|
||||
>
|
||||
<span className={cn('size-1.5 rounded-full', getStatusDot(status))} />
|
||||
<span className="truncate font-medium font-mono text-[11.5px] tracking-[-0.01em]">
|
||||
{selectedAgent?.name ?? 'Select agent'}
|
||||
</span>
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
<span className={cn('size-2 rounded-full', getStatusDot(status))} />
|
||||
<span className="max-w-32 truncate">
|
||||
{selectedAgent?.name ?? 'Select agent'}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
<span className={cn('size-2 rounded-full', getStatusDot(status))} />
|
||||
<span className="max-w-32 truncate">
|
||||
{selectedAgent?.name ?? 'Select agent'}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverTrigger asChild>{triggerNode}</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="start" className="w-72 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search agents..." className="h-9" />
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Bot, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef } from 'react'
|
||||
import { type FC, Fragment, useEffect, useRef } from 'react'
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation'
|
||||
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
|
||||
import type { ProducedFilesRailGroup } from '@/lib/agent-files'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FileCardStrip } from './agent-conversation.file-card-strip'
|
||||
import { ClawChatMessage } from './ClawChatMessage'
|
||||
import { ConversationMessage } from './ConversationMessage'
|
||||
import type { ClawChatMessage as ClawChatMessageModel } from './claw-chat-types'
|
||||
@@ -15,6 +17,29 @@ interface ClawChatProps {
|
||||
agentName: string
|
||||
historyMessages: ClawChatMessageModel[]
|
||||
turns: AgentConversationTurn[]
|
||||
/**
|
||||
* Persisted turns that still need to render their FileCardStrip
|
||||
* because the history items they were filtered against don't
|
||||
* carry produced-files data. Rendered between history and the
|
||||
* live `turns` so the strip lands at the bottom of the
|
||||
* corresponding assistant turn.
|
||||
*/
|
||||
stripOnlyTurns?: AgentConversationTurn[]
|
||||
/**
|
||||
* Maps each assistant history message id → the produced-files
|
||||
* group that came from its turn. Built by
|
||||
* `mapHistoryToProducedFilesGroups` upstream so the strip
|
||||
* renders directly under the matching message instead of
|
||||
* stacking at the conversation tail.
|
||||
*/
|
||||
filesByAssistantId?: Map<string, ProducedFilesRailGroup>
|
||||
/**
|
||||
* Produced-files groups that didn't match any persisted history
|
||||
* pair (e.g. orphaned turns where history loaded after the
|
||||
* group was attributed). Rendered at the conversation tail as
|
||||
* a fallback so the user can still see them.
|
||||
*/
|
||||
tailStripGroups?: ReadonlyArray<ProducedFilesRailGroup>
|
||||
streaming: boolean
|
||||
isInitialLoading: boolean
|
||||
error: Error | null
|
||||
@@ -22,6 +47,8 @@ interface ClawChatProps {
|
||||
isFetchingNextPage: boolean
|
||||
onFetchNextPage: () => void
|
||||
onRetry: () => void
|
||||
/** Wired through to the inline file-card strip on each assistant turn. */
|
||||
onOpenOutputsRail?: ((turnId?: string | null) => void) | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -78,6 +105,9 @@ export const ClawChat: FC<ClawChatProps> = ({
|
||||
agentName,
|
||||
historyMessages,
|
||||
turns,
|
||||
stripOnlyTurns,
|
||||
filesByAssistantId,
|
||||
tailStripGroups,
|
||||
streaming,
|
||||
isInitialLoading,
|
||||
error,
|
||||
@@ -85,6 +115,7 @@ export const ClawChat: FC<ClawChatProps> = ({
|
||||
isFetchingNextPage,
|
||||
onFetchNextPage,
|
||||
onRetry,
|
||||
onOpenOutputsRail,
|
||||
className,
|
||||
}) => {
|
||||
const topSentinelRef = useRef<HTMLDivElement>(null)
|
||||
@@ -147,14 +178,44 @@ export const ClawChat: FC<ClawChatProps> = ({
|
||||
Start of conversation
|
||||
</div>
|
||||
) : null}
|
||||
{historyMessages.map((message) => (
|
||||
<ClawChatMessage key={message.id} message={message} />
|
||||
{historyMessages.map((message) => {
|
||||
const matched = filesByAssistantId?.get(message.id)
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
<ClawChatMessage message={message} />
|
||||
{matched ? (
|
||||
<FileCardStrip
|
||||
turnId={matched.turnId}
|
||||
files={matched.files}
|
||||
onOpenRail={onOpenOutputsRail ?? (() => {})}
|
||||
/>
|
||||
) : null}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{(tailStripGroups ?? []).map((group) => (
|
||||
<FileCardStrip
|
||||
key={`tail-strip-${group.turnId}`}
|
||||
turnId={group.turnId}
|
||||
files={group.files}
|
||||
onOpenRail={onOpenOutputsRail ?? (() => {})}
|
||||
/>
|
||||
))}
|
||||
{(stripOnlyTurns ?? []).map((turn) => (
|
||||
<ConversationMessage
|
||||
key={`strip-${turn.id}`}
|
||||
turn={turn}
|
||||
streaming={false}
|
||||
stripOnly
|
||||
onOpenOutputsRail={onOpenOutputsRail}
|
||||
/>
|
||||
))}
|
||||
{turns.map((turn, index) => (
|
||||
<ConversationMessage
|
||||
key={turn.id}
|
||||
turn={turn}
|
||||
streaming={streaming && index === turns.length - 1}
|
||||
onOpenOutputsRail={onOpenOutputsRail}
|
||||
/>
|
||||
))}
|
||||
{error ? (
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { ArrowLeft, Home } from 'lucide-react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatRelativeTime } from '@/entrypoints/app/agents/agent-display.helpers'
|
||||
import type { HarnessAgent } from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import { AgentSummaryChips } from '@/entrypoints/app/agents/agent-row/AgentSummaryChips'
|
||||
import { formatTokens } from '@/entrypoints/app/agents/agent-row/agent-row.helpers'
|
||||
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
|
||||
import { PinToggle } from '@/entrypoints/app/agents/agent-row/PinToggle'
|
||||
import type { AgentLiveness } from '@/entrypoints/app/agents/LivenessDot'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ConversationHeaderProps {
|
||||
agent: HarnessAgent | null
|
||||
fallbackName: string
|
||||
fallbackAdapter: 'claude' | 'codex' | 'openclaw' | 'hermes' | 'unknown'
|
||||
adapterHealth: AgentAdapterHealth | null
|
||||
backLabel: string
|
||||
backTarget: 'home' | 'page'
|
||||
onGoHome: () => void
|
||||
onPinToggle: (next: boolean) => void
|
||||
/** Optional trailing slot — currently used for the Outputs rail toggle. */
|
||||
headerExtra?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip above the chat. Mirrors the `/agents` row card's title row +
|
||||
* summary chips so the user gets adapter health, pin state, and status
|
||||
* at a glance — but adds the meta line (last used · lifetime tokens ·
|
||||
* queued) that's specific to this surface.
|
||||
*
|
||||
* The mobile `lg:hidden` Back button is preserved so the small-screen
|
||||
* collapse keeps a navigable header without a sidebar.
|
||||
*/
|
||||
export const ConversationHeader: FC<ConversationHeaderProps> = ({
|
||||
agent,
|
||||
fallbackName,
|
||||
fallbackAdapter,
|
||||
adapterHealth,
|
||||
backLabel,
|
||||
backTarget,
|
||||
onGoHome,
|
||||
onPinToggle,
|
||||
headerExtra,
|
||||
}) => {
|
||||
const BackIcon = backTarget === 'home' ? Home : ArrowLeft
|
||||
const adapter = agent?.adapter ?? fallbackAdapter
|
||||
const status: AgentLiveness = agent?.status ?? 'unknown'
|
||||
const lastUsedAt = agent?.lastUsedAt ?? null
|
||||
const pinned = agent?.pinned ?? false
|
||||
const queueCount = agent?.queue?.length ?? 0
|
||||
const tokens = agent?.tokens ?? null
|
||||
const lifetimeTotal = tokens
|
||||
? tokens.cumulative.input + tokens.cumulative.output
|
||||
: 0
|
||||
|
||||
const metaParts: string[] = []
|
||||
if (lastUsedAt !== null) metaParts.push(formatRelativeTime(lastUsedAt))
|
||||
if (lifetimeTotal > 0) metaParts.push(`${formatTokens(lifetimeTotal)} tokens`)
|
||||
if (queueCount > 0) {
|
||||
metaParts.push(queueCount === 1 ? '1 queued' : `${queueCount} queued`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60px] shrink-0 items-center justify-between gap-4 px-5 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onGoHome}
|
||||
className="size-8 shrink-0 rounded-xl lg:hidden"
|
||||
title={backLabel}
|
||||
>
|
||||
<BackIcon className="size-4" />
|
||||
</Button>
|
||||
<div className="group min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-semibold text-[15px] leading-6">
|
||||
{agent?.name || fallbackName}
|
||||
</span>
|
||||
{agent ? (
|
||||
<PinToggle pinned={pinned} onToggle={onPinToggle} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
<AgentSummaryChips
|
||||
adapter={adapter}
|
||||
modelLabel={agent?.modelId ?? null}
|
||||
reasoningEffort={agent?.reasoningEffort ?? null}
|
||||
adapterHealth={adapterHealth}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<StatusPill
|
||||
status={status}
|
||||
hasActiveTurn={Boolean(agent?.activeTurnId)}
|
||||
/>
|
||||
<div className="flex h-4 items-center text-[11px] text-muted-foreground">
|
||||
<span className="truncate">
|
||||
{metaParts.length > 0 ? metaParts.join(' · ') : '\u00A0'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{headerExtra ? (
|
||||
<div className="flex shrink-0 items-center">{headerExtra}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StatusPillProps {
|
||||
status: AgentLiveness
|
||||
hasActiveTurn: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Working / Asleep / Attention all get distinctive styling; idle keeps
|
||||
* the legacy emerald `Ready` pill so the default state is visually
|
||||
* calm. Defensive working: `idle + activeTurnId` falls through to the
|
||||
* working pill since the server says a turn is in flight.
|
||||
*/
|
||||
const StatusPill: FC<StatusPillProps> = ({ status, hasActiveTurn }) => {
|
||||
const effective: AgentLiveness =
|
||||
status === 'idle' && hasActiveTurn ? 'working' : status
|
||||
|
||||
const base =
|
||||
'inline-flex items-center gap-2 rounded-full border px-3 py-0.5 text-[11px] uppercase tracking-[0.18em]'
|
||||
|
||||
if (effective === 'working') {
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
base,
|
||||
'border-amber-200 bg-amber-50 text-amber-900 hover:bg-amber-50',
|
||||
)}
|
||||
>
|
||||
<span className="size-1.5 animate-pulse rounded-full bg-amber-500" />
|
||||
Working
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (effective === 'asleep') {
|
||||
return (
|
||||
<Badge variant="outline" className={cn(base, 'text-muted-foreground')}>
|
||||
<span className="size-1.5 rounded-full bg-muted-foreground/50" />
|
||||
Asleep
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (effective === 'error') {
|
||||
return (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className={cn(base, 'border-destructive/30')}
|
||||
>
|
||||
<span className="size-1.5 rounded-full bg-destructive-foreground" />
|
||||
Attention
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (effective === 'idle') {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
base,
|
||||
'border-emerald-200 bg-emerald-50 text-emerald-900 hover:bg-emerald-50',
|
||||
)}
|
||||
>
|
||||
<span className="size-1.5 rounded-full bg-emerald-500" />
|
||||
Ready
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className={cn(base, 'text-muted-foreground')}>
|
||||
<span className="size-1.5 rounded-full bg-muted-foreground/30" />
|
||||
Setup
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -164,7 +164,16 @@ function VoiceButton({
|
||||
)
|
||||
}
|
||||
|
||||
function ContextControls({
|
||||
/**
|
||||
* Calm-composer footer shared by both `/home` (`variant="home"`) and
|
||||
* the chat surface at `/agents/:agentId` (`variant="conversation"`).
|
||||
* Pill-shaped chips on an internal dashed divider, with a right-
|
||||
* aligned keyboard hint. The agent selector is conditional via
|
||||
* `showAgentSelector`: home shows it as a filled pill on the left,
|
||||
* the chat surface hides it (the agent is locked once you're in the
|
||||
* conversation).
|
||||
*/
|
||||
function CalmContextControls({
|
||||
agents,
|
||||
onCreateAgent,
|
||||
onSelectAgent,
|
||||
@@ -201,110 +210,128 @@ function ContextControls({
|
||||
)?.is_authenticated
|
||||
})
|
||||
|
||||
const showApps = supports(Feature.MANAGED_MCP_SUPPORT)
|
||||
const showWorkspace = supports(Feature.WORKSPACE_FOLDER_SUPPORT)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-border/40 border-t px-4 py-2.5">
|
||||
<div className="flex items-center gap-1">
|
||||
{showAgentSelector ? (
|
||||
<div className="mx-3 flex items-center gap-1 border-border/60 border-t border-dashed py-2">
|
||||
{showAgentSelector ? (
|
||||
<>
|
||||
<AgentSelector
|
||||
agents={agents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={onSelectAgent}
|
||||
onCreateAgent={onCreateAgent}
|
||||
status={status}
|
||||
triggerVariant="pill"
|
||||
/>
|
||||
) : null}
|
||||
{supports(Feature.WORKSPACE_FOLDER_SUPPORT) ? (
|
||||
<WorkspaceSelector>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>{selectedFolder?.name || 'Add workspace'}</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</WorkspaceSelector>
|
||||
) : null}
|
||||
<TabPickerPopover
|
||||
variant="selector"
|
||||
selectedTabs={selectedTabs}
|
||||
onToggleTab={onToggleTab}
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
selectedTabs.length > 0
|
||||
? 'bg-[var(--accent-orange)]! text-white shadow-sm'
|
||||
: 'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mx-1 inline-block h-3.5 w-px shrink-0 bg-border"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{showWorkspace ? (
|
||||
<WorkspaceSelector>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground"
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
<span>Tabs</span>
|
||||
</Button>
|
||||
</TabPickerPopover>
|
||||
<Button
|
||||
<Folder className="size-3" />
|
||||
<span>Workspace</span>
|
||||
<span className="font-mono text-[10.5px] text-muted-foreground/70">
|
||||
{selectedFolder?.name ?? 'none'}
|
||||
</span>
|
||||
</button>
|
||||
</WorkspaceSelector>
|
||||
) : null}
|
||||
<TabPickerPopover
|
||||
variant="selector"
|
||||
selectedTabs={selectedTabs}
|
||||
onToggleTab={onToggleTab}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onAttachClick}
|
||||
disabled={attachDisabled || !attachmentsEnabled}
|
||||
title="Attach files"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] transition-colors data-[state=open]:bg-accent data-[state=open]:text-foreground',
|
||||
selectedTabs.length > 0
|
||||
? 'bg-[var(--accent-orange)] text-white hover:bg-[var(--accent-orange)]/90'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
<span>Attach</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{supports(Feature.MANAGED_MCP_SUPPORT) ? (
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<AppSelector side="bottom">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center -space-x-1.5">
|
||||
<Layers className="size-3" />
|
||||
<span>Tabs</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-[10.5px]',
|
||||
selectedTabs.length > 0
|
||||
? 'text-white/80'
|
||||
: 'text-muted-foreground/70',
|
||||
)}
|
||||
>
|
||||
{selectedTabs.length}
|
||||
</span>
|
||||
</button>
|
||||
</TabPickerPopover>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAttachClick}
|
||||
disabled={attachDisabled || !attachmentsEnabled}
|
||||
title="Attach files"
|
||||
className="inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Paperclip className="size-3" />
|
||||
<span>Attach</span>
|
||||
</button>
|
||||
{showApps ? (
|
||||
<AppSelector side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground"
|
||||
>
|
||||
{connectedManagedServers.length > 0 ? (
|
||||
<span className="flex items-center -space-x-1.5">
|
||||
{connectedManagedServers.slice(0, 4).map((server) => (
|
||||
<div
|
||||
<span
|
||||
key={server.id}
|
||||
className="rounded-full ring-2 ring-card"
|
||||
>
|
||||
<McpServerIcon
|
||||
serverName={server.managedServerName ?? ''}
|
||||
size={16}
|
||||
size={12}
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{connectedManagedServers.length > 4 ? (
|
||||
<span className="text-xs">
|
||||
+{connectedManagedServers.length - 4}
|
||||
</span>
|
||||
) : null}
|
||||
<span>Apps</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</AppSelector>
|
||||
</div>
|
||||
</span>
|
||||
) : (
|
||||
<FileText className="size-3" />
|
||||
)}
|
||||
<span>Apps</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</button>
|
||||
</AppSelector>
|
||||
) : null}
|
||||
<div className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground/70">
|
||||
<kbd className="inline-flex h-4 min-w-4 items-center justify-center rounded border border-border bg-accent/30 px-1 font-mono text-[10px] text-muted-foreground">
|
||||
↵
|
||||
</kbd>
|
||||
<span>to run</span>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<kbd className="inline-flex h-4 min-w-4 items-center justify-center rounded border border-border bg-accent/30 px-1 font-mono text-[10px] text-muted-foreground">
|
||||
⇧
|
||||
</kbd>
|
||||
<kbd className="inline-flex h-4 min-w-4 items-center justify-center rounded border border-border bg-accent/30 px-1 font-mono text-[10px] text-muted-foreground">
|
||||
↵
|
||||
</kbd>
|
||||
<span>new line</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[1.55rem] border border-border/60 bg-card/95 shadow-sm">
|
||||
<div className="overflow-hidden rounded-[1.55rem] border border-border/60 bg-card/95 shadow-sm transition-[border-color,box-shadow] duration-150 focus-within:border-[var(--accent-orange)]/40 focus-within:shadow-[0_0_0_4px_color-mix(in_oklch,var(--accent-orange)_15%,transparent),0_1px_2px_rgba(15,23,42,0.04)]">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -312,7 +339,7 @@ function HomeShell({ children }: { children: ReactNode }) {
|
||||
|
||||
function ConversationShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[1.35rem] border border-border/50 bg-background/95 shadow-[0_10px_30px_rgba(15,23,42,0.06)] backdrop-blur-md">
|
||||
<div className="overflow-hidden rounded-[1.35rem] border border-border/50 bg-background/95 shadow-[0_10px_30px_rgba(15,23,42,0.06)] backdrop-blur-md transition-[border-color,box-shadow] duration-150 focus-within:border-[var(--accent-orange)]/40 focus-within:shadow-[0_0_0_4px_color-mix(in_oklch,var(--accent-orange)_15%,transparent),0_10px_30px_rgba(15,23,42,0.06)]">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -542,7 +569,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
}
|
||||
disabled={disabled || voice.isTranscribing}
|
||||
className={cn(
|
||||
'resize-none border-none bg-transparent px-0 text-[15px] shadow-none focus-visible:ring-0',
|
||||
'resize-none border-none bg-transparent px-0 text-[15px] shadow-none focus-visible:ring-0 dark:bg-transparent',
|
||||
'[field-sizing:fixed]',
|
||||
variant === 'home'
|
||||
? 'min-h-[40px] py-2 leading-6'
|
||||
@@ -583,7 +610,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
{voice.error}
|
||||
</div>
|
||||
) : null}
|
||||
<ContextControls
|
||||
<CalmContextControls
|
||||
agents={agents}
|
||||
onCreateAgent={onCreateAgent}
|
||||
onSelectAgent={onSelectAgent}
|
||||
|
||||
@@ -22,10 +22,26 @@ import type {
|
||||
AgentConversationTurn,
|
||||
ToolEntry,
|
||||
} from '@/lib/agent-conversations/types'
|
||||
import { FileCardStrip } from './agent-conversation.file-card-strip'
|
||||
|
||||
interface ConversationMessageProps {
|
||||
turn: AgentConversationTurn
|
||||
streaming: boolean
|
||||
/**
|
||||
* Forwarded to the inline file-card strip's "View" / "+N"
|
||||
* button. Wired up by AgentCommandConversation so the strip can
|
||||
* deep-link straight into the Outputs rail at the matching turn
|
||||
* group. `null` here disables the strip's deep-link affordance
|
||||
* — the cards still open the preview Sheet directly.
|
||||
*/
|
||||
onOpenOutputsRail?: ((turnId?: string | null) => void) | null
|
||||
/**
|
||||
* Render only the trailing FileCardStrip for this turn — used
|
||||
* when the turn's user / assistant text is already rendered
|
||||
* elsewhere (e.g. by `ClawChatMessage` from persisted history)
|
||||
* but the produced-files affordance would otherwise be lost.
|
||||
*/
|
||||
stripOnly?: boolean
|
||||
}
|
||||
|
||||
interface RenderEntry {
|
||||
@@ -88,9 +104,22 @@ function ToolStatusIcon({ status }: { status: ToolEntry['status'] }) {
|
||||
export const ConversationMessage: FC<ConversationMessageProps> = ({
|
||||
turn,
|
||||
streaming,
|
||||
onOpenOutputsRail,
|
||||
stripOnly,
|
||||
}) => {
|
||||
const entries = useMemo(() => buildRenderEntries(turn), [turn])
|
||||
|
||||
if (stripOnly) {
|
||||
if (!turn.producedFiles || turn.producedFiles.length === 0) return null
|
||||
return (
|
||||
<FileCardStrip
|
||||
turnId={turn.turnId ?? null}
|
||||
files={turn.producedFiles}
|
||||
onOpenRail={onOpenOutputsRail ?? (() => {})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Message from="user">
|
||||
@@ -185,6 +214,14 @@ export const ConversationMessage: FC<ConversationMessageProps> = ({
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{turn.producedFiles && turn.producedFiles.length > 0 ? (
|
||||
<FileCardStrip
|
||||
turnId={turn.turnId ?? null}
|
||||
files={turn.producedFiles}
|
||||
onOpenRail={onOpenOutputsRail ?? (() => {})}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!turn.done && turn.parts.length === 0 && streaming && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* @deprecated Replaced by `FileCardStrip` in
|
||||
* `agent-conversation.file-card-strip.tsx`. Kept temporarily so
|
||||
* any in-flight callers don't fail to import; remove in a
|
||||
* follow-up once nothing external references it.
|
||||
*
|
||||
* Compact "Files produced" card rendered under an assistant turn.
|
||||
*/
|
||||
|
||||
import { FileText, Image as ImageIcon, Paperclip } from 'lucide-react'
|
||||
import { type FC, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { basenameOf, formatFileSize, inferFileKind } from '@/lib/agent-files'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FilePreviewSheet } from './agent-conversation.file-preview-sheet'
|
||||
|
||||
export interface ProducedFileLike {
|
||||
id: string
|
||||
path: string
|
||||
size: number
|
||||
}
|
||||
|
||||
interface ArtifactCardProps {
|
||||
files: ReadonlyArray<ProducedFileLike>
|
||||
className?: string
|
||||
}
|
||||
|
||||
const MAX_INLINE_ROWS = 4
|
||||
|
||||
export const ArtifactCard: FC<ArtifactCardProps> = ({ files, className }) => {
|
||||
const [openFileId, setOpenFileId] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const sortedFiles = useMemo(
|
||||
() => [...files].sort((a, b) => a.path.localeCompare(b.path)),
|
||||
[files],
|
||||
)
|
||||
|
||||
if (sortedFiles.length === 0) return null
|
||||
|
||||
const visible = expanded ? sortedFiles : sortedFiles.slice(0, MAX_INLINE_ROWS)
|
||||
const hiddenCount = sortedFiles.length - visible.length
|
||||
const openFile = sortedFiles.find((file) => file.id === openFileId) ?? null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-border/60 bg-card/50 px-3 py-2.5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<Paperclip className="size-3.5" />
|
||||
<span className="font-medium text-foreground">
|
||||
{sortedFiles.length === 1
|
||||
? '1 file produced'
|
||||
: `${sortedFiles.length} files produced`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-1">
|
||||
{visible.map((file) => (
|
||||
<li key={file.id}>
|
||||
<ArtifactRow file={file} onOpen={() => setOpenFileId(file.id)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{hiddenCount > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-1.5 h-7 px-2 text-xs"
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
Show {hiddenCount} more
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<FilePreviewSheet
|
||||
fileId={openFile?.id ?? null}
|
||||
filePath={openFile?.path ?? null}
|
||||
open={Boolean(openFileId)}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setOpenFileId(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactRow({
|
||||
file,
|
||||
onOpen,
|
||||
}: {
|
||||
file: ProducedFileLike
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const name = basenameOf(file.path)
|
||||
const kind = inferFileKind(file.path)
|
||||
const Icon = kind === 'image' ? ImageIcon : FileText
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors',
|
||||
'hover:bg-accent/60 focus:bg-accent/60 focus:outline-hidden',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{name}</span>
|
||||
<span className="shrink-0 text-muted-foreground text-xs tabular-nums">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* "Files produced" strip rendered at the bottom of any assistant
|
||||
* turn that produced files (openclaw only). Replaces Phase 5.3's
|
||||
* row-list ArtifactCard with small horizontal cards for a lighter
|
||||
* visual treatment.
|
||||
*
|
||||
* Click semantics:
|
||||
* - Card → opens FilePreviewSheet directly (preview + download).
|
||||
* - View → emits onOpenRail(turnId); the parent opens the rail
|
||||
* and scrolls to the matching turn group.
|
||||
* - +N → same as View (the user is asking to see what was
|
||||
* overflowed).
|
||||
*/
|
||||
|
||||
import { ChevronRight, FileText, Image as ImageIcon } from 'lucide-react'
|
||||
import { type FC, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { basenameOf, formatFileSize, inferFileKind } from '@/lib/agent-files'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FilePreviewSheet } from './agent-conversation.file-preview-sheet'
|
||||
|
||||
export interface CardStripFile {
|
||||
id: string
|
||||
path: string
|
||||
size: number
|
||||
}
|
||||
|
||||
interface FileCardStripProps {
|
||||
/**
|
||||
* The turn id that produced these files. Forwarded to
|
||||
* `onOpenRail` so the rail can scroll/expand the matching group.
|
||||
* Optional because the live `produced_files` event lands before
|
||||
* the harness has stamped a server-issued turn id on the
|
||||
* optimistic turn — in that brief window, View falls back to
|
||||
* just opening the rail at the top.
|
||||
*/
|
||||
turnId?: string | null
|
||||
files: ReadonlyArray<CardStripFile>
|
||||
/** Caller wires this to `setOutputsRailOpen(true)` + deep-link. */
|
||||
onOpenRail: (turnId?: string | null) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 4
|
||||
|
||||
export const FileCardStrip: FC<FileCardStripProps> = ({
|
||||
turnId,
|
||||
files,
|
||||
onOpenRail,
|
||||
className,
|
||||
}) => {
|
||||
const [openFileId, setOpenFileId] = useState<string | null>(null)
|
||||
|
||||
const sortedFiles = useMemo(
|
||||
() => [...files].sort((a, b) => a.path.localeCompare(b.path)),
|
||||
[files],
|
||||
)
|
||||
|
||||
if (sortedFiles.length === 0) return null
|
||||
|
||||
const visible = sortedFiles.slice(0, MAX_VISIBLE)
|
||||
const hiddenCount = sortedFiles.length - visible.length
|
||||
const openFile = sortedFiles.find((file) => file.id === openFileId) ?? null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-border/60 bg-card/50 px-3 py-2.5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{sortedFiles.length === 1
|
||||
? 'File produced'
|
||||
: `Files produced (${sortedFiles.length})`}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-7 gap-1 px-2 text-xs"
|
||||
onClick={() => onOpenRail(turnId ?? null)}
|
||||
>
|
||||
View
|
||||
<ChevronRight className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visible.map((file) => (
|
||||
<FileCard
|
||||
key={file.id}
|
||||
file={file}
|
||||
onOpen={() => setOpenFileId(file.id)}
|
||||
/>
|
||||
))}
|
||||
{hiddenCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenRail(turnId ?? null)}
|
||||
className={cn(
|
||||
'flex h-[56px] min-w-[56px] shrink-0 items-center justify-center rounded-lg border border-border/60 px-3 text-muted-foreground text-xs',
|
||||
'transition-colors hover:border-border hover:bg-accent/40 hover:text-foreground',
|
||||
'focus:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--accent-orange)]',
|
||||
)}
|
||||
title={`See ${hiddenCount} more in the Outputs rail`}
|
||||
>
|
||||
+{hiddenCount}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FilePreviewSheet
|
||||
fileId={openFile?.id ?? null}
|
||||
filePath={openFile?.path ?? null}
|
||||
open={Boolean(openFileId)}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setOpenFileId(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FileCard({
|
||||
file,
|
||||
onOpen,
|
||||
}: {
|
||||
file: CardStripFile
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const name = basenameOf(file.path)
|
||||
const kind = inferFileKind(file.path)
|
||||
const Icon = kind === 'image' ? ImageIcon : FileText
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
title={file.path}
|
||||
className={cn(
|
||||
'flex h-[56px] w-[140px] shrink-0 flex-col justify-between rounded-lg border border-border/60 bg-background px-2.5 py-1.5 text-left',
|
||||
'transition-colors hover:border-border hover:bg-accent/40',
|
||||
'focus:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--accent-orange)]',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-xs">
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Shared preview drawer used by the inline artifact card AND the
|
||||
* Outputs rail. Branches on the FilePreview discriminated union and
|
||||
* renders the appropriate body. Always opens via a controlled
|
||||
* `open`/`onOpenChange` pair so the parent owns the selected file.
|
||||
*/
|
||||
|
||||
import { Download, FileWarning, Loader2 } from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { MessageResponse } from '@/components/ai-elements/message'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
basenameOf,
|
||||
buildFileDownloadUrl,
|
||||
extensionOf,
|
||||
type FilePreview,
|
||||
formatFileSize,
|
||||
useFilePreview,
|
||||
} from '@/lib/agent-files'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FilePreviewSheetProps {
|
||||
fileId: string | null
|
||||
filePath: string | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown', 'mdx'])
|
||||
|
||||
export const FilePreviewSheet: FC<FilePreviewSheetProps> = ({
|
||||
fileId,
|
||||
filePath,
|
||||
open,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const { baseUrl } = useAgentServerUrl()
|
||||
const { preview, loading, error } = useFilePreview(fileId, open)
|
||||
|
||||
const fileName = filePath ? basenameOf(filePath) : 'File preview'
|
||||
const downloadUrl = useMemo(() => {
|
||||
if (!baseUrl || !fileId) return null
|
||||
return buildFileDownloadUrl(baseUrl, fileId)
|
||||
}, [baseUrl, fileId])
|
||||
|
||||
// Surface preview-load failures in a toast in addition to the
|
||||
// inline error block — the inline UI lives at the bottom of the
|
||||
// sheet and is easy to miss when scrolled into the body.
|
||||
const lastToastedFileIdRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
lastToastedFileIdRef.current = null
|
||||
return
|
||||
}
|
||||
if (!error || !fileId) return
|
||||
if (lastToastedFileIdRef.current === fileId) return
|
||||
lastToastedFileIdRef.current = fileId
|
||||
toast.error('Could not load preview', { description: error.message })
|
||||
}, [open, error, fileId])
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!downloadUrl) {
|
||||
toast.error("Couldn't reach the agent server", {
|
||||
description: 'Reconnect to BrowserOS and try again.',
|
||||
})
|
||||
return
|
||||
}
|
||||
// Manually trigger the download so any future failure (e.g. the
|
||||
// server returns 404 because the file was removed) can be
|
||||
// surfaced via toast — the bare <a download> path swallows
|
||||
// these errors silently.
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadUrl
|
||||
link.download = fileName
|
||||
link.rel = 'noopener'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-full flex-col gap-0 p-0 sm:max-w-xl"
|
||||
>
|
||||
<SheetHeader className="border-border/60 border-b px-5 py-4">
|
||||
<SheetTitle className="truncate pr-8">{fileName}</SheetTitle>
|
||||
<SheetDescription className="truncate">
|
||||
{filePath ?? ''}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="px-5 py-4">
|
||||
{loading ? (
|
||||
<PreviewSkeleton />
|
||||
) : error ? (
|
||||
<PreviewError message={error.message} />
|
||||
) : preview ? (
|
||||
<PreviewBody
|
||||
preview={preview}
|
||||
filePath={filePath}
|
||||
downloadUrl={downloadUrl}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{fileId ? (
|
||||
<div className="border-border/60 border-t bg-background/90 px-5 py-3 backdrop-blur">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="w-full gap-2"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Loading preview...
|
||||
</div>
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewError({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-sm">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<FileWarning className="size-4" />
|
||||
Could not load preview
|
||||
</div>
|
||||
<p className="text-destructive/80 text-xs">{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewBody({
|
||||
preview,
|
||||
filePath,
|
||||
downloadUrl,
|
||||
}: {
|
||||
preview: FilePreview
|
||||
filePath: string | null
|
||||
downloadUrl: string | null
|
||||
}) {
|
||||
if (preview.kind === 'missing') {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/40 px-4 py-6 text-center text-muted-foreground text-sm">
|
||||
This file is no longer in the workspace. The agent may have moved or
|
||||
deleted it after the turn finished.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.kind === 'image') {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<PreviewMeta preview={preview} />
|
||||
<div className="overflow-hidden rounded-lg border border-border/60 bg-muted/30">
|
||||
<img
|
||||
src={preview.dataUrl}
|
||||
alt={filePath ?? 'preview'}
|
||||
className="block max-h-[60vh] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.kind === 'pdf') {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<PreviewMeta preview={preview} />
|
||||
<div className="rounded-lg border border-border/60 bg-muted/40 px-4 py-6 text-center text-muted-foreground text-sm">
|
||||
PDF previews aren't supported inline yet. Use Download to open this
|
||||
file in your default PDF viewer.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.kind === 'binary') {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<PreviewMeta preview={preview} />
|
||||
<div className="rounded-lg border border-border/60 bg-muted/40 px-4 py-6 text-center text-muted-foreground text-sm">
|
||||
No inline preview for this file type.
|
||||
{downloadUrl ? ' Use Download to save it locally.' : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <TextPreviewBody preview={preview} filePath={filePath} />
|
||||
}
|
||||
|
||||
function TextPreviewBody({
|
||||
preview,
|
||||
filePath,
|
||||
}: {
|
||||
preview: Extract<FilePreview, { kind: 'text' }>
|
||||
filePath: string | null
|
||||
}) {
|
||||
const ext = filePath ? extensionOf(filePath).toLowerCase() : ''
|
||||
const renderAsMarkdown = MARKDOWN_EXTENSIONS.has(ext)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<PreviewMeta preview={preview} />
|
||||
{renderAsMarkdown ? (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-sm dark:prose-invert max-w-none break-words rounded-lg border border-border/60 bg-muted/30 px-4 py-3',
|
||||
"[&_[data-streamdown='code-block']]:!w-full [&_[data-streamdown='code-block']]:overflow-x-auto",
|
||||
)}
|
||||
>
|
||||
<MessageResponse mode="static" parseIncompleteMarkdown={false}>
|
||||
{preview.snippet}
|
||||
</MessageResponse>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="overflow-x-auto rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-xs leading-relaxed">
|
||||
<code className="font-mono text-foreground">{preview.snippet}</code>
|
||||
</pre>
|
||||
)}
|
||||
{preview.truncated ? (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Showing the first part of this file. Download to see the full
|
||||
contents.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewMeta({
|
||||
preview,
|
||||
}: {
|
||||
preview: Exclude<FilePreview, { kind: 'missing' }>
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground text-xs">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatFileSize(preview.size)}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">{preview.mimeType || 'unknown'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Per-agent right-side "Outputs" panel. Lists every file the harness
|
||||
* has attributed to this agent, grouped by the turn that produced
|
||||
* them. Click a row to open the shared preview Sheet.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - Open/closed state is controlled by the parent and persisted via
|
||||
* `useOutputsRailOpen(agentId)` so each agent remembers its
|
||||
* preference independently.
|
||||
* - Data refreshes whenever a turn finishes (the conversation hook
|
||||
* fires `useInvalidateAgentOutputs` from its finally block).
|
||||
* - Manual "Refresh" button is wired to `useRefreshAgentOutputs`
|
||||
* for users who navigate in mid-turn.
|
||||
*/
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Inbox,
|
||||
Loader2,
|
||||
PanelRightClose,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
basenameOf,
|
||||
formatFileSize,
|
||||
inferFileKind,
|
||||
type ProducedFilesRailGroup,
|
||||
useAgentOutputs,
|
||||
useRefreshAgentOutputs,
|
||||
} from '@/lib/agent-files'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FilePreviewSheet } from './agent-conversation.file-preview-sheet'
|
||||
|
||||
interface OutputsRailProps {
|
||||
agentId: string
|
||||
onClose: () => void
|
||||
/**
|
||||
* When set, the rail scrolls the matching `RailTurnGroup` into
|
||||
* view and force-opens its `Collapsible`. Used by the inline
|
||||
* file-card strip's "View" / "+N" deep-link path. Cleared by
|
||||
* the parent (via `onFocusTurnConsumed`) once the rail has
|
||||
* acknowledged the deep-link so subsequent renders don't keep
|
||||
* re-scrolling the same group.
|
||||
*/
|
||||
focusTurnId?: string | null
|
||||
onFocusTurnConsumed?: () => void
|
||||
}
|
||||
|
||||
const RAIL_LOCAL_STORAGE_PREFIX = 'browseros:outputs-rail:'
|
||||
|
||||
/**
|
||||
* Controlled open/close state with per-agent localStorage memory.
|
||||
* Returns a tuple compatible with React's useState shape so the
|
||||
* parent can pass it straight into the rail without an extra effect.
|
||||
*/
|
||||
export function useOutputsRailOpen(
|
||||
agentId: string,
|
||||
): [boolean, (next: boolean) => void] {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !agentId) return
|
||||
try {
|
||||
const stored = window.localStorage.getItem(
|
||||
`${RAIL_LOCAL_STORAGE_PREFIX}${agentId}`,
|
||||
)
|
||||
setOpen(stored === '1')
|
||||
} catch {
|
||||
// localStorage may be unavailable (private mode, locked-down
|
||||
// contexts) — fall back to closed.
|
||||
}
|
||||
}, [agentId])
|
||||
|
||||
const update = (next: boolean) => {
|
||||
setOpen(next)
|
||||
if (typeof window === 'undefined' || !agentId) return
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
`${RAIL_LOCAL_STORAGE_PREFIX}${agentId}`,
|
||||
next ? '1' : '0',
|
||||
)
|
||||
} catch {
|
||||
// Best-effort persistence.
|
||||
}
|
||||
}
|
||||
|
||||
return [open, update]
|
||||
}
|
||||
|
||||
export const OutputsRail: FC<OutputsRailProps> = ({
|
||||
agentId,
|
||||
onClose,
|
||||
focusTurnId,
|
||||
onFocusTurnConsumed,
|
||||
}) => {
|
||||
const { groups, loading, error } = useAgentOutputs(agentId)
|
||||
const refresh = useRefreshAgentOutputs(agentId)
|
||||
|
||||
const [openFile, setOpenFile] = useState<{
|
||||
id: string
|
||||
path: string
|
||||
} | null>(null)
|
||||
|
||||
const totalFiles = useMemo(
|
||||
() => groups.reduce((sum, group) => sum + group.files.length, 0),
|
||||
[groups],
|
||||
)
|
||||
|
||||
return (
|
||||
<aside className="flex h-full min-h-0 w-full flex-col border-border/50 border-l bg-background">
|
||||
<header className="flex shrink-0 items-center gap-2 border-border/50 border-b px-3 py-3">
|
||||
<span className="font-semibold text-[13px] uppercase tracking-wide">
|
||||
Outputs
|
||||
</span>
|
||||
{totalFiles > 0 ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{totalFiles}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() =>
|
||||
refresh.mutate(undefined, {
|
||||
onError: (err) =>
|
||||
toast.error('Refresh failed', {
|
||||
description:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
})
|
||||
}
|
||||
disabled={refresh.isPending}
|
||||
title="Refresh"
|
||||
>
|
||||
{refresh.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={onClose}
|
||||
title="Hide outputs"
|
||||
>
|
||||
<PanelRightClose className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="px-2 py-2">
|
||||
{loading && groups.length === 0 ? (
|
||||
<RailSkeleton />
|
||||
) : error ? (
|
||||
<RailError message={error.message} />
|
||||
) : groups.length === 0 ? (
|
||||
<RailEmpty />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{groups.map((group) => (
|
||||
<li key={group.turnId}>
|
||||
<RailTurnGroup
|
||||
group={group}
|
||||
focused={
|
||||
Boolean(focusTurnId) && focusTurnId === group.turnId
|
||||
}
|
||||
onFocusConsumed={onFocusTurnConsumed}
|
||||
onOpenFile={(file) =>
|
||||
setOpenFile({ id: file.id, path: file.path })
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<FilePreviewSheet
|
||||
fileId={openFile?.id ?? null}
|
||||
filePath={openFile?.path ?? null}
|
||||
open={Boolean(openFile)}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setOpenFile(null)
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function RailTurnGroup({
|
||||
group,
|
||||
focused,
|
||||
onFocusConsumed,
|
||||
onOpenFile,
|
||||
}: {
|
||||
group: ProducedFilesRailGroup
|
||||
focused: boolean
|
||||
onFocusConsumed?: () => void
|
||||
onOpenFile: (file: { id: string; path: string }) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const headerLabel = group.turnPrompt.trim() || 'Turn'
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Deep-link consumption: when the parent passes `focused=true`,
|
||||
// expand the collapsible (in case the user had collapsed it
|
||||
// earlier) and scroll into view. Fire `onFocusConsumed` so the
|
||||
// parent can drop the URL param and we don't re-scroll on every
|
||||
// render after that.
|
||||
useEffect(() => {
|
||||
if (!focused) return
|
||||
setOpen(true)
|
||||
containerRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
onFocusConsumed?.()
|
||||
}, [focused, onFocusConsumed])
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'flex w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-muted-foreground text-xs',
|
||||
'transition-colors hover:bg-accent/40 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="size-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{headerLabel}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">{group.files.length}</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ul className="mt-1 ml-1 flex flex-col gap-0.5 border-border/40 border-l pl-2">
|
||||
{group.files.map((file) => (
|
||||
<li key={file.id}>
|
||||
<RailFileRow file={file} onOpen={() => onOpenFile(file)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RailFileRow({
|
||||
file,
|
||||
onOpen,
|
||||
}: {
|
||||
file: ProducedFilesRailGroup['files'][number]
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const name = basenameOf(file.path)
|
||||
const kind = inferFileKind(file.path)
|
||||
const Icon = kind === 'image' ? ImageIcon : FileText
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left text-xs transition-colors',
|
||||
'hover:bg-accent/60 focus:bg-accent/60 focus:outline-hidden',
|
||||
)}
|
||||
title={file.path}
|
||||
>
|
||||
<Icon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{name}</span>
|
||||
<span className="shrink-0 text-muted-foreground tabular-nums">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function RailSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-1.5 py-1">
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RailEmpty() {
|
||||
return (
|
||||
<div className="mx-2 my-3 flex flex-col items-center gap-1.5 rounded-lg border border-border/60 border-dashed bg-muted/20 px-3 py-6 text-center text-muted-foreground text-xs">
|
||||
<Inbox className="size-4" />
|
||||
<p className="font-medium">No outputs yet</p>
|
||||
<p className="text-[11px] text-muted-foreground/70 leading-snug">
|
||||
Files this agent creates will appear here, grouped by the turn that made
|
||||
them.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RailError({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="mx-2 my-3 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-xs">
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawChatHistoryMessage } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
|
||||
import type { ProducedFilesRailGroup } from '@/lib/agent-files'
|
||||
|
||||
export type ClawChatRole = 'user' | 'assistant'
|
||||
|
||||
@@ -234,6 +235,30 @@ export function filterTurnsPersistedInHistory(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted turns that still carry `producedFiles` — once history
|
||||
* reloads, the assistant text is rendered by `ClawChatMessage` and
|
||||
* the optimistic turn is filtered out by
|
||||
* `filterTurnsPersistedInHistory`. The historical message has no
|
||||
* `producedFiles` field (history items don't carry that), so the
|
||||
* inline file-card strip would vanish on history reload.
|
||||
*
|
||||
* Returning these here lets the caller render a strip-only entry
|
||||
* after the corresponding history bubble — full message stays as
|
||||
* the persisted history pair, but the produced-files affordance
|
||||
* survives.
|
||||
*/
|
||||
export function selectStripOnlyTurns(
|
||||
turns: AgentConversationTurn[],
|
||||
historyMessages: ClawChatMessage[],
|
||||
): AgentConversationTurn[] {
|
||||
return turns.filter(
|
||||
(turn) =>
|
||||
Boolean(turn.producedFiles && turn.producedFiles.length > 0) &&
|
||||
isTurnPersistedInHistory(turn, historyMessages),
|
||||
)
|
||||
}
|
||||
|
||||
function isTurnPersistedInHistory(
|
||||
turn: AgentConversationTurn,
|
||||
historyMessages: ClawChatMessage[],
|
||||
@@ -285,3 +310,59 @@ function getClawMessageText(message: ClawChatMessage): string {
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function firstNonBlankLine(value: string): string {
|
||||
for (const raw of value.split('\n')) {
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed) return trimmed
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Map each assistant history message to the produced-files group
|
||||
* that came from its turn. Match key is `group.turnPrompt` (first
|
||||
* non-blank line of the user prompt that initiated the turn) vs.
|
||||
* the first non-blank line of the user message that immediately
|
||||
* preceded this assistant message — the same shape the server
|
||||
* emits when storing turnPrompt.
|
||||
*
|
||||
* Walks history forward (oldest-first per `flattenHistoryPages`)
|
||||
* and consumes groups in chronological order. A group can only
|
||||
* match once — if two turns share the same prompt the earlier
|
||||
* one wins, and the later assistant message stays unassociated
|
||||
* (those land back in `tailStripGroups` at the conversation tail).
|
||||
*/
|
||||
export function mapHistoryToProducedFilesGroups(
|
||||
historyMessages: ClawChatMessage[],
|
||||
groups: ReadonlyArray<ProducedFilesRailGroup>,
|
||||
): {
|
||||
byAssistantMessageId: Map<string, ProducedFilesRailGroup>
|
||||
unmatched: ProducedFilesRailGroup[]
|
||||
} {
|
||||
const byAssistantMessageId = new Map<string, ProducedFilesRailGroup>()
|
||||
if (groups.length === 0) {
|
||||
return { byAssistantMessageId, unmatched: [] }
|
||||
}
|
||||
// Oldest-first so the iteration order matches history.
|
||||
const remaining = [...groups].sort((a, b) => a.createdAt - b.createdAt)
|
||||
|
||||
let pendingPrompt: string | null = null
|
||||
for (const message of historyMessages) {
|
||||
if (message.role === 'user') {
|
||||
pendingPrompt = firstNonBlankLine(getClawMessageText(message))
|
||||
continue
|
||||
}
|
||||
if (message.role !== 'assistant' || !pendingPrompt) continue
|
||||
const matchIndex = remaining.findIndex(
|
||||
(group) => group.turnPrompt === pendingPrompt,
|
||||
)
|
||||
if (matchIndex >= 0) {
|
||||
const [match] = remaining.splice(matchIndex, 1)
|
||||
byAssistantMessageId.set(message.id, match)
|
||||
}
|
||||
pendingPrompt = null
|
||||
}
|
||||
|
||||
return { byAssistantMessageId, unmatched: remaining }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import type { StagedAttachment } from '@/lib/attachments'
|
||||
import {
|
||||
consumePendingInitialMessage,
|
||||
peekPendingInitialMessage,
|
||||
setPendingInitialMessage,
|
||||
} from './pending-initial-message'
|
||||
|
||||
function makeAttachment(id: string): StagedAttachment {
|
||||
return {
|
||||
id,
|
||||
kind: 'image',
|
||||
mediaType: 'image/png',
|
||||
name: `${id}.png`,
|
||||
dataUrl: `data:image/png;base64,${id}`,
|
||||
payload: {
|
||||
kind: 'image',
|
||||
mediaType: 'image/png',
|
||||
name: `${id}.png`,
|
||||
dataUrl: `data:image/png;base64,${id}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// Drain any leftover pending entry so tests don't leak into each
|
||||
// other (the module-scope state survives across `it` blocks).
|
||||
consumePendingInitialMessage('drain')
|
||||
// If still set, clear by consuming with the matching id.
|
||||
const leftover = peekPendingInitialMessage()
|
||||
if (leftover) consumePendingInitialMessage(leftover.agentId)
|
||||
})
|
||||
|
||||
describe('pending-initial-message', () => {
|
||||
it('consume returns the payload set for the same agentId', () => {
|
||||
setPendingInitialMessage({
|
||||
agentId: 'agent-a',
|
||||
text: 'hello',
|
||||
attachments: [makeAttachment('one')],
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
const result = consumePendingInitialMessage('agent-a')
|
||||
expect(result?.text).toBe('hello')
|
||||
expect(result?.attachments).toHaveLength(1)
|
||||
expect(result?.attachments[0]?.id).toBe('one')
|
||||
})
|
||||
|
||||
it('consume is destructive — second call returns null', () => {
|
||||
setPendingInitialMessage({
|
||||
agentId: 'agent-a',
|
||||
text: 'hello',
|
||||
attachments: [],
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
expect(consumePendingInitialMessage('agent-a')).not.toBeNull()
|
||||
expect(consumePendingInitialMessage('agent-a')).toBeNull()
|
||||
})
|
||||
|
||||
it('consume returns null and preserves entry when agentId differs', () => {
|
||||
setPendingInitialMessage({
|
||||
agentId: 'agent-a',
|
||||
text: 'hello',
|
||||
attachments: [],
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
expect(consumePendingInitialMessage('agent-b')).toBeNull()
|
||||
expect(peekPendingInitialMessage()?.agentId).toBe('agent-a')
|
||||
expect(consumePendingInitialMessage('agent-a')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for entries older than the TTL', () => {
|
||||
setPendingInitialMessage({
|
||||
agentId: 'agent-a',
|
||||
text: 'old',
|
||||
attachments: [],
|
||||
createdAt: Date.now() - 11_000, // older than 10 s TTL
|
||||
})
|
||||
expect(consumePendingInitialMessage('agent-a')).toBeNull()
|
||||
})
|
||||
|
||||
it('replaces a previous pending entry when set is called again', () => {
|
||||
setPendingInitialMessage({
|
||||
agentId: 'agent-a',
|
||||
text: 'first',
|
||||
attachments: [],
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
setPendingInitialMessage({
|
||||
agentId: 'agent-b',
|
||||
text: 'second',
|
||||
attachments: [makeAttachment('two')],
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
expect(consumePendingInitialMessage('agent-a')).toBeNull()
|
||||
const result = consumePendingInitialMessage('agent-b')
|
||||
expect(result?.text).toBe('second')
|
||||
expect(result?.attachments[0]?.id).toBe('two')
|
||||
})
|
||||
|
||||
it('no-ops when set is called with empty agentId', () => {
|
||||
setPendingInitialMessage({
|
||||
agentId: '',
|
||||
text: 'oops',
|
||||
attachments: [],
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
expect(peekPendingInitialMessage()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { StagedAttachment } from '@/lib/attachments'
|
||||
|
||||
/**
|
||||
* Same-tab in-memory handoff between the `/home` composer and the
|
||||
* chat screen at `/home/agents/:agentId`. URL search params (`?q=`)
|
||||
* carry the text fine, but cannot carry binary attachments — a multi-
|
||||
* megabyte image dataUrl would explode URL length limits and round-
|
||||
* trip badly. This module is the rich-data side channel for the same
|
||||
* navigation: the composer writes here, the chat screen reads here on
|
||||
* mount.
|
||||
*
|
||||
* Intentionally module-scope. Same render tree, same tab — no need
|
||||
* for sessionStorage (which would force JSON-serialising the dataUrls
|
||||
* and re-parsing on the read side). Cross-tab handoff is out of
|
||||
* scope: the user typing at home in tab A and switching to tab B's
|
||||
* chat would surface an empty registry there, which is the correct
|
||||
* behaviour.
|
||||
*/
|
||||
|
||||
export interface PendingInitialMessage {
|
||||
agentId: string
|
||||
text: string
|
||||
attachments: StagedAttachment[]
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 10s TTL on the entry. A stale entry from a back-button journey
|
||||
* shouldn't fire on a future visit; if real-world latency makes 10s
|
||||
* too tight under slow harness boot, bump but never make it
|
||||
* indefinite.
|
||||
*/
|
||||
const PENDING_TTL_MS = 10_000
|
||||
|
||||
let pending: PendingInitialMessage | null = null
|
||||
let pendingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearPending(): void {
|
||||
pending = null
|
||||
if (pendingTimer !== null) {
|
||||
clearTimeout(pendingTimer)
|
||||
pendingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
export function setPendingInitialMessage(payload: PendingInitialMessage): void {
|
||||
// Defensive: the home composer should never call this without an
|
||||
// agent selected. If it somehow does, no-op rather than holding a
|
||||
// payload we can't route.
|
||||
if (!payload.agentId) return
|
||||
clearPending()
|
||||
pending = payload
|
||||
pendingTimer = setTimeout(clearPending, PENDING_TTL_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructive read. Returns the entry only if `agentId` matches and
|
||||
* the entry is fresh; clears the entry on success so Strict-Mode
|
||||
* double-invokes can't double-send.
|
||||
*/
|
||||
export function consumePendingInitialMessage(
|
||||
agentId: string,
|
||||
): PendingInitialMessage | null {
|
||||
if (!pending) return null
|
||||
if (pending.agentId !== agentId) return null
|
||||
if (Date.now() - pending.createdAt >= PENDING_TTL_MS) {
|
||||
clearPending()
|
||||
return null
|
||||
}
|
||||
const entry = pending
|
||||
clearPending()
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-mutating read for tests. Production code should never need this
|
||||
* — use `consume` and own the lifecycle.
|
||||
*/
|
||||
export function peekPendingInitialMessage(): PendingInitialMessage | null {
|
||||
return pending
|
||||
}
|
||||
@@ -10,9 +10,11 @@ import type { OpenClawChatHistoryMessage } from '@/entrypoints/app/agents/useOpe
|
||||
import type {
|
||||
AgentConversationTurn,
|
||||
AssistantPart,
|
||||
ConversationTurnFile,
|
||||
ToolEntry,
|
||||
UserAttachmentPreview,
|
||||
} from '@/lib/agent-conversations/types'
|
||||
import { useInvalidateAgentOutputs } from '@/lib/agent-files'
|
||||
import type { ServerAttachmentPayload } from '@/lib/attachments'
|
||||
import { consumeSSEStream } from '@/lib/sse'
|
||||
import { buildToolLabel } from '@/lib/tool-labels'
|
||||
@@ -53,6 +55,12 @@ export function useAgentConversation(
|
||||
) {
|
||||
const [turns, setTurns] = useState<AgentConversationTurn[]>([])
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const invalidateAgentOutputs = useInvalidateAgentOutputs()
|
||||
// Stable ref so the resume effect doesn't re-subscribe on every
|
||||
// render (the hook's returned callable is freshly closured each
|
||||
// time, but the underlying queryClient is stable).
|
||||
const invalidateAgentOutputsRef = useRef(invalidateAgentOutputs)
|
||||
invalidateAgentOutputsRef.current = invalidateAgentOutputs
|
||||
const sessionKeyRef = useRef(options.sessionKey ?? '')
|
||||
const historyRef = useRef<OpenClawChatHistoryMessage[]>(options.history ?? [])
|
||||
const textAccRef = useRef('')
|
||||
@@ -152,6 +160,17 @@ export function useAgentConversation(
|
||||
})
|
||||
}
|
||||
|
||||
const setProducedFilesOnCurrentTurn = (files: ConversationTurnFile[]) => {
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
// Replace, don't merge: the server's diff is authoritative for
|
||||
// the just-completed turn — duplicate events shouldn't grow the
|
||||
// list, and a re-attribution should overwrite an earlier one.
|
||||
return [...prev.slice(0, -1), { ...last, producedFiles: files }]
|
||||
})
|
||||
}
|
||||
|
||||
const upsertAgentHarnessTool = (event: AgentHarnessStreamEvent) => {
|
||||
if (event.type !== 'tool_call') return
|
||||
const rawName = event.title || event.rawType || 'tool call'
|
||||
@@ -208,6 +227,9 @@ export function useAgentConversation(
|
||||
case 'tool_call':
|
||||
upsertAgentHarnessTool(event)
|
||||
break
|
||||
case 'produced_files':
|
||||
setProducedFilesOnCurrentTurn(event.files)
|
||||
break
|
||||
case 'done':
|
||||
markCurrentTurnDone()
|
||||
break
|
||||
@@ -259,6 +281,7 @@ export function useAgentConversation(
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
turnId: active.turnId,
|
||||
userText: active.prompt ?? '',
|
||||
parts: [],
|
||||
done: false,
|
||||
@@ -304,9 +327,14 @@ export function useAgentConversation(
|
||||
// When `cancelled` is true the next run will set these
|
||||
// itself, so resetting here would only cause a brief flicker.
|
||||
if (!cancelled && weStartedStream) {
|
||||
const finishedTurnId = turnIdRef.current
|
||||
turnIdRef.current = null
|
||||
lastSeqRef.current = null
|
||||
setStreaming(false)
|
||||
void invalidateAgentOutputsRef.current(
|
||||
agentId,
|
||||
finishedTurnId ?? undefined,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,6 +346,60 @@ export function useAgentConversation(
|
||||
}
|
||||
}, [agentId, activeTurnIdDep])
|
||||
|
||||
/**
|
||||
* Send the chat request and follow the 409-active-turn redirect
|
||||
* once. Pulled out of `send` to keep its cognitive complexity in
|
||||
* check — the retry adds a branch that biome counts heavily.
|
||||
*/
|
||||
const openSendStream = async (
|
||||
targetAgentId: string,
|
||||
text: string,
|
||||
attachments: ServerAttachmentPayload[],
|
||||
signal: AbortSignal,
|
||||
): Promise<Response> => {
|
||||
const initial = await chatWithHarnessAgent(
|
||||
targetAgentId,
|
||||
text,
|
||||
signal,
|
||||
attachments,
|
||||
)
|
||||
if (initial.status !== 409) return initial
|
||||
// 409 means the server already has an active turn for this agent
|
||||
// (a previous tab kicked one off and we're a fresh mount that
|
||||
// missed the resume window). Attach to it instead of double-sending.
|
||||
const body = (await initial.json()) as { turnId?: string }
|
||||
if (!body.turnId) return initial
|
||||
return attachToHarnessTurn(targetAgentId, {
|
||||
turnId: body.turnId,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull session-key / turn-id off response headers and propagate to
|
||||
* refs + the optimistic turn. Stamping `turnId` here lets the
|
||||
* inline artifact card fall back to /files/turn/<id> on a resumed
|
||||
* mount that missed the live `produced_files` event.
|
||||
*/
|
||||
const applyResponseHeadersToTurn = (response: Response) => {
|
||||
const responseSessionKey =
|
||||
response.headers.get('X-Session-Key') ??
|
||||
response.headers.get('X-Session-Id')
|
||||
if (responseSessionKey) {
|
||||
sessionKeyRef.current = responseSessionKey
|
||||
onSessionKeyChangeRef.current?.(responseSessionKey)
|
||||
}
|
||||
const responseTurnId = response.headers.get('X-Turn-Id')
|
||||
if (!responseTurnId) return
|
||||
turnIdRef.current = responseTurnId
|
||||
lastSeqRef.current = null
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
return [...prev.slice(0, -1), { ...last, turnId: responseTurnId }]
|
||||
})
|
||||
}
|
||||
|
||||
const send = async (input: string | SendInput) => {
|
||||
const normalized: SendInput =
|
||||
typeof input === 'string' ? { text: input } : input
|
||||
@@ -346,37 +428,13 @@ export function useAgentConversation(
|
||||
streamAbortRef.current = abortController
|
||||
|
||||
try {
|
||||
let response = await chatWithHarnessAgent(
|
||||
const response = await openSendStream(
|
||||
agentId,
|
||||
trimmed,
|
||||
abortController.signal,
|
||||
attachments,
|
||||
abortController.signal,
|
||||
)
|
||||
// 409 means the server already has an active turn for this
|
||||
// agent (e.g. a previous tab kicked one off and we're a fresh
|
||||
// mount that missed the resume window). Attach to it instead of
|
||||
// double-sending.
|
||||
if (response.status === 409) {
|
||||
const body = (await response.json()) as { turnId?: string }
|
||||
if (body.turnId) {
|
||||
response = await attachToHarnessTurn(agentId, {
|
||||
turnId: body.turnId,
|
||||
signal: abortController.signal,
|
||||
})
|
||||
}
|
||||
}
|
||||
const responseSessionKey =
|
||||
response.headers.get('X-Session-Key') ??
|
||||
response.headers.get('X-Session-Id')
|
||||
if (responseSessionKey) {
|
||||
sessionKeyRef.current = responseSessionKey
|
||||
onSessionKeyChangeRef.current?.(responseSessionKey)
|
||||
}
|
||||
const responseTurnId = response.headers.get('X-Turn-Id')
|
||||
if (responseTurnId) {
|
||||
turnIdRef.current = responseTurnId
|
||||
lastSeqRef.current = null
|
||||
}
|
||||
applyResponseHeadersToTurn(response)
|
||||
if (!response.ok) {
|
||||
const err = await response.text()
|
||||
updateCurrentTurnParts((parts) => [
|
||||
@@ -404,10 +462,15 @@ export function useAgentConversation(
|
||||
if (streamAbortRef.current === abortController) {
|
||||
streamAbortRef.current = null
|
||||
}
|
||||
// Capture before nulling — the invalidation needs the turn id so
|
||||
// useAgentTurnFiles consumers also flush, not just the agent-wide
|
||||
// rail query.
|
||||
const finishedTurnId = turnIdRef.current
|
||||
turnIdRef.current = null
|
||||
lastSeqRef.current = null
|
||||
onCompleteRef.current?.()
|
||||
setStreaming(false)
|
||||
void invalidateAgentOutputs(agentId, finishedTurnId ?? undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bot, Cpu, Sparkles } from 'lucide-react'
|
||||
import { Bot, Cpu, Sparkles, Wand2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import type { HarnessAgentAdapter } from './agent-harness-types'
|
||||
|
||||
@@ -23,6 +23,9 @@ export const AdapterIcon: FC<AdapterIconProps> = ({ adapter, className }) => {
|
||||
case 'openclaw':
|
||||
// OpenClaw — bot/automation framing.
|
||||
return <Bot className={className} aria-label="OpenClaw" />
|
||||
case 'hermes':
|
||||
// Hermes — messenger god framing, wand evokes the agentic conjuring.
|
||||
return <Wand2 className={className} aria-label="Hermes" />
|
||||
default:
|
||||
return <Bot className={className} aria-label="Agent" />
|
||||
}
|
||||
@@ -36,6 +39,8 @@ export function adapterLabel(adapter: HarnessAgentAdapter | 'unknown'): string {
|
||||
return 'Codex'
|
||||
case 'openclaw':
|
||||
return 'OpenClaw'
|
||||
case 'hermes':
|
||||
return 'Hermes'
|
||||
default:
|
||||
return 'Agent'
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
AgentAdapterHealth,
|
||||
AgentRowData,
|
||||
} from './agent-row/agent-row.types'
|
||||
import { compareAgentsByPinThenRecency } from './agents-list-order'
|
||||
import type { AgentListItem } from './agents-page-types'
|
||||
import type { AgentLiveness } from './LivenessDot'
|
||||
|
||||
@@ -56,31 +57,18 @@ export const AgentList: FC<AgentListProps> = ({
|
||||
return map
|
||||
}, [adapters])
|
||||
|
||||
// Sort: pinned rows first, then most recently used, then never-used
|
||||
// agents in id-stable order. The gateway's `main` agent stays
|
||||
// pinned-to-top when never touched so a fresh install has an
|
||||
// obvious starting point.
|
||||
const ordered = useMemo(() => {
|
||||
const withMeta = agents.map((agent) => {
|
||||
const harness = harnessAgentLookup?.get(agent.agentId)
|
||||
return {
|
||||
agent,
|
||||
id: agent.agentId,
|
||||
pinned: harness?.pinned ?? false,
|
||||
lastUsedAt: activity?.[agent.agentId]?.lastUsedAt ?? null,
|
||||
}
|
||||
})
|
||||
return withMeta
|
||||
.sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
|
||||
const aSeed = a.agent.agentId === 'main' && a.lastUsedAt === null
|
||||
const bSeed = b.agent.agentId === 'main' && b.lastUsedAt === null
|
||||
if (aSeed && !bSeed) return -1
|
||||
if (!aSeed && bSeed) return 1
|
||||
const aValue = a.lastUsedAt ?? -Infinity
|
||||
const bValue = b.lastUsedAt ?? -Infinity
|
||||
if (aValue !== bValue) return bValue - aValue
|
||||
return a.agent.agentId.localeCompare(b.agent.agentId)
|
||||
})
|
||||
.sort(compareAgentsByPinThenRecency)
|
||||
.map((entry) => entry.agent)
|
||||
}, [activity, agents, harnessAgentLookup])
|
||||
|
||||
@@ -129,6 +117,7 @@ function inferAdapterFromLabel(label: string): HarnessAgentAdapter | 'unknown' {
|
||||
if (lower === 'claude code') return 'claude'
|
||||
if (lower === 'codex') return 'codex'
|
||||
if (lower === 'openclaw') return 'openclaw'
|
||||
if (lower === 'hermes') return 'hermes'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createAgentPageActions } from './agents-page-actions'
|
||||
import {
|
||||
useDefaultAgentName,
|
||||
useHarnessAgentDefaults,
|
||||
useHermesProviderSelection,
|
||||
useOpenClawProviderSelection,
|
||||
} from './agents-page-hooks'
|
||||
import {
|
||||
@@ -106,6 +107,7 @@ export const AgentsPage: FC = () => {
|
||||
)
|
||||
const [harnessModelId, setHarnessModelId] = useState('')
|
||||
const [harnessReasoningEffort, setHarnessReasoningEffort] = useState('')
|
||||
const [createHermesProviderId, setCreateHermesProviderId] = useState('')
|
||||
const [showTerminal, setShowTerminal] = useState(false)
|
||||
const [cliAuthModalOpen, setCliAuthModalOpen] = useState(false)
|
||||
const [pageError, setPageError] = useState<string | null>(null)
|
||||
@@ -133,6 +135,14 @@ export const AgentsPage: FC = () => {
|
||||
cliAuthModalOpen,
|
||||
setCliAuthModalOpen,
|
||||
})
|
||||
const { selectableHermesProviders } = useHermesProviderSelection({
|
||||
providers,
|
||||
defaultProviderId,
|
||||
createOpen,
|
||||
createRuntime,
|
||||
createHermesProviderId,
|
||||
setCreateHermesProviderId,
|
||||
})
|
||||
useDefaultAgentName(createOpen, setNewName)
|
||||
useHarnessAgentDefaults({
|
||||
adapters,
|
||||
@@ -226,11 +236,13 @@ export const AgentsPage: FC = () => {
|
||||
createAgentPageActions({
|
||||
createProviderId,
|
||||
createRuntime,
|
||||
createHermesProviderId,
|
||||
harnessModelId,
|
||||
harnessReasoningEffort,
|
||||
navigate,
|
||||
newName,
|
||||
selectableOpenClawProviders,
|
||||
selectableHermesProviders,
|
||||
setupProviderId,
|
||||
createHarnessAgent: createHarnessAgent.mutateAsync,
|
||||
createOpenClawAgent,
|
||||
@@ -386,6 +398,8 @@ export const AgentsPage: FC = () => {
|
||||
harnessAdapterId={harnessAdapterId}
|
||||
harnessModelId={harnessModelId}
|
||||
harnessReasoningEffort={harnessReasoningEffort}
|
||||
hermesProviders={selectableHermesProviders}
|
||||
hermesSelectedProviderId={createHermesProviderId}
|
||||
name={newName}
|
||||
open={createOpen}
|
||||
providers={selectableOpenClawProviders}
|
||||
@@ -401,12 +415,14 @@ export const AgentsPage: FC = () => {
|
||||
if (!open) {
|
||||
setCreateError(null)
|
||||
createHarnessAgent.reset()
|
||||
setCreateHermesProviderId('')
|
||||
}
|
||||
}}
|
||||
onRuntimeChange={setCreateRuntime}
|
||||
onHarnessAdapterChange={handleHarnessAdapterChange}
|
||||
onHarnessModelChange={setHarnessModelId}
|
||||
onHarnessReasoningChange={setHarnessReasoningEffort}
|
||||
onHermesProviderChange={setCreateHermesProviderId}
|
||||
onNameChange={setNewName}
|
||||
onProviderChange={setCreateProviderId}
|
||||
/>
|
||||
|
||||
@@ -40,6 +40,8 @@ interface NewAgentDialogProps {
|
||||
harnessAdapterId: HarnessAgentAdapter
|
||||
harnessModelId: string
|
||||
harnessReasoningEffort: string
|
||||
hermesProviders: ProviderOption[]
|
||||
hermesSelectedProviderId: string
|
||||
name: string
|
||||
open: boolean
|
||||
providers: ProviderOption[]
|
||||
@@ -55,6 +57,7 @@ interface NewAgentDialogProps {
|
||||
onHarnessAdapterChange: (adapter: HarnessAgentAdapter) => void
|
||||
onHarnessModelChange: (modelId: string) => void
|
||||
onHarnessReasoningChange: (reasoningEffort: string) => void
|
||||
onHermesProviderChange: (providerId: string) => void
|
||||
onNameChange: (name: string) => void
|
||||
onProviderChange: (providerId: string) => void
|
||||
}
|
||||
@@ -69,6 +72,8 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
harnessAdapterId,
|
||||
harnessModelId,
|
||||
harnessReasoningEffort,
|
||||
hermesProviders,
|
||||
hermesSelectedProviderId,
|
||||
name,
|
||||
open,
|
||||
providers,
|
||||
@@ -84,22 +89,29 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
onHarnessAdapterChange,
|
||||
onHarnessModelChange,
|
||||
onHarnessReasoningChange,
|
||||
onHermesProviderChange,
|
||||
onNameChange,
|
||||
onProviderChange,
|
||||
}) => {
|
||||
const selectedHarnessAdapter =
|
||||
adapters.find((adapter) => adapter.id === harnessAdapterId) ?? adapters[0]
|
||||
const isHarnessRuntime = createRuntime !== 'openclaw'
|
||||
const isHermesRuntime = createRuntime === 'hermes'
|
||||
const isClassicHarnessRuntime = isHarnessRuntime && !isHermesRuntime
|
||||
const openClawBlocked = createRuntime === 'openclaw' && !canManageOpenClaw
|
||||
const cliBlocked =
|
||||
createRuntime === 'openclaw' &&
|
||||
!!selectedCliProvider &&
|
||||
!cliAuthStatus?.loggedIn
|
||||
const hermesBlocked =
|
||||
isHermesRuntime &&
|
||||
(hermesProviders.length === 0 || !hermesSelectedProviderId)
|
||||
const canCreate =
|
||||
Boolean(name.trim()) &&
|
||||
!creating &&
|
||||
!openClawBlocked &&
|
||||
!cliBlocked &&
|
||||
!hermesBlocked &&
|
||||
(createRuntime === 'openclaw'
|
||||
? providers.length > 0
|
||||
: Boolean(selectedHarnessAdapter))
|
||||
@@ -143,7 +155,8 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
if (
|
||||
value === 'openclaw' ||
|
||||
value === 'claude' ||
|
||||
value === 'codex'
|
||||
value === 'codex' ||
|
||||
value === 'hermes'
|
||||
) {
|
||||
onRuntimeChange(value)
|
||||
if (value !== 'openclaw') onHarnessAdapterChange(value)
|
||||
@@ -196,7 +209,16 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isHarnessRuntime ? (
|
||||
{isHermesRuntime ? (
|
||||
<ProviderSelector
|
||||
providers={hermesProviders}
|
||||
defaultProviderId={defaultProviderId}
|
||||
selectedId={hermesSelectedProviderId}
|
||||
onSelect={onHermesProviderChange}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isClassicHarnessRuntime ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="harness-model">Model</Label>
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import type { AgentEntry } from './useOpenClaw'
|
||||
|
||||
export type HarnessAgentAdapter = 'claude' | 'codex' | 'openclaw'
|
||||
export type HarnessAgentAdapter = 'claude' | 'codex' | 'openclaw' | 'hermes'
|
||||
|
||||
/**
|
||||
* One file the harness attributed to the assistant turn that just
|
||||
* finished. Mirrors the server-side `ProducedFileEventEntry` shape so
|
||||
* the inline artifact card can render alongside the streamed text the
|
||||
* user just watched complete. Only present for openclaw adapter
|
||||
* turns; claude / codex don't produce these events in v1.
|
||||
*/
|
||||
export interface HarnessProducedFile {
|
||||
id: string
|
||||
/** Workspace-relative POSIX path. */
|
||||
path: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
export type AgentHarnessStreamEvent =
|
||||
| {
|
||||
@@ -22,6 +37,10 @@ export type AgentHarnessStreamEvent =
|
||||
text: string
|
||||
rawType?: string
|
||||
}
|
||||
| {
|
||||
type: 'produced_files'
|
||||
files: HarnessProducedFile[]
|
||||
}
|
||||
| {
|
||||
type: 'done'
|
||||
text?: string
|
||||
@@ -111,6 +130,17 @@ export interface CreateHarnessAgentInput {
|
||||
adapter: HarnessAgentAdapter
|
||||
modelId?: string
|
||||
reasoningEffort?: string
|
||||
/**
|
||||
* Hermes-only — provider id from `HERMES_SUPPORTED_PROVIDERS`. When
|
||||
* paired with `apiKey`, the backend writes a per-agent
|
||||
* config.yaml + .env into the agent's HERMES_HOME so the first chat
|
||||
* doesn't depend on the user having run `hermes setup` globally.
|
||||
*/
|
||||
providerType?: string
|
||||
/** Hermes-only — API key paired with `providerType`. */
|
||||
apiKey?: string
|
||||
/** Hermes-only — base URL for the `custom` provider. */
|
||||
baseUrl?: string
|
||||
}
|
||||
|
||||
export interface HarnessHistoryReasoning {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import type { HarnessAgent } from './agent-harness-types'
|
||||
import {
|
||||
compareAgentsByPinThenRecency,
|
||||
orderAgentsByPinThenRecency,
|
||||
} from './agents-list-order'
|
||||
|
||||
function makeAgent(input: {
|
||||
id: string
|
||||
pinned?: boolean
|
||||
lastUsedAt?: number | null
|
||||
}): HarnessAgent {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.id,
|
||||
adapter: 'codex',
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: 'session',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
pinned: input.pinned,
|
||||
lastUsedAt: input.lastUsedAt,
|
||||
}
|
||||
}
|
||||
|
||||
describe('orderAgentsByPinThenRecency', () => {
|
||||
it('floats pinned agents to the top regardless of recency', () => {
|
||||
const result = orderAgentsByPinThenRecency([
|
||||
makeAgent({ id: 'a', pinned: false, lastUsedAt: 1_000 }),
|
||||
makeAgent({ id: 'b', pinned: true, lastUsedAt: 100 }),
|
||||
makeAgent({ id: 'c', pinned: false, lastUsedAt: 500 }),
|
||||
])
|
||||
expect(result.map((entry) => entry.id)).toEqual(['b', 'a', 'c'])
|
||||
})
|
||||
|
||||
it('sorts by lastUsedAt desc within each pin group', () => {
|
||||
const result = orderAgentsByPinThenRecency([
|
||||
makeAgent({ id: 'older-pin', pinned: true, lastUsedAt: 100 }),
|
||||
makeAgent({ id: 'newer-pin', pinned: true, lastUsedAt: 200 }),
|
||||
makeAgent({ id: 'older', pinned: false, lastUsedAt: 50 }),
|
||||
makeAgent({ id: 'newer', pinned: false, lastUsedAt: 80 }),
|
||||
])
|
||||
expect(result.map((entry) => entry.id)).toEqual([
|
||||
'newer-pin',
|
||||
'older-pin',
|
||||
'newer',
|
||||
'older',
|
||||
])
|
||||
})
|
||||
|
||||
it('seed-pins the gateway main agent above other never-used agents', () => {
|
||||
const result = orderAgentsByPinThenRecency([
|
||||
makeAgent({ id: 'aaa', pinned: false, lastUsedAt: null }),
|
||||
makeAgent({ id: 'main', pinned: false, lastUsedAt: null }),
|
||||
makeAgent({ id: 'zzz', pinned: false, lastUsedAt: null }),
|
||||
])
|
||||
expect(result.map((entry) => entry.id)).toEqual(['main', 'aaa', 'zzz'])
|
||||
})
|
||||
|
||||
it('drops the main seed-pin once the agent has been used', () => {
|
||||
const result = orderAgentsByPinThenRecency([
|
||||
makeAgent({ id: 'aaa', pinned: false, lastUsedAt: 999 }),
|
||||
makeAgent({ id: 'main', pinned: false, lastUsedAt: 1 }),
|
||||
])
|
||||
expect(result.map((entry) => entry.id)).toEqual(['aaa', 'main'])
|
||||
})
|
||||
|
||||
it('puts never-used agents below recently-used ones', () => {
|
||||
const result = orderAgentsByPinThenRecency([
|
||||
makeAgent({ id: 'fresh', pinned: false, lastUsedAt: null }),
|
||||
makeAgent({ id: 'used', pinned: false, lastUsedAt: 100 }),
|
||||
])
|
||||
expect(result.map((entry) => entry.id)).toEqual(['used', 'fresh'])
|
||||
})
|
||||
|
||||
it('id-stable tiebreaks two agents with identical lastUsedAt', () => {
|
||||
const result = orderAgentsByPinThenRecency([
|
||||
makeAgent({ id: 'b', pinned: false, lastUsedAt: 100 }),
|
||||
makeAgent({ id: 'a', pinned: false, lastUsedAt: 100 }),
|
||||
])
|
||||
expect(result.map((entry) => entry.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('compareAgentsByPinThenRecency', () => {
|
||||
it('produces the same order as the harness-shape helper', () => {
|
||||
const items = [
|
||||
{ id: 'older', pinned: false, lastUsedAt: 50 },
|
||||
{ id: 'newer', pinned: false, lastUsedAt: 80 },
|
||||
{ id: 'pinned', pinned: true, lastUsedAt: 1 },
|
||||
]
|
||||
const sorted = [...items].sort(compareAgentsByPinThenRecency)
|
||||
expect(sorted.map((item) => item.id)).toEqual(['pinned', 'newer', 'older'])
|
||||
})
|
||||
|
||||
it('seeds the main agent above other never-used rows', () => {
|
||||
const items = [
|
||||
{ id: 'zzz', pinned: false, lastUsedAt: null },
|
||||
{ id: 'main', pinned: false, lastUsedAt: null },
|
||||
]
|
||||
const sorted = [...items].sort(compareAgentsByPinThenRecency)
|
||||
expect(sorted.map((item) => item.id)).toEqual(['main', 'zzz'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { HarnessAgent } from './agent-harness-types'
|
||||
|
||||
/**
|
||||
* Stable ordering for index-shaped agent surfaces (the `/agents` rail
|
||||
* and the chat-screen rail at `/agents/:agentId`). Pinned rows float
|
||||
* to the top, then recency desc, with never-used agents falling to
|
||||
* the bottom in id-stable order. The gateway's `main` agent gets
|
||||
* seed-pinned to the top of the never-used group so a fresh install
|
||||
* has an obvious starting point even before the user has used it.
|
||||
*
|
||||
* NOT the same rule as the home grid (`orderHomeAgents`): home is
|
||||
* action-shaped — active-turn floats to the top — so users can
|
||||
* resume what's running. The chat rail keeps recency stable so it
|
||||
* doesn't reshuffle as turns transition every 5s.
|
||||
*/
|
||||
export function orderAgentsByPinThenRecency(
|
||||
agents: HarnessAgent[],
|
||||
): HarnessAgent[] {
|
||||
return [...agents].sort((a, b) => {
|
||||
const aPinned = a.pinned ?? false
|
||||
const bPinned = b.pinned ?? false
|
||||
if (aPinned !== bPinned) return aPinned ? -1 : 1
|
||||
|
||||
const aSeed = a.id === 'main' && (a.lastUsedAt ?? null) === null
|
||||
const bSeed = b.id === 'main' && (b.lastUsedAt ?? null) === null
|
||||
if (aSeed && !bSeed) return -1
|
||||
if (!aSeed && bSeed) return 1
|
||||
|
||||
const aValue = a.lastUsedAt ?? Number.NEGATIVE_INFINITY
|
||||
const bValue = b.lastUsedAt ?? Number.NEGATIVE_INFINITY
|
||||
if (aValue !== bValue) return bValue - aValue
|
||||
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Same comparator, but operates over arbitrary records that carry
|
||||
* `pinned`, `lastUsedAt`, and an `id`-equivalent key. Used by the
|
||||
* `/agents` `AgentList` which pivots `AgentListItem` + harness
|
||||
* lookup into a sortable shape; both surfaces stay on identical
|
||||
* sort semantics through this adapter.
|
||||
*/
|
||||
export function compareAgentsByPinThenRecency<
|
||||
T extends { pinned: boolean; lastUsedAt: number | null; id: string },
|
||||
>(a: T, b: T): number {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
|
||||
|
||||
const aSeed = a.id === 'main' && a.lastUsedAt === null
|
||||
const bSeed = b.id === 'main' && b.lastUsedAt === null
|
||||
if (aSeed && !bSeed) return -1
|
||||
if (!aSeed && bSeed) return 1
|
||||
|
||||
const aValue = a.lastUsedAt ?? Number.NEGATIVE_INFINITY
|
||||
const bValue = b.lastUsedAt ?? Number.NEGATIVE_INFINITY
|
||||
if (aValue !== bValue) return bValue - aValue
|
||||
|
||||
return a.id.localeCompare(b.id)
|
||||
}
|
||||
@@ -20,17 +20,22 @@ import type {
|
||||
export interface AgentPageActionInput {
|
||||
createProviderId: string
|
||||
createRuntime: CreateAgentRuntime
|
||||
createHermesProviderId: string
|
||||
harnessModelId: string
|
||||
harnessReasoningEffort: string
|
||||
navigate: NavigateFunction
|
||||
newName: string
|
||||
selectableOpenClawProviders: ProviderOption[]
|
||||
selectableHermesProviders: ProviderOption[]
|
||||
setupProviderId: string
|
||||
createHarnessAgent: (input: {
|
||||
name: string
|
||||
adapter: HarnessAgentAdapter
|
||||
modelId?: string
|
||||
reasoningEffort?: string
|
||||
providerType?: string
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
}) => Promise<HarnessAgent>
|
||||
createOpenClawAgent: (
|
||||
input: OpenClawAgentMutationInput,
|
||||
@@ -114,20 +119,37 @@ export function createAgentPageActions(input: AgentPageActionInput) {
|
||||
const handleHarnessCreate = async () => {
|
||||
if (!input.newName.trim()) return
|
||||
|
||||
const isHermes = input.createRuntime === 'hermes'
|
||||
// Hermes pulls every provider field from the user's selected entry
|
||||
// in the global LLM-providers list (managed under AI Settings). The
|
||||
// backend rejects creation if any required field is missing.
|
||||
const hermesProvider = isHermes
|
||||
? input.selectableHermesProviders.find(
|
||||
(option) => option.id === input.createHermesProviderId,
|
||||
)
|
||||
: undefined
|
||||
const effectiveModelId = isHermes
|
||||
? hermesProvider?.modelId
|
||||
: input.harnessModelId || undefined
|
||||
|
||||
input.setCreateError(null)
|
||||
try {
|
||||
const agent = await input.createHarnessAgent({
|
||||
name: input.newName.trim(),
|
||||
adapter: input.createRuntime as HarnessAgentAdapter,
|
||||
modelId: input.harnessModelId || undefined,
|
||||
modelId: effectiveModelId,
|
||||
reasoningEffort: input.harnessReasoningEffort || undefined,
|
||||
providerType: hermesProvider?.type,
|
||||
apiKey: hermesProvider?.apiKey,
|
||||
baseUrl: hermesProvider?.baseUrl,
|
||||
})
|
||||
input.setCreateOpen(false)
|
||||
input.setNewName('')
|
||||
track(AGENT_CREATED_EVENT, {
|
||||
runtime: input.createRuntime,
|
||||
model_id: input.harnessModelId || undefined,
|
||||
model_id: effectiveModelId,
|
||||
reasoning_effort: input.harnessReasoningEffort || undefined,
|
||||
provider_type: hermesProvider?.type,
|
||||
})
|
||||
input.navigate(`/agents/${agent.id}`)
|
||||
} catch (err) {
|
||||
@@ -140,6 +162,7 @@ export function createAgentPageActions(input: AgentPageActionInput) {
|
||||
openclaw: handleOpenClawCreate,
|
||||
claude: handleHarnessCreate,
|
||||
codex: handleHarnessCreate,
|
||||
hermes: handleHarnessCreate,
|
||||
}
|
||||
void createByRuntime[input.createRuntime]()
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import type {
|
||||
HarnessAdapterDescriptor,
|
||||
HarnessAgentAdapter,
|
||||
} from './agent-harness-types'
|
||||
import type { CreateAgentRuntime } from './agents-page-types'
|
||||
import type { CreateAgentRuntime, ProviderOption } from './agents-page-types'
|
||||
import { toProviderOptions } from './agents-page-utils'
|
||||
import { getHermesSupportedProviders } from './hermes-supported-providers'
|
||||
import {
|
||||
buildOpenClawCliProviderOptions,
|
||||
findOpenClawCliProviderById,
|
||||
@@ -171,3 +172,60 @@ export function useOpenClawProviderSelection(input: {
|
||||
cliAuthError,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of useOpenClawProviderSelection but for Hermes. Hermes only
|
||||
* needs the create-dialog flow (no setup dialog, no CLI providers), so
|
||||
* this hook is much smaller — it just filters the global provider list
|
||||
* to ones Hermes can drive and seeds the selected id when the dialog
|
||||
* opens.
|
||||
*/
|
||||
export function useHermesProviderSelection(input: {
|
||||
providers: LlmProviderConfig[]
|
||||
defaultProviderId: string
|
||||
createOpen: boolean
|
||||
createRuntime: CreateAgentRuntime
|
||||
createHermesProviderId: string
|
||||
setCreateHermesProviderId: Dispatch<SetStateAction<string>>
|
||||
}) {
|
||||
const {
|
||||
providers,
|
||||
defaultProviderId,
|
||||
createOpen,
|
||||
createRuntime,
|
||||
createHermesProviderId,
|
||||
setCreateHermesProviderId,
|
||||
} = input
|
||||
|
||||
const selectableHermesProviders = useMemo<ProviderOption[]>(
|
||||
() =>
|
||||
getHermesSupportedProviders(providers).map((provider) => ({
|
||||
id: provider.id,
|
||||
type: provider.type,
|
||||
name: provider.name,
|
||||
modelId: provider.modelId,
|
||||
baseUrl: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
})),
|
||||
[providers],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectableHermesProviders.length === 0) return
|
||||
if (!createOpen || createRuntime !== 'hermes') return
|
||||
if (createHermesProviderId) return
|
||||
const fallbackId =
|
||||
selectableHermesProviders.find((p) => p.id === defaultProviderId)?.id ??
|
||||
selectableHermesProviders[0].id
|
||||
setCreateHermesProviderId(fallbackId)
|
||||
}, [
|
||||
createHermesProviderId,
|
||||
createOpen,
|
||||
createRuntime,
|
||||
defaultProviderId,
|
||||
selectableHermesProviders,
|
||||
setCreateHermesProviderId,
|
||||
])
|
||||
|
||||
return { selectableHermesProviders }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES,
|
||||
type HermesSupportedBrowserosProviderType,
|
||||
} from '@browseros/shared/constants/hermes'
|
||||
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
|
||||
|
||||
export function isHermesSupportedProviderType(
|
||||
providerType: ProviderType,
|
||||
): providerType is HermesSupportedBrowserosProviderType {
|
||||
return (
|
||||
HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES as readonly ProviderType[]
|
||||
).includes(providerType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the user's global LLM providers down to ones Hermes can use.
|
||||
* A provider qualifies when its type is in the Hermes-supported set
|
||||
* AND it has an API key wired up. CLI-style providers (chatgpt-pro,
|
||||
* github-copilot, qwen-code) and other unsupported types (browseros,
|
||||
* ollama, lmstudio, bedrock, azure, google, moonshot) are filtered
|
||||
* out — Hermes can't drive them today.
|
||||
*/
|
||||
export function getHermesSupportedProviders(
|
||||
providers: LlmProviderConfig[],
|
||||
): LlmProviderConfig[] {
|
||||
return providers.filter(
|
||||
(provider) =>
|
||||
!!provider.apiKey && isHermesSupportedProviderType(provider.type),
|
||||
)
|
||||
}
|
||||
@@ -25,12 +25,18 @@ interface HarnessAgentsResponse {
|
||||
|
||||
export type { AgentHarnessStreamEvent }
|
||||
|
||||
const AGENT_QUERY_KEYS = {
|
||||
export const AGENT_QUERY_KEYS = {
|
||||
adapters: 'agent-harness-adapters',
|
||||
agents: 'agent-harness-agents',
|
||||
/** Outputs-rail data for one agent — `[agentOutputs, baseUrl, agentId]`. */
|
||||
agentOutputs: 'agent-harness-agent-outputs',
|
||||
/** Per-turn artifact-card files — `[agentTurnFiles, baseUrl, agentId, turnId]`. */
|
||||
agentTurnFiles: 'agent-harness-agent-turn-files',
|
||||
/** Single-file preview payload — `[filePreview, baseUrl, fileId]`. */
|
||||
filePreview: 'agent-harness-file-preview',
|
||||
} as const
|
||||
|
||||
async function agentsFetch<T>(
|
||||
export async function agentsFetch<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
|
||||
@@ -85,7 +85,8 @@ export const SidebarLayout: FC = () => {
|
||||
|
||||
return (
|
||||
<RpcClientProvider>
|
||||
<div className="relative min-h-screen bg-background">
|
||||
{/* pl-14 offsets all content by the collapsed sidebar width (w-14 = 56px) so it never sits under the rail */}
|
||||
<div className="relative min-h-screen bg-background pl-14">
|
||||
{/* Sidebar - fixed overlay */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: hover interactions needed */}
|
||||
<div
|
||||
@@ -96,7 +97,6 @@ export const SidebarLayout: FC = () => {
|
||||
<AppSidebar expanded={sidebarOpen} onOpenShortcuts={openShortcuts} />
|
||||
</div>
|
||||
|
||||
{/* Main content - full width, centered */}
|
||||
{location.pathname === '/home/chat' ? (
|
||||
<main className="relative h-dvh overflow-hidden">
|
||||
<Outlet />
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import type { FC } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { CloudSyncDisclosure } from '@/components/auth/CloudSyncDisclosure'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -199,6 +200,8 @@ export const LoginPage: FC = () => {
|
||||
)}
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<CloudSyncDisclosure />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
|
||||
export const MEMORY_QUERY_KEY = 'memory'
|
||||
|
||||
async function fetchMemory(baseUrl: string): Promise<string> {
|
||||
const response = await fetch(`${baseUrl}/memory`)
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
@@ -30,7 +32,7 @@ export function useMemoryContent() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery<string, Error>({
|
||||
queryKey: ['memory', baseUrl],
|
||||
queryKey: [MEMORY_QUERY_KEY, baseUrl],
|
||||
queryFn: () => fetchMemory(baseUrl as string),
|
||||
enabled: !!baseUrl && !urlLoading,
|
||||
})
|
||||
@@ -38,7 +40,7 @@ export function useMemoryContent() {
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (content: string) => saveMemory(baseUrl as string, content),
|
||||
onSuccess: (_data, content) => {
|
||||
queryClient.setQueryData(['memory', baseUrl], content)
|
||||
queryClient.setQueryData([MEMORY_QUERY_KEY, baseUrl], content)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Brain, FileText, Loader2, RotateCcw } from 'lucide-react'
|
||||
import { type FC, type ReactNode, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import { MEMORY_QUERY_KEY } from '../memory/useMemoryContent'
|
||||
import { SOUL_QUERY_KEY } from '../soul/useSoulContent'
|
||||
|
||||
type ResetTarget = 'memory' | 'soul'
|
||||
|
||||
type ResetAction = {
|
||||
target: ResetTarget
|
||||
title: string
|
||||
description: string
|
||||
buttonLabel: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
async function deleteServerResource(
|
||||
baseUrl: string,
|
||||
resource: ResetTarget,
|
||||
): Promise<void> {
|
||||
const response = await fetch(`${baseUrl}/${resource}`, { method: 'DELETE' })
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
export const ResetDataPage: FC = () => {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: isUrlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
const queryClient = useQueryClient()
|
||||
const [pendingAction, setPendingAction] = useState<ResetAction | null>(null)
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: async (target: ResetTarget) => {
|
||||
if (!baseUrl) throw new Error('BrowserOS server URL is unavailable')
|
||||
await deleteServerResource(baseUrl, target)
|
||||
return target
|
||||
},
|
||||
onSuccess: async (target) => {
|
||||
if (target === 'memory') {
|
||||
queryClient.setQueryData([MEMORY_QUERY_KEY, baseUrl], '')
|
||||
}
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: target === 'memory' ? [MEMORY_QUERY_KEY] : [SOUL_QUERY_KEY],
|
||||
})
|
||||
toast.success(target === 'memory' ? 'Memory reset' : 'SOUL.md reset')
|
||||
},
|
||||
onError: (_error, target) => {
|
||||
toast.error(
|
||||
target === 'memory'
|
||||
? 'Failed to reset memory'
|
||||
: 'Failed to reset SOUL.md',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const actions: ResetAction[] = [
|
||||
{
|
||||
target: 'memory',
|
||||
title: 'Reset memory?',
|
||||
description:
|
||||
'This deletes CORE.md and daily memory files. This cannot be undone.',
|
||||
buttonLabel: 'Reset memory',
|
||||
icon: <Brain className="h-4 w-4 text-muted-foreground" />,
|
||||
},
|
||||
{
|
||||
target: 'soul',
|
||||
title: 'Reset SOUL.md?',
|
||||
description:
|
||||
'This replaces SOUL.md with the default template. This cannot be undone.',
|
||||
buttonLabel: 'Reset SOUL.md',
|
||||
icon: <FileText className="h-4 w-4 text-muted-foreground" />,
|
||||
},
|
||||
]
|
||||
|
||||
const isBusy = isUrlLoading || resetMutation.isPending
|
||||
const disabled = isBusy || Boolean(urlError) || !baseUrl
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!pendingAction) return
|
||||
resetMutation.mutate(pendingAction.target)
|
||||
setPendingAction(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6 p-6">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
<span className="font-medium text-xs uppercase tracking-wider">
|
||||
Reset
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="font-semibold text-2xl">Reset Data</h1>
|
||||
</div>
|
||||
|
||||
{urlError ? (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/5 p-4">
|
||||
<p className="text-destructive text-sm">
|
||||
BrowserOS server is unavailable.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{actions.map((action) => (
|
||||
<div
|
||||
key={action.target}
|
||||
className="flex flex-col gap-3 rounded-lg border bg-card p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{action.icon}
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-medium text-sm">{action.buttonLabel}</h2>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
{action.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={disabled}
|
||||
onClick={() => setPendingAction(action)}
|
||||
>
|
||||
{resetMutation.isPending &&
|
||||
resetMutation.variables === action.target ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{action.buttonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(pendingAction)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingAction(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{pendingAction?.title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingAction?.description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{pendingAction?.buttonLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -57,6 +57,7 @@ const formSchema = z
|
||||
scheduleTime: z.string().optional(),
|
||||
scheduleInterval: z.number().int().min(1).max(60).optional(),
|
||||
providerId: z.string().optional(),
|
||||
runSilently: z.boolean(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
@@ -107,6 +108,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
scheduleTime: '09:00',
|
||||
scheduleInterval: 1,
|
||||
providerId: undefined,
|
||||
runSilently: true,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
@@ -144,6 +146,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
scheduleTime: initialValues.scheduleTime || '09:00',
|
||||
scheduleInterval: initialValues.scheduleInterval || 1,
|
||||
providerId: initialValues.providerId,
|
||||
runSilently: initialValues.runSilently ?? true,
|
||||
enabled: initialValues.enabled,
|
||||
})
|
||||
} else {
|
||||
@@ -154,6 +157,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
scheduleTime: '09:00',
|
||||
scheduleInterval: 1,
|
||||
providerId: undefined,
|
||||
runSilently: true,
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
@@ -253,6 +257,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
scheduleInterval:
|
||||
values.scheduleType !== 'daily' ? values.scheduleInterval : undefined,
|
||||
providerId: values.providerId,
|
||||
runSilently: values.runSilently,
|
||||
enabled: values.enabled,
|
||||
})
|
||||
form.reset()
|
||||
@@ -458,6 +463,28 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="runSilently"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start gap-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1">
|
||||
<FormLabel className="font-normal">Run silently</FormLabel>
|
||||
<FormDescription>
|
||||
Use a hidden background page without opening or focusing a
|
||||
browser window.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
|
||||
@@ -149,6 +149,10 @@ export const ScheduledTaskCard: FC<ScheduledTaskCardProps> = ({
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span>•</span>
|
||||
<span>
|
||||
{job.runSilently === false ? 'Visible run' : 'Silent run'}
|
||||
</span>
|
||||
{job.lastRunAt && (
|
||||
<>
|
||||
<span>•</span>
|
||||
|
||||
@@ -71,6 +71,7 @@ export const ScheduledTasksPage: FC = () => {
|
||||
'daily',
|
||||
scheduleTime: searchParams.get('scheduleTime') ?? '09:00',
|
||||
scheduleInterval: 1,
|
||||
runSilently: true,
|
||||
enabled: true,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
|
||||
@@ -118,6 +118,7 @@ export const scheduledJobRuns = async () => {
|
||||
message: job.query,
|
||||
signal: abortController.signal,
|
||||
providerId: job.providerId,
|
||||
runSilently: job.runSilently ?? true,
|
||||
})
|
||||
|
||||
await updateJobRun(jobRun.id, {
|
||||
|
||||
@@ -37,7 +37,7 @@ export const TIPS: Tip[] = [
|
||||
},
|
||||
{
|
||||
id: 'background-tasks',
|
||||
text: 'Scheduled tasks run in a separate window so they never interrupt your browsing.',
|
||||
text: 'Scheduled tasks can run silently in the background without opening a new browser window.',
|
||||
},
|
||||
{
|
||||
id: 'claude-code-mcp',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AlertCircle, CheckCircle2, Loader2, Mail } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { CloudSyncDisclosure } from '@/components/auth/CloudSyncDisclosure'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -199,6 +200,8 @@ export const StepTwo = ({ direction, onContinue }: StepTwoProps) => {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<CloudSyncDisclosure />
|
||||
|
||||
<div className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -2,7 +2,8 @@ import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
|
||||
import type { UIMessage } from 'ai'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useSessionInfo } from '@/lib/auth/sessionStorage'
|
||||
import { useConversations } from '@/lib/conversations/conversationStorage'
|
||||
import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
|
||||
@@ -21,8 +22,11 @@ import {
|
||||
import { LocalChatHistory } from './local/LocalChatHistory'
|
||||
|
||||
const RemoteChatHistory: FC<{ userId: string }> = ({ userId }) => {
|
||||
const { conversationId: activeConversationId } = useChatSessionContext()
|
||||
const { conversationId: activeConversationId, resetConversation } =
|
||||
useChatSessionContext()
|
||||
const { clearConversations } = useConversations()
|
||||
const queryClient = useQueryClient()
|
||||
const [isClearingAll, setIsClearingAll] = useState(false)
|
||||
|
||||
const { data: profileData } = useGraphqlQuery(GetProfileIdByUserIdDocument, {
|
||||
userId,
|
||||
@@ -68,6 +72,50 @@ const RemoteChatHistory: FC<{ userId: string }> = ({ userId }) => {
|
||||
deleteConversationMutation.mutate({ rowId: id })
|
||||
}
|
||||
|
||||
const getAllRemoteConversationIds = async () => {
|
||||
let pages = graphqlData?.pages ?? []
|
||||
let hasMore = Boolean(
|
||||
pages.at(-1)?.conversations?.pageInfo.hasNextPage ?? hasNextPage,
|
||||
)
|
||||
|
||||
while (hasMore) {
|
||||
const result = await fetchNextPage()
|
||||
pages = result.data?.pages ?? pages
|
||||
hasMore = Boolean(pages.at(-1)?.conversations?.pageInfo.hasNextPage)
|
||||
}
|
||||
|
||||
return pages.flatMap((page) =>
|
||||
(page.conversations?.nodes ?? [])
|
||||
.filter((node): node is NonNullable<typeof node> => node !== null)
|
||||
.map((node) => node.rowId),
|
||||
)
|
||||
}
|
||||
|
||||
const handleClearAll = async () => {
|
||||
setIsClearingAll(true)
|
||||
try {
|
||||
const ids = [...new Set(await getAllRemoteConversationIds())]
|
||||
for (let i = 0; i < ids.length; i += 10) {
|
||||
const batch = ids.slice(i, i + 10)
|
||||
await Promise.all(
|
||||
batch.map((rowId) =>
|
||||
deleteConversationMutation.mutateAsync({ rowId }),
|
||||
),
|
||||
)
|
||||
}
|
||||
await clearConversations()
|
||||
resetConversation()
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [getQueryKeyFromDocument(GetConversationsForHistoryDocument)],
|
||||
})
|
||||
toast.success('Chat sessions cleared')
|
||||
} catch {
|
||||
toast.error('Failed to clear chat sessions')
|
||||
} finally {
|
||||
setIsClearingAll(false)
|
||||
}
|
||||
}
|
||||
|
||||
const conversations = useMemo<HistoryConversation[]>(() => {
|
||||
if (!graphqlData?.pages) return []
|
||||
|
||||
@@ -110,6 +158,8 @@ const RemoteChatHistory: FC<{ userId: string }> = ({ userId }) => {
|
||||
groupedConversations={groupedConversations}
|
||||
activeConversationId={activeConversationId}
|
||||
onDelete={handleDelete}
|
||||
onClearAll={handleClearAll}
|
||||
isClearingAll={isClearingAll || deleteConversationMutation.isPending}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onLoadMore={fetchNextPage}
|
||||
@@ -121,8 +171,6 @@ const RemoteChatHistory: FC<{ userId: string }> = ({ userId }) => {
|
||||
export const ChatHistory: FC = () => {
|
||||
const { sessionInfo } = useSessionInfo()
|
||||
const userId = sessionInfo.user?.id
|
||||
// needed to initiate remote-sync
|
||||
useConversations()
|
||||
|
||||
if (userId) {
|
||||
return <RemoteChatHistory userId={userId} />
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Loader2, MessageSquare } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef } from 'react'
|
||||
import { Loader2, MessageSquare, Trash2 } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { ConversationGroup } from './ConversationGroup'
|
||||
import type { GroupedConversations } from './types'
|
||||
import { TIME_GROUP_LABELS } from './utils'
|
||||
@@ -13,6 +23,8 @@ interface ConversationListProps {
|
||||
isFetchingNextPage?: boolean
|
||||
onLoadMore?: () => void
|
||||
isRefreshing?: boolean
|
||||
onClearAll?: () => void
|
||||
isClearingAll?: boolean
|
||||
}
|
||||
|
||||
export const ConversationList: FC<ConversationListProps> = ({
|
||||
@@ -23,8 +35,11 @@ export const ConversationList: FC<ConversationListProps> = ({
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
isRefreshing,
|
||||
onClearAll,
|
||||
isClearingAll,
|
||||
}) => {
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null)
|
||||
const [showClearAllDialog, setShowClearAllDialog] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasNextPage || !onLoadMore) return
|
||||
@@ -56,65 +71,118 @@ export const ConversationList: FC<ConversationListProps> = ({
|
||||
groupedConversations.thisMonth.length > 0 ||
|
||||
groupedConversations.older.length > 0
|
||||
|
||||
return (
|
||||
<main className="mt-4 flex h-full flex-1 flex-col space-y-4 overflow-y-auto">
|
||||
<div className="w-full p-3">
|
||||
{isRefreshing && (
|
||||
<div className="flex items-center justify-center gap-2 pb-3 text-muted-foreground text-xs">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Fetching latest conversations</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasConversations ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No conversations yet
|
||||
</p>
|
||||
<Link to="/" className="mt-2 text-primary text-sm hover:underline">
|
||||
Start a new chat
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ConversationGroup
|
||||
label={TIME_GROUP_LABELS.today}
|
||||
conversations={groupedConversations.today}
|
||||
onDelete={onDelete}
|
||||
activeConversationId={activeConversationId}
|
||||
/>
|
||||
<ConversationGroup
|
||||
label={TIME_GROUP_LABELS.thisWeek}
|
||||
conversations={groupedConversations.thisWeek}
|
||||
onDelete={onDelete}
|
||||
activeConversationId={activeConversationId}
|
||||
/>
|
||||
<ConversationGroup
|
||||
label={TIME_GROUP_LABELS.thisMonth}
|
||||
conversations={groupedConversations.thisMonth}
|
||||
onDelete={onDelete}
|
||||
activeConversationId={activeConversationId}
|
||||
/>
|
||||
<ConversationGroup
|
||||
label={TIME_GROUP_LABELS.older}
|
||||
conversations={groupedConversations.older}
|
||||
onDelete={onDelete}
|
||||
activeConversationId={activeConversationId}
|
||||
/>
|
||||
const handleConfirmClearAll = () => {
|
||||
onClearAll?.()
|
||||
setShowClearAllDialog(false)
|
||||
}
|
||||
|
||||
{hasNextPage && (
|
||||
<div
|
||||
ref={loadMoreRef}
|
||||
className="flex items-center justify-center py-4"
|
||||
return (
|
||||
<>
|
||||
<main className="mt-4 flex h-full flex-1 flex-col space-y-4 overflow-y-auto">
|
||||
<div className="w-full p-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3 px-1">
|
||||
<h2 className="font-semibold text-sm">Chat history</h2>
|
||||
{onClearAll && hasConversations && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowClearAllDialog(true)}
|
||||
disabled={isClearingAll}
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 font-medium text-muted-foreground text-xs transition-colors hover:bg-destructive/10 hover:text-destructive disabled:pointer-events-none disabled:opacity-50"
|
||||
title="Clear sessions"
|
||||
>
|
||||
{isFetchingNextPage && (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
{isClearingAll ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</div>
|
||||
Clear sessions
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{isRefreshing && (
|
||||
<div className="flex items-center justify-center gap-2 pb-3 text-muted-foreground text-xs">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Fetching latest conversations</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasConversations ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No conversations yet
|
||||
</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="mt-2 text-primary text-sm hover:underline"
|
||||
>
|
||||
Start a new chat
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ConversationGroup
|
||||
label={TIME_GROUP_LABELS.today}
|
||||
conversations={groupedConversations.today}
|
||||
onDelete={onDelete}
|
||||
activeConversationId={activeConversationId}
|
||||
/>
|
||||
<ConversationGroup
|
||||
label={TIME_GROUP_LABELS.thisWeek}
|
||||
conversations={groupedConversations.thisWeek}
|
||||
onDelete={onDelete}
|
||||
activeConversationId={activeConversationId}
|
||||
/>
|
||||
<ConversationGroup
|
||||
label={TIME_GROUP_LABELS.thisMonth}
|
||||
conversations={groupedConversations.thisMonth}
|
||||
onDelete={onDelete}
|
||||
activeConversationId={activeConversationId}
|
||||
/>
|
||||
<ConversationGroup
|
||||
label={TIME_GROUP_LABELS.older}
|
||||
conversations={groupedConversations.older}
|
||||
onDelete={onDelete}
|
||||
activeConversationId={activeConversationId}
|
||||
/>
|
||||
|
||||
{hasNextPage && (
|
||||
<div
|
||||
ref={loadMoreRef}
|
||||
className="flex items-center justify-center py-4"
|
||||
>
|
||||
{isFetchingNextPage && (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<AlertDialog
|
||||
open={showClearAllDialog}
|
||||
onOpenChange={setShowClearAllDialog}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear all sessions?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action permanently deletes every chat session in history.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmClearAll}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Clear sessions
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FC } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useConversations } from '@/lib/conversations/conversationStorage'
|
||||
import { useChatSessionContext } from '../../layout/ChatSessionContext'
|
||||
import { ConversationList } from '../components/ConversationList'
|
||||
@@ -7,9 +8,13 @@ import type { HistoryConversation } from '../components/types'
|
||||
import { extractLastUserMessage, groupConversations } from '../components/utils'
|
||||
|
||||
export const LocalChatHistory: FC = () => {
|
||||
const { conversations: localConversations, removeConversation } =
|
||||
useConversations()
|
||||
const { conversationId: activeConversationId } = useChatSessionContext()
|
||||
const {
|
||||
conversations: localConversations,
|
||||
removeConversation,
|
||||
clearConversations,
|
||||
} = useConversations()
|
||||
const { conversationId: activeConversationId, resetConversation } =
|
||||
useChatSessionContext()
|
||||
|
||||
const conversations = useMemo<HistoryConversation[]>(() => {
|
||||
return localConversations.map((conv) => ({
|
||||
@@ -24,11 +29,22 @@ export const LocalChatHistory: FC = () => {
|
||||
[conversations],
|
||||
)
|
||||
|
||||
const handleClearAll = async () => {
|
||||
try {
|
||||
await clearConversations()
|
||||
resetConversation()
|
||||
toast.success('Chat sessions cleared')
|
||||
} catch {
|
||||
toast.error('Failed to clear chat sessions')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ConversationList
|
||||
groupedConversations={groupedConversations}
|
||||
activeConversationId={activeConversationId}
|
||||
onDelete={removeConversation}
|
||||
onClearAll={handleClearAll}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ function formatAdapterName(adapter: HarnessAgentAdapter): string {
|
||||
if (adapter === 'claude') return 'Claude Code'
|
||||
if (adapter === 'codex') return 'Codex'
|
||||
if (adapter === 'openclaw') return 'OpenClaw'
|
||||
if (adapter === 'hermes') return 'Hermes'
|
||||
return adapter
|
||||
}
|
||||
|
||||
|
||||
@@ -42,11 +42,34 @@ export interface UserAttachmentPreview {
|
||||
dataUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Files attributed to this turn by the harness's per-turn workspace
|
||||
* diff. Populated either via the live `produced_files` SSE event or
|
||||
* (on resume) the `useAgentTurnFiles` fallback. Mirrors the wire
|
||||
* shape from `agent-harness-types.HarnessProducedFile` minus the
|
||||
* stream-only fields the inline card doesn't need.
|
||||
*/
|
||||
export interface ConversationTurnFile {
|
||||
id: string
|
||||
path: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
export interface AgentConversationTurn {
|
||||
id: string
|
||||
/**
|
||||
* Server-issued turn id, set as soon as the response headers arrive
|
||||
* (`X-Turn-Id`) for fresh sends, or from the active-turn payload on
|
||||
* resume. Required for the historic-files fallback fetch; absent on
|
||||
* the brief optimistic window before the first header.
|
||||
*/
|
||||
turnId?: string | null
|
||||
userText: string
|
||||
userAttachments?: UserAttachmentPreview[]
|
||||
parts: AssistantPart[]
|
||||
/** Files produced during this turn (openclaw only in v1). */
|
||||
producedFiles?: ConversationTurnFile[]
|
||||
done: boolean
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Pure helpers used by the artifact card and the Outputs rail.
|
||||
* Display formatting only — no React, no fetch, no DOM. Anything
|
||||
* stateful belongs in `./useAgentOutputs` or `./useFilePreview`.
|
||||
*/
|
||||
|
||||
import { buildAgentApiUrl } from '@/entrypoints/app/agents/agent-api-url'
|
||||
|
||||
/**
|
||||
* Coarse classification of a file's intended preview / icon path.
|
||||
* Mirrors the server-side `FilePreviewKind` minus `missing` — the
|
||||
* client only ever computes a kind for a row it already has.
|
||||
*/
|
||||
export type FileKind = 'text' | 'image' | 'pdf' | 'binary'
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'txt',
|
||||
'md',
|
||||
'markdown',
|
||||
'json',
|
||||
'jsonl',
|
||||
'csv',
|
||||
'tsv',
|
||||
'xml',
|
||||
'yaml',
|
||||
'yml',
|
||||
'toml',
|
||||
'ini',
|
||||
'log',
|
||||
'html',
|
||||
'htm',
|
||||
'css',
|
||||
'js',
|
||||
'mjs',
|
||||
'cjs',
|
||||
'ts',
|
||||
'tsx',
|
||||
'jsx',
|
||||
'py',
|
||||
'rb',
|
||||
'go',
|
||||
'rs',
|
||||
'java',
|
||||
'kt',
|
||||
'swift',
|
||||
'c',
|
||||
'h',
|
||||
'cpp',
|
||||
'hpp',
|
||||
'sh',
|
||||
'zsh',
|
||||
'bash',
|
||||
'sql',
|
||||
'svg',
|
||||
])
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'webp',
|
||||
'bmp',
|
||||
'ico',
|
||||
'heic',
|
||||
'heif',
|
||||
])
|
||||
|
||||
/** Best-effort kind based on extension only. Server's preview API
|
||||
* is the source of truth for actual rendering — this is just for
|
||||
* picking an icon / sort hint without a network round-trip. */
|
||||
export function inferFileKind(path: string): FileKind {
|
||||
const ext = extensionOf(path).toLowerCase()
|
||||
if (ext === 'pdf') return 'pdf'
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return 'image'
|
||||
if (TEXT_EXTENSIONS.has(ext)) return 'text'
|
||||
return 'binary'
|
||||
}
|
||||
|
||||
/** Plain extension without the leading dot. Empty string when none. */
|
||||
export function extensionOf(path: string): string {
|
||||
const dot = path.lastIndexOf('.')
|
||||
if (dot === -1) return ''
|
||||
const slash = path.lastIndexOf('/')
|
||||
if (dot < slash) return ''
|
||||
return path.slice(dot + 1)
|
||||
}
|
||||
|
||||
/** File name (final path segment), no directory prefix. */
|
||||
export function basenameOf(path: string): string {
|
||||
const slash = path.lastIndexOf('/')
|
||||
return slash === -1 ? path : path.slice(slash + 1)
|
||||
}
|
||||
|
||||
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const
|
||||
|
||||
/** "2.4 MB" / "340 KB" / "78 B" — for the artifact card's right-side
|
||||
* metadata. Not localised; the rail uses one space + the unit. */
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '—'
|
||||
if (bytes < 1024) return `${bytes} ${SIZE_UNITS[0]}`
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
|
||||
value /= 1024
|
||||
unit += 1
|
||||
}
|
||||
// 1-digit precision below 10, integer above — feels less noisy.
|
||||
const formatted = value < 10 ? value.toFixed(1) : Math.round(value).toString()
|
||||
return `${formatted} ${SIZE_UNITS[unit]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-file download URL using the same agent-api root the
|
||||
* rest of the harness hits. Returned URL is already absolute.
|
||||
*/
|
||||
export function buildFileDownloadUrl(baseUrl: string, fileId: string): string {
|
||||
return buildAgentApiUrl(
|
||||
baseUrl,
|
||||
`/files/${encodeURIComponent(fileId)}/download`,
|
||||
)
|
||||
}
|
||||
32
packages/browseros-agent/apps/agent/lib/agent-files/index.ts
Normal file
32
packages/browseros-agent/apps/agent/lib/agent-files/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export {
|
||||
basenameOf,
|
||||
buildFileDownloadUrl,
|
||||
extensionOf,
|
||||
type FileKind,
|
||||
formatFileSize,
|
||||
inferFileKind,
|
||||
} from './file-helpers'
|
||||
export type {
|
||||
BinaryFilePreview,
|
||||
FilePreview,
|
||||
FilePreviewKind,
|
||||
ImageFilePreview,
|
||||
MissingFilePreview,
|
||||
PdfFilePreview,
|
||||
ProducedFile,
|
||||
ProducedFilesRailGroup,
|
||||
TextFilePreview,
|
||||
} from './types'
|
||||
export {
|
||||
useAgentOutputs,
|
||||
useAgentTurnFiles,
|
||||
useInvalidateAgentOutputs,
|
||||
useRefreshAgentOutputs,
|
||||
} from './useAgentOutputs'
|
||||
export { useFilePreview } from './useFilePreview'
|
||||
75
packages/browseros-agent/apps/agent/lib/agent-files/types.ts
Normal file
75
packages/browseros-agent/apps/agent/lib/agent-files/types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Wire types shared by the inline artifact card and the per-agent
|
||||
* Outputs rail. These mirror `ProducedFileEntry` /
|
||||
* `ProducedFilesRailGroup` on the server and the `FilePreview`
|
||||
* discriminated union from `apps/server/src/api/services/openclaw/file-preview.ts`.
|
||||
*
|
||||
* The schema mirror is deliberate (vs sharing a workspace package)
|
||||
* because the server keeps the on-disk row shape — `agentDefinitionId`,
|
||||
* `sessionKey` — out of the wire payload. Dropping those columns at the
|
||||
* type boundary keeps the client honest about what it can refer to.
|
||||
*/
|
||||
|
||||
export interface ProducedFile {
|
||||
id: string
|
||||
/** Workspace-relative POSIX path. */
|
||||
path: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
/** Server clock when the file was first attributed to its turn. */
|
||||
createdAt: number
|
||||
detectedBy: 'diff' | 'tool'
|
||||
}
|
||||
|
||||
export interface ProducedFilesRailGroup {
|
||||
turnId: string
|
||||
/** First non-blank line of the user prompt that initiated this turn. */
|
||||
turnPrompt: string
|
||||
createdAt: number
|
||||
files: ProducedFile[]
|
||||
}
|
||||
|
||||
export type FilePreviewKind = 'text' | 'image' | 'pdf' | 'binary' | 'missing'
|
||||
|
||||
interface BasePreview {
|
||||
kind: FilePreviewKind
|
||||
mimeType: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
export interface TextFilePreview extends BasePreview {
|
||||
kind: 'text'
|
||||
snippet: string
|
||||
/** True when the on-disk file is larger than the server's snippet cap. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export interface ImageFilePreview extends BasePreview {
|
||||
kind: 'image'
|
||||
/** Base64 data URL (incl. `data:` prefix). Suitable for `<img src>`. */
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
export interface PdfFilePreview extends BasePreview {
|
||||
kind: 'pdf'
|
||||
}
|
||||
|
||||
export interface BinaryFilePreview extends BasePreview {
|
||||
kind: 'binary'
|
||||
}
|
||||
|
||||
export interface MissingFilePreview {
|
||||
kind: 'missing'
|
||||
}
|
||||
|
||||
export type FilePreview =
|
||||
| TextFilePreview
|
||||
| ImageFilePreview
|
||||
| PdfFilePreview
|
||||
| BinaryFilePreview
|
||||
| MissingFilePreview
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* React Query hooks backing the per-agent Outputs rail and the
|
||||
* inline artifact card.
|
||||
*
|
||||
* Live updates: the consumer of `useAgentConversation` (see Phase 5)
|
||||
* is expected to call `useInvalidateAgentOutputs(agentId)` whenever
|
||||
* an assistant turn completes, so the rail picks up the new
|
||||
* `produced_files` rows the server attributed during that turn.
|
||||
* No SSE channel here — invalidation off the existing chat-stream
|
||||
* completion is enough for v1.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
AGENT_QUERY_KEYS,
|
||||
agentsFetch,
|
||||
} from '@/entrypoints/app/agents/useAgents'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import type { ProducedFile, ProducedFilesRailGroup } from './types'
|
||||
|
||||
interface OutputsResponse {
|
||||
groups: ProducedFilesRailGroup[]
|
||||
}
|
||||
|
||||
interface TurnFilesResponse {
|
||||
files: ProducedFile[]
|
||||
}
|
||||
|
||||
export function useAgentOutputs(agentId: string, enabled = true) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<ProducedFilesRailGroup[], Error>({
|
||||
queryKey: [AGENT_QUERY_KEYS.agentOutputs, baseUrl, agentId],
|
||||
queryFn: async () => {
|
||||
const data = await agentsFetch<OutputsResponse>(
|
||||
baseUrl as string,
|
||||
`/${encodeURIComponent(agentId)}/files`,
|
||||
)
|
||||
return data.groups ?? []
|
||||
},
|
||||
enabled: Boolean(baseUrl) && !urlLoading && enabled && Boolean(agentId),
|
||||
})
|
||||
|
||||
return {
|
||||
groups: query.data ?? [],
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-turn fetch for the inline artifact card. Used both as the
|
||||
* fallback when an SSE `produced_files` event was missed, and to
|
||||
* rehydrate a turn the user scrolled back to.
|
||||
*/
|
||||
export function useAgentTurnFiles(
|
||||
agentId: string,
|
||||
turnId: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<ProducedFile[], Error>({
|
||||
queryKey: [AGENT_QUERY_KEYS.agentTurnFiles, baseUrl, agentId, turnId],
|
||||
queryFn: async () => {
|
||||
const data = await agentsFetch<TurnFilesResponse>(
|
||||
baseUrl as string,
|
||||
`/${encodeURIComponent(agentId)}/files/turn/${encodeURIComponent(
|
||||
turnId as string,
|
||||
)}`,
|
||||
)
|
||||
return data.files ?? []
|
||||
},
|
||||
enabled:
|
||||
Boolean(baseUrl) &&
|
||||
!urlLoading &&
|
||||
enabled &&
|
||||
Boolean(agentId) &&
|
||||
Boolean(turnId),
|
||||
})
|
||||
|
||||
return {
|
||||
files: query.data ?? [],
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a callable that invalidates outputs / turn-files queries
|
||||
* for one agent across any baseUrl. Call after an assistant turn
|
||||
* completes so the rail (and the inline file-card strip) pick up
|
||||
* the new attributed rows. Cheap when the queries aren't mounted
|
||||
* — react-query just marks the cached value stale.
|
||||
*
|
||||
* Implementation note: react-query's `invalidateQueries({ queryKey })`
|
||||
* does positional partial-match, so passing `undefined` as the
|
||||
* baseUrl placeholder does NOT match a cached `[…, baseUrl, …]`
|
||||
* key — the cache stayed stale. Use a predicate so we ignore the
|
||||
* baseUrl position entirely.
|
||||
*/
|
||||
export function useInvalidateAgentOutputs() {
|
||||
const queryClient = useQueryClient()
|
||||
return async (agentId: string, turnId?: string) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey
|
||||
return (
|
||||
Array.isArray(key) &&
|
||||
key[0] === AGENT_QUERY_KEYS.agentOutputs &&
|
||||
key[2] === agentId
|
||||
)
|
||||
},
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey
|
||||
if (
|
||||
!Array.isArray(key) ||
|
||||
key[0] !== AGENT_QUERY_KEYS.agentTurnFiles ||
|
||||
key[2] !== agentId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// When a turnId was supplied, scope to just that turn's
|
||||
// entry. Otherwise flush every cached turn for this agent.
|
||||
return turnId ? key[3] === turnId : true
|
||||
},
|
||||
}),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny mutation wrapper so the Outputs rail's "Refresh" button can
|
||||
* surface an `isPending` indicator while the new query is in flight.
|
||||
* No body — just triggers `refetch` on the rail's query for this
|
||||
* agent and resolves when it settles.
|
||||
*/
|
||||
export function useRefreshAgentOutputs(agentId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
const { baseUrl } = useAgentServerUrl()
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: [AGENT_QUERY_KEYS.agentOutputs, baseUrl, agentId],
|
||||
exact: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Single-file preview hook used by the inline artifact card and the
|
||||
* Outputs rail's preview Sheet. Always opt-in (`enabled`) — the
|
||||
* preview is fetched only when the user clicks a row, never
|
||||
* eagerly.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
AGENT_QUERY_KEYS,
|
||||
agentsFetch,
|
||||
} from '@/entrypoints/app/agents/useAgents'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import type { FilePreview } from './types'
|
||||
|
||||
export function useFilePreview(fileId: string | null, enabled = true) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<FilePreview, Error>({
|
||||
queryKey: [AGENT_QUERY_KEYS.filePreview, baseUrl, fileId],
|
||||
queryFn: async () => {
|
||||
return agentsFetch<FilePreview>(
|
||||
baseUrl as string,
|
||||
`/files/${encodeURIComponent(fileId as string)}/preview`,
|
||||
)
|
||||
},
|
||||
enabled: Boolean(baseUrl) && !urlLoading && enabled && Boolean(fileId),
|
||||
// Previews are immutable for a given fileId — once loaded, never
|
||||
// refetch on focus / reconnect. They go stale only when the
|
||||
// underlying file is removed (rare in v1; no rename / delete).
|
||||
staleTime: Infinity,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
return {
|
||||
preview: query.data ?? null,
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
96
packages/browseros-agent/apps/agent/lib/attachments.test.ts
Normal file
96
packages/browseros-agent/apps/agent/lib/attachments.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { stageAttachment } from './attachments'
|
||||
|
||||
function restoreGlobal(name: string, value: unknown) {
|
||||
if (value === undefined) {
|
||||
Reflect.deleteProperty(globalThis, name)
|
||||
return
|
||||
}
|
||||
Reflect.set(globalThis, name, value)
|
||||
}
|
||||
|
||||
describe('stageAttachment', () => {
|
||||
it('stages pasted clipboard images that do not have a filename', async () => {
|
||||
const file = new File([new Uint8Array([1, 2, 3])], '', {
|
||||
type: 'image/png',
|
||||
})
|
||||
|
||||
const result = await stageAttachment(file)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error(result.error.message)
|
||||
expect(result.attachment.kind).toBe('image')
|
||||
expect(result.attachment.name).toBe('image')
|
||||
expect(result.attachment.mediaType).toBe('image/png')
|
||||
expect(result.attachment.dataUrl).toStartWith('data:image/png;base64,')
|
||||
expect(result.attachment.payload).toMatchObject({
|
||||
kind: 'image',
|
||||
mediaType: 'image/png',
|
||||
dataUrl: result.attachment.dataUrl,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the recompressed blob media type for large pasted images', async () => {
|
||||
const originalCreateImageBitmap = Reflect.get(
|
||||
globalThis,
|
||||
'createImageBitmap',
|
||||
)
|
||||
const originalOffscreenCanvas = Reflect.get(globalThis, 'OffscreenCanvas')
|
||||
const originalHTMLCanvasElement = Reflect.get(
|
||||
globalThis,
|
||||
'HTMLCanvasElement',
|
||||
)
|
||||
|
||||
class FakeOffscreenCanvas {
|
||||
width: number
|
||||
height: number
|
||||
|
||||
constructor(width: number, height: number) {
|
||||
this.width = width
|
||||
this.height = height
|
||||
}
|
||||
|
||||
getContext() {
|
||||
return {
|
||||
drawImage() {},
|
||||
}
|
||||
}
|
||||
|
||||
async convertToBlob(options: { type?: string }) {
|
||||
return new Blob([new Uint8Array([9, 8, 7])], {
|
||||
type: options.type ?? 'image/jpeg',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Reflect.set(globalThis, 'createImageBitmap', async () => ({
|
||||
width: 4096,
|
||||
height: 2048,
|
||||
close() {},
|
||||
}))
|
||||
Reflect.set(globalThis, 'OffscreenCanvas', FakeOffscreenCanvas)
|
||||
Reflect.set(globalThis, 'HTMLCanvasElement', class HTMLCanvasElement {})
|
||||
|
||||
const file = new File([new Uint8Array(2 * 1024 * 1024)], 'shot.png', {
|
||||
type: 'image/png',
|
||||
})
|
||||
|
||||
const result = await stageAttachment(file)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error(result.error.message)
|
||||
expect(result.attachment.mediaType).toBe('image/jpeg')
|
||||
expect(result.attachment.dataUrl).toStartWith('data:image/jpeg;base64,')
|
||||
expect(result.attachment.payload).toMatchObject({
|
||||
kind: 'image',
|
||||
mediaType: 'image/jpeg',
|
||||
dataUrl: result.attachment.dataUrl,
|
||||
})
|
||||
} finally {
|
||||
restoreGlobal('createImageBitmap', originalCreateImageBitmap)
|
||||
restoreGlobal('OffscreenCanvas', originalOffscreenCanvas)
|
||||
restoreGlobal('HTMLCanvasElement', originalHTMLCanvasElement)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -100,6 +100,7 @@ export async function stageAttachment(
|
||||
try {
|
||||
const compressed = await compressImageIfNeeded(file)
|
||||
const dataUrl = await readAsDataUrl(compressed)
|
||||
const encodedMediaType = compressed.type || mediaType
|
||||
// Rough byte ceiling — `data:image/png;base64,...` doubles size with
|
||||
// base64. Reject early so we never POST something the route will 400.
|
||||
if (dataUrl.length > MAX_IMAGE_BYTES * 2) {
|
||||
@@ -118,12 +119,12 @@ export async function stageAttachment(
|
||||
attachment: {
|
||||
id: makeId(),
|
||||
kind: 'image',
|
||||
mediaType,
|
||||
mediaType: encodedMediaType,
|
||||
name: file.name || 'image',
|
||||
dataUrl,
|
||||
payload: {
|
||||
kind: 'image',
|
||||
mediaType,
|
||||
mediaType: encodedMediaType,
|
||||
dataUrl,
|
||||
name: file.name || undefined,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
cloudSyncHelpUrl,
|
||||
cloudSyncSignInLinks,
|
||||
privacyPolicyUrl,
|
||||
termsOfServiceUrl,
|
||||
} from './productUrls'
|
||||
|
||||
describe('cloud sync sign-in links', () => {
|
||||
it('points to the public legal and cloud sync documentation URLs', () => {
|
||||
expect(termsOfServiceUrl).toBe('https://browseros.com/terms')
|
||||
expect(privacyPolicyUrl).toBe('https://browseros.com/privacy')
|
||||
expect(cloudSyncHelpUrl).toBe(
|
||||
'https://docs.browseros.com/features/sync-to-cloud',
|
||||
)
|
||||
})
|
||||
|
||||
it('includes legal and cloud sync documentation links in display order', () => {
|
||||
expect(cloudSyncSignInLinks).toEqual([
|
||||
{ label: 'Terms of Service', url: termsOfServiceUrl },
|
||||
{ label: 'Privacy Policy', url: privacyPolicyUrl },
|
||||
{ label: 'Learn more about cloud sync', url: cloudSyncHelpUrl },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,26 @@ export const githubOrgUrl = 'https://github.com/browseros-ai'
|
||||
*/
|
||||
export const privacyPolicyUrl = 'https://browseros.com/privacy'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const termsOfServiceUrl = 'https://browseros.com/terms'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const cloudSyncHelpUrl =
|
||||
'https://docs.browseros.com/features/sync-to-cloud'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const cloudSyncSignInLinks = [
|
||||
{ label: 'Terms of Service', url: termsOfServiceUrl },
|
||||
{ label: 'Privacy Policy', url: privacyPolicyUrl },
|
||||
{ label: 'Learn more about cloud sync', url: cloudSyncHelpUrl },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,10 @@ import { storage } from '@wxt-dev/storage'
|
||||
import type { UIMessage } from 'ai'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSessionInfo } from '../auth/sessionStorage'
|
||||
import { removeConversationExecutionHistory } from '../execution-history/storage'
|
||||
import {
|
||||
clearConversationExecutionHistory,
|
||||
removeConversationExecutionHistory,
|
||||
} from '../execution-history/storage'
|
||||
import { uploadConversationsToGraphql } from './uploadConversationsToGraphql'
|
||||
|
||||
const MAX_CONVERSATIONS = 50
|
||||
@@ -46,6 +49,11 @@ export function useConversations() {
|
||||
await removeConversationExecutionHistory(id)
|
||||
}
|
||||
|
||||
const clearConversations = async () => {
|
||||
await conversationStorage.setValue([])
|
||||
await clearConversationExecutionHistory()
|
||||
}
|
||||
|
||||
const saveConversation = async (id: string, messages: UIMessage[]) => {
|
||||
const current = (await conversationStorage.getValue()) ?? []
|
||||
const existingIndex = current.findIndex((c) => c.id === id)
|
||||
@@ -90,6 +98,7 @@ export function useConversations() {
|
||||
return {
|
||||
conversations,
|
||||
removeConversation,
|
||||
clearConversations,
|
||||
saveConversation,
|
||||
getConversation,
|
||||
}
|
||||
|
||||
@@ -82,6 +82,10 @@ export async function removeConversationExecutionHistory(
|
||||
await executionHistoryStorage.setValue(rest)
|
||||
}
|
||||
|
||||
export async function clearConversationExecutionHistory(): Promise<void> {
|
||||
await executionHistoryStorage.setValue({})
|
||||
}
|
||||
|
||||
export async function removeConversationExecutionTask(args: {
|
||||
conversationId: string
|
||||
taskId: string
|
||||
|
||||
@@ -81,4 +81,16 @@ describe('buildChatRequestBody', () => {
|
||||
|
||||
expect(body.toolApprovalConfig).toBeUndefined()
|
||||
})
|
||||
|
||||
it('passes scheduled task silent mode through to the server', () => {
|
||||
const body = buildChatRequestBody({
|
||||
conversationId: '6ff46e3b-e45a-40a4-9157-ca520e800f43',
|
||||
provider,
|
||||
isScheduledTask: true,
|
||||
runSilently: false,
|
||||
})
|
||||
|
||||
expect(body.isScheduledTask).toBe(true)
|
||||
expect(body.runSilently).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,6 +53,7 @@ interface ChatRequestBodyParams {
|
||||
toolApprovalConfig?: ToolApprovalConfig
|
||||
toolApprovalResponses?: ApprovalResponseData[]
|
||||
isScheduledTask?: boolean
|
||||
runSilently?: boolean
|
||||
}
|
||||
|
||||
export const toRequestToolApprovalConfig = (
|
||||
@@ -81,6 +82,7 @@ export const buildChatRequestBody = ({
|
||||
toolApprovalConfig,
|
||||
toolApprovalResponses,
|
||||
isScheduledTask,
|
||||
runSilently,
|
||||
}: ChatRequestBodyParams) => ({
|
||||
message,
|
||||
provider: provider.type,
|
||||
@@ -112,4 +114,5 @@ export const buildChatRequestBody = ({
|
||||
toolApprovalConfig: toRequestToolApprovalConfig(toolApprovalConfig),
|
||||
toolApprovalResponses,
|
||||
isScheduledTask,
|
||||
runSilently,
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@ interface ChatServerRequest {
|
||||
activeTab?: ActiveTab
|
||||
signal?: AbortSignal
|
||||
providerId?: string
|
||||
runSilently?: boolean
|
||||
}
|
||||
|
||||
interface ChatServerResponse {
|
||||
@@ -137,6 +138,7 @@ export async function getChatServerResponse(
|
||||
userSystemPrompt: `${personalization}\n${scheduleSystemPrompt}`,
|
||||
supportsImages: provider.supportsImages,
|
||||
isScheduledTask: true,
|
||||
runSilently: request.runSilently ?? true,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface ScheduledJob {
|
||||
scheduleTime?: string
|
||||
scheduleInterval?: number
|
||||
enabled: boolean
|
||||
runSilently?: boolean
|
||||
providerId?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
|
||||
@@ -26,7 +26,7 @@ type RemoteScheduledJob = {
|
||||
lastRunAt: string | null
|
||||
}
|
||||
|
||||
const IGNORED_FIELDS = ['id', 'createdAt', 'lastRunAt'] as const
|
||||
const IGNORED_FIELDS = ['id', 'createdAt', 'lastRunAt', 'runSilently'] as const
|
||||
|
||||
function toComparable(job: ScheduledJob) {
|
||||
const data = omit(job, IGNORED_FIELDS)
|
||||
@@ -63,6 +63,7 @@ function remoteToLocal(remote: RemoteScheduledJob): ScheduledJob {
|
||||
scheduleTime: remote.scheduleTime ?? undefined,
|
||||
scheduleInterval: remote.scheduleInterval ?? undefined,
|
||||
enabled: remote.enabled,
|
||||
runSilently: true,
|
||||
providerId: remote.llmProviderId ?? undefined,
|
||||
createdAt: normalizeTimestamp(remote.createdAt),
|
||||
updatedAt: normalizeTimestamp(remote.updatedAt),
|
||||
|
||||
26
packages/browseros-agent/apps/eval/configs/legacy/browseros-agent-kimi-k2-5-agisdk-real.json
vendored
Normal file
26
packages/browseros-agent/apps/eval/configs/legacy/browseros-agent-kimi-k2-5-agisdk-real.json
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"agent": {
|
||||
"type": "single",
|
||||
"provider": "openai-compatible",
|
||||
"model": "moonshotai/kimi-k2.5",
|
||||
"apiKey": "OPENROUTER_API_KEY",
|
||||
"baseUrl": "https://openrouter.ai/api/v1",
|
||||
"supportsImages": true
|
||||
},
|
||||
"dataset": "../../data/agisdk-real.jsonl",
|
||||
"num_workers": 3,
|
||||
"restart_server_per_task": true,
|
||||
"browseros": {
|
||||
"server_url": "http://127.0.0.1:9110",
|
||||
"base_cdp_port": 9010,
|
||||
"base_server_port": 9110,
|
||||
"base_extension_port": 9310,
|
||||
"load_extensions": false,
|
||||
"headless": false
|
||||
},
|
||||
"captcha": {
|
||||
"api_key_env": "NOPECHA_API_KEY"
|
||||
},
|
||||
"graders": ["agisdk_state_diff"],
|
||||
"timeout_ms": 1800000
|
||||
}
|
||||
27
packages/browseros-agent/apps/eval/configs/legacy/browseros-agent-opus-4-6-agisdk-real.json
vendored
Normal file
27
packages/browseros-agent/apps/eval/configs/legacy/browseros-agent-opus-4-6-agisdk-real.json
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"agent": {
|
||||
"type": "single",
|
||||
"provider": "bedrock",
|
||||
"model": "global.anthropic.claude-opus-4-6-v1",
|
||||
"region": "AWS_REGION",
|
||||
"accessKeyId": "AWS_ACCESS_KEY_ID",
|
||||
"secretAccessKey": "AWS_SECRET_ACCESS_KEY",
|
||||
"supportsImages": true
|
||||
},
|
||||
"dataset": "../../data/agisdk-real.jsonl",
|
||||
"num_workers": 2,
|
||||
"restart_server_per_task": true,
|
||||
"browseros": {
|
||||
"server_url": "http://127.0.0.1:9110",
|
||||
"base_cdp_port": 9010,
|
||||
"base_server_port": 9110,
|
||||
"base_extension_port": 9310,
|
||||
"load_extensions": false,
|
||||
"headless": false
|
||||
},
|
||||
"captcha": {
|
||||
"api_key_env": "NOPECHA_API_KEY"
|
||||
},
|
||||
"graders": ["agisdk_state_diff"],
|
||||
"timeout_ms": 1800000
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
"supportsImages": true
|
||||
},
|
||||
"dataset": "../../data/agisdk-real.jsonl",
|
||||
"num_workers": 10,
|
||||
"num_workers": 3,
|
||||
"restart_server_per_task": true,
|
||||
"browseros": {
|
||||
"server_url": "http://127.0.0.1:9110",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"agent": {
|
||||
"type": "claude-code",
|
||||
"model": "opus"
|
||||
"model": "opus",
|
||||
"extraArgs": ["--permission-mode", "bypassPermissions"]
|
||||
},
|
||||
"dataset": "../../data/agisdk-real.jsonl",
|
||||
"num_workers": 1,
|
||||
|
||||
191
packages/browseros-agent/apps/eval/scripts/generate-report.ts
vendored
Normal file
191
packages/browseros-agent/apps/eval/scripts/generate-report.ts
vendored
Normal file
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { mkdir, stat } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { query as claudeQuery } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { readRunMetricSummary } from '../src/reporting/task-metrics'
|
||||
|
||||
export const DEFAULT_REPORT_MODEL = 'claude-opus-4-6'
|
||||
export const DEFAULT_REPORT_MAX_TURNS = 300
|
||||
|
||||
type Env = Record<string, string | undefined>
|
||||
type ClaudeQuery = (input: unknown) => AsyncIterable<Record<string, unknown>>
|
||||
|
||||
export interface ReportAgentInvocation {
|
||||
inputDir: string
|
||||
outputPath: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
export interface GenerateEvalReportOptions {
|
||||
inputDir: string
|
||||
outputPath: string
|
||||
runAgent?: (invocation: ReportAgentInvocation) => Promise<void>
|
||||
}
|
||||
|
||||
interface ClaudeReportAgentDeps {
|
||||
query?: ClaudeQuery
|
||||
env?: Env
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return `Usage: bun scripts/generate-report.ts --input <run-dir> --output <report.html>`
|
||||
}
|
||||
|
||||
function parseArgs(
|
||||
argv: string[],
|
||||
): Pick<GenerateEvalReportOptions, 'inputDir' | 'outputPath'> {
|
||||
let inputDir = ''
|
||||
let outputPath = ''
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]
|
||||
if (arg === '--input' || arg === '--run') {
|
||||
inputDir = argv[++i] ?? ''
|
||||
} else if (arg === '--output' || arg === '--out') {
|
||||
outputPath = argv[++i] ?? ''
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
console.log(usage())
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
if (!inputDir || !outputPath) {
|
||||
throw new Error(usage())
|
||||
}
|
||||
return { inputDir, outputPath }
|
||||
}
|
||||
|
||||
function claudeCodeEnv(env: Env): Env {
|
||||
return {
|
||||
CLAUDE_CODE_OAUTH_TOKEN: env.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY,
|
||||
HOME: env.HOME,
|
||||
PATH: env.PATH,
|
||||
SHELL: env.SHELL,
|
||||
TMPDIR: env.TMPDIR,
|
||||
TMP: env.TMP,
|
||||
TEMP: env.TEMP,
|
||||
USER: env.USER,
|
||||
CLAUDECODE: '',
|
||||
}
|
||||
}
|
||||
|
||||
async function buildReportPrompt(
|
||||
inputDir: string,
|
||||
outputPath: string,
|
||||
): Promise<string> {
|
||||
const metrics = await readRunMetricSummary(inputDir)
|
||||
|
||||
return `Analyze this BrowserOS eval run and write a shareable HTML report.
|
||||
|
||||
Run directory: ${inputDir}
|
||||
Output file to write: ${outputPath}
|
||||
|
||||
You are running with the run directory as cwd. Inspect the local artifacts:
|
||||
- summary.json for run totals and pass rate
|
||||
- each task directory's metadata.json for query, final answer, timing, screenshots, and grader results
|
||||
- each task directory's messages.jsonl for tool calls, tool errors, and recent trajectory
|
||||
- screenshots/ for visual evidence
|
||||
- grader-artifacts/ when present for grader-specific context
|
||||
|
||||
Write the final report directly to the output file path above. Do not print the
|
||||
report instead of writing it. Do not modify any input artifacts. The only file
|
||||
you should create or overwrite is the requested report.html.
|
||||
|
||||
The report should follow the style and density of the Shadowfax AGI SDK report:
|
||||
- Title like "AGI SDK Random-10 Failure Report" or a run-specific equivalent
|
||||
- Run directory and note that screenshots are embedded as data URIs
|
||||
- Summary cards for total tasks, passed, failed, pass rate, average duration, average steps, and average tool calls
|
||||
- A Metrics section with compact charts for Duration by task, Steps by task, Tool calls by task, and Tool errors by task
|
||||
- Task Summary table with task id, status, score, duration, steps, and prompt
|
||||
- Include tool calls and tool errors in the Task Summary table
|
||||
- Failure sections with stable anchors using each task id, for example <section id="agisdk-networkin-10">
|
||||
- For each failed task: Diagnosis, Evidence, Next Check, final screenshot, AGI SDK / grader criteria, final answer, and recent trajectory events
|
||||
- Make failure links in the summary table point to the task anchors
|
||||
- Keep the HTML self-contained: inline CSS and embedded final screenshots as data:image/png;base64 URIs
|
||||
- Escape user/model text correctly so task outputs cannot break the page
|
||||
|
||||
Analysis guidance:
|
||||
- Focus on why the model failed: task understanding, browser/tool usage, missing verification, tool errors, max-step/timeout, bad final answer, or grader ambiguity
|
||||
- Use messages.jsonl strategically. Do not paste huge DOM outputs into the report. Summarize only the relevant recent trajectory and evidence.
|
||||
- Limit trajectory analysis to the most relevant 200-300 events/calls across the run. Prefer failed tasks and the final/key actions for each failure.
|
||||
- If a grader criterion is boolean-only or ambiguous, say so and identify what additional artifact would make it debuggable.
|
||||
|
||||
Deterministic run metrics computed from metadata.json and messages.jsonl:
|
||||
\`\`\`json
|
||||
${JSON.stringify(metrics, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
After writing the file, verify that ${outputPath} exists and is non-empty.`
|
||||
}
|
||||
|
||||
async function assertRunDir(inputDir: string): Promise<void> {
|
||||
const inputStat = await stat(inputDir).catch(() => null)
|
||||
if (!inputStat?.isDirectory()) {
|
||||
throw new Error(`Not a run directory: ${inputDir}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function assertReportWritten(outputPath: string): Promise<void> {
|
||||
const outputStat = await stat(outputPath).catch(() => null)
|
||||
if (!outputStat?.isFile() || outputStat.size === 0) {
|
||||
throw new Error(`Report was not written: ${outputPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function runClaudeCodeReportAgent(
|
||||
invocation: ReportAgentInvocation,
|
||||
deps: ClaudeReportAgentDeps = {},
|
||||
): Promise<void> {
|
||||
const query = deps.query ?? (claudeQuery as unknown as ClaudeQuery)
|
||||
let resultSubtype: string | undefined
|
||||
|
||||
for await (const message of query({
|
||||
prompt: invocation.prompt,
|
||||
options: {
|
||||
cwd: invocation.inputDir,
|
||||
model: DEFAULT_REPORT_MODEL,
|
||||
systemPrompt:
|
||||
'You are an eval failure analyst. Produce a concise, evidence-backed, self-contained HTML report from local run artifacts.',
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
maxTurns: DEFAULT_REPORT_MAX_TURNS,
|
||||
env: claudeCodeEnv(deps.env ?? process.env),
|
||||
},
|
||||
})) {
|
||||
if (message.type === 'result') {
|
||||
resultSubtype =
|
||||
typeof message.subtype === 'string' ? message.subtype : undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (resultSubtype && resultSubtype !== 'success') {
|
||||
throw new Error(`Claude Code report agent failed: ${resultSubtype}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateEvalReport(
|
||||
options: GenerateEvalReportOptions,
|
||||
): Promise<void> {
|
||||
const inputDir = resolve(options.inputDir)
|
||||
const outputPath = resolve(options.outputPath)
|
||||
|
||||
await assertRunDir(inputDir)
|
||||
await mkdir(dirname(outputPath), { recursive: true })
|
||||
|
||||
const invocation = {
|
||||
inputDir,
|
||||
outputPath,
|
||||
prompt: await buildReportPrompt(inputDir, outputPath),
|
||||
}
|
||||
await (options.runAgent ?? runClaudeCodeReportAgent)(invocation)
|
||||
await assertReportWritten(outputPath)
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
await generateEvalReport(parseArgs(Bun.argv.slice(2)))
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,10 @@ export class OrchestratorExecutorEvaluator implements AgentEvaluator {
|
||||
|
||||
// Connect to Chrome via CDP — same per-worker offset used by app-manager.
|
||||
const cdpPort = config.browseros.base_cdp_port + workerIndex
|
||||
const cdp = new CdpBackend({ port: cdpPort })
|
||||
const cdp = new CdpBackend({
|
||||
port: cdpPort,
|
||||
exitOnReconnectFailure: false,
|
||||
})
|
||||
await cdp.connect()
|
||||
const browser = new Browser(cdp)
|
||||
capture.screenshot.setBrowser(browser)
|
||||
|
||||
@@ -43,7 +43,10 @@ export class SingleAgentEvaluator implements AgentEvaluator {
|
||||
|
||||
// Connect to Chrome via CDP — same per-worker offset used by app-manager.
|
||||
const cdpPort = config.browseros.base_cdp_port + workerIndex
|
||||
const cdp = new CdpBackend({ port: cdpPort })
|
||||
const cdp = new CdpBackend({
|
||||
port: cdpPort,
|
||||
exitOnReconnectFailure: false,
|
||||
})
|
||||
await cdp.connect()
|
||||
|
||||
const browser = new Browser(cdp)
|
||||
|
||||
@@ -536,6 +536,12 @@ export interface DashboardConfig {
|
||||
configMode?: boolean
|
||||
}
|
||||
|
||||
export function shouldAutoOpenDashboard(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): boolean {
|
||||
return env.CI !== 'true'
|
||||
}
|
||||
|
||||
export function startDashboard(config: DashboardConfig) {
|
||||
const port = config.port ?? 9900
|
||||
dashboardConfigMode = config.configMode ?? false
|
||||
@@ -558,10 +564,12 @@ export function startDashboard(config: DashboardConfig) {
|
||||
console.log(` Dashboard: ${url}`)
|
||||
|
||||
// Auto-open browser
|
||||
try {
|
||||
Bun.spawn(['open', url], { stdout: 'ignore', stderr: 'ignore' })
|
||||
} catch {
|
||||
/* ignore if open command fails */
|
||||
if (shouldAutoOpenDashboard()) {
|
||||
try {
|
||||
Bun.spawn(['open', url], { stdout: 'ignore', stderr: 'ignore' })
|
||||
} catch {
|
||||
/* ignore if open command fails */
|
||||
}
|
||||
}
|
||||
|
||||
return { url, port }
|
||||
|
||||
@@ -61,6 +61,17 @@
|
||||
.header-stats .stat-pass { color: #3fb950; }
|
||||
.header-stats .stat-fail { color: #f85149; }
|
||||
.header-stats .stat-score { color: #f0883e; }
|
||||
.header-report {
|
||||
color: #58a6ff;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
padding: 5px 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.header-report:hover { border-color: #58a6ff; background: #1c2333; }
|
||||
|
||||
/* ── 3-column layout ─────────────────────────────────────────── */
|
||||
.layout {
|
||||
@@ -84,6 +95,7 @@
|
||||
background: #161b22;
|
||||
border-bottom: 1px solid #30363d;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -93,6 +105,80 @@
|
||||
}
|
||||
.sidebar-stats .s-pass { color: #3fb950; }
|
||||
.sidebar-stats .s-fail { color: #f85149; }
|
||||
.sidebar-metrics {
|
||||
padding: 12px 16px;
|
||||
background: #0d1117;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.metric-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
.metric-label {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: #6e7681;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.metric-value {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #e6edf3;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mini-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.mini-chart-title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #8b949e;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.mini-bar-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(60px, 1fr) 70px 28px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
color: #8b949e;
|
||||
}
|
||||
.mini-bar-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
}
|
||||
.mini-bar-track {
|
||||
height: 6px;
|
||||
background: #21262d;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mini-bar-fill {
|
||||
height: 100%;
|
||||
background: #58a6ff;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.mini-bar-value {
|
||||
color: #e6edf3;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
.sidebar-filter {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #21262d;
|
||||
@@ -526,6 +612,7 @@
|
||||
<div class="header-sep"></div>
|
||||
<span class="header-run" id="header-run"></span>
|
||||
<span class="header-date" id="header-date"></span>
|
||||
<a class="header-report" id="header-report" target="_blank" rel="noopener" style="display: none;">Run Report</a>
|
||||
<div class="header-stats" id="header-stats"></div>
|
||||
</div>
|
||||
|
||||
@@ -533,6 +620,7 @@
|
||||
<!-- Left sidebar -->
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-stats" id="sidebar-stats"></div>
|
||||
<div class="sidebar-metrics" id="sidebar-metrics"></div>
|
||||
<div class="sidebar-filter">
|
||||
<input type="text" id="filter-input" placeholder="Search tasks..." autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
@@ -627,7 +715,23 @@
|
||||
if (stats.avgScore !== null) {
|
||||
parts.push(`<span class="stat-score">avg ${stats.avgScore}%</span>`);
|
||||
}
|
||||
if (stats.avgDurationMs !== null) {
|
||||
parts.push(`<span>${fmtDuration(stats.avgDurationMs)} avg</span>`);
|
||||
}
|
||||
if (stats.avgToolCalls !== null) {
|
||||
parts.push(`<span>${fmtCompact(stats.avgToolCalls)} tools/task</span>`);
|
||||
}
|
||||
el.innerHTML = parts.join('');
|
||||
|
||||
const reportLink = document.getElementById('header-report');
|
||||
const url = reportUrl(manifest);
|
||||
if (url) {
|
||||
reportLink.href = url;
|
||||
reportLink.style.display = '';
|
||||
} else {
|
||||
reportLink.removeAttribute('href');
|
||||
reportLink.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sidebar rendering ─────────────────────────────────────────
|
||||
@@ -639,11 +743,49 @@
|
||||
statsEl.innerHTML =
|
||||
'<span>' + stats.total + ' total</span>' +
|
||||
'<span class="s-pass">' + stats.passed + ' pass</span>' +
|
||||
'<span class="s-fail">' + stats.failed + ' fail</span>';
|
||||
'<span class="s-fail">' + stats.failed + ' fail</span>' +
|
||||
(stats.avgSteps !== null ? '<span>' + fmtCompact(stats.avgSteps) + ' steps/task</span>' : '') +
|
||||
(stats.avgToolCalls !== null ? '<span>' + fmtCompact(stats.avgToolCalls) + ' tools/task</span>' : '');
|
||||
|
||||
renderSidebarMetrics(tasks, stats);
|
||||
|
||||
renderTaskList('');
|
||||
}
|
||||
|
||||
function renderSidebarMetrics(tasks, stats) {
|
||||
const el = document.getElementById('sidebar-metrics');
|
||||
if (!el) return;
|
||||
|
||||
const chartTasks = tasks
|
||||
.slice()
|
||||
.sort((a, b) => taskMetrics(b).toolCalls - taskMetrics(a).toolCalls)
|
||||
.slice(0, 5);
|
||||
const maxCalls = Math.max(1, ...chartTasks.map((task) => taskMetrics(task).toolCalls));
|
||||
|
||||
const bars = chartTasks.map((task) => {
|
||||
const calls = taskMetrics(task).toolCalls;
|
||||
const width = Math.max(4, Math.round((calls / maxCalls) * 100));
|
||||
return (
|
||||
'<div class="mini-bar-row">' +
|
||||
'<span class="mini-bar-name" title="' + escAttr(task.queryId || task.id || 'Untitled') + '">' + esc(task.queryId || task.id || 'Untitled') + '</span>' +
|
||||
'<span class="mini-bar-track"><span class="mini-bar-fill" style="width: ' + width + '%"></span></span>' +
|
||||
'<span class="mini-bar-value">' + fmtCompact(calls) + '</span>' +
|
||||
'</div>'
|
||||
);
|
||||
}).join('');
|
||||
|
||||
el.innerHTML =
|
||||
'<div class="metric-grid">' +
|
||||
'<div class="metric-cell"><span class="metric-label">Avg Time</span><span class="metric-value">' + (stats.avgDurationMs !== null ? fmtDuration(stats.avgDurationMs) : '-') + '</span></div>' +
|
||||
'<div class="metric-cell"><span class="metric-label">Avg Steps</span><span class="metric-value">' + (stats.avgSteps !== null ? fmtCompact(stats.avgSteps) : '-') + '</span></div>' +
|
||||
'<div class="metric-cell"><span class="metric-label">Avg Tools</span><span class="metric-value">' + (stats.avgToolCalls !== null ? fmtCompact(stats.avgToolCalls) : '-') + '</span></div>' +
|
||||
'</div>' +
|
||||
'<div class="mini-chart">' +
|
||||
'<div class="mini-chart-title">Tool Calls by Task</div>' +
|
||||
(bars || '<div class="task-meta-line"><span>No tool calls recorded</span></div>') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderTaskList(filter) {
|
||||
const list = document.getElementById('task-list');
|
||||
list.innerHTML = '';
|
||||
@@ -668,8 +810,11 @@
|
||||
}
|
||||
|
||||
const metaParts = [];
|
||||
if (task.durationMs) metaParts.push(fmtDuration(task.durationMs));
|
||||
if (task.screenshotCount) metaParts.push(`${task.screenshotCount} steps`);
|
||||
const metrics = taskMetrics(task);
|
||||
if (metrics.durationMs) metaParts.push(fmtDuration(metrics.durationMs));
|
||||
if (metrics.steps) metaParts.push(`${fmtCompact(metrics.steps)} steps`);
|
||||
if (metrics.toolCalls) metaParts.push(`${fmtCompact(metrics.toolCalls)} tools`);
|
||||
if (metrics.toolErrors) metaParts.push(`${fmtCompact(metrics.toolErrors)} errors`);
|
||||
|
||||
item.innerHTML =
|
||||
'<div class="task-row">' +
|
||||
@@ -714,7 +859,7 @@
|
||||
}
|
||||
|
||||
function artifactPath(task, artifact) {
|
||||
const manifestPath = task.paths && task.paths[artifact];
|
||||
const manifestPath = task.paths?.[artifact];
|
||||
if (typeof manifestPath === 'string' && manifestPath.length > 0) {
|
||||
return manifestPath.replace(/^\/+/, '');
|
||||
}
|
||||
@@ -725,6 +870,17 @@
|
||||
return `${basePath}/${artifactPath(task, artifact)}`;
|
||||
}
|
||||
|
||||
function runArtifactUrl(path) {
|
||||
if (typeof path !== 'string' || path.length === 0) return null;
|
||||
return `${basePath}/${path.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
function reportUrl(manifest, task) {
|
||||
const url = runArtifactUrl(manifest?.reportPath);
|
||||
if (!url || !task) return url;
|
||||
return `${url}#${encodeURIComponent(task.queryId || task.id || '')}`;
|
||||
}
|
||||
|
||||
function metadataUrl(task) {
|
||||
return artifactUrl(task, 'metadata');
|
||||
}
|
||||
@@ -905,10 +1061,38 @@
|
||||
}
|
||||
|
||||
// Duration
|
||||
if (task.durationMs) {
|
||||
const metrics = taskMetrics(task);
|
||||
if (metrics.durationMs) {
|
||||
html += '<div class="db-section">';
|
||||
html += '<span class="db-label">Duration</span>';
|
||||
html += `<span class="db-value">${fmtDuration(task.durationMs)}</span>`;
|
||||
html += `<span class="db-value">${fmtDuration(metrics.durationMs)}</span>`;
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
if (metrics.steps) {
|
||||
html += '<div class="db-section">';
|
||||
html += '<span class="db-label">Steps</span>';
|
||||
html += `<span class="db-value">${fmtCompact(metrics.steps)}</span>`;
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '<div class="db-section">';
|
||||
html += '<span class="db-label">Tool Calls</span>';
|
||||
html += `<span class="db-value">${fmtCompact(metrics.toolCalls)}</span>`;
|
||||
html += '</div>';
|
||||
|
||||
if (metrics.toolErrors) {
|
||||
html += '<div class="db-section">';
|
||||
html += '<span class="db-label">Tool Errors</span>';
|
||||
html += `<span class="db-value">${fmtCompact(metrics.toolErrors)}</span>`;
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
const reportLink = reportUrl(manifest, task);
|
||||
if (reportLink) {
|
||||
html += '<div class="db-section">';
|
||||
html += '<span class="db-label">Report</span>';
|
||||
html += `<span class="db-value"><a href="${escAttr(reportLink)}" target="_blank" rel="noopener">Open task analysis</a></span>`;
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
@@ -1234,8 +1418,25 @@
|
||||
function computeStats(tasks) {
|
||||
const total = tasks.length;
|
||||
let passed = 0, failed = 0, totalScore = 0, scoredCount = 0;
|
||||
let totalDurationMs = 0, durationCount = 0;
|
||||
let totalSteps = 0, stepsCount = 0;
|
||||
let totalToolCalls = 0, toolCount = 0;
|
||||
let totalToolErrors = 0;
|
||||
|
||||
tasks.forEach((t) => {
|
||||
const metrics = taskMetrics(t);
|
||||
if (metrics.durationMs > 0) {
|
||||
totalDurationMs += metrics.durationMs;
|
||||
durationCount++;
|
||||
}
|
||||
if (metrics.steps > 0) {
|
||||
totalSteps += metrics.steps;
|
||||
stepsCount++;
|
||||
}
|
||||
totalToolCalls += metrics.toolCalls;
|
||||
totalToolErrors += metrics.toolErrors;
|
||||
toolCount++;
|
||||
|
||||
const graders = t.graderResults || {};
|
||||
const keys = Object.keys(graders);
|
||||
if (keys.length > 0) {
|
||||
@@ -1254,7 +1455,34 @@
|
||||
total: total,
|
||||
passed: passed,
|
||||
failed: failed,
|
||||
avgScore: scoredCount > 0 ? Math.round((totalScore / scoredCount) * 100) : null
|
||||
avgScore: scoredCount > 0 ? Math.round((totalScore / scoredCount) * 100) : null,
|
||||
avgDurationMs: durationCount > 0 ? totalDurationMs / durationCount : null,
|
||||
avgSteps: stepsCount > 0 ? totalSteps / stepsCount : null,
|
||||
avgToolCalls: toolCount > 0 ? totalToolCalls / toolCount : null,
|
||||
totalToolCalls: totalToolCalls,
|
||||
totalToolErrors: totalToolErrors
|
||||
};
|
||||
}
|
||||
|
||||
function taskMetrics(task) {
|
||||
const metrics = task.metrics || {};
|
||||
const screenshots = Number.isFinite(Number(metrics.screenshots))
|
||||
? Number(metrics.screenshots)
|
||||
: Number(task.screenshotCount || 0);
|
||||
return {
|
||||
durationMs: Number.isFinite(Number(metrics.durationMs))
|
||||
? Number(metrics.durationMs)
|
||||
: Number(task.durationMs || 0),
|
||||
steps: Number.isFinite(Number(metrics.steps))
|
||||
? Number(metrics.steps)
|
||||
: screenshots,
|
||||
screenshots: screenshots,
|
||||
toolCalls: Number.isFinite(Number(metrics.toolCalls))
|
||||
? Number(metrics.toolCalls)
|
||||
: 0,
|
||||
toolErrors: Number.isFinite(Number(metrics.toolErrors))
|
||||
? Number(metrics.toolErrors)
|
||||
: 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1310,6 +1538,13 @@
|
||||
return `${h}h ${remM}m`;
|
||||
}
|
||||
|
||||
function fmtCompact(value) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return '0';
|
||||
if (Number.isInteger(num)) return String(num);
|
||||
return num.toFixed(1);
|
||||
}
|
||||
|
||||
function showFatalError(msgHtml) {
|
||||
document.getElementById('center-panel').innerHTML =
|
||||
'<div class="placeholder error">' +
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3'
|
||||
import { readTaskMetrics } from '../reporting/task-metrics'
|
||||
import {
|
||||
buildViewerManifest,
|
||||
type ViewerManifestTaskInput,
|
||||
@@ -315,6 +316,7 @@ export class R2Publisher {
|
||||
graderResults:
|
||||
(meta.grader_results as ViewerManifestTaskInput['graderResults']) ||
|
||||
{},
|
||||
metrics: await readTaskMetrics(taskPath, meta, screenshotCount),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -379,10 +381,12 @@ export class R2Publisher {
|
||||
await readFile(join(runDir, 'summary.json'), 'utf-8'),
|
||||
) as Record<string, unknown>
|
||||
} catch {}
|
||||
const reportStat = await stat(join(runDir, 'report.html')).catch(() => null)
|
||||
|
||||
return buildViewerManifest({
|
||||
runId,
|
||||
uploadedAt: this.now().toISOString(),
|
||||
reportPath: reportStat?.isFile() ? 'report.html' : undefined,
|
||||
agentConfig,
|
||||
dataset,
|
||||
summary: summaryData
|
||||
|
||||
188
packages/browseros-agent/apps/eval/src/reporting/task-metrics.ts
vendored
Normal file
188
packages/browseros-agent/apps/eval/src/reporting/task-metrics.ts
vendored
Normal file
@@ -0,0 +1,188 @@
|
||||
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export interface EvalTaskMetrics {
|
||||
durationMs: number
|
||||
steps: number
|
||||
screenshots: number
|
||||
toolCalls: number
|
||||
toolErrors: number
|
||||
}
|
||||
|
||||
export interface EvalRunMetrics {
|
||||
taskCount: number
|
||||
totalDurationMs: number
|
||||
avgDurationMs: number
|
||||
totalSteps: number
|
||||
avgSteps: number
|
||||
totalToolCalls: number
|
||||
avgToolCalls: number
|
||||
totalToolErrors: number
|
||||
avgToolErrors: number
|
||||
}
|
||||
|
||||
export interface EvalTaskMetricSummary {
|
||||
queryId: string
|
||||
status: string
|
||||
score?: number
|
||||
pass?: boolean
|
||||
metrics: EvalTaskMetrics
|
||||
}
|
||||
|
||||
export interface EvalRunMetricSummary {
|
||||
run: EvalRunMetrics
|
||||
tasks: EvalTaskMetricSummary[]
|
||||
}
|
||||
|
||||
interface TaskDirEntry {
|
||||
taskId: string
|
||||
taskPath: string
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
export function countMessageMetrics(messagesJsonl: string): {
|
||||
toolCalls: number
|
||||
toolErrors: number
|
||||
} {
|
||||
let toolCalls = 0
|
||||
let toolErrors = 0
|
||||
|
||||
for (const line of messagesJsonl.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
try {
|
||||
const event = JSON.parse(trimmed) as { type?: unknown }
|
||||
if (event.type === 'tool-input-available') toolCalls++
|
||||
if (event.type === 'tool-output-error') toolErrors++
|
||||
} catch {
|
||||
// Ignore malformed telemetry lines; the raw artifact is still uploaded.
|
||||
}
|
||||
}
|
||||
|
||||
return { toolCalls, toolErrors }
|
||||
}
|
||||
|
||||
export function buildTaskMetrics(
|
||||
metadata: Record<string, unknown>,
|
||||
messageMetrics: { toolCalls: number; toolErrors: number },
|
||||
screenshotCount = 0,
|
||||
): EvalTaskMetrics {
|
||||
const screenshots = numberValue(metadata.screenshot_count) || screenshotCount
|
||||
return {
|
||||
durationMs: numberValue(metadata.total_duration_ms),
|
||||
steps: numberValue(metadata.total_steps) || screenshots,
|
||||
screenshots,
|
||||
toolCalls: messageMetrics.toolCalls,
|
||||
toolErrors: messageMetrics.toolErrors,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRunMetrics(metrics: EvalTaskMetrics[]): EvalRunMetrics {
|
||||
const taskCount = metrics.length
|
||||
const totalDurationMs = metrics.reduce((sum, metric) => {
|
||||
return sum + metric.durationMs
|
||||
}, 0)
|
||||
const totalSteps = metrics.reduce((sum, metric) => sum + metric.steps, 0)
|
||||
const totalToolCalls = metrics.reduce((sum, metric) => {
|
||||
return sum + metric.toolCalls
|
||||
}, 0)
|
||||
const totalToolErrors = metrics.reduce((sum, metric) => {
|
||||
return sum + metric.toolErrors
|
||||
}, 0)
|
||||
|
||||
return {
|
||||
taskCount,
|
||||
totalDurationMs,
|
||||
avgDurationMs: taskCount > 0 ? totalDurationMs / taskCount : 0,
|
||||
totalSteps,
|
||||
avgSteps: taskCount > 0 ? totalSteps / taskCount : 0,
|
||||
totalToolCalls,
|
||||
avgToolCalls: taskCount > 0 ? totalToolCalls / taskCount : 0,
|
||||
totalToolErrors,
|
||||
avgToolErrors: taskCount > 0 ? totalToolErrors / taskCount : 0,
|
||||
}
|
||||
}
|
||||
|
||||
export async function readTaskMetrics(
|
||||
taskPath: string,
|
||||
metadata: Record<string, unknown>,
|
||||
screenshotCount = 0,
|
||||
): Promise<EvalTaskMetrics> {
|
||||
const messages = await readFile(join(taskPath, 'messages.jsonl'), 'utf-8')
|
||||
.then(countMessageMetrics)
|
||||
.catch(() => ({ toolCalls: 0, toolErrors: 0 }))
|
||||
return buildTaskMetrics(metadata, messages, screenshotCount)
|
||||
}
|
||||
|
||||
function statusFromMetadata(metadata: Record<string, unknown>): string {
|
||||
const termination = metadata.termination_reason
|
||||
if (termination === 'timeout') return 'timeout'
|
||||
if (Array.isArray(metadata.errors) && metadata.errors.length > 0) {
|
||||
return 'failed'
|
||||
}
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
function primaryGrade(metadata: Record<string, unknown>): {
|
||||
score?: number
|
||||
pass?: boolean
|
||||
} {
|
||||
const graders = metadata.grader_results as
|
||||
| Record<string, { score?: unknown; pass?: unknown }>
|
||||
| undefined
|
||||
const first = graders ? Object.values(graders)[0] : undefined
|
||||
return {
|
||||
...(typeof first?.score === 'number' ? { score: first.score } : {}),
|
||||
...(typeof first?.pass === 'boolean' ? { pass: first.pass } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
async function readTaskDirs(runDir: string): Promise<TaskDirEntry[]> {
|
||||
const canonicalTasksDir = join(runDir, 'tasks')
|
||||
const canonicalStat = await stat(canonicalTasksDir).catch(() => null)
|
||||
const baseDir = canonicalStat?.isDirectory() ? canonicalTasksDir : runDir
|
||||
const entries = await readdir(baseDir, { withFileTypes: true }).catch(
|
||||
() => [],
|
||||
)
|
||||
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.filter((entry) => entry.name !== 'screenshots')
|
||||
.filter((entry) => entry.name !== 'tasks')
|
||||
.map((entry) => ({
|
||||
taskId: entry.name,
|
||||
taskPath: join(baseDir, entry.name),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function readRunMetricSummary(
|
||||
runDir: string,
|
||||
): Promise<EvalRunMetricSummary> {
|
||||
const tasks: EvalTaskMetricSummary[] = []
|
||||
|
||||
for (const entry of await readTaskDirs(runDir)) {
|
||||
const metadata = await readFile(
|
||||
join(entry.taskPath, 'metadata.json'),
|
||||
'utf-8',
|
||||
)
|
||||
.then((text) => JSON.parse(text) as Record<string, unknown>)
|
||||
.catch(() => null)
|
||||
if (!metadata) continue
|
||||
|
||||
const metrics = await readTaskMetrics(entry.taskPath, metadata)
|
||||
tasks.push({
|
||||
queryId: (metadata.query_id as string | undefined) || entry.taskId,
|
||||
status: statusFromMetadata(metadata),
|
||||
...primaryGrade(metadata),
|
||||
metrics,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
run: buildRunMetrics(tasks.map((task) => task.metrics)),
|
||||
tasks,
|
||||
}
|
||||
}
|
||||
@@ -36,5 +36,6 @@ export async function resolveProviderConfig(
|
||||
accessKeyId: resolveEnvValue(agent.accessKeyId),
|
||||
secretAccessKey: resolveEnvValue(agent.secretAccessKey),
|
||||
sessionToken: resolveEnvValue(agent.sessionToken),
|
||||
region: resolveEnvValue(agent.region),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
buildRunMetrics,
|
||||
type EvalRunMetrics,
|
||||
type EvalTaskMetrics,
|
||||
} from '../reporting/task-metrics'
|
||||
import type { GraderResult } from '../types'
|
||||
|
||||
export const VIEWER_MANIFEST_SCHEMA_VERSION = 2
|
||||
@@ -20,6 +25,7 @@ export interface ViewerManifestTaskInput {
|
||||
status: string
|
||||
durationMs: number
|
||||
screenshotCount: number
|
||||
metrics?: EvalTaskMetrics
|
||||
graderResults: Record<string, GraderResult>
|
||||
}
|
||||
|
||||
@@ -35,9 +41,11 @@ export interface ViewerManifest {
|
||||
suiteId?: string
|
||||
variantId?: string
|
||||
uploadedAt?: string
|
||||
reportPath?: string
|
||||
agentConfig?: Record<string, unknown>
|
||||
dataset?: string
|
||||
summary?: Record<string, unknown>
|
||||
metrics?: EvalRunMetrics
|
||||
tasks: ViewerManifestTask[]
|
||||
}
|
||||
|
||||
@@ -46,6 +54,7 @@ export interface BuildViewerManifestInput {
|
||||
suiteId?: string
|
||||
variantId?: string
|
||||
uploadedAt?: string
|
||||
reportPath?: string
|
||||
agentConfig?: Record<string, unknown>
|
||||
dataset?: string
|
||||
summary?: Record<string, unknown>
|
||||
@@ -68,22 +77,37 @@ function taskPaths(queryId: string): ViewerManifestTaskPaths {
|
||||
export function buildViewerManifest(
|
||||
input: BuildViewerManifestInput,
|
||||
): ViewerManifest {
|
||||
const tasks = input.tasks.map((task) => {
|
||||
const { artifactId, ...publicTask } = task
|
||||
const metrics =
|
||||
publicTask.metrics ??
|
||||
({
|
||||
durationMs: publicTask.durationMs,
|
||||
steps: publicTask.screenshotCount,
|
||||
screenshots: publicTask.screenshotCount,
|
||||
toolCalls: 0,
|
||||
toolErrors: 0,
|
||||
} satisfies EvalTaskMetrics)
|
||||
|
||||
return {
|
||||
...publicTask,
|
||||
metrics,
|
||||
startUrl: publicTask.startUrl ?? '',
|
||||
paths: taskPaths(artifactId ?? publicTask.queryId),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
schemaVersion: VIEWER_MANIFEST_SCHEMA_VERSION,
|
||||
runId: input.runId,
|
||||
...(input.suiteId ? { suiteId: input.suiteId } : {}),
|
||||
...(input.variantId ? { variantId: input.variantId } : {}),
|
||||
...(input.uploadedAt ? { uploadedAt: input.uploadedAt } : {}),
|
||||
...(input.reportPath ? { reportPath: input.reportPath } : {}),
|
||||
...(input.agentConfig ? { agentConfig: input.agentConfig } : {}),
|
||||
...(input.dataset ? { dataset: input.dataset } : {}),
|
||||
...(input.summary ? { summary: input.summary } : {}),
|
||||
tasks: input.tasks.map((task) => {
|
||||
const { artifactId, ...publicTask } = task
|
||||
return {
|
||||
...publicTask,
|
||||
startUrl: publicTask.startUrl ?? '',
|
||||
paths: taskPaths(artifactId ?? publicTask.queryId),
|
||||
}
|
||||
}),
|
||||
metrics: buildRunMetrics(tasks.map((task) => task.metrics)),
|
||||
tasks,
|
||||
}
|
||||
}
|
||||
|
||||
12
packages/browseros-agent/apps/eval/tests/dashboard/server.test.ts
vendored
Normal file
12
packages/browseros-agent/apps/eval/tests/dashboard/server.test.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { shouldAutoOpenDashboard } from '../../src/dashboard/server'
|
||||
|
||||
describe('dashboard server', () => {
|
||||
it('does not auto-open the dashboard in CI', () => {
|
||||
expect(shouldAutoOpenDashboard({ CI: 'true' })).toBe(false)
|
||||
})
|
||||
|
||||
it('auto-opens the dashboard outside CI by default', () => {
|
||||
expect(shouldAutoOpenDashboard({})).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -40,6 +40,7 @@ async function writeRunFixture(
|
||||
start_url: 'https://example.test',
|
||||
termination_reason: 'completed',
|
||||
total_duration_ms: 1200,
|
||||
total_steps: 4,
|
||||
screenshot_count: 1,
|
||||
agent_config: { type: 'single', model: 'kimi' },
|
||||
grader_results: {
|
||||
@@ -47,13 +48,22 @@ async function writeRunFixture(
|
||||
},
|
||||
}),
|
||||
)
|
||||
await writeFile(join(taskDir, 'messages.jsonl'), '{"type":"user"}\n')
|
||||
await writeFile(
|
||||
join(taskDir, 'messages.jsonl'),
|
||||
[
|
||||
'{"type":"user"}',
|
||||
'{"type":"tool-input-available","toolName":"click"}',
|
||||
'{"type":"tool-input-available","toolName":"take_snapshot"}',
|
||||
'{"type":"tool-output-error","toolName":"click"}',
|
||||
].join('\n'),
|
||||
)
|
||||
await writeFile(join(taskDir, 'grades.json'), '{"ok":true}')
|
||||
await writeFile(join(taskDir, 'screenshots', '1.png'), 'png')
|
||||
await writeFile(
|
||||
join(runDir, 'summary.json'),
|
||||
JSON.stringify({ passRate: 1, avgDurationMs: 1200 }),
|
||||
)
|
||||
await writeFile(join(runDir, 'report.html'), '<html>report</html>')
|
||||
return { runDir, runId: `${configName}-${timestamp}` }
|
||||
}
|
||||
|
||||
@@ -110,6 +120,9 @@ describe('R2Publisher', () => {
|
||||
expect(byKey.get(`runs/${runId}/summary.json`)?.ContentType).toBe(
|
||||
'application/json',
|
||||
)
|
||||
expect(byKey.get(`runs/${runId}/report.html`)?.ContentType).toBe(
|
||||
'text/html',
|
||||
)
|
||||
expect(byKey.get('viewer.html')?.ContentType).toBe('text/html')
|
||||
expect(result.viewerUrl).toBe(
|
||||
`https://eval.example.test/viewer.html?run=${runId}`,
|
||||
@@ -126,12 +139,28 @@ describe('R2Publisher', () => {
|
||||
uploadedAt: '2026-04-29T12:00:00.000Z',
|
||||
agentConfig: { type: 'single', model: 'kimi' },
|
||||
dataset: 'webbench',
|
||||
reportPath: 'report.html',
|
||||
summary: { passRate: 1, avgDurationMs: 1200 },
|
||||
metrics: {
|
||||
taskCount: 1,
|
||||
avgDurationMs: 1200,
|
||||
avgSteps: 4,
|
||||
avgToolCalls: 2,
|
||||
totalToolCalls: 2,
|
||||
totalToolErrors: 1,
|
||||
},
|
||||
tasks: [
|
||||
{
|
||||
queryId: 'task-1',
|
||||
status: 'completed',
|
||||
screenshotCount: 1,
|
||||
metrics: {
|
||||
durationMs: 1200,
|
||||
steps: 4,
|
||||
screenshots: 1,
|
||||
toolCalls: 2,
|
||||
toolErrors: 1,
|
||||
},
|
||||
paths: {
|
||||
attempt: 'tasks/task-1/attempt.json',
|
||||
metadata: 'tasks/task-1/metadata.json',
|
||||
|
||||
@@ -6,6 +6,7 @@ interface ViewerPathResolvers {
|
||||
artifactUrl(task: Record<string, unknown>, artifact: string): string
|
||||
metadataUrl(task: Record<string, unknown>): string
|
||||
messagesUrl(task: Record<string, unknown>): string
|
||||
reportUrl(manifest: Record<string, unknown>): string | null
|
||||
screenshotUrl(task: Record<string, unknown>, step: number): string
|
||||
}
|
||||
|
||||
@@ -24,7 +25,7 @@ async function loadViewerPathResolvers(): Promise<ViewerPathResolvers> {
|
||||
`
|
||||
const basePath = 'runs/run-1';
|
||||
${block}
|
||||
return { artifactUrl, metadataUrl, messagesUrl, screenshotUrl };
|
||||
return { artifactUrl, metadataUrl, messagesUrl, reportUrl, screenshotUrl };
|
||||
`,
|
||||
) as () => ViewerPathResolvers
|
||||
return createResolvers()
|
||||
@@ -60,6 +61,35 @@ async function runAutoSelectFromHash(hash: string): Promise<unknown> {
|
||||
return runAutoSelect()
|
||||
}
|
||||
|
||||
async function runComputeStats(): Promise<unknown> {
|
||||
const html = await readFile(
|
||||
join(import.meta.dir, '..', '..', 'src', 'dashboard', 'viewer.html'),
|
||||
'utf-8',
|
||||
)
|
||||
const start = html.indexOf('function computeStats(tasks)')
|
||||
const end = html.indexOf('function resolveStatus(task)', start)
|
||||
expect(start).toBeGreaterThan(-1)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
|
||||
const block = html.slice(start, end)
|
||||
const compute = new Function(
|
||||
`
|
||||
${block}
|
||||
return computeStats([
|
||||
{
|
||||
graderResults: { agisdk_state_diff: { pass: true, score: 1 } },
|
||||
metrics: { durationMs: 1000, steps: 4, toolCalls: 3, toolErrors: 0 }
|
||||
},
|
||||
{
|
||||
graderResults: { agisdk_state_diff: { pass: false, score: 0 } },
|
||||
metrics: { durationMs: 3000, steps: 8, toolCalls: 5, toolErrors: 2 }
|
||||
}
|
||||
]);
|
||||
`,
|
||||
) as () => unknown
|
||||
return compute()
|
||||
}
|
||||
|
||||
describe('R2 viewer artifact path compatibility', () => {
|
||||
it('uses explicit manifest paths for new uploaded runs', async () => {
|
||||
const resolvers = await loadViewerPathResolvers()
|
||||
@@ -95,6 +125,15 @@ describe('R2 viewer artifact path compatibility', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves manifest-level run report links', async () => {
|
||||
const resolvers = await loadViewerPathResolvers()
|
||||
|
||||
expect(resolvers.reportUrl({ reportPath: 'report.html' })).toBe(
|
||||
'runs/run-1/report.html',
|
||||
)
|
||||
expect(resolvers.reportUrl({})).toBe(null)
|
||||
})
|
||||
|
||||
it('falls back to legacy inferred paths for old uploaded runs', async () => {
|
||||
const resolvers = await loadViewerPathResolvers()
|
||||
const task = { queryId: 'legacy-task' }
|
||||
@@ -127,4 +166,17 @@ describe('R2 viewer artifact path compatibility', () => {
|
||||
queryId: 'legacy-task',
|
||||
})
|
||||
})
|
||||
|
||||
it('computes run-level timing and tool metrics for the viewer', async () => {
|
||||
expect(await runComputeStats()).toMatchObject({
|
||||
total: 2,
|
||||
passed: 1,
|
||||
failed: 1,
|
||||
avgDurationMs: 2000,
|
||||
avgSteps: 6,
|
||||
avgToolCalls: 4,
|
||||
totalToolCalls: 8,
|
||||
totalToolErrors: 2,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
159
packages/browseros-agent/apps/eval/tests/reporting/generate-report-script.test.ts
vendored
Normal file
159
packages/browseros-agent/apps/eval/tests/reporting/generate-report-script.test.ts
vendored
Normal file
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
DEFAULT_REPORT_MAX_TURNS,
|
||||
DEFAULT_REPORT_MODEL,
|
||||
generateEvalReport,
|
||||
runClaudeCodeReportAgent,
|
||||
} from '../../scripts/generate-report'
|
||||
|
||||
async function writeRunFixture(): Promise<string> {
|
||||
const runDir = await mkdtemp(join(tmpdir(), 'eval-report-script-'))
|
||||
const taskDir = join(runDir, 'agisdk-networkin-10')
|
||||
await mkdir(join(taskDir, 'screenshots'), { recursive: true })
|
||||
await writeFile(
|
||||
join(runDir, 'summary.json'),
|
||||
JSON.stringify({
|
||||
total: 1,
|
||||
completed: 1,
|
||||
passRate: 0,
|
||||
avgDurationMs: 1234,
|
||||
}),
|
||||
)
|
||||
await writeFile(
|
||||
join(taskDir, 'metadata.json'),
|
||||
JSON.stringify({
|
||||
query_id: 'agisdk-networkin-10',
|
||||
dataset: 'agisdk-real',
|
||||
query: 'Send a follow-up message starting with "Following up on".',
|
||||
termination_reason: 'completed',
|
||||
total_duration_ms: 1234,
|
||||
total_steps: 2,
|
||||
screenshot_count: 1,
|
||||
final_answer: 'No app action was taken.',
|
||||
errors: [],
|
||||
warnings: [],
|
||||
agent_config: { type: 'single', model: 'kimi' },
|
||||
grader_results: {
|
||||
agisdk_state_diff: {
|
||||
score: 0,
|
||||
pass: false,
|
||||
reasoning: 'Some criteria failed',
|
||||
details: {
|
||||
per_criterion: [
|
||||
{ passed: true, detail: 'message starts correctly' },
|
||||
{ passed: false, detail: 'message was not sent' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
await writeFile(
|
||||
join(taskDir, 'messages.jsonl'),
|
||||
[
|
||||
JSON.stringify({
|
||||
type: 'tool-input-available',
|
||||
timestamp: '2026-04-30T00:00:00.000Z',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'memory_search',
|
||||
input: { q: 'chat' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'tool-output-error',
|
||||
timestamp: '2026-04-30T00:00:01.000Z',
|
||||
toolCallId: 'call-1',
|
||||
errorText: 'memory unavailable',
|
||||
}),
|
||||
].join('\n'),
|
||||
)
|
||||
await writeFile(join(taskDir, 'screenshots', '1.png'), 'png')
|
||||
return runDir
|
||||
}
|
||||
|
||||
describe('generate-report script', () => {
|
||||
it('delegates report.html creation to Claude Code', async () => {
|
||||
const runDir = await writeRunFixture()
|
||||
const outputPath = join(runDir, 'report.html')
|
||||
let prompt = ''
|
||||
|
||||
await generateEvalReport({
|
||||
inputDir: runDir,
|
||||
outputPath,
|
||||
runAgent: async (invocation) => {
|
||||
prompt = invocation.prompt
|
||||
await writeFile(
|
||||
invocation.outputPath,
|
||||
'<!doctype html><h1>Claude-written report</h1>',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
expect(await readFile(outputPath, 'utf-8')).toContain(
|
||||
'Claude-written report',
|
||||
)
|
||||
expect(prompt).toContain('AGI SDK Random-10 Failure Report')
|
||||
expect(prompt).toContain('summary.json')
|
||||
expect(prompt).toContain('messages.jsonl')
|
||||
expect(prompt).toContain('screenshots')
|
||||
expect(prompt).toContain('Deterministic run metrics')
|
||||
expect(prompt).toContain('"queryId": "agisdk-networkin-10"')
|
||||
expect(prompt).toContain('"toolCalls": 1')
|
||||
expect(prompt).toContain('"toolErrors": 1')
|
||||
expect(prompt).toContain('Duration by task')
|
||||
expect(prompt).toContain('Tool calls by task')
|
||||
expect(prompt).toContain(outputPath)
|
||||
})
|
||||
|
||||
it('fails when the Claude Code agent does not write the report', async () => {
|
||||
const runDir = await writeRunFixture()
|
||||
|
||||
await expect(
|
||||
generateEvalReport({
|
||||
inputDir: runDir,
|
||||
outputPath: join(runDir, 'missing-report.html'),
|
||||
runAgent: async () => {},
|
||||
}),
|
||||
).rejects.toThrow('Report was not written')
|
||||
})
|
||||
|
||||
it('runs Claude Code with Opus 4.6, full bypass, and bounded turns', async () => {
|
||||
const runDir = await writeRunFixture()
|
||||
const calls: unknown[] = []
|
||||
|
||||
await runClaudeCodeReportAgent(
|
||||
{
|
||||
inputDir: runDir,
|
||||
outputPath: join(runDir, 'report.html'),
|
||||
prompt: 'write the report',
|
||||
},
|
||||
{
|
||||
query: async function* (call: unknown) {
|
||||
calls.push(call)
|
||||
yield { type: 'result', subtype: 'success', result: 'done' }
|
||||
},
|
||||
env: {
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'token',
|
||||
EVAL_R2_SECRET_ACCESS_KEY: 'secret',
|
||||
HOME: '/tmp/home',
|
||||
PATH: '/bin',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
prompt: 'write the report',
|
||||
options: {
|
||||
cwd: runDir,
|
||||
model: DEFAULT_REPORT_MODEL,
|
||||
maxTurns: DEFAULT_REPORT_MAX_TURNS,
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
},
|
||||
})
|
||||
expect(JSON.stringify(calls[0])).not.toContain('secret')
|
||||
})
|
||||
})
|
||||
@@ -13,10 +13,10 @@ describe('adaptEvalConfigFile', () => {
|
||||
expect(adapted.suite.id).toBe('browseros-agent-weekly')
|
||||
expect(adapted.suite.dataset).toBe('../../data/agisdk-real.jsonl')
|
||||
expect(adapted.suite.graders).toEqual(['agisdk_state_diff'])
|
||||
expect(adapted.suite.workers).toBe(10)
|
||||
expect(adapted.suite.workers).toBe(3)
|
||||
expect(adapted.suite.restartBrowserPerTask).toBe(true)
|
||||
expect(adapted.suite.timeoutMs).toBe(1_800_000)
|
||||
expect(adapted.evalConfig.num_workers).toBe(10)
|
||||
expect(adapted.evalConfig.num_workers).toBe(3)
|
||||
expect(adapted.evalConfig.browseros.server_url).toBe(
|
||||
'http://127.0.0.1:9110',
|
||||
)
|
||||
@@ -38,6 +38,34 @@ describe('adaptEvalConfigFile', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('adapts BrowserOS AGI SDK comparison configs', async () => {
|
||||
const kimi = await adaptEvalConfigFile(
|
||||
'apps/eval/configs/legacy/browseros-agent-kimi-k2-5-agisdk-real.json',
|
||||
)
|
||||
const opus = await adaptEvalConfigFile(
|
||||
'apps/eval/configs/legacy/browseros-agent-opus-4-6-agisdk-real.json',
|
||||
)
|
||||
|
||||
expect(kimi.suite.id).toBe('browseros-agent-kimi-k2-5-agisdk-real')
|
||||
expect(kimi.evalConfig.agent).toMatchObject({
|
||||
type: 'single',
|
||||
provider: 'openai-compatible',
|
||||
model: 'moonshotai/kimi-k2.5',
|
||||
})
|
||||
expect(kimi.evalConfig.num_workers).toBe(3)
|
||||
|
||||
expect(opus.suite.id).toBe('browseros-agent-opus-4-6-agisdk-real')
|
||||
expect(opus.evalConfig.agent).toMatchObject({
|
||||
type: 'single',
|
||||
provider: 'bedrock',
|
||||
model: 'global.anthropic.claude-opus-4-6-v1',
|
||||
region: 'AWS_REGION',
|
||||
accessKeyId: 'AWS_ACCESS_KEY_ID',
|
||||
secretAccessKey: 'AWS_SECRET_ACCESS_KEY',
|
||||
})
|
||||
expect(opus.evalConfig.num_workers).toBe(2)
|
||||
})
|
||||
|
||||
it('adapts claude-code configs without provider credentials', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'claude-code-config-'))
|
||||
const configPath = join(dir, 'claude-code-agisdk.json')
|
||||
|
||||
38
packages/browseros-agent/apps/eval/tests/utils/resolve-provider-config.test.ts
vendored
Normal file
38
packages/browseros-agent/apps/eval/tests/utils/resolve-provider-config.test.ts
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { resolveProviderConfig } from '../../src/utils/resolve-provider-config'
|
||||
|
||||
describe('resolveProviderConfig', () => {
|
||||
it('resolves Bedrock region from environment variables', async () => {
|
||||
const previous = {
|
||||
AWS_REGION: process.env.AWS_REGION,
|
||||
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
}
|
||||
process.env.AWS_REGION = 'us-west-2'
|
||||
process.env.AWS_ACCESS_KEY_ID = 'test-access-key'
|
||||
process.env.AWS_SECRET_ACCESS_KEY = 'test-secret-key'
|
||||
|
||||
try {
|
||||
const resolved = await resolveProviderConfig({
|
||||
provider: 'bedrock',
|
||||
model: 'global.anthropic.claude-opus-4-6-v1',
|
||||
region: 'AWS_REGION',
|
||||
accessKeyId: 'AWS_ACCESS_KEY_ID',
|
||||
secretAccessKey: 'AWS_SECRET_ACCESS_KEY',
|
||||
})
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
provider: 'bedrock',
|
||||
model: 'global.anthropic.claude-opus-4-6-v1',
|
||||
region: process.env.AWS_REGION,
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
})
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ describe('buildViewerManifest', () => {
|
||||
suiteId: 'agisdk-daily-10',
|
||||
variantId: 'kimi',
|
||||
uploadedAt: '2026-04-29T06:00:00.000Z',
|
||||
reportPath: 'report.html',
|
||||
summary: { total: 1, passRate: 0 },
|
||||
tasks: [
|
||||
{
|
||||
@@ -18,6 +19,13 @@ describe('buildViewerManifest', () => {
|
||||
status: 'completed',
|
||||
durationMs: 353_000,
|
||||
screenshotCount: 42,
|
||||
metrics: {
|
||||
durationMs: 353_000,
|
||||
steps: 47,
|
||||
screenshots: 42,
|
||||
toolCalls: 19,
|
||||
toolErrors: 2,
|
||||
},
|
||||
graderResults: {
|
||||
agisdk_state_diff: {
|
||||
score: 0,
|
||||
@@ -32,6 +40,7 @@ describe('buildViewerManifest', () => {
|
||||
|
||||
const publishManifest: R2RunManifest = manifest
|
||||
expect(publishManifest.schemaVersion).toBe(2)
|
||||
expect(manifest.reportPath).toBe('report.html')
|
||||
expect(manifest.tasks[0].paths.messages).toBe(
|
||||
'tasks/agisdk-dashdish-4/messages.jsonl',
|
||||
)
|
||||
@@ -41,6 +50,21 @@ describe('buildViewerManifest', () => {
|
||||
expect(manifest.tasks[0].paths.graderArtifacts).toBe(
|
||||
'tasks/agisdk-dashdish-4/grader-artifacts',
|
||||
)
|
||||
expect(manifest.metrics).toMatchObject({
|
||||
taskCount: 1,
|
||||
avgDurationMs: 353_000,
|
||||
avgSteps: 47,
|
||||
avgToolCalls: 19,
|
||||
totalToolCalls: 19,
|
||||
totalToolErrors: 2,
|
||||
})
|
||||
expect(manifest.tasks[0].metrics).toEqual({
|
||||
durationMs: 353_000,
|
||||
steps: 47,
|
||||
screenshots: 42,
|
||||
toolCalls: 19,
|
||||
toolErrors: 2,
|
||||
})
|
||||
expect(manifest.tasks[0].graderResults.agisdk_state_diff.details).toEqual({
|
||||
missing: ['checkout item'],
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
tmp-shot-*/
|
||||
tmp-upload-*/
|
||||
.devtools
|
||||
db/
|
||||
identity/
|
||||
|
||||
7
packages/browseros-agent/apps/server/drizzle.config.ts
Normal file
7
packages/browseros-agent/apps/server/drizzle.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'drizzle-kit'
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'sqlite',
|
||||
schema: './src/lib/db/schema/index.ts',
|
||||
out: './src/lib/db/migrations',
|
||||
})
|
||||
@@ -11,6 +11,7 @@
|
||||
"start": "bun --watch --env-file=.env.development src/index.ts",
|
||||
"start:ci": "bun --env-file=.env.development src/index.ts",
|
||||
"build": "bun ../../scripts/build/server.ts --target=all",
|
||||
"db:generate": "drizzle-kit generate --config drizzle.config.ts",
|
||||
"test": "bun run test:all",
|
||||
"test:all": "bun run ./tests/__helpers__/run-test-group.ts all",
|
||||
"test:agent": "bun run ./tests/__helpers__/run-test-group.ts agent",
|
||||
@@ -100,6 +101,7 @@
|
||||
"commander": "^14.0.1",
|
||||
"core-js": "3.45.1",
|
||||
"debug": "4.4.3",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
@@ -122,6 +124,7 @@
|
||||
"@types/sinon": "^21.0.0",
|
||||
"@types/ws": "^8.5.13",
|
||||
"async-mutex": "^0.5.0",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"puppeteer": "24.23.0",
|
||||
"sinon": "^21.0.1",
|
||||
|
||||
@@ -503,7 +503,7 @@ async function scenarioConfig(): Promise<void> {
|
||||
await tearDown(s)
|
||||
return
|
||||
}
|
||||
const newValue = target.options![0].value
|
||||
const newValue = target.options?.[0].value
|
||||
console.log(`[config] setting configId=${target.id} value=${newValue}`)
|
||||
try {
|
||||
// @ts-expect-error - input shape varies
|
||||
|
||||
@@ -224,6 +224,7 @@ export class AiSdkAgent {
|
||||
userSystemPrompt: config.resolvedConfig.userSystemPrompt,
|
||||
exclude: excludeSections,
|
||||
isScheduledTask: config.resolvedConfig.isScheduledTask,
|
||||
scheduledTaskRunSilently: config.resolvedConfig.scheduledTaskRunSilently,
|
||||
scheduledTaskPageId: config.browserContext?.activeTab?.pageId,
|
||||
workspaceDir: config.resolvedConfig.workingDir,
|
||||
soulContent,
|
||||
|
||||
@@ -48,8 +48,11 @@ You do not have a filesystem workspace in this session. Return all results direc
|
||||
|
||||
// Mode-aware framing
|
||||
if (options?.isScheduledTask) {
|
||||
role +=
|
||||
'\n\nYou are running as a scheduled background task on a system-managed hidden page. Complete the task autonomously and report results.'
|
||||
const location =
|
||||
options.scheduledTaskRunSilently !== false
|
||||
? ' on a system-managed hidden page'
|
||||
: ''
|
||||
role += `\n\nYou are running as a scheduled background task${location}. Complete the task autonomously and report results.`
|
||||
} else if (options?.chatMode) {
|
||||
role +=
|
||||
'\n\nYou are in read-only chat mode. You can observe pages but cannot interact with them, modify files, or store memories.'
|
||||
@@ -659,9 +662,13 @@ function getUserContext(
|
||||
if (!options?.chatMode) {
|
||||
let pageCtx = '<page_context>'
|
||||
|
||||
const isSilentScheduledTask =
|
||||
options?.isScheduledTask && options.scheduledTaskRunSilently !== false
|
||||
|
||||
if (options?.isScheduledTask) {
|
||||
pageCtx +=
|
||||
'\nYou are running as a **scheduled background task** on a system-managed hidden page.'
|
||||
pageCtx += isSilentScheduledTask
|
||||
? '\nYou are running as a **scheduled background task** on a system-managed hidden page.'
|
||||
: '\nYou are running as a **scheduled background task**.'
|
||||
}
|
||||
|
||||
pageCtx +=
|
||||
@@ -671,14 +678,21 @@ function getUserContext(
|
||||
const pageRef = options.scheduledTaskPageId
|
||||
? `\`${options.scheduledTaskPageId}\``
|
||||
: 'the page ID from the Browser Context'
|
||||
pageCtx += `\n2. **Use starting page ID ${pageRef} directly.** For additional browsing, prefer \`new_hidden_page\` so the work stays invisible to the user.`
|
||||
pageCtx +=
|
||||
'\n3. **Do NOT close your starting hidden page** (via `close_page` on that page ID). It is managed by the system and will be cleaned up automatically.'
|
||||
pageCtx +=
|
||||
'\n4. **Do NOT create new windows** (via `create_window` or `create_hidden_window`). Use hidden pages instead.'
|
||||
pageCtx +=
|
||||
'\n5. **Close extra hidden pages when you are done with them** unless you explicitly reveal them with `show_page`.'
|
||||
pageCtx += '\n6. Complete the task end-to-end and report results.'
|
||||
if (isSilentScheduledTask) {
|
||||
pageCtx += `\n2. **Use starting page ID ${pageRef} directly.** For additional browsing, prefer \`new_hidden_page\` so the work stays invisible to the user.`
|
||||
pageCtx +=
|
||||
'\n3. **Do NOT close your starting hidden page** (via `close_page` on that page ID). It is managed by the system and will be cleaned up automatically.'
|
||||
pageCtx +=
|
||||
'\n4. **Do NOT create new windows** (via `create_window` or `create_hidden_window`). Use hidden pages instead.'
|
||||
pageCtx +=
|
||||
'\n5. **Close extra hidden pages when you are done with them** unless you explicitly reveal them with `show_page`.'
|
||||
pageCtx += '\n6. Complete the task end-to-end and report results.'
|
||||
} else {
|
||||
pageCtx += `\n2. **Use starting page ID ${pageRef} directly.** For additional browsing, prefer \`new_page\` with background mode so the task does not steal focus.`
|
||||
pageCtx +=
|
||||
'\n3. **Do NOT create new windows** unless the scheduled task explicitly requires a separate window.'
|
||||
pageCtx += '\n4. Complete the task end-to-end and report results.'
|
||||
}
|
||||
}
|
||||
|
||||
pageCtx += '\n</page_context>'
|
||||
@@ -739,6 +753,7 @@ export interface BuildSystemPromptOptions {
|
||||
userSystemPrompt?: string
|
||||
exclude?: string[]
|
||||
isScheduledTask?: boolean
|
||||
scheduledTaskRunSilently?: boolean
|
||||
scheduledTaskPageId?: number
|
||||
workspaceDir?: string
|
||||
soulContent?: string
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface ResolvedAgentConfig {
|
||||
chatMode?: boolean
|
||||
/** Scheduled task mode - disables tab grouping. Defaults to false. */
|
||||
isScheduledTask?: boolean
|
||||
/** Scheduled task silent mode - runs on hidden pages without visible windows. Defaults to true. */
|
||||
scheduledTaskRunSilently?: boolean
|
||||
/** Apps the user previously declined to connect via MCP (chose "do it manually"). */
|
||||
declinedApps?: string[]
|
||||
/** Where the chat session originates from — determines navigation behavior. */
|
||||
|
||||
@@ -35,15 +35,18 @@ import {
|
||||
type AgentDefinitionWithActivity,
|
||||
AgentHarnessService,
|
||||
type GatewayStatusSnapshot,
|
||||
HermesProviderConfigInvalidError,
|
||||
InvalidAgentUpdateError,
|
||||
MessageQueueFullError,
|
||||
type OpenClawProvisioner,
|
||||
OpenClawProvisionerUnavailableError,
|
||||
type ProducedFileEntry,
|
||||
type ProducedFilesRailGroup,
|
||||
type QueuedMessage,
|
||||
TurnAlreadyActiveError,
|
||||
UnknownAgentError,
|
||||
} from '../services/agents/agent-harness-service'
|
||||
import type { OpenClawGatewayChatClient } from '../services/openclaw/openclaw-gateway-chat-client'
|
||||
import type { FilePreview } from '../services/openclaw/file-preview'
|
||||
import type { Env } from '../types'
|
||||
import { resolveBrowserContextPageIds } from '../utils/resolve-browser-context-page-ids'
|
||||
|
||||
@@ -95,6 +98,23 @@ type AgentRouteService = {
|
||||
messageId: string
|
||||
}): Promise<boolean>
|
||||
listQueuedMessages(agentId: string): Promise<QueuedMessage[]>
|
||||
|
||||
// Files API — Phase 3 of TKT-762.
|
||||
listAgentFiles(
|
||||
agentId: string,
|
||||
options?: { limit?: number },
|
||||
): Promise<ProducedFilesRailGroup[]>
|
||||
listAgentFilesForTurn(
|
||||
agentId: string,
|
||||
turnId: string,
|
||||
): Promise<ProducedFileEntry[]>
|
||||
previewProducedFile(fileId: string): Promise<FilePreview | null>
|
||||
resolveProducedFileForDownload(fileId: string): Promise<{
|
||||
absolutePath: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
} | null>
|
||||
}
|
||||
|
||||
type AgentRouteDeps = {
|
||||
@@ -109,18 +129,19 @@ type AgentRouteDeps = {
|
||||
openclawGateway?: OpenclawGatewayAccessor
|
||||
/**
|
||||
* Optional. Enables the image-attachment carve-out for OpenClaw
|
||||
* agents — image-bearing turns route through the gateway HTTP
|
||||
* `/v1/chat/completions` instead of the ACP bridge (which drops
|
||||
* image content blocks).
|
||||
*/
|
||||
openclawGatewayChat?: OpenClawGatewayChatClient
|
||||
/**
|
||||
* Required to dual-create/delete `openclaw` adapter agents on the
|
||||
* gateway side. Without this, openclaw create requests fail with 503.
|
||||
*/
|
||||
openclawProvisioner?: OpenClawProvisioner
|
||||
/** Optional override; defaults to a fresh in-memory checker. */
|
||||
adapterHealth?: AdapterHealthChecker
|
||||
/**
|
||||
* Optional listener attached to the constructed harness. Receives
|
||||
* turn lifecycle events for every running agent. Wired by the server
|
||||
* to feed OpenClaw's ClawSession dashboard from the same stream the
|
||||
* chat panel sees, so no second WS observer is needed.
|
||||
*/
|
||||
onTurnLifecycle?: import('../services/agents/agent-harness-service').TurnLifecycleListener
|
||||
}
|
||||
|
||||
type SidepanelAgentChatRequest = {
|
||||
@@ -139,266 +160,381 @@ export function createAgentRoutes(deps: AgentRouteDeps = {}) {
|
||||
new AgentHarnessService({
|
||||
browserosServerPort: deps.browserosServerPort,
|
||||
openclawGateway: deps.openclawGateway,
|
||||
openclawGatewayChat: deps.openclawGatewayChat,
|
||||
openclawProvisioner: deps.openclawProvisioner,
|
||||
})
|
||||
if (deps.onTurnLifecycle && service instanceof AgentHarnessService) {
|
||||
service.onTurnLifecycle(deps.onTurnLifecycle)
|
||||
}
|
||||
// One checker per route mount. Cached probes refresh every 5min;
|
||||
// tests can swap in an alternate via deps if needed.
|
||||
const adapterHealth = deps.adapterHealth ?? new AdapterHealthChecker()
|
||||
|
||||
return new Hono<Env>()
|
||||
.get('/adapters', async (c) => {
|
||||
const adapters = await Promise.all(
|
||||
AGENT_ADAPTER_CATALOG.map(async (descriptor) => ({
|
||||
...descriptor,
|
||||
health: await adapterHealth.getHealth(descriptor.id),
|
||||
})),
|
||||
)
|
||||
return c.json({ adapters })
|
||||
})
|
||||
.get('/', async (c) => {
|
||||
// Single round-trip the agents page consumes: enriched agents
|
||||
// (status + lastUsedAt) plus the gateway lifecycle snapshot the
|
||||
// GatewayStatusBar / GatewayStateCards / ControlPlaneAlert used
|
||||
// to fetch from `/claw/status`. Lets the page poll one endpoint.
|
||||
const [agents, gateway] = await Promise.all([
|
||||
service.listAgentsWithActivity(),
|
||||
service.getGatewayStatus(),
|
||||
])
|
||||
return c.json({ agents, gateway })
|
||||
})
|
||||
.post('/', async (c) => {
|
||||
const parsed = await parseCreateAgentBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
return c.json({ agent: await service.createAgent(parsed) })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/sidepanel/chat', async (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const parsed = await parseSidepanelAgentChatBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
|
||||
try {
|
||||
const agent = await service.getAgent(agentId)
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
|
||||
let browserContext = parsed.browserContext
|
||||
if (deps.browser) {
|
||||
browserContext = await resolveBrowserContextPageIds(
|
||||
deps.browser,
|
||||
browserContext,
|
||||
)
|
||||
}
|
||||
|
||||
const userContent = formatUserMessage(
|
||||
parsed.message,
|
||||
browserContext,
|
||||
parsed.selectedText,
|
||||
parsed.selectedTextSource,
|
||||
return (
|
||||
new Hono<Env>()
|
||||
.get('/adapters', async (c) => {
|
||||
const adapters = await Promise.all(
|
||||
AGENT_ADAPTER_CATALOG.map(async (descriptor) => ({
|
||||
...descriptor,
|
||||
health: await adapterHealth.getHealth(descriptor.id),
|
||||
})),
|
||||
)
|
||||
const message = parsed.userSystemPrompt?.trim()
|
||||
? `${parsed.userSystemPrompt.trim()}\n\n${userContent}`
|
||||
: userContent
|
||||
return c.json({ adapters })
|
||||
})
|
||||
.get('/', async (c) => {
|
||||
// Single round-trip the agents page consumes: enriched agents
|
||||
// (status + lastUsedAt) plus the gateway lifecycle snapshot the
|
||||
// GatewayStatusBar / GatewayStateCards / ControlPlaneAlert used
|
||||
// to fetch from `/claw/status`. Lets the page poll one endpoint.
|
||||
const [agents, gateway] = await Promise.all([
|
||||
service.listAgentsWithActivity(),
|
||||
service.getGatewayStatus(),
|
||||
])
|
||||
return c.json({ agents, gateway })
|
||||
})
|
||||
.post('/', async (c) => {
|
||||
const parsed = await parseCreateAgentBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
return c.json({ agent: await service.createAgent(parsed) })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/sidepanel/chat', async (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const parsed = await parseSidepanelAgentChatBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
|
||||
try {
|
||||
const agent = await service.getAgent(agentId)
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
|
||||
let browserContext = parsed.browserContext
|
||||
if (deps.browser) {
|
||||
browserContext = await resolveBrowserContextPageIds(
|
||||
deps.browser,
|
||||
browserContext,
|
||||
)
|
||||
}
|
||||
|
||||
const userContent = formatUserMessage(
|
||||
parsed.message,
|
||||
browserContext,
|
||||
parsed.selectedText,
|
||||
parsed.selectedTextSource,
|
||||
)
|
||||
const message = parsed.userSystemPrompt?.trim()
|
||||
? `${parsed.userSystemPrompt.trim()}\n\n${userContent}`
|
||||
: userContent
|
||||
|
||||
let started: { turnId: string; frames: ReadableStream<TurnFrame> }
|
||||
try {
|
||||
started = await service.startTurn({
|
||||
agentId: agent.id,
|
||||
message,
|
||||
cwd: parsed.userWorkingDir,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof TurnAlreadyActiveError) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Turn already active',
|
||||
turnId: err.turnId,
|
||||
attachUrl: `/agents/${agent.id}/chat/stream?turnId=${err.turnId}`,
|
||||
},
|
||||
409,
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
let didRequestCancel = false
|
||||
const cancelStartedTurn = () => {
|
||||
if (didRequestCancel) return
|
||||
didRequestCancel = true
|
||||
service.cancelTurn({
|
||||
agentId: agent.id,
|
||||
turnId: started.turnId,
|
||||
reason: 'sidepanel stream cancelled',
|
||||
})
|
||||
}
|
||||
if (c.req.raw.signal.aborted) {
|
||||
cancelStartedTurn()
|
||||
} else {
|
||||
c.req.raw.signal.addEventListener('abort', cancelStartedTurn, {
|
||||
once: true,
|
||||
})
|
||||
}
|
||||
|
||||
const events = turnFramesToAgentEvents(started.frames, {
|
||||
onCancel: cancelStartedTurn,
|
||||
})
|
||||
|
||||
return createAcpUIMessageStreamResponse(events, {
|
||||
headers: {
|
||||
'X-Session-Id': 'main',
|
||||
'X-Turn-Id': started.turnId,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId', async (c) => {
|
||||
try {
|
||||
const agent = await service.getAgent(c.req.param('agentId'))
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
return c.json({ agent })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.delete('/:agentId', async (c) => {
|
||||
try {
|
||||
return c.json({
|
||||
success: await service.deleteAgent(c.req.param('agentId')),
|
||||
})
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.patch('/:agentId', async (c) => {
|
||||
const parsed = await parseAgentPatchBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
const agent = await service.updateAgent(
|
||||
c.req.param('agentId'),
|
||||
parsed.patch,
|
||||
)
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
return c.json({ agent })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId/sessions/main/history', async (c) => {
|
||||
try {
|
||||
return c.json(await service.getHistory(c.req.param('agentId')))
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/chat', async (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const parsed = await parseChatBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
|
||||
let started: { turnId: string; frames: ReadableStream<TurnFrame> }
|
||||
try {
|
||||
started = await service.startTurn({
|
||||
agentId: agent.id,
|
||||
message,
|
||||
cwd: parsed.userWorkingDir,
|
||||
agentId,
|
||||
message: parsed.message,
|
||||
attachments: parsed.attachments,
|
||||
cwd: parsed.cwd,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof TurnAlreadyActiveError) {
|
||||
// Caller can attach via GET /chat/stream?turnId=… instead.
|
||||
return c.json(
|
||||
{
|
||||
error: 'Turn already active',
|
||||
turnId: err.turnId,
|
||||
attachUrl: `/agents/${agent.id}/chat/stream?turnId=${err.turnId}`,
|
||||
attachUrl: `/agents/${agentId}/chat/stream?turnId=${err.turnId}`,
|
||||
},
|
||||
409,
|
||||
)
|
||||
}
|
||||
throw err
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
|
||||
let didRequestCancel = false
|
||||
const cancelStartedTurn = () => {
|
||||
if (didRequestCancel) return
|
||||
didRequestCancel = true
|
||||
service.cancelTurn({
|
||||
agentId: agent.id,
|
||||
turnId: started.turnId,
|
||||
reason: 'sidepanel stream cancelled',
|
||||
})
|
||||
}
|
||||
if (c.req.raw.signal.aborted) {
|
||||
cancelStartedTurn()
|
||||
} else {
|
||||
c.req.raw.signal.addEventListener('abort', cancelStartedTurn, {
|
||||
once: true,
|
||||
})
|
||||
}
|
||||
|
||||
const events = turnFramesToAgentEvents(started.frames, {
|
||||
onCancel: cancelStartedTurn,
|
||||
return streamTurnFrames(c, started.frames, {
|
||||
turnId: started.turnId,
|
||||
})
|
||||
|
||||
return createAcpUIMessageStreamResponse(events, {
|
||||
headers: {
|
||||
'X-Session-Id': 'main',
|
||||
'X-Turn-Id': started.turnId,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId', async (c) => {
|
||||
try {
|
||||
const agent = await service.getAgent(c.req.param('agentId'))
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
return c.json({ agent })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.delete('/:agentId', async (c) => {
|
||||
try {
|
||||
return c.json({
|
||||
success: await service.deleteAgent(c.req.param('agentId')),
|
||||
})
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.patch('/:agentId', async (c) => {
|
||||
const parsed = await parseAgentPatchBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
const agent = await service.updateAgent(
|
||||
c.req.param('agentId'),
|
||||
parsed.patch,
|
||||
)
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
return c.json({ agent })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId/sessions/main/history', async (c) => {
|
||||
try {
|
||||
return c.json(await service.getHistory(c.req.param('agentId')))
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/chat', async (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const parsed = await parseChatBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
|
||||
let started: { turnId: string; frames: ReadableStream<TurnFrame> }
|
||||
try {
|
||||
started = await service.startTurn({
|
||||
agentId,
|
||||
message: parsed.message,
|
||||
attachments: parsed.attachments,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof TurnAlreadyActiveError) {
|
||||
// Caller can attach via GET /chat/stream?turnId=… instead.
|
||||
return c.json(
|
||||
{
|
||||
error: 'Turn already active',
|
||||
turnId: err.turnId,
|
||||
attachUrl: `/agents/${agentId}/chat/stream?turnId=${err.turnId}`,
|
||||
},
|
||||
409,
|
||||
)
|
||||
}
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
|
||||
return streamTurnFrames(c, started.frames, {
|
||||
turnId: started.turnId,
|
||||
})
|
||||
})
|
||||
.get('/:agentId/chat/active', (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const info = service.getActiveTurn(agentId, 'main')
|
||||
return c.json({ active: info })
|
||||
})
|
||||
.get('/:agentId/chat/stream', (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const url = new URL(c.req.url)
|
||||
const queryTurnId = url.searchParams.get('turnId')?.trim() || undefined
|
||||
const turnId =
|
||||
queryTurnId ?? service.getActiveTurn(agentId, 'main')?.turnId
|
||||
if (!turnId) {
|
||||
return c.json({ error: 'No active turn for this agent' }, 404)
|
||||
}
|
||||
const lastEventId =
|
||||
c.req.header('Last-Event-ID') ??
|
||||
url.searchParams.get('lastSeq') ??
|
||||
undefined
|
||||
const lastSeq = parseLastSeq(lastEventId)
|
||||
const frames = service.attachTurn({ turnId, lastSeq })
|
||||
if (!frames) {
|
||||
return c.json({ error: 'Unknown turn' }, 404)
|
||||
}
|
||||
return streamTurnFrames(c, frames, { turnId })
|
||||
})
|
||||
.post('/:agentId/chat/cancel', async (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const body = await readJsonBody(c)
|
||||
const turnId =
|
||||
'value' in body && typeof body.value.turnId === 'string'
|
||||
? body.value.turnId.trim() || undefined
|
||||
: undefined
|
||||
const reason =
|
||||
'value' in body && typeof body.value.reason === 'string'
|
||||
? body.value.reason
|
||||
: undefined
|
||||
const cancelled = service.cancelTurn({ agentId, turnId, reason })
|
||||
return c.json({ cancelled })
|
||||
})
|
||||
.get('/:agentId/queue', async (c) => {
|
||||
try {
|
||||
const queue = await service.listQueuedMessages(c.req.param('agentId'))
|
||||
return c.json({ queue })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/queue', async (c) => {
|
||||
const parsed = await parseEnqueueBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
const queued = await service.enqueueMessage({
|
||||
agentId: c.req.param('agentId'),
|
||||
message: parsed.message,
|
||||
attachments: parsed.attachments,
|
||||
})
|
||||
return c.json({ queued })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.delete('/:agentId/queue/:messageId', async (c) => {
|
||||
try {
|
||||
const removed = await service.removeQueuedMessage({
|
||||
agentId: c.req.param('agentId'),
|
||||
messageId: c.req.param('messageId'),
|
||||
})
|
||||
if (!removed) return c.json({ error: 'Queued message not found' }, 404)
|
||||
return c.json({ removed })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId/chat/active', (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const info = service.getActiveTurn(agentId, 'main')
|
||||
return c.json({ active: info })
|
||||
})
|
||||
.get('/:agentId/chat/stream', (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const url = new URL(c.req.url)
|
||||
const queryTurnId = url.searchParams.get('turnId')?.trim() || undefined
|
||||
const turnId =
|
||||
queryTurnId ?? service.getActiveTurn(agentId, 'main')?.turnId
|
||||
if (!turnId) {
|
||||
return c.json({ error: 'No active turn for this agent' }, 404)
|
||||
}
|
||||
const lastEventId =
|
||||
c.req.header('Last-Event-ID') ??
|
||||
url.searchParams.get('lastSeq') ??
|
||||
undefined
|
||||
const lastSeq = parseLastSeq(lastEventId)
|
||||
const frames = service.attachTurn({ turnId, lastSeq })
|
||||
if (!frames) {
|
||||
return c.json({ error: 'Unknown turn' }, 404)
|
||||
}
|
||||
return streamTurnFrames(c, frames, { turnId })
|
||||
})
|
||||
.post('/:agentId/chat/cancel', async (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const body = await readJsonBody(c)
|
||||
const turnId =
|
||||
'value' in body && typeof body.value.turnId === 'string'
|
||||
? body.value.turnId.trim() || undefined
|
||||
: undefined
|
||||
const reason =
|
||||
'value' in body && typeof body.value.reason === 'string'
|
||||
? body.value.reason
|
||||
: undefined
|
||||
const cancelled = service.cancelTurn({ agentId, turnId, reason })
|
||||
return c.json({ cancelled })
|
||||
})
|
||||
.get('/:agentId/queue', async (c) => {
|
||||
try {
|
||||
const queue = await service.listQueuedMessages(c.req.param('agentId'))
|
||||
return c.json({ queue })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/queue', async (c) => {
|
||||
const parsed = await parseEnqueueBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
const queued = await service.enqueueMessage({
|
||||
agentId: c.req.param('agentId'),
|
||||
message: parsed.message,
|
||||
attachments: parsed.attachments,
|
||||
})
|
||||
return c.json({ queued })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.delete('/:agentId/queue/:messageId', async (c) => {
|
||||
try {
|
||||
const removed = await service.removeQueuedMessage({
|
||||
agentId: c.req.param('agentId'),
|
||||
messageId: c.req.param('messageId'),
|
||||
})
|
||||
if (!removed)
|
||||
return c.json({ error: 'Queued message not found' }, 404)
|
||||
return c.json({ removed })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Files (TKT-762) ────────────────────────────────────────────
|
||||
//
|
||||
// V1 surfaces files OpenClaw agents produce inside their workspace
|
||||
// dir (`~/.browseros/vm/openclaw/.openclaw/workspace[-<name>]/`)
|
||||
// as outputs, attributed back to the chat turn that produced them
|
||||
// by the per-turn workspace diff in
|
||||
// `agent-harness-service.runDetachedTurn`. Adapter-gated to
|
||||
// openclaw on the service side; for claude / codex these endpoints
|
||||
// simply return empty lists.
|
||||
//
|
||||
// The file-id-scoped endpoints (`/files/:fileId/{preview,download}`)
|
||||
// accept an opaque `fileId` and resolve the on-disk path
|
||||
// server-side, so the client never sees a raw path and traversal
|
||||
// is impossible by construction.
|
||||
|
||||
.get('/:agentId/files', async (c) => {
|
||||
try {
|
||||
const groups = await service.listAgentFiles(
|
||||
c.req.param('agentId'),
|
||||
parseAgentFilesLimit(c.req.query('limit')),
|
||||
)
|
||||
return c.json({ groups })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId/files/turn/:turnId', async (c) => {
|
||||
try {
|
||||
const files = await service.listAgentFilesForTurn(
|
||||
c.req.param('agentId'),
|
||||
c.req.param('turnId'),
|
||||
)
|
||||
return c.json({ files })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/files/:fileId/preview', async (c) => {
|
||||
try {
|
||||
const preview = await service.previewProducedFile(
|
||||
c.req.param('fileId'),
|
||||
)
|
||||
if (!preview || preview.kind === 'missing') {
|
||||
return c.json({ error: 'File not found' }, 404)
|
||||
}
|
||||
return c.json(preview)
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/files/:fileId/download', async (c) => {
|
||||
try {
|
||||
const resolved = await service.resolveProducedFileForDownload(
|
||||
c.req.param('fileId'),
|
||||
)
|
||||
if (!resolved) return c.json({ error: 'File not found' }, 404)
|
||||
|
||||
// Stream raw bytes via Bun's lazy file handle. Sets
|
||||
// Content-Disposition so browsers save instead of preview.
|
||||
const file = Bun.file(resolved.absolutePath)
|
||||
return new Response(file.stream(), {
|
||||
headers: {
|
||||
'Content-Type': resolved.mimeType,
|
||||
'Content-Length': String(resolved.size),
|
||||
'Content-Disposition': `attachment; ${encodeRfc6266Filename(resolved.fileName)}`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Hard cap on `?limit=` for /agents/:id/files — guards against
|
||||
* a caller-supplied huge value forcing a per-agent table scan. */
|
||||
const MAX_FILES_LIMIT = 500
|
||||
|
||||
/**
|
||||
* Parse + clamp the `limit` query for /agents/:id/files. Returns
|
||||
* `undefined` when the param is absent or unparseable so the
|
||||
* service falls back to its own default.
|
||||
*/
|
||||
function parseAgentFilesLimit(
|
||||
raw: string | undefined,
|
||||
): { limit: number } | undefined {
|
||||
if (!raw) return undefined
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isFinite(parsed)) return undefined
|
||||
return { limit: Math.min(Math.max(1, parsed), MAX_FILES_LIMIT) }
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 6266 / RFC 5987 filename attributes for `Content-Disposition`.
|
||||
* Returns the `filename="..."` attribute (always) plus a
|
||||
* percent-encoded `filename*=UTF-8''…` attribute when the name
|
||||
* contains non-ASCII characters, so browsers download with the
|
||||
* original name even on stricter HTTP clients.
|
||||
*/
|
||||
function encodeRfc6266Filename(filename: string): string {
|
||||
// Strip CRLFs and quotes (header injection guard).
|
||||
const safe = filename.replace(/["\r\n]/g, '_')
|
||||
// Detect non-ASCII; emit the RFC 5987 fallback attribute when
|
||||
// present. `encodeURIComponent` is the standard browser-safe
|
||||
// percent-encoder for this purpose.
|
||||
const hasNonAscii = /[^ -~]/.test(safe)
|
||||
if (!hasNonAscii) return `filename="${safe}"`
|
||||
return `filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`
|
||||
}
|
||||
|
||||
function turnFramesToAgentEvents(
|
||||
@@ -539,11 +675,14 @@ async function parseCreateAgentBody(c: Context<Env>): Promise<
|
||||
? record.reasoningEffort.trim()
|
||||
: undefined
|
||||
|
||||
// OpenClaw agents resolve their model from the gateway-side provider
|
||||
// config rather than from the harness catalog. Skip catalog model
|
||||
// validation for that adapter; everything else still uses the catalog.
|
||||
// OpenClaw and Hermes resolve their model from per-agent provider
|
||||
// config (gateway / config.yaml) rather than from the harness catalog.
|
||||
// Skip catalog model validation for those adapters — both have an
|
||||
// empty `models: []` in the catalog by design — everything else still
|
||||
// uses the catalog for validation.
|
||||
if (
|
||||
record.adapter !== 'openclaw' &&
|
||||
record.adapter !== 'hermes' &&
|
||||
!isSupportedAgentModel(record.adapter, modelId)
|
||||
) {
|
||||
return { error: 'Invalid modelId' }
|
||||
@@ -621,7 +760,8 @@ async function parseEnqueueBody(
|
||||
async function parseChatBody(
|
||||
c: Context<Env>,
|
||||
): Promise<
|
||||
{ message: string; attachments: InboundImageAttachment[] } | { error: string }
|
||||
| { message: string; attachments: InboundImageAttachment[]; cwd?: string }
|
||||
| { error: string }
|
||||
> {
|
||||
const body = await readJsonBody(c)
|
||||
if ('error' in body) return body
|
||||
@@ -670,7 +810,13 @@ async function parseChatBody(
|
||||
if (!message && attachments.length === 0) {
|
||||
return { error: 'Message is required' }
|
||||
}
|
||||
return { message, attachments }
|
||||
return {
|
||||
message,
|
||||
attachments,
|
||||
cwd:
|
||||
readOptionalTrimmedString(body.value, 'cwd') ??
|
||||
readOptionalTrimmedString(body.value, 'userWorkingDir'),
|
||||
}
|
||||
}
|
||||
|
||||
async function parseSidepanelAgentChatBody(
|
||||
@@ -773,6 +919,9 @@ function handleAgentRouteError(c: Context<Env>, err: unknown) {
|
||||
if (err instanceof InvalidAgentUpdateError) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
if (err instanceof HermesProviderConfigInvalidError) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
if (err instanceof MessageQueueFullError) {
|
||||
return c.json({ error: err.message }, 429)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { mkdir, rm } from 'node:fs/promises'
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
@@ -26,4 +26,9 @@ export function createMemoryRoutes() {
|
||||
await Bun.write(getCoreMemoryPath(), content)
|
||||
return c.json({ success: true })
|
||||
})
|
||||
.delete('/', async (c) => {
|
||||
await rm(getMemoryDir(), { recursive: true, force: true })
|
||||
await mkdir(getMemoryDir(), { recursive: true })
|
||||
return c.json({ success: true })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { readSoul, writeSoul } from '../../lib/soul'
|
||||
import { readSoul, resetSoulTemplate, writeSoul } from '../../lib/soul'
|
||||
|
||||
const WriteSoulSchema = z.object({
|
||||
content: z.string(),
|
||||
@@ -18,4 +18,8 @@ export function createSoulRoutes() {
|
||||
const result = await writeSoul(content)
|
||||
return c.json(result)
|
||||
})
|
||||
.delete('/', async (c) => {
|
||||
const result = await resetSoulTemplate()
|
||||
return c.json(result)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
||||
import { HttpAgentError } from '../agent/errors'
|
||||
import { INLINED_ENV } from '../env'
|
||||
import { KlavisClient } from '../lib/clients/klavis/klavis-client'
|
||||
import { initializeOAuth } from '../lib/clients/oauth'
|
||||
import { initializeOAuth, shutdownOAuth } from '../lib/clients/oauth'
|
||||
import { getDb } from '../lib/db'
|
||||
import { logger } from '../lib/logger'
|
||||
import { Sentry } from '../lib/sentry'
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
connectKlavisInBackground,
|
||||
type KlavisProxyRef,
|
||||
} from './services/klavis/strata-proxy'
|
||||
import { OpenClawGatewayChatClient } from './services/openclaw/openclaw-gateway-chat-client'
|
||||
import { convertOpenClawHistoryToAgentHistory } from './services/openclaw/history-mapper'
|
||||
import { getOpenClawService } from './services/openclaw/openclaw-service'
|
||||
import type { Env, HttpServerConfig } from './types'
|
||||
import { defaultCorsConfig } from './utils/cors'
|
||||
@@ -88,11 +88,10 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
} = config
|
||||
|
||||
const { onShutdown } = config
|
||||
|
||||
// Initialize OAuth token manager (callback server binds lazily on first PKCE login)
|
||||
const tokenManager = browserosId
|
||||
? initializeOAuth(getDb(), browserosId)
|
||||
: null
|
||||
if (!browserosId) shutdownOAuth()
|
||||
|
||||
const aclPolicyService = new GlobalAclPolicyService()
|
||||
await aclPolicyService.load()
|
||||
@@ -138,16 +137,11 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
browserosServerPort: port,
|
||||
browser,
|
||||
openclawGateway: {
|
||||
getGatewayToken: () => getOpenClawService().getGatewayToken(),
|
||||
getContainerName: () => OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
getLimaHomeDir: () => getLimaHomeDir(),
|
||||
getLimactlPath: () => resolveBundledLimactl(resourcesDir),
|
||||
getVmName: () => VM_NAME,
|
||||
},
|
||||
openclawGatewayChat: new OpenClawGatewayChatClient(
|
||||
() => getOpenClawService().getPort(),
|
||||
async () => getOpenClawService().getGatewayToken(),
|
||||
),
|
||||
openclawProvisioner: {
|
||||
createAgent: (input) => getOpenClawService().createAgent(input),
|
||||
removeAgent: (agentId) => getOpenClawService().removeAgent(agentId),
|
||||
@@ -160,6 +154,23 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
}))
|
||||
},
|
||||
getStatus: () => getOpenClawService().getStatus(),
|
||||
getAgentHistory: async (agentId) => {
|
||||
// Aggregated across the agent's main + every sub-session
|
||||
// (cron / hook / channel) so autonomous turns surface in
|
||||
// the chat panel alongside user-initiated ones.
|
||||
const raw = await getOpenClawService().getSessionHistory(
|
||||
`agent:${agentId}:main`,
|
||||
)
|
||||
return convertOpenClawHistoryToAgentHistory(agentId, raw)
|
||||
},
|
||||
},
|
||||
onTurnLifecycle: (agent, event) => {
|
||||
if (agent.adapter !== 'openclaw') return
|
||||
getOpenClawService().recordAgentTurnEvent(
|
||||
agent.id,
|
||||
agent.sessionKey,
|
||||
event,
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -171,7 +182,7 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
'/shutdown',
|
||||
createShutdownRoute({
|
||||
onShutdown: () => {
|
||||
tokenManager?.stopCallbackServer()
|
||||
shutdownOAuth()
|
||||
stopKlavisBackground()
|
||||
klavisRef.handle?.close().catch((err) =>
|
||||
logger.warn('Failed to close Klavis proxy transport', {
|
||||
|
||||
@@ -13,16 +13,19 @@ import {
|
||||
type TurnFrame,
|
||||
TurnRegistry,
|
||||
} from '../../../lib/agents/active-turn-registry'
|
||||
import type {
|
||||
AgentStore,
|
||||
CreateAgentInput,
|
||||
} from '../../../lib/agents/agent-store'
|
||||
import type { AgentDefinition } from '../../../lib/agents/agent-types'
|
||||
import {
|
||||
type CreateAgentInput,
|
||||
FileAgentStore,
|
||||
} from '../../../lib/agents/file-agent-store'
|
||||
import { DbAgentStore } from '../../../lib/agents/db-agent-store'
|
||||
import {
|
||||
FileMessageQueue,
|
||||
type QueuedMessage,
|
||||
type QueuedMessageAttachment,
|
||||
} from '../../../lib/agents/message-queue'
|
||||
import { writeHermesPerAgentProvider } from '../hermes/hermes-paths'
|
||||
import { getHermesProviderMapping } from '../hermes/hermes-provider-map'
|
||||
|
||||
export {
|
||||
MessageQueueFullError,
|
||||
@@ -30,14 +33,26 @@ export {
|
||||
type QueuedMessageAttachment,
|
||||
} from '../../../lib/agents/message-queue'
|
||||
|
||||
import { basename } from 'node:path'
|
||||
import type {
|
||||
AgentHistoryPage,
|
||||
AgentRowSnapshot,
|
||||
AgentRuntime,
|
||||
AgentStreamEvent,
|
||||
} from '../../../lib/agents/types'
|
||||
import { getOpenClawDir } from '../../../lib/browseros-dir'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import type { OpenClawGatewayChatClient } from '../openclaw/openclaw-gateway-chat-client'
|
||||
import {
|
||||
buildFilePreview,
|
||||
detectMimeType,
|
||||
type FilePreview,
|
||||
} from '../openclaw/file-preview'
|
||||
import { getHostWorkspaceDir } from '../openclaw/openclaw-env'
|
||||
import {
|
||||
type FileSnapshot,
|
||||
type ProducedFileRow,
|
||||
ProducedFilesStore,
|
||||
} from '../openclaw/produced-files-store'
|
||||
|
||||
export type AgentLiveness = 'working' | 'idle' | 'asleep' | 'error'
|
||||
|
||||
@@ -119,6 +134,15 @@ export interface OpenClawProvisioner {
|
||||
* gateway is not configured at all).
|
||||
*/
|
||||
getStatus?(): Promise<GatewayStatusSnapshot | null>
|
||||
/**
|
||||
* Optional. When wired, the harness uses this for `getHistory` on
|
||||
* openclaw-adapter agents so the chat panel sees autonomous
|
||||
* (cron / hook / channel) turns alongside user-typed turns. Without
|
||||
* this, history reads come from AcpxRuntime's local session record
|
||||
* which only contains user-initiated turns — autonomous activity
|
||||
* fires correctly but stays invisible to the panel.
|
||||
*/
|
||||
getAgentHistory?(agentId: string): Promise<AgentHistoryPage>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,12 +175,47 @@ export interface GatewayStatusSnapshot {
|
||||
| null
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-turn event the harness emits to subscribers. Lets services that
|
||||
* want to track liveness for a specific adapter (e.g. OpenClaw's
|
||||
* ClawSession dashboard) react to the same stream the chat panel sees,
|
||||
* without each adapter spawning its own gateway-side observer.
|
||||
*/
|
||||
export type TurnLifecycleEvent =
|
||||
| { type: 'turn_started' }
|
||||
| { type: 'turn_event'; event: AgentStreamEvent }
|
||||
| { type: 'turn_ended'; error?: string }
|
||||
|
||||
export type TurnLifecycleListener = (
|
||||
agent: {
|
||||
id: string
|
||||
adapter: AgentDefinition['adapter']
|
||||
sessionKey: string
|
||||
},
|
||||
event: TurnLifecycleEvent,
|
||||
) => void
|
||||
|
||||
export class AgentHarnessService {
|
||||
private readonly agentStore: FileAgentStore
|
||||
private readonly agentStore: AgentStore
|
||||
private readonly runtime: AgentRuntime
|
||||
private readonly openclawProvisioner: OpenClawProvisioner | null
|
||||
private readonly turnRegistry: TurnRegistry
|
||||
private readonly messageQueue: FileMessageQueue
|
||||
private readonly turnLifecycleListeners = new Set<TurnLifecycleListener>()
|
||||
/**
|
||||
* Optional override for the BrowserOS dir used by Hermes per-agent
|
||||
* provider config writes. Defaults to the global `getBrowserosDir()`
|
||||
* lookup at write time when undefined; tests can inject a tmp dir.
|
||||
*/
|
||||
private readonly browserosDir: string | undefined
|
||||
/**
|
||||
* Lazy-initialised so tests that swap in a fake `agentStore` don't
|
||||
* eagerly hit `getDb()` (which throws when the test harness hasn't
|
||||
* called `initializeDb`). Tests that exercise file attribution can
|
||||
* inject an explicit store via `deps.producedFilesStore`.
|
||||
*/
|
||||
private explicitProducedFilesStore: ProducedFilesStore | null = null
|
||||
private cachedProducedFilesStore: ProducedFilesStore | null = null
|
||||
private inFlightReconcile: Promise<void> | null = null
|
||||
// In-memory liveness tracker. Lost on server restart (acceptable —
|
||||
// `lastUsedAt` survives via the acpx session record's `lastUsedAt`,
|
||||
@@ -169,27 +228,31 @@ export class AgentHarnessService {
|
||||
|
||||
constructor(
|
||||
deps: {
|
||||
agentStore?: FileAgentStore
|
||||
agentStore?: AgentStore
|
||||
runtime?: AgentRuntime
|
||||
browserosServerPort?: number
|
||||
browserosDir?: string
|
||||
openclawGateway?: OpenclawGatewayAccessor
|
||||
openclawGatewayChat?: OpenClawGatewayChatClient
|
||||
openclawProvisioner?: OpenClawProvisioner
|
||||
turnRegistry?: TurnRegistry
|
||||
messageQueue?: FileMessageQueue
|
||||
producedFilesStore?: ProducedFilesStore
|
||||
} = {},
|
||||
) {
|
||||
this.agentStore = deps.agentStore ?? new FileAgentStore()
|
||||
this.agentStore = deps.agentStore ?? new DbAgentStore()
|
||||
this.runtime =
|
||||
deps.runtime ??
|
||||
new AcpxRuntime({
|
||||
browserosServerPort: deps.browserosServerPort,
|
||||
openclawGateway: deps.openclawGateway,
|
||||
openclawGatewayChat: deps.openclawGatewayChat,
|
||||
})
|
||||
this.openclawProvisioner = deps.openclawProvisioner ?? null
|
||||
this.turnRegistry = deps.turnRegistry ?? new TurnRegistry()
|
||||
this.messageQueue = deps.messageQueue ?? new FileMessageQueue()
|
||||
this.browserosDir = deps.browserosDir
|
||||
if (deps.producedFilesStore) {
|
||||
this.explicitProducedFilesStore = deps.producedFilesStore
|
||||
}
|
||||
// Drain any agents whose queue file survived a restart. The check
|
||||
// for `getActiveFor` inside `maybeStartNextFromQueue` guards
|
||||
// against double-firing if the in-memory turn registry happens to
|
||||
@@ -313,6 +376,39 @@ export class AgentHarnessService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to turn lifecycle events for every running agent. Returns
|
||||
* an unsubscribe function. Listeners are best-effort: a throwing
|
||||
* listener does not break the turn.
|
||||
*/
|
||||
onTurnLifecycle(listener: TurnLifecycleListener): () => void {
|
||||
this.turnLifecycleListeners.add(listener)
|
||||
return () => this.turnLifecycleListeners.delete(listener)
|
||||
}
|
||||
|
||||
private emitTurnLifecycle(
|
||||
agent: AgentDefinition,
|
||||
event: TurnLifecycleEvent,
|
||||
): void {
|
||||
if (this.turnLifecycleListeners.size === 0) return
|
||||
const summary = {
|
||||
id: agent.id,
|
||||
adapter: agent.adapter,
|
||||
sessionKey: agent.sessionKey,
|
||||
}
|
||||
for (const listener of this.turnLifecycleListeners) {
|
||||
try {
|
||||
listener(summary, event)
|
||||
} catch (err) {
|
||||
logger.warn('Turn lifecycle listener threw', {
|
||||
agentId: agent.id,
|
||||
eventType: event.type,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark `agentId` as actively running a turn. */
|
||||
notifyTurnStarted(agentId: string): void {
|
||||
this.activity.set(agentId, { status: 'working', lastEventAt: Date.now() })
|
||||
@@ -447,8 +543,24 @@ export class AgentHarnessService {
|
||||
}
|
||||
|
||||
async createAgent(input: CreateAgentInput): Promise<AgentDefinition> {
|
||||
if (input.adapter === 'hermes') {
|
||||
// Validate before touching the store so we don't leave an orphan
|
||||
// record on the unhappy path.
|
||||
assertHermesProviderInputValid(input)
|
||||
}
|
||||
|
||||
const agent = await this.agentStore.create(input)
|
||||
|
||||
if (agent.adapter === 'hermes') {
|
||||
try {
|
||||
await this.writeHermesPerAgentProvider(agent.id, input)
|
||||
} catch (err) {
|
||||
await this.agentStore.delete(agent.id).catch(() => {})
|
||||
throw err
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
if (agent.adapter !== 'openclaw') {
|
||||
return agent
|
||||
}
|
||||
@@ -489,6 +601,35 @@ export class AgentHarnessService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write Hermes' per-agent config.yaml + .env into the on-host home
|
||||
* dir. Caller must have already run assertHermesProviderInputValid;
|
||||
* any throw here is a real I/O failure and must roll back the agent
|
||||
* record.
|
||||
*/
|
||||
private async writeHermesPerAgentProvider(
|
||||
agentId: string,
|
||||
input: CreateAgentInput,
|
||||
): Promise<void> {
|
||||
// Non-null assertions are safe: assertHermesProviderInputValid ran
|
||||
// first and rejects when any required field is missing.
|
||||
const mapping = getHermesProviderMapping(input.providerType as string)
|
||||
if (!mapping) {
|
||||
throw new HermesProviderConfigInvalidError(
|
||||
`Provider type "${input.providerType}" is not supported by Hermes`,
|
||||
)
|
||||
}
|
||||
await writeHermesPerAgentProvider({
|
||||
browserosDir: this.browserosDir,
|
||||
agentId,
|
||||
providerId: mapping.hermesProvider,
|
||||
envVarName: mapping.envVarName,
|
||||
apiKey: (input.apiKey as string).trim(),
|
||||
modelId: (input.modelId as string).trim(),
|
||||
baseUrl: input.baseUrl?.trim() || mapping.defaultBaseUrl,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls every gateway-side OpenClaw agent into the harness store as a
|
||||
* harness record (idempotent, safe to call repeatedly). This lets
|
||||
@@ -598,9 +739,112 @@ export class AgentHarnessService {
|
||||
|
||||
async getHistory(agentId: string): Promise<AgentHistoryPage> {
|
||||
const agent = await this.requireAgent(agentId)
|
||||
// OpenClaw agents persist conversation in the gateway, not in the
|
||||
// AcpxRuntime's local session record. Reading the local record
|
||||
// would miss autonomous (cron / hook / channel) turns. Route
|
||||
// through the provisioner so the panel sees the full history.
|
||||
if (
|
||||
agent.adapter === 'openclaw' &&
|
||||
this.openclawProvisioner?.getAgentHistory
|
||||
) {
|
||||
return this.openclawProvisioner.getAgentHistory(agentId)
|
||||
}
|
||||
return this.runtime.getHistory({ agent, sessionId: 'main' })
|
||||
}
|
||||
|
||||
// ── Produced files (Files rail / inline artifact card) ───────────
|
||||
|
||||
/**
|
||||
* Outputs-rail data for one agent. Returns groups of files keyed
|
||||
* by the assistant turn that produced them, newest first. Empty
|
||||
* array when the agent hasn't produced anything yet, or when the
|
||||
* adapter doesn't track outputs (claude / codex — see Phase 2
|
||||
* commit).
|
||||
*/
|
||||
async listAgentFiles(
|
||||
agentId: string,
|
||||
options: { limit?: number } = {},
|
||||
): Promise<ProducedFilesRailGroup[]> {
|
||||
const agent = await this.requireAgent(agentId)
|
||||
const store = this.tryGetProducedFilesStore()
|
||||
if (!store) return []
|
||||
const rows = await store.listByAgent(agent.id, options)
|
||||
return store
|
||||
.groupByTurn(rows)
|
||||
.map(({ turnId, turnPrompt, createdAt, files }) => ({
|
||||
turnId,
|
||||
turnPrompt,
|
||||
createdAt,
|
||||
files: files.map(toProducedFileEntry),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline-card data for one assistant turn. Used by the SSE
|
||||
* `produced_files` event consumer to refresh metadata after the
|
||||
* turn completes; also handy for direct fetches by clients that
|
||||
* missed the live event.
|
||||
*/
|
||||
async listAgentFilesForTurn(
|
||||
agentId: string,
|
||||
turnId: string,
|
||||
): Promise<ProducedFileEntry[]> {
|
||||
await this.requireAgent(agentId)
|
||||
const store = this.tryGetProducedFilesStore()
|
||||
if (!store) return []
|
||||
const rows = await store.listByTurn(turnId)
|
||||
return rows.map(toProducedFileEntry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a preview payload for a single file. Returns null when the
|
||||
* file id is unknown OR the on-disk path no longer exists. The
|
||||
* route layer maps null → 404.
|
||||
*/
|
||||
async previewProducedFile(fileId: string): Promise<FilePreview | null> {
|
||||
const store = this.tryGetProducedFilesStore()
|
||||
if (!store) return null
|
||||
const row = await store.findById(fileId)
|
||||
if (!row) return null
|
||||
const agent = await this.agentStore.get(row.agentDefinitionId)
|
||||
if (!agent || agent.adapter !== 'openclaw') return null
|
||||
const workspaceDir = getHostWorkspaceDir(getOpenClawDir(), agent.name)
|
||||
const resolved = await store.resolveFilePath({ fileId, workspaceDir })
|
||||
if (!resolved) return null
|
||||
return buildFilePreview(resolved.absolutePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a file id to an absolute on-disk path + metadata for the
|
||||
* download route to stream. Null when the file id is unknown or
|
||||
* the path escaped the workspace root (containment check happens
|
||||
* inside `producedFilesStore.resolveFilePath`).
|
||||
*/
|
||||
async resolveProducedFileForDownload(fileId: string): Promise<{
|
||||
absolutePath: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
} | null> {
|
||||
const store = this.tryGetProducedFilesStore()
|
||||
if (!store) return null
|
||||
const row = await store.findById(fileId)
|
||||
if (!row) return null
|
||||
const agent = await this.agentStore.get(row.agentDefinitionId)
|
||||
if (!agent || agent.adapter !== 'openclaw') return null
|
||||
const workspaceDir = getHostWorkspaceDir(getOpenClawDir(), agent.name)
|
||||
const resolved = await store.resolveFilePath({ fileId, workspaceDir })
|
||||
if (!resolved) return null
|
||||
const mimeType = await detectMimeType(resolved.absolutePath)
|
||||
const fileName = basename(row.path)
|
||||
return {
|
||||
absolutePath: resolved.absolutePath,
|
||||
fileName,
|
||||
mimeType,
|
||||
size: row.size,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick off a new agent turn that survives the caller's HTTP lifetime.
|
||||
* Events are pushed into a per-turn buffer; the returned `frames`
|
||||
@@ -626,6 +870,7 @@ export class AgentHarnessService {
|
||||
prompt: input.message,
|
||||
})
|
||||
this.notifyTurnStarted(agent.id)
|
||||
this.emitTurnLifecycle(agent, { type: 'turn_started' })
|
||||
|
||||
// Kick off the runtime call in the background. The per-turn
|
||||
// AbortController — NOT the HTTP request signal — is what cancels
|
||||
@@ -727,6 +972,26 @@ export class AgentHarnessService {
|
||||
const turn = this.turnRegistry.get(turnId)
|
||||
if (!turn) return
|
||||
let lastErrorMessage: string | undefined
|
||||
|
||||
// Bracket openclaw turns with a workspace snapshot so any file the
|
||||
// agent produces during the turn is attributable back to it (rail
|
||||
// + inline artifact UX). Adapter-gated for v1 — Claude / Codex
|
||||
// write to the user's host filesystem and don't need this; their
|
||||
// outputs are already visible via the user's own tools.
|
||||
const isOpenclaw = agent.adapter === 'openclaw'
|
||||
const workspaceDir = isOpenclaw ? this.resolveSafeWorkspaceDir(agent) : null
|
||||
const producedFilesStore = workspaceDir
|
||||
? this.tryGetProducedFilesStore()
|
||||
: null
|
||||
const workspaceSnapshot =
|
||||
workspaceDir && producedFilesStore
|
||||
? await this.snapshotWorkspaceForTurn(
|
||||
agent,
|
||||
workspaceDir,
|
||||
producedFilesStore,
|
||||
)
|
||||
: null
|
||||
|
||||
try {
|
||||
const upstream = await this.runtime.send({
|
||||
agent,
|
||||
@@ -745,6 +1010,7 @@ export class AgentHarnessService {
|
||||
if (done) break
|
||||
if (value.type === 'error') lastErrorMessage = value.message
|
||||
this.turnRegistry.pushEvent(turnId, value)
|
||||
this.emitTurnLifecycle(agent, { type: 'turn_event', event: value })
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
@@ -781,10 +1047,141 @@ export class AgentHarnessService {
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
// Attribute any files the agent produced during this turn. We
|
||||
// run on success, error, AND inside `finally` so an upstream
|
||||
// failure mid-turn that still managed to write files doesn't
|
||||
// lose them. We skip only when the user explicitly cancelled —
|
||||
// in that case the side effects shouldn't be surfaced as
|
||||
// "outputs you asked for."
|
||||
if (
|
||||
workspaceDir &&
|
||||
workspaceSnapshot !== null &&
|
||||
producedFilesStore &&
|
||||
!turn.abortController.signal.aborted
|
||||
) {
|
||||
await this.attributeTurnFiles({
|
||||
producedFilesStore,
|
||||
workspaceDir,
|
||||
before: workspaceSnapshot,
|
||||
agent,
|
||||
turnId,
|
||||
turnPrompt: input.message,
|
||||
})
|
||||
}
|
||||
this.notifyTurnEnded(agent.id, {
|
||||
ok: lastErrorMessage === undefined,
|
||||
error: lastErrorMessage,
|
||||
})
|
||||
this.emitTurnLifecycle(agent, {
|
||||
type: 'turn_ended',
|
||||
error: lastErrorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the host-side workspace dir for an openclaw agent,
|
||||
* returning `null` when the agent's display name fails the
|
||||
* path-traversal guard. Logs a warning so the safety-disabled
|
||||
* case is observable in production.
|
||||
*/
|
||||
private resolveSafeWorkspaceDir(agent: AgentDefinition): string | null {
|
||||
try {
|
||||
return getHostWorkspaceDir(getOpenClawDir(), agent.name)
|
||||
} catch (err) {
|
||||
logger.warn('Skipping openclaw file attribution: unsafe agent name', {
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-turn workspace snapshot. Returns `null` on any failure so
|
||||
* the rest of the turn flow continues without file attribution.
|
||||
*/
|
||||
private async snapshotWorkspaceForTurn(
|
||||
agent: AgentDefinition,
|
||||
workspaceDir: string,
|
||||
producedFilesStore: ProducedFilesStore,
|
||||
): Promise<FileSnapshot | null> {
|
||||
try {
|
||||
return await producedFilesStore.snapshotWorkspace(workspaceDir)
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'Failed to snapshot openclaw workspace; file attribution disabled for this turn',
|
||||
{
|
||||
agentId: agent.id,
|
||||
workspaceDir,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily resolve the produced-files store. Returns `null` if the
|
||||
* SQLite handle isn't initialised yet — keeps the harness usable in
|
||||
* tests + during early server boot, where chat turns are unlikely
|
||||
* but allowed.
|
||||
*/
|
||||
private tryGetProducedFilesStore(): ProducedFilesStore | null {
|
||||
if (this.explicitProducedFilesStore) return this.explicitProducedFilesStore
|
||||
if (this.cachedProducedFilesStore) return this.cachedProducedFilesStore
|
||||
try {
|
||||
this.cachedProducedFilesStore = new ProducedFilesStore()
|
||||
return this.cachedProducedFilesStore
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'Produced-files store unavailable; turn-level file attribution disabled',
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff the workspace, persist new/modified files, and emit a
|
||||
* `produced_files` event so subscribers can render the inline
|
||||
* artifact card. Tolerant of all errors — a failure here must
|
||||
* never block the rest of the turn-end bookkeeping.
|
||||
*/
|
||||
private async attributeTurnFiles(input: {
|
||||
producedFilesStore: ProducedFilesStore
|
||||
workspaceDir: string
|
||||
before: FileSnapshot
|
||||
agent: AgentDefinition
|
||||
turnId: string
|
||||
turnPrompt: string
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const rows = await input.producedFilesStore.finalizeTurn({
|
||||
agentDefinitionId: input.agent.id,
|
||||
sessionKey: input.agent.sessionKey,
|
||||
turnId: input.turnId,
|
||||
turnPrompt: input.turnPrompt,
|
||||
workspaceDir: input.workspaceDir,
|
||||
before: input.before,
|
||||
})
|
||||
if (rows.length === 0) return
|
||||
this.turnRegistry.pushEvent(input.turnId, {
|
||||
type: 'produced_files',
|
||||
files: rows.map((row) => ({
|
||||
id: row.id,
|
||||
path: row.path,
|
||||
size: row.size,
|
||||
mtimeMs: row.mtimeMs,
|
||||
})),
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn('Failed to attribute produced files for turn', {
|
||||
agentId: input.agent.id,
|
||||
turnId: input.turnId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -845,6 +1242,48 @@ export class InvalidAgentUpdateError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a Hermes adapter agent is created without a complete
|
||||
* provider config (provider type, API key, model id; base URL when the
|
||||
* provider mapping requires it). Surfaces as a 400 in the route layer.
|
||||
*/
|
||||
export class HermesProviderConfigInvalidError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'HermesProviderConfigInvalidError'
|
||||
}
|
||||
}
|
||||
|
||||
function assertHermesProviderInputValid(input: CreateAgentInput): void {
|
||||
const providerType = input.providerType?.trim()
|
||||
if (!providerType) {
|
||||
throw new HermesProviderConfigInvalidError(
|
||||
'Hermes agent requires providerType (pick a provider configured in BrowserOS AI Settings)',
|
||||
)
|
||||
}
|
||||
const mapping = getHermesProviderMapping(providerType)
|
||||
if (!mapping) {
|
||||
throw new HermesProviderConfigInvalidError(
|
||||
`Provider type "${providerType}" is not supported by Hermes`,
|
||||
)
|
||||
}
|
||||
if (!input.apiKey?.trim()) {
|
||||
throw new HermesProviderConfigInvalidError(
|
||||
'Hermes agent requires apiKey from the selected provider',
|
||||
)
|
||||
}
|
||||
if (!input.modelId?.trim()) {
|
||||
throw new HermesProviderConfigInvalidError(
|
||||
'Hermes agent requires modelId from the selected provider',
|
||||
)
|
||||
}
|
||||
if (mapping.requiresBaseUrl && !input.baseUrl?.trim()) {
|
||||
throw new HermesProviderConfigInvalidError(
|
||||
`Provider type "${providerType}" requires baseUrl`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when `startTurn` is called for an agent that already has an
|
||||
* in-flight turn. The route layer maps this to 409 + the existing
|
||||
@@ -859,3 +1298,38 @@ export class TurnAlreadyActiveError extends Error {
|
||||
this.name = 'TurnAlreadyActiveError'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Files API DTO ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wire shape for one produced-file entry returned by the rail and
|
||||
* inline-card endpoints. Trimmed from the on-disk row — clients
|
||||
* never see `agentDefinitionId` or `sessionKey`.
|
||||
*/
|
||||
export interface ProducedFileEntry {
|
||||
id: string
|
||||
path: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
createdAt: number
|
||||
detectedBy: 'diff' | 'tool'
|
||||
}
|
||||
|
||||
export interface ProducedFilesRailGroup {
|
||||
turnId: string
|
||||
/** First non-blank line of the user prompt that initiated this turn. */
|
||||
turnPrompt: string
|
||||
createdAt: number
|
||||
files: ProducedFileEntry[]
|
||||
}
|
||||
|
||||
function toProducedFileEntry(row: ProducedFileRow): ProducedFileEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
path: row.path,
|
||||
size: row.size,
|
||||
mtimeMs: row.mtimeMs,
|
||||
createdAt: row.createdAt,
|
||||
detectedBy: row.detectedBy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export class ChatService {
|
||||
supportsImages: request.supportsImages,
|
||||
chatMode: request.mode === 'chat',
|
||||
isScheduledTask: request.isScheduledTask,
|
||||
scheduledTaskRunSilently: request.runSilently,
|
||||
origin: request.origin,
|
||||
declinedApps: request.declinedApps,
|
||||
browserosId: this.deps.browserosId,
|
||||
@@ -186,7 +187,7 @@ export class ChatService {
|
||||
this.deps.browser,
|
||||
request.browserContext,
|
||||
)
|
||||
if (request.isScheduledTask) {
|
||||
if (request.isScheduledTask && request.runSilently !== false) {
|
||||
try {
|
||||
hiddenPageId = await this.deps.browser.newPage('about:blank', {
|
||||
hidden: true,
|
||||
@@ -311,17 +312,49 @@ export class ChatService {
|
||||
contextChanges.length > 0
|
||||
? `${contextChanges.map((c) => `[Context: ${c}]`).join('\n')}\n\n`
|
||||
: ''
|
||||
session.agent.appendUserMessage(contextPrefix + userContent)
|
||||
|
||||
// Persist the *raw* user text in session.agent.messages so it
|
||||
// round-trips clean to the client's useChat state and to any
|
||||
// future history reload. The wrapped form (browser context +
|
||||
// <selected_text> + <USER_QUERY>) is built as a transient prompt
|
||||
// copy below — the LLM sees it, the user-visible state never
|
||||
// does.
|
||||
session.agent.appendUserMessage(request.message)
|
||||
const promptUserText = contextPrefix + userContent
|
||||
const wrappedUserMessageId =
|
||||
session.agent.messages[session.agent.messages.length - 1]?.id
|
||||
|
||||
const promptUiMessages = filterValidMessages(session.agent.messages).map(
|
||||
(msg) =>
|
||||
msg.id === wrappedUserMessageId && msg.role === 'user'
|
||||
? {
|
||||
...msg,
|
||||
parts: [{ type: 'text' as const, text: promptUserText }],
|
||||
}
|
||||
: msg,
|
||||
)
|
||||
|
||||
return createAgentUIStreamResponse({
|
||||
agent: session.agent.toolLoopAgent,
|
||||
uiMessages: filterValidMessages(session.agent.messages),
|
||||
uiMessages: promptUiMessages,
|
||||
abortSignal,
|
||||
onFinish: async ({ messages }: { messages: UIMessage[] }) => {
|
||||
session.agent.messages = filterValidMessages(messages)
|
||||
// The agent loop returns `messages` containing the prompt-
|
||||
// wrapped user text. Restore the raw form before persisting
|
||||
// so subsequent turns see the clean text and the client's
|
||||
// local UIMessage matches what was originally typed.
|
||||
const restored = messages.map((msg) =>
|
||||
msg.id === wrappedUserMessageId && msg.role === 'user'
|
||||
? {
|
||||
...msg,
|
||||
parts: [{ type: 'text' as const, text: request.message }],
|
||||
}
|
||||
: msg,
|
||||
)
|
||||
session.agent.messages = filterValidMessages(restored)
|
||||
logger.info('Agent execution complete', {
|
||||
conversationId: request.conversationId,
|
||||
totalMessages: messages.length,
|
||||
totalMessages: restored.length,
|
||||
})
|
||||
|
||||
if (session?.hiddenPageId) {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Host-side path helpers for the Hermes container.
|
||||
*
|
||||
* Hermes per-agent state lives under the BrowserOS-managed VM state
|
||||
* directory (so it's reachable inside the Lima VM via the existing
|
||||
* vm/ → /mnt/browseros/vm bind mount). The Hermes container then bind-
|
||||
* mounts the guest-side path (/mnt/browseros/vm/hermes/harness) into
|
||||
* /data/agents/harness, so `HERMES_HOME` ends up pointing at a path
|
||||
* the container can actually open.
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { getVmStateDir } from '../../../lib/browseros-dir'
|
||||
|
||||
/** Top-level Hermes state directory: `<browserosDir>/vm/hermes`. */
|
||||
export function getHermesHostStateDir(browserosDir?: string): string {
|
||||
return join(
|
||||
browserosDir ? join(browserosDir, 'vm') : getVmStateDir(),
|
||||
'hermes',
|
||||
)
|
||||
}
|
||||
|
||||
/** Per-agent harness root: `<browserosDir>/vm/hermes/harness`. */
|
||||
export function getHermesHarnessHostDir(browserosDir?: string): string {
|
||||
return join(getHermesHostStateDir(browserosDir), 'harness')
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-agent home directory on the host. The Hermes container reads
|
||||
* `config.yaml` + `.env` from here via the harness bind mount; both
|
||||
* files are written at agent-create time by AgentHarnessService and
|
||||
* stay constant across turns.
|
||||
*/
|
||||
export function getHermesAgentHomeHostDir(input: {
|
||||
browserosDir?: string
|
||||
agentId: string
|
||||
}): string {
|
||||
return join(
|
||||
getHermesHarnessHostDir(input.browserosDir),
|
||||
input.agentId,
|
||||
'home',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a Hermes per-agent provider config into the on-host home dir.
|
||||
* The dir lives under <browserosDir>/vm/hermes/harness/<agentId>/home/
|
||||
* which is bind-mounted into the container at /data/agents/harness/<id>/home/.
|
||||
*
|
||||
* Idempotent: writes always overwrite (last-write-wins). The provider
|
||||
* id, env var name, and credentials must be supplied by the caller —
|
||||
* Hermes agents always carry their own config; there is no
|
||||
* `~/.hermes/` fallback.
|
||||
*/
|
||||
export async function writeHermesPerAgentProvider(input: {
|
||||
browserosDir?: string
|
||||
agentId: string
|
||||
providerId: string
|
||||
envVarName: string
|
||||
apiKey: string
|
||||
modelId: string
|
||||
baseUrl?: string
|
||||
}): Promise<void> {
|
||||
const home = getHermesAgentHomeHostDir({
|
||||
browserosDir: input.browserosDir,
|
||||
agentId: input.agentId,
|
||||
})
|
||||
await mkdir(home, { recursive: true })
|
||||
|
||||
// Hermes' `provider: custom` requires a `base_url` — without one the
|
||||
// model loader rejects with `unknown provider 'custom'`. Callers that
|
||||
// use a named Hermes provider (e.g. anthropic, openrouter) can omit
|
||||
// baseUrl and Hermes resolves the URL itself.
|
||||
if (input.providerId === 'custom' && !input.baseUrl) {
|
||||
throw new Error(
|
||||
'Hermes provider "custom" requires base_url; set HermesProviderMapping.defaultBaseUrl or supply input.baseUrl',
|
||||
)
|
||||
}
|
||||
const yamlLines = [
|
||||
'model:',
|
||||
` default: ${JSON.stringify(input.modelId)}`,
|
||||
` provider: ${JSON.stringify(input.providerId)}`,
|
||||
]
|
||||
if (input.baseUrl) {
|
||||
yamlLines.push(` base_url: ${JSON.stringify(input.baseUrl)}`)
|
||||
}
|
||||
yamlLines.push('')
|
||||
await writeFile(join(home, 'config.yaml'), yamlLines.join('\n'), {
|
||||
mode: 0o600,
|
||||
})
|
||||
|
||||
const envLines: string[] = [`${input.envVarName}=${input.apiKey}`, '']
|
||||
await writeFile(join(home, '.env'), envLines.join('\n'), { mode: 0o600 })
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Translation table from BrowserOS LLM provider types (the values that
|
||||
* live in `LlmProviderConfig.type` on the extension side) to Hermes
|
||||
* runtime configuration. Hermes itself only knows a small fixed set of
|
||||
* provider keys; BrowserOS exposes a richer registry, so we explicitly
|
||||
* gate which BrowserOS provider types Hermes can consume.
|
||||
*
|
||||
* The set of allowed BrowserOS provider types is shared with the
|
||||
* frontend via `HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES`. Adding a
|
||||
* new type there without an entry here will fail the type check below
|
||||
* (every supported type must have a mapping).
|
||||
*
|
||||
* Anything not listed is rejected at agent-create time with a clear
|
||||
* error — there is no `~/.hermes/` fallback.
|
||||
*/
|
||||
import {
|
||||
HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES,
|
||||
type HermesSupportedBrowserosProviderType,
|
||||
} from '@browseros/shared/constants/hermes'
|
||||
|
||||
export interface HermesProviderMapping {
|
||||
/** Hermes' own provider key written into `model.provider` in config.yaml. */
|
||||
hermesProvider: string
|
||||
/** Env var Hermes reads the API key from (written into per-agent `.env`). */
|
||||
envVarName: string
|
||||
/** True when the harness must require an explicit baseUrl from input. */
|
||||
requiresBaseUrl: boolean
|
||||
/**
|
||||
* Used when `hermesProvider === 'custom'` and the input has no
|
||||
* baseUrl — Hermes treats `provider: custom` as "call this URL
|
||||
* directly", so `base_url` must always end up in config.yaml.
|
||||
*/
|
||||
defaultBaseUrl?: string
|
||||
}
|
||||
|
||||
const HERMES_PROVIDER_MAP: Record<
|
||||
HermesSupportedBrowserosProviderType,
|
||||
HermesProviderMapping
|
||||
> = {
|
||||
anthropic: {
|
||||
hermesProvider: 'anthropic',
|
||||
envVarName: 'ANTHROPIC_API_KEY',
|
||||
requiresBaseUrl: false,
|
||||
},
|
||||
// Hermes (v2026.4.x) has no provider key named `"openai"`. Per the
|
||||
// upstream docs, `provider: custom` + `base_url` is the canonical
|
||||
// shape for any OpenAI-compatible endpoint with an API key — Hermes
|
||||
// skips provider lookup and calls the URL directly. Used for both
|
||||
// pure OpenAI (default base URL) and openai-compatible (caller URL).
|
||||
openai: {
|
||||
hermesProvider: 'custom',
|
||||
envVarName: 'OPENAI_API_KEY',
|
||||
requiresBaseUrl: false,
|
||||
defaultBaseUrl: 'https://api.openai.com/v1',
|
||||
},
|
||||
openrouter: {
|
||||
hermesProvider: 'openrouter',
|
||||
envVarName: 'OPENROUTER_API_KEY',
|
||||
requiresBaseUrl: false,
|
||||
},
|
||||
'openai-compatible': {
|
||||
hermesProvider: 'custom',
|
||||
envVarName: 'OPENAI_API_KEY',
|
||||
requiresBaseUrl: true,
|
||||
},
|
||||
}
|
||||
|
||||
export function isHermesSupportedProviderType(
|
||||
providerType: string,
|
||||
): providerType is HermesSupportedBrowserosProviderType {
|
||||
return (
|
||||
HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES as readonly string[]
|
||||
).includes(providerType)
|
||||
}
|
||||
|
||||
export function getHermesProviderMapping(
|
||||
providerType: string,
|
||||
): HermesProviderMapping | undefined {
|
||||
if (!isHermesSupportedProviderType(providerType)) return undefined
|
||||
return HERMES_PROVIDER_MAP[providerType]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user