diff --git a/.changeset/config.json b/.changeset/config.json index 1944402..d2e4a8c 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -6,10 +6,13 @@ [ "@s0nderlabs/anima", "@s0nderlabs/anima-core", - "@s0nderlabs/anima-harness", + "@s0nderlabs/anima-gateway", "@s0nderlabs/anima-plugin-comms", "@s0nderlabs/anima-plugin-onchain", - "@s0nderlabs/anima-plugin-system" + "@s0nderlabs/anima-plugin-system", + "@s0nderlabs/anima-plugin-telegram", + "@s0nderlabs/anima-plugin-webapp", + "@s0nderlabs/anima-relay" ] ], "linked": [], diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b62f9b7..22dcadc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,24 +9,80 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# Four legible, parallel jobs. Each is individually requireable as a branch- +# protection check (gate PRs before merge). Together they close the gaps the +# old single `check` job left open: forge ran ONLY at tag-publish time, apps/web +# was never built/typechecked/tested anywhere, and the cross-surface integration +# seam (relay+tunnel+gateway, EIP-191 parity, SSE format, dispatch/approval +# floors) had no automated coverage. None of these require live 0G or funds. jobs: - check: + # Lint + typecheck + the full unit suite (packages/*/src). The day-to-day gate. + unit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: oven-sh/setup-bun@v2 with: bun-version: latest - - name: Install deps run: bun install --frozen-lockfile - - name: Lint run: bun run lint - - name: Typecheck run: bun run typecheck - - - name: Test + - name: Unit tests run: bun run test + + # Solidity contract tests. Moved from tag-time (release.yml) to PR-time so a + # contract regression blocks the PR instead of aborting a release mid-publish. + # contracts/lib is gitignored, so the pinned deps are fetched fresh, matching + # what release.yml does. + forge: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: foundry-rs/foundry-toolchain@v1 + - name: Install Forge libs + run: | + mkdir -p contracts/lib + git clone --depth 1 --branch v1.16.0 \ + https://github.com/foundry-rs/forge-std contracts/lib/forge-std + git clone --depth 1 --branch v5.6.1 \ + https://github.com/OpenZeppelin/openzeppelin-contracts contracts/lib/openzeppelin-contracts + - name: Forge tests + run: forge test + + # The web-app gateway surface (apps/web). Previously ungated everywhere: not + # typechecked (root tsc -b skips it), not built, not tested. A `next build` + # failure, a route-export violation, or a broken auth route now blocks the PR. + web: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Install deps + run: bun install --frozen-lockfile + - name: Web typecheck + run: bun run web:typecheck + - name: Web unit tests (SIWE auth, ownerOf gate, slug binding, SSE parser) + run: bun test ./apps/web + - name: Web build (next build — catches route-export + build-time type errors) + run: bun run web:build + + # Committed cross-package integration + contract tests (test/integration). + # Wires the REAL relay + tunnel + gateway + EIP-191 verification with a stub + # brain (no 0G); plus EIP-191 web<->gateway digest parity, the SSE wire-format + # golden, and the dispatch/permission/approval/hard-floor harness drive. + integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Install deps + run: bun install --frozen-lockfile + - name: Integration tests + run: bun run test:integration diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df5944e..9c5e6ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,19 @@ jobs: - name: Forge tests run: forge test + # Defense-in-depth before the irreversible publish: the web surface and the + # cross-package integration seam are gated at PR-time, but re-run them here + # so a tag can never publish an artifact that fails them. Same commands as + # ci.yml; deterministic, no live 0G. + - name: Web typecheck + tests + build + run: | + bun run web:typecheck + bun test ./apps/web + bun run web:build + + - name: Integration tests (relay+tunnel+gateway, EIP-191 parity, SSE, harness) + run: bun run test:integration + # bun publish does NOT honor `~/.npmrc` reliably on CI runners (verified # silently broken on v0.16.3 manual recovery: workflow reported success # but every publish step output `error: missing authentication`, masked @@ -152,6 +165,20 @@ jobs: NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} run: bun publish --access=public + # plugin-webapp must publish BEFORE gateway (gateway deps it via workspace:*). + - name: Publish @s0nderlabs/anima-plugin-webapp + working-directory: packages/plugin-webapp + env: + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + run: bun publish --access=public + + # relay is standalone infra (no internal deps); order is flexible. + - name: Publish @s0nderlabs/anima-relay + working-directory: packages/relay + env: + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + run: bun publish --access=public + - name: Publish @s0nderlabs/anima-gateway working-directory: packages/gateway env: @@ -264,8 +291,11 @@ jobs: echo " ✓ $bin → $(command -v "$bin")" done # Verify version of every installed @s0nderlabs/anima-* package. - # 6 packages total: cli + gateway + core + 3 plugins (telegram lives under deps). - for pkg in anima anima-gateway anima-core anima-plugin-comms anima-plugin-onchain anima-plugin-system anima-plugin-telegram; do + # cli + gateway + core + 4 plugins (telegram + webapp live under gateway deps). + # anima-relay is standalone infra (not a cli/gateway dep) so it is NOT + # installed transitively here — it is published + verified by the + # auto-discovered PACKAGES list above, not this global-install loop. + for pkg in anima anima-gateway anima-core anima-plugin-comms anima-plugin-onchain anima-plugin-system anima-plugin-telegram anima-plugin-webapp; do PKG_JSON="$HOME/.bun/install/global/node_modules/@s0nderlabs/${pkg}/package.json" if [ ! -f "$PKG_JSON" ]; then echo "::error::@s0nderlabs/${pkg} not installed (missing $PKG_JSON)" @@ -278,7 +308,38 @@ jobs: fi echo " ✓ @s0nderlabs/${pkg}@${INSTALLED_VERSION}" done - echo "Smoke test passed: 7 packages installed + bins resolve at ${PKG_VERSION}" + # BOOT PROBE: actually EXECUTE the installed binaries, not just check + # presence. Every package ships RAW TypeScript (main -> ./src/index.ts), + # so a broken transitive import, a missing runtime dep, or a top-level + # throw passes every presence/version check above yet bricks every + # fresh install + npm-bootstrap sandbox + `bun add -g` upgrade. Running + # the bin loads the real import graph and surfaces that class here. + echo "Boot-probing installed binaries..." + if ! anima help 2>&1 | grep -q 'Commands:'; then + echo "::error::anima CLI failed to boot (\`anima help\` did not print 'Commands:')" + anima help 2>&1 | head -30 + exit 1 + fi + echo " ✓ anima help booted (CLI + transitive gateway/core/plugins import graph)" + # Relay is the load-bearing binary for the web gateway and is otherwise + # never executed at release time. Install it standalone, start it on an + # OS-assigned port, confirm it binds, then stop it. + for attempt in 1 2 3; do + rm -rf "$HOME/.bun/install/cache/@s0nderlabs" + if bun add -g "@s0nderlabs/anima-relay@${PKG_VERSION}"; then break; fi + [ "$attempt" -lt 3 ] && { echo "::warning::relay install attempt $attempt failed; retrying"; sleep 15; } || { echo "::error::anima-relay install failed at ${PKG_VERSION}"; exit 1; } + done + RELAY_PORT=0 anima-relay > /tmp/anima-relay-boot.log 2>&1 & + RELAY_PID=$! + for _ in $(seq 1 20); do grep -q 'listening' /tmp/anima-relay-boot.log && break; sleep 0.5; done + kill "$RELAY_PID" 2>/dev/null || true + if ! grep -q 'listening' /tmp/anima-relay-boot.log; then + echo "::error::anima-relay failed to boot + bind a port" + cat /tmp/anima-relay-boot.log + exit 1 + fi + echo " ✓ anima-relay booted + bound a port" + echo "Smoke test passed: packages installed, bins resolve AND boot at ${PKG_VERSION}" - name: Create GitHub release env: diff --git a/apps/web/.env.local.example b/apps/web/.env.local.example index 8487ef4..f5927c9 100644 --- a/apps/web/.env.local.example +++ b/apps/web/.env.local.example @@ -8,3 +8,18 @@ SESSION_SECRET="replace-with-strong-random-32-plus-char-secret" # Add this origin + prod origin to the project allowlist at cloud.reown.com # or Reown will pop an "origin not on allowlist" modal. NEXT_PUBLIC_WC_PROJECT_ID="974ed7663d88e07086104fa9a73b2d87" + +# --- Web-app gateway (reach your agent from any browser) --- +# Public HTTP base of the dumb reverse-tunnel relay the agent daemon dials out +# to. The browser reaches the agent at /a//... +# Run the relay with: bun packages/relay/bin/relay.ts (or deploy it anywhere). +ANIMA_WEBAPP_RELAY_URL="http://localhost:8787" +# Per-agent SECRET routing slug map (tokenId -> slug). Only the verified owner is +# handed the slug (server-side ownerOf gate in /api/agent/[tokenId]/connection). +# Example: ANIMA_WEBAPP_SLUGS='{"16":"slug-abc123","17":"slug-xyz789"}' +ANIMA_WEBAPP_SLUGS="" +# Fallback slug for single-agent deployments. Honored ONLY for the token id in +# ANIMA_WEBAPP_DEFAULT_TOKEN_ID below — otherwise a verified owner of any +# unrelated iNFT would be handed this slug and reach the wrong agent. +ANIMA_WEBAPP_DEFAULT_SLUG="" +ANIMA_WEBAPP_DEFAULT_TOKEN_ID="" diff --git a/apps/web/app/api/agent/[tokenId]/connection/resolve-slug.test.ts b/apps/web/app/api/agent/[tokenId]/connection/resolve-slug.test.ts new file mode 100644 index 0000000..1aebe2a --- /dev/null +++ b/apps/web/app/api/agent/[tokenId]/connection/resolve-slug.test.ts @@ -0,0 +1,88 @@ +// Tests for resolveSlug — the slug-binding logic of the connection route. The +// documented cross-agent leak: a verified owner of token A must NOT be handed +// token B's default slug. The single-agent fallback honors ANIMA_WEBAPP_DEFAULT_SLUG +// ONLY when ANIMA_WEBAPP_DEFAULT_TOKEN_ID equals the requested token. This test +// asserts that binding without any chain or session (resolveSlug is pure over +// process.env). + +import { test, expect, mock, beforeEach } from 'bun:test' + +// The route imports @/lib/siwe/authorize -> ./session -> 'server-only'. +mock.module('server-only', () => ({})) + +const { resolveSlug } = await import('./slug') + +// Snapshot + restore the three env vars resolveSlug reads. +const ENV_KEYS = [ + 'ANIMA_WEBAPP_SLUGS', + 'ANIMA_WEBAPP_DEFAULT_SLUG', + 'ANIMA_WEBAPP_DEFAULT_TOKEN_ID', +] as const + +beforeEach(() => { + for (const k of ENV_KEYS) delete process.env[k] +}) + +test('per-agent slug map returns the slug bound to that exact token', () => { + process.env.ANIMA_WEBAPP_SLUGS = JSON.stringify({ '16': 'slug-sixteen', '17': 'slug-seventeen' }) + expect(resolveSlug('16')).toBe('slug-sixteen') + expect(resolveSlug('17')).toBe('slug-seventeen') +}) + +test('per-agent map does NOT leak another token\'s slug for an unmapped token', () => { + process.env.ANIMA_WEBAPP_SLUGS = JSON.stringify({ '16': 'slug-sixteen' }) + // Token 99 is not in the map and there is no default -> no slug. + expect(resolveSlug('99')).toBeNull() +}) + +test('CROSS-AGENT LEAK GUARD: default slug is honored ONLY for its own token', () => { + // The owner of token 17 is authenticated (the ownerOf gate already passed for + // 17), but the single-agent default belongs to token 16. resolveSlug must + // refuse to hand token 16's slug to a request for token 17. + process.env.ANIMA_WEBAPP_DEFAULT_SLUG = 'secret-slug-for-16' + process.env.ANIMA_WEBAPP_DEFAULT_TOKEN_ID = '16' + expect(resolveSlug('16')).toBe('secret-slug-for-16') // own token: allowed + expect(resolveSlug('17')).toBeNull() // different token: blocked (no leak) + expect(resolveSlug('1')).toBeNull() + expect(resolveSlug('160')).toBeNull() // not a prefix/substring match either +}) + +test('per-agent map takes precedence over the single-agent default', () => { + process.env.ANIMA_WEBAPP_SLUGS = JSON.stringify({ '17': 'mapped-17' }) + process.env.ANIMA_WEBAPP_DEFAULT_SLUG = 'default-16' + process.env.ANIMA_WEBAPP_DEFAULT_TOKEN_ID = '16' + expect(resolveSlug('17')).toBe('mapped-17') + // 16 is not in the map, falls through to default which matches 16. + expect(resolveSlug('16')).toBe('default-16') +}) + +test('malformed ANIMA_WEBAPP_SLUGS JSON falls through to default (no crash)', () => { + process.env.ANIMA_WEBAPP_SLUGS = '{ not valid json' + process.env.ANIMA_WEBAPP_DEFAULT_SLUG = 'default-16' + process.env.ANIMA_WEBAPP_DEFAULT_TOKEN_ID = '16' + expect(() => resolveSlug('16')).not.toThrow() + expect(resolveSlug('16')).toBe('default-16') + expect(resolveSlug('17')).toBeNull() +}) + +test('default slug requires BOTH default vars set', () => { + process.env.ANIMA_WEBAPP_DEFAULT_SLUG = 'orphan-slug' + // No DEFAULT_TOKEN_ID -> binding cannot be established -> null. + expect(resolveSlug('16')).toBeNull() + + delete process.env.ANIMA_WEBAPP_DEFAULT_SLUG + process.env.ANIMA_WEBAPP_DEFAULT_TOKEN_ID = '16' + // No DEFAULT_SLUG -> null. + expect(resolveSlug('16')).toBeNull() +}) + +test('no env configured at all -> null (agent-not-web-enabled)', () => { + expect(resolveSlug('16')).toBeNull() +}) + +test('empty-string slug in map is falsy -> treated as not present', () => { + // map['16'] === '' is falsy under the `if (map[tokenId])` guard, so it falls + // through. With no default, that is null. Documents current behavior. + process.env.ANIMA_WEBAPP_SLUGS = JSON.stringify({ '16': '' }) + expect(resolveSlug('16')).toBeNull() +}) diff --git a/apps/web/app/api/agent/[tokenId]/connection/route.ts b/apps/web/app/api/agent/[tokenId]/connection/route.ts new file mode 100644 index 0000000..9a1367a --- /dev/null +++ b/apps/web/app/api/agent/[tokenId]/connection/route.ts @@ -0,0 +1,43 @@ +// GET /api/agent/[tokenId]/connection +// +// The server-side ownerOf gate + the web-gateway connection handshake. Only the +// verified owner of tokenId learns how to reach the agent: the relay's public +// base URL + the agent's SECRET routing slug. The browser then talks to the +// relay directly (reach-from-anywhere); writes are additionally protected by +// the operator's EIP-191 signature at the daemon, and SSE reads are gated by +// the secrecy of this slug (never the public tokenId). + +import { authorizeAgent } from '@/lib/siwe/authorize' +import type { NextRequest } from 'next/server' +import { resolveSlug } from './slug' + +export const runtime = 'nodejs' + +export async function GET(_req: NextRequest, ctx: { params: Promise<{ tokenId: string }> }) { + const { tokenId: raw } = await ctx.params + let tokenId: bigint + try { + tokenId = BigInt(raw) + } catch { + return Response.json({ error: 'bad-token-id' }, { status: 400 }) + } + + // SERVER-SIDE OWNERSHIP GATE — runs before any connection info is revealed. + const auth = await authorizeAgent(tokenId) + if (!auth) return Response.json({ error: 'unauthorized' }, { status: 401 }) + + const relayUrl = process.env.ANIMA_WEBAPP_RELAY_URL + if (!relayUrl) { + return Response.json({ error: 'web-gateway-not-configured' }, { status: 503 }) + } + const slug = resolveSlug(raw) + if (!slug) { + return Response.json({ error: 'agent-not-web-enabled' }, { status: 503 }) + } + + return Response.json({ + relayUrl: relayUrl.replace(/\/$/, ''), + agentId: slug, + operator: auth.operator, + }) +} diff --git a/apps/web/app/api/agent/[tokenId]/connection/slug.ts b/apps/web/app/api/agent/[tokenId]/connection/slug.ts new file mode 100644 index 0000000..7f02067 --- /dev/null +++ b/apps/web/app/api/agent/[tokenId]/connection/slug.ts @@ -0,0 +1,24 @@ +// Slug resolution for the web-gateway connection handshake. Lives OUTSIDE +// route.ts because Next.js route files may only export route handlers +// (GET/POST/…) + reserved config — exporting a helper from a route module fails +// `next build` ("not a valid Route export field"). Kept testable in isolation. + +export function resolveSlug(tokenId: string): string | null { + // Per-agent slug map, e.g. ANIMA_WEBAPP_SLUGS={"16":"slug-abc","17":"slug-xyz"} + const raw = process.env.ANIMA_WEBAPP_SLUGS + if (raw) { + try { + const map = JSON.parse(raw) as Record + if (map[tokenId]) return map[tokenId] + } catch { + /* fall through to default */ + } + } + // Single-agent fallback: the default slug is honored ONLY for its own token. + // Without this binding, a verified owner of ANY unrelated iNFT would pass the + // ownerOf gate for their token and be handed a DIFFERENT agent's slug. + const defaultSlug = process.env.ANIMA_WEBAPP_DEFAULT_SLUG + const defaultToken = process.env.ANIMA_WEBAPP_DEFAULT_TOKEN_ID + if (defaultSlug && defaultToken && defaultToken === tokenId) return defaultSlug + return null +} diff --git a/apps/web/app/console/[tokenId]/activity/page.tsx b/apps/web/app/console/[tokenId]/activity/page.tsx index fa5c85d..dec3473 100644 --- a/apps/web/app/console/[tokenId]/activity/page.tsx +++ b/apps/web/app/console/[tokenId]/activity/page.tsx @@ -9,7 +9,7 @@ export default function ActivityTab() { if (!ctx.agentEOA) { return (
- ACTIVITY · WAITING ON SUBNAME + Activity · Waiting on Subname

We could not resolve this agent’s wallet address from the SANN registry. Decrypt needs that address. Register a subname via the CLI and reload. diff --git a/apps/web/app/console/[tokenId]/chat/page.tsx b/apps/web/app/console/[tokenId]/chat/page.tsx new file mode 100644 index 0000000..8f49ded --- /dev/null +++ b/apps/web/app/console/[tokenId]/chat/page.tsx @@ -0,0 +1,13 @@ +'use client' + +import { useAgentContext } from '@/components/console/agent-context' +import { ChatPanel } from '@/components/console/chat/ChatPanel' + +// Live chat with the agent over the web gateway. Unlike the activity/memory +// tabs, this does NOT require a keystore unlock — chat talks to the live daemon +// (which holds the agent key) via wallet-signed messages, decrypting nothing +// locally. Auth is the SIWE session + the per-message EIP-191 signature. +export default function ChatTab() { + const ctx = useAgentContext() + return +} diff --git a/apps/web/app/console/[tokenId]/layout.tsx b/apps/web/app/console/[tokenId]/layout.tsx index 0fdf28b..f607e9e 100644 --- a/apps/web/app/console/[tokenId]/layout.tsx +++ b/apps/web/app/console/[tokenId]/layout.tsx @@ -94,7 +94,7 @@ function DetailShell({ tokenId, children }: { tokenId: bigint; children: ReactNo ) : error ? (

- AGENT · ACCESS + Agent · Access

{error}

) : ( diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx index 4d82b8d..894f50d 100644 --- a/apps/web/app/docs/page.tsx +++ b/apps/web/app/docs/page.tsx @@ -11,7 +11,7 @@ export default async function DocsOverviewPage() { const groups = await getNavTree() return (
- +

-
+
Chapter {String(gi + 1).padStart(2, '0')}
on every render and every concurrent message instance. */ +.anima-md { + font-size: 14px; + line-height: 1.65; + color: var(--color-ink); +} +.anima-md [data-streamdown="code-block"] { + margin: 0.75rem 0; + border-radius: 0.75rem; + overflow: hidden; + border: 1px solid var(--color-border) !important; + background-color: var(--color-paper); +} +.anima-md [data-streamdown="code-block-header"] { + background-color: transparent !important; + border: none !important; + box-shadow: none !important; + padding: 0.5rem 0.9rem; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.04em; + text-transform: lowercase; + color: var(--color-ink-3); +} +.anima-md [data-streamdown="code-block-header"]:has(span:empty) { + display: none !important; +} +.anima-md [data-streamdown="code-block-body"] { + background-color: transparent !important; + border: none !important; + box-shadow: none !important; + margin: 0; + padding: 0.5rem 0.9rem 0.9rem; + font-size: 12.5px; + line-height: 1.6; +} +.anima-md [data-streamdown="code-block-body"] code { + background: transparent !important; + padding: 0; + display: block; + font-family: var(--font-mono); +} +.dark .anima-md [data-streamdown="code-block-body"] code { + filter: brightness(1.7); +} +.anima-md code:not([class*="language-"]) { + padding: 1px 5px; + border-radius: 4px; + font-size: 12.5px; + font-family: var(--font-mono); + background-color: color-mix(in oklab, var(--color-ink) 5%, transparent); + color: var(--color-ink-2); +} +.anima-md table { + width: 100%; + border-collapse: collapse; + font-family: var(--font-mono); + font-size: 12.5px; + font-feature-settings: "tnum" 1; + margin: 0.75rem 0; +} +.anima-md th, +.anima-md td { + border: 1px solid var(--color-border); + padding: 0.4rem 0.7rem; + text-align: left; +} +.anima-md thead { + background: color-mix(in oklab, var(--color-ink) 4%, transparent); +} +.anima-md [data-streamdown="mermaid-block"] { + font-family: var(--font-body); +} +.dark .anima-md [data-streamdown="mermaid-block"] svg { + filter: invert(0.92) hue-rotate(180deg) saturate(0.85) brightness(0.95) !important; +} diff --git a/apps/web/components/Footer.tsx b/apps/web/components/Footer.tsx index fe71937..e0f4867 100644 --- a/apps/web/components/Footer.tsx +++ b/apps/web/components/Footer.tsx @@ -1,6 +1,6 @@ -import Link from 'next/link' -import { CONTRACTS, addressUrl, truncate } from '@/lib/chainscan' import { ThemeToggle } from '@/components/theme/ThemeToggle' +import { CONTRACTS, addressUrl, truncate } from '@/lib/chainscan' +import Link from 'next/link' const PROJECT_LINKS = [ { label: 'GitHub', href: 'https://github.com/s0nderlabs/anima', external: true }, @@ -39,7 +39,7 @@ export function Footer() {

- + {PROJECT_LINKS.map(link => ( {link.label} ↗ @@ -47,7 +47,7 @@ export function Footer() { ))} - + {ON_CHAIN.map(item => (
{item.name} - ↗ + + ↗ +
{truncate(item.address, 7, 5)} @@ -67,7 +69,7 @@ export function Footer() { ))} - + {COMMUNITY_LINKS.map(link => ( {link.label} ↗ @@ -98,7 +100,7 @@ function FooterColumn({ }) { return (
-
+
{label}
{children}
diff --git a/apps/web/components/console/ActivityFeed.tsx b/apps/web/components/console/ActivityFeed.tsx index b17cdc3..45fe2cf 100644 --- a/apps/web/components/console/ActivityFeed.tsx +++ b/apps/web/components/console/ActivityFeed.tsx @@ -1,5 +1,6 @@ 'use client' +import { zgMainnet } from '@/lib/chain/chain' import { fetchSlots } from '@/lib/chain/inft' import { decryptMemoryToText } from '@/lib/crypto/memory' import { shortHash } from '@/lib/format' @@ -9,7 +10,6 @@ import { useEffect, useMemo, useState } from 'react' import ReactMarkdown, { type Components } from 'react-markdown' import rehypeSanitize from 'rehype-sanitize' import { usePublicClient } from 'wagmi' -import { zgMainnet } from '@/lib/chain/chain' const brainComponents: Components = { p: ({ children }) => ( @@ -68,11 +68,12 @@ type ActivityEntry = { } type AutoTopupKind = 'topup-fired' | 'topup-failed' | 'wallet-low' -const AUTO_TOPUP_STATUS: Record = { - 'topup-fired': { label: 'ok', tone: 'ok' }, - 'topup-failed': { label: 'failed', tone: 'failed' }, - 'wallet-low': { label: 'low', tone: 'warn' }, -} +const AUTO_TOPUP_STATUS: Record = + { + 'topup-fired': { label: 'ok', tone: 'ok' }, + 'topup-failed': { label: 'failed', tone: 'failed' }, + 'wallet-low': { label: 'low', tone: 'warn' }, + } type LoadState = | { kind: 'loading' } @@ -123,7 +124,7 @@ export function ActivityFeed({ if (state.kind === 'loading') { return ( -

+

Unsealing

@@ -156,7 +157,7 @@ export function ActivityFeed({ {grouped?.map((group, gi) => (
- + {group.label}
@@ -274,7 +275,9 @@ function renderEntry(entry: ActivityEntry) { ↳ {d.name ?? 'tool'}{' '} - + {ok ? 'ok' : 'failed'} · {summary} @@ -335,7 +338,7 @@ function renderEntry(entry: ActivityEntry) { {message} + {entry.kind} ) @@ -379,7 +382,7 @@ function groupByHour(entries: ActivityEntry[]) { let currentKey = '' for (const entry of entries) { const d = new Date(entry.ts) - const date = d.toLocaleString([], { month: 'short', day: 'numeric' }).toUpperCase() + const date = d.toLocaleString([], { month: 'short', day: 'numeric' }) const hour = `${String(d.getHours()).padStart(2, '0')}:00` const label = `${date} · ${hour}` if (label !== currentKey) { @@ -406,7 +409,8 @@ function summarizeArgs(v: unknown): string { // Skip noise: empty arrays/objects/null/undefined. if (val === null || val === undefined) continue if (Array.isArray(val) && val.length === 0) continue - if (typeof val === 'object' && !Array.isArray(val) && Object.keys(val as object).length === 0) continue + if (typeof val === 'object' && !Array.isArray(val) && Object.keys(val as object).length === 0) + continue parts.push(`${k}=${prettyArgValue(val)}`) if (parts.join(', ').length > 80) break } @@ -468,7 +472,9 @@ function ToolPairRow({ entry }: { entry: ActivityEntry }) { const name = call?.name ?? 'tool' const args = call?.args const argsHas = - args !== undefined && args !== null && !(typeof args === 'object' && Object.keys(args as object).length === 0) + args !== undefined && + args !== null && + !(typeof args === 'object' && Object.keys(args as object).length === 0) const argsShort = argsHas ? summarizeArgs(args) : '' const hasResult = !!result const ok = !result || result.ok !== false @@ -498,7 +504,7 @@ function ToolPairRow({ entry }: { entry: ActivityEntry }) { {!argsShort && } {hasResult && ( )} @@ -551,7 +553,7 @@ function ExpandBlock({ }) { return (
-

+

{label}

-        
-          memory · unsealing
+        
+          Memory · Unsealing
         
         

Pulling the encrypted index from 0G Storage and decrypting in this tab. @@ -374,7 +374,7 @@ function FileDetail({ + + +

+ + + ) +} + +function ChatNotice({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +

+ {children} +

+
+ ) +} diff --git a/apps/web/components/console/chat/MarkdownRenderer.tsx b/apps/web/components/console/chat/MarkdownRenderer.tsx new file mode 100644 index 0000000..ca0b9e8 --- /dev/null +++ b/apps/web/components/console/chat/MarkdownRenderer.tsx @@ -0,0 +1,96 @@ +'use client' + +import { Streamdown } from 'streamdown' +import type { MermaidOptions } from 'streamdown' + +// Streaming markdown for assistant replies, ported from pragma-v2-stable's +// Streamdown wrapper but restyled to anima's editorial "literary cream" palette +// (--color-* tokens, Geist Mono code, no terracotta, no red/green). Dark mode is +// driven by the `.dark` ancestor class (same as the rest of the app) so theme +// switches don't re-render and lose scroll. Mermaid is always rendered light and +// CSS-inverted in dark, matching the pragma approach. + +interface MarkdownRendererProps { + content: string + isAnimating?: boolean +} + +function preprocessContent(content: string): string { + let cleaned = content.replace(/\r\n/g, '\n') + // Drop agent-internal bracketed addresses + quote-id HTML comments. + cleaned = cleaned.replace(/\[0x[a-fA-F0-9]{40,}\]/g, '') + cleaned = cleaned.replace(//g, '') + return cleaned +} + +const mermaidOptions: MermaidOptions = { + config: { + theme: 'default', + themeVariables: { + primaryColor: '#f2f1ee', + primaryTextColor: '#100f09', + primaryBorderColor: '#100f09', + lineColor: '#525251', + secondaryColor: '#f0e8d6', + tertiaryColor: '#fbfbf9', + background: '#f9f8f6', + mainBkg: '#fbfbf9', + titleColor: '#100f09', + }, + }, +} + +export function MarkdownRenderer({ content, isAnimating = false }: MarkdownRendererProps) { + return ( +
+ ) +} diff --git a/apps/web/components/console/chat/ThinkingIndicator.tsx b/apps/web/components/console/chat/ThinkingIndicator.tsx new file mode 100644 index 0000000..6c9625a --- /dev/null +++ b/apps/web/components/console/chat/ThinkingIndicator.tsx @@ -0,0 +1,71 @@ +'use client' + +import { AnimatePresence, motion } from 'framer-motion' +import { useEffect, useState } from 'react' + +// Unicode star spinner, ported from pragma-v2-stable's ThinkingIndicator but +// re-voiced for anima's sovereign-agent context and restyled to the editorial +// palette (muted ink, no terracotta — failure/status never uses color here). +const SPINNER_FRAMES = ['✦', '✧', '✶', '✷', '✸', '✹', '✺', '✻'] + +const ANIMA_VIBES = [ + 'Reasoning', + 'Consulting memory', + 'Reaching for tools', + 'Reading the chain', + 'Weighing the options', + 'Composing a reply', + 'Checking permissions', + 'Recalling context', + 'Thinking it through', + 'Working on-chain', +] + +interface ThinkingIndicatorProps { + /** Override the rotating phrase (e.g. a tool name currently running). */ + label?: string +} + +export function ThinkingIndicator({ label }: ThinkingIndicatorProps) { + const [frameIndex, setFrameIndex] = useState(0) + const [phraseIndex, setPhraseIndex] = useState(0) + + useEffect(() => { + const id = setInterval(() => setFrameIndex(p => (p + 1) % SPINNER_FRAMES.length), 110) + return () => clearInterval(id) + }, []) + + useEffect(() => { + if (label) return + const id = setInterval(() => setPhraseIndex(p => (p + 1) % ANIMA_VIBES.length), 1600) + return () => clearInterval(id) + }, [label]) + + const message = label ?? ANIMA_VIBES[phraseIndex] + + return ( + + + {SPINNER_FRAMES[frameIndex]} + + + + {message}… + + + + ) +} diff --git a/apps/web/components/console/chat/parts.tsx b/apps/web/components/console/chat/parts.tsx new file mode 100644 index 0000000..3da9fa5 --- /dev/null +++ b/apps/web/components/console/chat/parts.tsx @@ -0,0 +1,523 @@ +'use client' + +// Shared, refined chat primitives used by the real console ChatPanel AND the +// /mocks design + live pages, so polish lands in one place. Editorial palette +// throughout (--color-* tokens, Fraunces/Outfit/Geist Mono); Claude/ChatGPT +// layout paradigm: centered column, full-width agent prose, understated user +// turns, one composer with the send control inside. +// +// Interaction logic ported from pragma-v2-stable, rendered entirely in anima's +// ink/cream vocabulary: paced reveal of the assistant blob, turn-boundary copy + +// retry, a state-aware composer whose send arrow morphs to a stop while a turn +// runs, and a warm (never-red) error row. + +import { AnimatePresence, motion } from 'framer-motion' +import { useEffect, useMemo, useState } from 'react' +import { MarkdownRenderer } from './MarkdownRenderer' +import { ThinkingIndicator } from './ThinkingIndicator' +import { useRevealedText } from './useRevealedText' + +export const EASE = [0.22, 1, 0.36, 1] as const + +export type ToolStatus = 'running' | 'ok' | 'failed' +export type ChatItem = + | { kind: 'op'; id: string; text: string } + | { kind: 'assistant'; id: string; text: string } + | { + kind: 'tool' + id: string + callId: string + name: string + status: ToolStatus + durationMs?: number + } + | { kind: 'note'; id: string; text: string } + | { kind: 'error'; id: string; text: string; retry?: string } + +export function AnimaMark({ size = 28 }: { size?: number }) { + return ( + + a + + ) +} + +function IconButton({ + label, + onClick, + children, +}: { + label: string + onClick: () => void + children: React.ReactNode +}) { + return ( + + ) +} + +// One quiet action row anchored to the END of an agent turn. Copy grabs the +// whole turn's prose (not a single fragment); retry re-runs the preceding op. +function TurnActions({ copyText, onRetry }: { copyText: string; onRetry?: () => void }) { + const [copied, setCopied] = useState(false) + return ( +
+ { + navigator.clipboard?.writeText(copyText).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1400) + }) + }} + > + {copied ? ( + + ) : ( + + )} + + {onRetry ? ( + + + + ) : null} +
+ ) +} + +function ToolRow({ + name, + status, + durationMs, +}: { name: string; status: ToolStatus; durationMs?: number }) { + const glyph = status === 'running' ? '▸' : '↳' + const label = status === 'running' ? 'running' : status + return ( +
+ {glyph} + {name} + {status === 'failed' ? ( + + {label} + + ) : ( + {label} + )} + {status === 'ok' && typeof durationMs === 'number' ? ( + + · {formatDuration(durationMs)} + + ) : null} +
+ ) +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms` + return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s` +} + +// Warm, never-red terminal state with an inline retry. Uses a frozen star glyph +// (motion decayed to rest = done) rather than an alarming colored note. +function ErrorRow({ text, onRetry }: { text: string; onRetry?: () => void }) { + return ( +
+ + {text} + {onRetry ? ( + + ) : null} +
+ ) +} + +// A single revealed assistant fragment (paces the final string into bursts). +function AssistantBody({ text }: { text: string }) { + const { shown, done } = useRevealedText(text) + return ( +
+ +
+ ) +} + +type Group = + | { side: 'user'; items: ChatItem[] } + | { side: 'agent'; items: ChatItem[]; retryText?: string } + +function groupTurns(items: ChatItem[]): Group[] { + const groups: Group[] = [] + let lastUserText: string | undefined + for (const it of items) { + if (it.kind === 'op') { + lastUserText = it.text + const last = groups[groups.length - 1] + if (last && last.side === 'user') last.items.push(it) + else groups.push({ side: 'user', items: [it] }) + } else { + const last = groups[groups.length - 1] + if (last && last.side === 'agent') last.items.push(it) + else groups.push({ side: 'agent', items: [it], retryText: lastUserText }) + } + } + return groups +} + +function AgentTurn({ + items, + thinking, + retryText, + onRetry, +}: { + items: ChatItem[] + thinking?: boolean + retryText?: string + onRetry?: (text: string) => void +}) { + const assistantText = items + .filter(it => it.kind === 'assistant') + .map(it => (it.kind === 'assistant' ? it.text : '')) + .join('\n\n') + const hasAssistant = assistantText.length > 0 + return ( + + +
+ {items.map(it => { + if (it.kind === 'tool') + return ( + + ) + if (it.kind === 'note') + return ( +
+ {it.text} +
+ ) + if (it.kind === 'error') + return ( + onRetry(it.retry as string) : undefined} + /> + ) + if (it.kind === 'assistant') return + return null + })} + {thinking ? : null} + {hasAssistant && !thinking ? ( + onRetry(retryText) : undefined} + /> + ) : null} +
+
+ ) +} + +function UserTurn({ items }: { items: ChatItem[] }) { + return ( +
+ {items.map(it => ( + + {it.kind === 'op' ? it.text : null} + + ))} +
+ ) +} + +export function Transcript({ + items, + pending, + onRetry, +}: { + items: ChatItem[] + pending: boolean + onRetry?: (text: string) => void +}) { + const groups = groupTurns(items) + const lastIsUser = items[items.length - 1]?.kind === 'op' + return ( +
+ {groups.map((g, i) => { + const key = g.items[0]?.id ?? `g${i}` + const isLast = i === groups.length - 1 + if (g.side === 'user') return + return ( + + ) + })} + {/* pending right after a user turn → fresh agent turn with just the spinner */} + {pending && lastIsUser ? ( + + +
+ +
+
+ ) : null} +
+ ) +} + +// Floating "jump to latest" affordance, shown by the caller when the operator +// has scrolled up mid-turn. Flat ink ring, no drop-shadow beyond the soft +// composer treatment — never a glassy floating glow. +export function ScrollToBottomPill({ onClick }: { onClick: () => void }) { + return ( + + + + ) +} + +export function EmptyState({ title, sub }: { title: string; sub: string }) { + return ( +
+ +

+ {title} +

+

{sub}

+
+ ) +} + +const FIRST_PROMPTS = [ + 'ask me to remember something', + "check the agent's onchain balance", + 'what did we decide yesterday?', + 'what time is it on the host?', + 'summarize the agent’s recent activity', +] + +export function Composer({ + value, + onChange, + onSend, + onStop, + pending = false, + blocked = false, + hasMessages = false, + hint, +}: { + value: string + onChange: (v: string) => void + onSend: () => void + onStop?: () => void + pending?: boolean + blocked?: boolean + hasMessages?: boolean + hint?: string +}) { + const empty = value.trim().length === 0 + const canSend = !blocked && !pending && !empty + + // Rotated literary prompt for the very first message, picked once on the + // client (null-then-set) to avoid a hydration mismatch. + const [firstPrompt, setFirstPrompt] = useState(null) + useEffect(() => { + setFirstPrompt(FIRST_PROMPTS[Math.floor(Math.random() * FIRST_PROMPTS.length)]) + }, []) + + const placeholder = useMemo(() => { + if (blocked) return 'connecting to your agent…' + if (hasMessages) return 'what else?' + return firstPrompt ? `Message your agent — e.g. ${firstPrompt}` : 'Message your agent…' + }, [blocked, hasMessages, firstPrompt]) + + return ( +
+
+