3 Commits

10 changed files with 862 additions and 58 deletions

View File

@@ -299,7 +299,7 @@ Behavior notes:
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks. - For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
- Available Sybil-managed tool calls for `openai` and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available. - Available Sybil-managed tool calls for `openai` and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. - `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
- `fetch_url` fetches a URL and returns plaintext page content (HTML converted to text server-side). - `fetch_url` fetches a URL with browser-like navigation headers and returns plaintext page content (HTML converted to text server-side).
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed. - `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
- `shell_exec` runs arbitrary non-interactive shell commands on the same configured devbox, starting in `CHAT_CODEX_REMOTE_WORKDIR`. It uses `bash -lc` when bash exists, otherwise `sh -lc`, closes SSH stdin, and does not run inside the Sybil server container. - `shell_exec` runs arbitrary non-interactive shell commands on the same configured devbox, starting in `CHAT_CODEX_REMOTE_WORKDIR`. It uses `bash -lc` when bash exists, otherwise `sh -lc`, closes SSH stdin, and does not run inside the Sybil server container.
- Devbox tool configuration: - Devbox tool configuration:

View File

@@ -172,6 +172,7 @@ Terminal tool-call event:
- `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text. - `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text.
- `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text. - `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text.
- `fetch_url` sends browser-like navigation headers for outbound URL requests to reduce false 403s from sites that reject generic server clients.
- `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract. - `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract.
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items. - `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
- `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts. - `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.

View File

@@ -10,6 +10,9 @@ struct SybilChatTranscriptView: View {
var bottomPinRequestID: Int = 0 var bottomPinRequestID: Int = 0
private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor" private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
private var renderItems: [TranscriptRenderItem] {
buildTranscriptRenderItems(from: messages)
}
var body: some View { var body: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
@@ -22,9 +25,16 @@ struct SybilChatTranscriptView: View {
.padding(.top, 24) .padding(.top, 24)
} }
ForEach(messages) { message in ForEach(renderItems) { item in
MessageBubble(message: message, isSending: isSending) switch item {
.frame(maxWidth: .infinity) case let .message(message):
MessageBubble(message: message, isSending: isSending)
.frame(maxWidth: .infinity)
case let .toolGroup(id, messages):
ToolCallStackView(groupID: id, messages: messages)
.frame(maxWidth: .infinity)
.id(id)
}
} }
Color.clear Color.clear
@@ -59,6 +69,47 @@ struct SybilChatTranscriptView: View {
} }
} }
enum TranscriptRenderItem: Identifiable {
case message(Message)
case toolGroup(id: String, messages: [Message])
var id: String {
switch self {
case let .message(message):
return message.id
case let .toolGroup(id, _):
return "tool-group-\(id)"
}
}
}
func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderItem] {
var items: [TranscriptRenderItem] = []
var toolRun: [Message] = []
func flushToolRun() {
guard !toolRun.isEmpty else { return }
if toolRun.count == 1, let message = toolRun.first {
items.append(.message(message))
} else if let first = toolRun.first {
items.append(.toolGroup(id: first.id, messages: toolRun))
}
toolRun.removeAll(keepingCapacity: true)
}
for message in messages {
if message.toolCallMetadata != nil {
toolRun.append(message)
} else {
flushToolRun()
items.append(.message(message))
}
}
flushToolRun()
return items
}
private struct MessageBubble: View { private struct MessageBubble: View {
var message: Message var message: Message
var isSending: Bool var isSending: Bool
@@ -154,6 +205,196 @@ private struct MessageBubble: View {
} }
} }
private struct ToolCallStackView: View {
private struct CardLayout {
var x: CGFloat
var y: CGFloat
var scale: CGFloat
var opacity: Double
var zIndex: Double
}
var groupID: String
var messages: [Message]
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isExpanded = false
private let visibleCollapsedLimit = 4
private let cardHeight: CGFloat = 62
private let expandedGap: CGFloat = 10
private let collapsedStepX: CGFloat = 11
private let collapsedStepY: CGFloat = 10
private let toggleSize: CGFloat = 32
private let toggleGap: CGFloat = 12
private var animation: Animation? {
reduceMotion ? nil : .easeInOut(duration: 0.34)
}
private var visibleCollapsedCount: Int {
min(messages.count, visibleCollapsedLimit)
}
private var hiddenCount: Int {
max(0, messages.count - visibleCollapsedLimit)
}
private var containerHeight: CGFloat {
if isExpanded {
return cardHeight + CGFloat(max(0, messages.count - 1)) * (cardHeight + expandedGap)
}
return cardHeight + CGFloat(max(0, visibleCollapsedCount - 1)) * collapsedStepY
}
private var accessibilityLabel: String {
"\(messages.count) tool \(messages.count == 1 ? "call" : "calls")"
}
var body: some View {
HStack(alignment: .top, spacing: 0) {
GeometryReader { geometry in
let cardWidth = max(220, min(520, geometry.size.width - toggleSize - toggleGap))
let toggleX = cardWidth + toggleGap
ZStack(alignment: .topLeading) {
ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in
let layout = layout(for: index)
let depth = messages.count - index - 1
let isHidden = !isExpanded && depth >= visibleCollapsedLimit
ToolCallStackCard(message: message, cardHeight: cardHeight, compactLayout: true)
.frame(width: cardWidth, height: cardHeight, alignment: .topLeading)
.scaleEffect(layout.scale, anchor: .topLeading)
.opacity(layout.opacity)
.offset(x: layout.x, y: layout.y)
.zIndex(layout.zIndex)
.allowsHitTesting(!isHidden)
.accessibilityHidden(isHidden)
}
if !isExpanded && hiddenCount > 0 {
Text("+\(hiddenCount)")
.font(.sybil(.caption2, weight: .semibold))
.foregroundStyle(SybilTheme.accent.opacity(0.95))
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(
Capsule()
.fill(Color.black.opacity(0.58))
.overlay(
Capsule()
.stroke(SybilTheme.accent.opacity(0.34), lineWidth: 1)
)
)
.offset(x: max(0, cardWidth - 56), y: containerHeight - 13)
.transition(.opacity)
}
Button {
withAnimation(animation) {
isExpanded.toggle()
}
} label: {
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(SybilTheme.accent.opacity(0.95))
.frame(width: toggleSize, height: toggleSize)
.background(
Circle()
.fill(
LinearGradient(
colors: [
Color(red: 0.06, green: 0.08, blue: 0.15).opacity(0.96),
Color(red: 0.03, green: 0.04, blue: 0.10).opacity(0.96)
],
startPoint: .top,
endPoint: .bottom
)
)
.overlay(
Circle()
.stroke(SybilTheme.accent.opacity(0.38), lineWidth: 1)
)
.shadow(color: Color.black.opacity(0.30), radius: 10, x: 0, y: 6)
)
}
.buttonStyle(.plain)
.accessibilityLabel("\(isExpanded ? "Collapse" : "Expand") \(accessibilityLabel)")
.offset(x: toggleX, y: 8)
.zIndex(Double(messages.count + 2))
}
.frame(width: cardWidth + toggleSize + toggleGap, height: containerHeight, alignment: .topLeading)
.animation(animation, value: isExpanded)
}
.frame(height: containerHeight)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func layout(for index: Int) -> CardLayout {
if isExpanded {
return CardLayout(
x: 0,
y: CGFloat(index) * (cardHeight + expandedGap),
scale: 1,
opacity: 1,
zIndex: Double(messages.count - index)
)
}
let depth = messages.count - index - 1
let visibleDepth = min(depth, visibleCollapsedLimit - 1)
let isHidden = depth >= visibleCollapsedLimit
return CardLayout(
x: CGFloat(visibleDepth) * collapsedStepX,
y: CGFloat(visibleDepth) * collapsedStepY,
scale: max(0.88, 1 - CGFloat(visibleDepth) * 0.035),
opacity: isHidden ? 0 : max(0.34, 1 - Double(visibleDepth) * 0.22),
zIndex: isHidden ? 0 : Double(visibleCollapsedCount - visibleDepth)
)
}
}
private struct ToolCallStackCard: View {
var message: Message
var cardHeight: CGFloat
var compactLayout: Bool
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var didEnter = false
var body: some View {
Group {
if let metadata = message.toolCallMetadata {
ToolCallActivityChip(
metadata: metadata,
fallbackContent: message.content,
createdAt: message.createdAt,
compactLayout: compactLayout
)
}
}
.frame(height: cardHeight, alignment: .top)
.scaleEffect(didEnter ? 1 : 1.025, anchor: .topLeading)
.offset(y: didEnter ? 0 : -8)
.rotation3DEffect(.degrees(didEnter ? 0 : 3), axis: (x: 1, y: 0, z: 0), anchor: .top)
.opacity(didEnter ? 1 : 0.72)
.onAppear {
guard !didEnter else { return }
if reduceMotion {
didEnter = true
} else {
withAnimation(.easeOut(duration: 0.32).delay(0.03)) {
didEnter = true
}
}
}
}
}
private struct ToolCallActivityChip: View { private struct ToolCallActivityChip: View {
enum VisualState { enum VisualState {
case initiated case initiated
@@ -164,6 +405,7 @@ private struct ToolCallActivityChip: View {
var metadata: ToolCallMetadata var metadata: ToolCallMetadata
var fallbackContent: String var fallbackContent: String
var createdAt: Date var createdAt: Date
var compactLayout: Bool = false
private var summary: String { private var summary: String {
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
@@ -250,7 +492,9 @@ private struct ToolCallActivityChip: View {
.font(.sybil(.subheadline)) .font(.sybil(.subheadline))
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.96) : SybilTheme.text.opacity(0.94)) .foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.96) : SybilTheme.text.opacity(0.94))
.lineSpacing(3) .lineSpacing(3)
.fixedSize(horizontal: false, vertical: true) .lineLimit(compactLayout ? 1 : nil)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: !compactLayout)
HStack(spacing: 6) { HStack(spacing: 6) {
Text(toolLabel) Text(toolLabel)

View File

@@ -402,6 +402,70 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
) )
} }
private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran a tool") -> Message {
Message(
id: id,
createdAt: date,
role: .tool,
content: summary,
name: "web_search",
metadata: .object([
"kind": .string("tool_call"),
"toolCallId": .string("call-\(id)"),
"toolName": .string("web_search"),
"status": .string("completed"),
"summary": .string(summary),
"durationMs": .number(120)
])
)
}
@Test func transcriptRenderItemsGroupAdjacentToolCalls() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_000)
let user = Message(id: "user-1", createdAt: date, role: .user, content: "Search this", name: nil)
let toolA = makeToolCallMessage(id: "tool-a", date: date, summary: "Search A")
let toolB = makeToolCallMessage(id: "tool-b", date: date, summary: "Search B")
let assistant = Message(id: "assistant-1", createdAt: date, role: .assistant, content: "Answer", name: nil)
let items = buildTranscriptRenderItems(from: [user, toolA, toolB, assistant])
#expect(items.count == 3)
guard case let .message(firstMessage) = items[0] else {
Issue.record("Expected the first item to remain a normal message")
return
}
#expect(firstMessage.id == "user-1")
guard case let .toolGroup(groupID, groupedMessages) = items[1] else {
Issue.record("Expected adjacent tool calls to be grouped")
return
}
#expect(groupID == "tool-a")
#expect(groupedMessages.map(\.id) == ["tool-a", "tool-b"])
guard case let .message(lastMessage) = items[2] else {
Issue.record("Expected the assistant response to remain a normal message")
return
}
#expect(lastMessage.id == "assistant-1")
}
@Test func transcriptRenderItemsKeepSingleToolCallsInline() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_000)
let user = Message(id: "user-1", createdAt: date, role: .user, content: "Search this", name: nil)
let tool = makeToolCallMessage(id: "tool-a", date: date)
let assistant = Message(id: "assistant-1", createdAt: date, role: .assistant, content: "Answer", name: nil)
let items = buildTranscriptRenderItems(from: [user, tool, assistant])
#expect(items.count == 3)
guard case let .message(toolMessage) = items[1] else {
Issue.record("Expected a single tool call to use the existing inline chip")
return
}
#expect(toolMessage.id == "tool-a")
}
@MainActor @MainActor
@Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws { @Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
let defaults = UserDefaults(suiteName: #function)! let defaults = UserDefaults(suiteName: #function)!

View File

@@ -0,0 +1,26 @@
export const CHROMIUM_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
export const BROWSER_ACCEPT_LANGUAGE = "en-US,en;q=0.9";
export const FETCH_URL_ACCEPT =
"text/html,application/xhtml+xml,application/xml;q=0.9,application/pdf;q=0.9,*/*;q=0.8";
export function buildBrowserLikeRequestHeaders(accept: string): Record<string, string> {
return {
"User-Agent": CHROMIUM_USER_AGENT,
Accept: accept,
"Accept-Language": BROWSER_ACCEPT_LANGUAGE,
};
}
export function buildBrowserLikeNavigationHeaders(accept = FETCH_URL_ACCEPT): Record<string, string> {
return {
...buildBrowserLikeRequestHeaders(accept),
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
};
}

View File

@@ -6,6 +6,7 @@ import { promisify } from "node:util";
import { convert as htmlToText } from "html-to-text"; import { convert as htmlToText } from "html-to-text";
import type OpenAI from "openai"; import type OpenAI from "openai";
import { z } from "zod"; import { z } from "zod";
import { buildBrowserLikeNavigationHeaders } from "../browser-fetch-headers.js";
import { env } from "../env.js"; import { env } from "../env.js";
import { exaClient } from "../search/exa.js"; import { exaClient } from "../search/exa.js";
import { searchSearxng } from "../search/searxng.js"; import { searchSearxng } from "../search/searxng.js";
@@ -570,10 +571,7 @@ async function runFetchUrlTool(input: unknown): Promise<ToolRunOutcome> {
response = await fetch(parsed.toString(), { response = await fetch(parsed.toString(), {
redirect: "follow", redirect: "follow",
signal: controller.signal, signal: controller.signal,
headers: { headers: buildBrowserLikeNavigationHeaders(),
"User-Agent": "SybilBot/1.0 (+https://sybil.local)",
Accept: "text/html, text/plain, application/json;q=0.9, */*;q=0.5",
},
}); });
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);

View File

@@ -1,3 +1,4 @@
import { buildBrowserLikeRequestHeaders } from "../browser-fetch-headers.js";
import { env } from "../env.js"; import { env } from "../env.js";
const SEARXNG_TIMEOUT_MS = 12_000; const SEARXNG_TIMEOUT_MS = 12_000;
@@ -106,10 +107,7 @@ async function fetchSearxng(url: URL, accept: string) {
return await fetch(url, { return await fetch(url, {
redirect: "follow", redirect: "follow",
signal: controller.signal, signal: controller.signal,
headers: { headers: buildBrowserLikeRequestHeaders(accept),
"User-Agent": "SybilBot/1.0 (+https://sybil.local)",
Accept: accept,
},
}); });
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);

View File

@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { import {
runPlainChatCompletionsStream, runPlainChatCompletionsStream,
runToolAwareChatCompletions,
runToolAwareChatCompletionsStream, runToolAwareChatCompletionsStream,
runToolAwareOpenAIChatStream, runToolAwareOpenAIChatStream,
type ToolAwareStreamingEvent, type ToolAwareStreamingEvent,
@@ -141,6 +142,79 @@ test("plain Chat Completions stream does not send Sybil-managed tools", async ()
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi"); assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
}); });
test("fetch_url sends browser-like navigation headers", async () => {
const originalFetch = globalThis.fetch;
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
fetchCalls.push({ input, init });
return new Response("<!doctype html><title>CPI</title><main>Consumer price index</main>", {
status: 200,
headers: { "content-type": "text/html; charset=utf-8" },
});
}) as typeof fetch;
try {
let requestCount = 0;
const client = {
chat: {
completions: {
create: async () => {
requestCount += 1;
if (requestCount === 1) {
return {
choices: [
{
message: {
tool_calls: [
{
id: "call_1",
type: "function",
function: {
name: "fetch_url",
arguments: JSON.stringify({ url: "https://www.bls.gov/news.release/pdf/cpi.pdf" }),
},
},
],
},
},
],
};
}
return {
choices: [{ message: { content: "Fetched" } }],
};
},
},
},
};
const result = await runToolAwareChatCompletions({
client: client as any,
model: "grok-test",
messages: [{ role: "user", content: "Fetch CPI PDF" }],
});
assert.equal(result.text, "Fetched");
assert.equal(fetchCalls.length, 1);
assert.equal(String(fetchCalls[0]?.input), "https://www.bls.gov/news.release/pdf/cpi.pdf");
assert.deepEqual(fetchCalls[0]?.init?.headers, {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,application/pdf;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
});
assert.equal(result.toolEvents[0]?.status, "completed");
} finally {
globalThis.fetch = originalFetch;
}
});
test("OpenAI-compatible Chat Completions stream emits initiated and terminal tool call updates", async () => { test("OpenAI-compatible Chat Completions stream emits initiated and terminal tool call updates", async () => {
let requestCount = 0; let requestCount = 0;
const client = { const client = {

View File

@@ -1,8 +1,10 @@
import { useMemo, useRef, useState } from "preact/hooks";
import type { JSX } from "preact";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list"; import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
import { getMessageAttachments, type Message } from "@/lib/api"; import { getMessageAttachments, type Message } from "@/lib/api";
import { MarkdownContent } from "@/components/markdown/markdown-content"; import { MarkdownContent } from "@/components/markdown/markdown-content";
import { Globe2, Link2, Wrench } from "lucide-preact"; import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
type Props = { type Props = {
messages: Message[]; messages: Message[];
@@ -72,6 +74,29 @@ function formatToolTimestamp(...values: Array<string | null | undefined>) {
} }
type ToolCallVisualState = "initiated" | "completed" | "failed"; type ToolCallVisualState = "initiated" | "completed" | "failed";
type MessageRenderItem = { kind: "message"; message: Message } | { kind: "tool_group"; key: string; messages: Message[] };
type ToolStackStyle = JSX.CSSProperties & {
"--tool-stack-x"?: string;
"--tool-stack-y"?: string;
"--tool-stack-z"?: string;
"--tool-stack-scale"?: string;
"--tool-stack-opacity"?: string;
"--tool-stack-delay"?: string;
"--tool-stack-from-transform"?: string;
"--tool-stack-to-transform"?: string;
"--tool-stack-from-opacity"?: string;
"--tool-stack-to-opacity"?: string;
};
type ToolStackContainerStyle = JSX.CSSProperties & {
"--tool-stack-from-height"?: string;
"--tool-stack-to-height"?: string;
};
type ToolStackMotionDirection = "expand" | "collapse" | null;
const COLLAPSED_TOOL_STACK_LIMIT = 4;
const TOOL_STACK_CARD_HEIGHT = 62;
const TOOL_STACK_CARD_GAP = 10;
const TOOL_STACK_LAYOUT_ANIMATION_MS = 340;
function getToolVisualState(metadata: ToolLogMetadata): ToolCallVisualState { function getToolVisualState(metadata: ToolLogMetadata): ToolCallVisualState {
if (metadata.status === "failed") return "failed"; if (metadata.status === "failed") return "failed";
@@ -89,61 +114,293 @@ function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, state:
.join(" • "); .join(" • ");
} }
function buildMessageRenderItems(messages: Message[]) {
const items: MessageRenderItem[] = [];
let toolRun: Message[] = [];
const flushToolRun = () => {
if (!toolRun.length) return;
if (toolRun.length === 1) {
items.push({ kind: "message", message: toolRun[0] });
} else {
items.push({ kind: "tool_group", key: toolRun[0].id, messages: toolRun });
}
toolRun = [];
};
for (const message of messages) {
if (message.role === "tool" && asToolLogMetadata(message.metadata)) {
toolRun.push(message);
continue;
}
flushToolRun();
items.push({ kind: "message", message });
}
flushToolRun();
return items;
}
function getToolStackHeight(messageCount: number, expanded: boolean) {
const visibleCount = Math.min(messageCount, COLLAPSED_TOOL_STACK_LIMIT);
return expanded
? `${TOOL_STACK_CARD_HEIGHT + Math.max(0, messageCount - 1) * (TOOL_STACK_CARD_HEIGHT + TOOL_STACK_CARD_GAP)}px`
: `${TOOL_STACK_CARD_HEIGHT + Math.max(0, visibleCount - 1) * TOOL_STACK_CARD_GAP}px`;
}
function getToolStackContainerStyle(messageCount: number, expanded: boolean, motionDirection: ToolStackMotionDirection): ToolStackContainerStyle {
const collapsedHeight = getToolStackHeight(messageCount, false);
const expandedHeight = getToolStackHeight(messageCount, true);
const targetHeight = expanded ? expandedHeight : collapsedHeight;
const fromHeight = motionDirection === "expand" ? collapsedHeight : motionDirection === "collapse" ? expandedHeight : targetHeight;
return {
"--tool-stack-from-height": fromHeight,
"--tool-stack-to-height": targetHeight,
height: targetHeight,
};
}
function getExpandedToolLayout(index: number, messageCount: number) {
const y = `${index * (TOOL_STACK_CARD_HEIGHT + TOOL_STACK_CARD_GAP)}px`;
return {
opacity: "1",
transform: `translate3d(0px, ${y}, 0px) scale(1)`,
x: "0px",
y,
z: "0px",
scale: "1",
zIndex: messageCount - index,
};
}
function getCollapsedToolLayout(index: number, messageCount: number) {
const depth = messageCount - index - 1;
const visibleDepth = Math.min(depth, COLLAPSED_TOOL_STACK_LIMIT - 1);
const isHidden = depth >= COLLAPSED_TOOL_STACK_LIMIT;
const visibleCount = Math.min(messageCount, COLLAPSED_TOOL_STACK_LIMIT);
const x = `${visibleDepth * 11}px`;
const y = `${visibleDepth * TOOL_STACK_CARD_GAP}px`;
const z = `${visibleDepth * -36}px`;
const scale = `${Math.max(0.88, 1 - visibleDepth * 0.035)}`;
const opacity = isHidden ? "0" : `${Math.max(0.34, 1 - visibleDepth * 0.22)}`;
return {
opacity,
transform: `translate3d(${x}, ${y}, ${z}) scale(${scale})`,
x,
y,
z,
scale,
zIndex: isHidden ? 0 : visibleCount - visibleDepth,
};
}
function getToolStackStyle(index: number, messageCount: number, expanded: boolean, motionDirection: ToolStackMotionDirection): ToolStackStyle {
const expandedLayout = getExpandedToolLayout(index, messageCount);
const collapsedLayout = getCollapsedToolLayout(index, messageCount);
const targetLayout = expanded ? expandedLayout : collapsedLayout;
const fromLayout = motionDirection === "expand" ? collapsedLayout : motionDirection === "collapse" ? expandedLayout : targetLayout;
return {
"--tool-stack-x": targetLayout.x,
"--tool-stack-y": targetLayout.y,
"--tool-stack-z": targetLayout.z,
"--tool-stack-scale": targetLayout.scale,
"--tool-stack-opacity": targetLayout.opacity,
"--tool-stack-delay": `${Math.min(messageCount - index - 1, COLLAPSED_TOOL_STACK_LIMIT - 1) * 34}ms`,
"--tool-stack-from-transform": fromLayout.transform,
"--tool-stack-to-transform": targetLayout.transform,
"--tool-stack-from-opacity": fromLayout.opacity,
"--tool-stack-to-opacity": targetLayout.opacity,
opacity: targetLayout.opacity,
transform: targetLayout.transform,
zIndex: targetLayout.zIndex,
};
}
function ToolCallCard({
message,
className,
style,
}: {
message: Message;
className?: string;
style?: JSX.CSSProperties;
}) {
const toolLogMetadata = asToolLogMetadata(message.metadata);
if (!toolLogMetadata) return null;
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
const toolState = getToolVisualState(toolLogMetadata);
const isFailed = toolState === "failed";
const isInitiated = toolState === "initiated";
const toolSummary = getToolSummary(message, toolLogMetadata);
const toolLabel = getToolLabel(message, toolLogMetadata);
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, toolState);
return (
<div
className={cn(
"inline-flex min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
isFailed
? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]"
: isInitiated
? "border-amber-300/34 bg-[linear-gradient(90deg,hsl(43_74%_30%_/_0.34),hsl(260_48%_13%_/_0.74))]"
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]",
className
)}
style={style}
title={`${toolSummary}\n${toolLabel}${toolDetailLabel}`}
>
<span
className={cn(
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
isFailed
? "border-rose-400/34 bg-rose-400/13 text-rose-300"
: isInitiated
? "border-amber-300/34 bg-amber-300/13 text-amber-200"
: "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
)}
>
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1 space-y-1">
<span className={cn("block truncate text-sm leading-5", isFailed ? "text-rose-200" : "text-violet-50/95")}>{toolSummary}</span>
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4">
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : isInitiated ? "text-amber-200/90" : "text-cyan-200/90")}>
{toolLabel}
</span>
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>
</span>
</span>
</div>
);
}
function ToolCallStack({
groupKey,
messages,
expanded,
onToggle,
}: {
groupKey: string;
messages: Message[];
expanded: boolean;
onToggle: (groupKey: string) => void;
}) {
const hiddenCount = Math.max(0, messages.length - COLLAPSED_TOOL_STACK_LIMIT);
const countLabel = `${messages.length} tool ${messages.length === 1 ? "call" : "calls"}`;
const [motionDirection, setMotionDirection] = useState<ToolStackMotionDirection>(null);
const [motionRevision, setMotionRevision] = useState(0);
const motionResetTimerRef = useRef<number | null>(null);
const handleToggle = () => {
setMotionDirection(expanded ? "collapse" : "expand");
setMotionRevision((current) => current + 1);
if (typeof window !== "undefined") {
if (motionResetTimerRef.current !== null) window.clearTimeout(motionResetTimerRef.current);
motionResetTimerRef.current = window.setTimeout(() => {
setMotionDirection(null);
motionResetTimerRef.current = null;
}, TOOL_STACK_LAYOUT_ANIMATION_MS + 60);
}
onToggle(groupKey);
};
return (
<div className="flex justify-start">
<div
className={cn(
"tool-call-stack-shell relative w-full max-w-[85%] min-w-0 pr-10",
motionDirection && (motionRevision % 2 === 0 ? "tool-call-stack-shell-layout-a" : "tool-call-stack-shell-layout-b")
)}
data-tool-stack-group={groupKey}
data-expanded={expanded ? "true" : "false"}
style={getToolStackContainerStyle(messages.length, expanded, motionDirection)}
>
{messages.map((message, index) => {
const depth = messages.length - index - 1;
const isHidden = !expanded && depth >= COLLAPSED_TOOL_STACK_LIMIT;
return (
<div
key={message.id}
className={cn(
"tool-call-stack-card absolute left-0 right-10 top-0 w-auto max-w-none",
motionDirection && (motionRevision % 2 === 0 ? "tool-call-stack-card-layout-a" : "tool-call-stack-card-layout-b"),
isHidden && "pointer-events-none"
)}
style={getToolStackStyle(index, messages.length, expanded, motionDirection)}
aria-hidden={isHidden ? "true" : undefined}
>
<div
className={cn("tool-call-stack-card-surface", !isHidden && "tool-call-stack-card-enter")}
data-tool-stack-card-id={message.id}
>
<ToolCallCard message={message} className="tool-call-stack-card-glass w-full max-w-full" />
</div>
</div>
);
})}
{!expanded && hiddenCount ? (
<span className="absolute bottom-1 right-10 z-20 rounded-full border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
+{hiddenCount}
</span>
) : null}
<button
type="button"
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full"
aria-expanded={expanded ? "true" : "false"}
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
title={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
onClick={handleToggle}
>
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
</div>
</div>
);
}
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) { export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0); const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
const [expandedToolGroups, setExpandedToolGroups] = useState<Set<string>>(() => new Set());
const toggleToolGroup = (groupKey: string) => {
setExpandedToolGroups((current) => {
const next = new Set(current);
if (next.has(groupKey)) next.delete(groupKey);
else next.add(groupKey);
return next;
});
};
return ( return (
<> <>
{isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null} {isLoading && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
<div className="mx-auto max-w-4xl space-y-6"> <div className="mx-auto max-w-4xl space-y-6">
{messages.map((message) => { {renderItems.map((item) => {
if (item.kind === "tool_group") {
return (
<ToolCallStack
key={`tool-group-${item.key}`}
groupKey={item.key}
messages={item.messages}
expanded={expandedToolGroups.has(item.key)}
onToggle={toggleToolGroup}
/>
);
}
const { message } = item;
const toolLogMetadata = asToolLogMetadata(message.metadata); const toolLogMetadata = asToolLogMetadata(message.metadata);
if (message.role === "tool" && toolLogMetadata) { if (message.role === "tool" && toolLogMetadata) {
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
const toolState = getToolVisualState(toolLogMetadata);
const isFailed = toolState === "failed";
const isInitiated = toolState === "initiated";
const toolSummary = getToolSummary(message, toolLogMetadata);
const toolLabel = getToolLabel(message, toolLogMetadata);
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, toolState);
return ( return (
<div key={message.id} className="flex justify-start"> <div key={message.id} className="flex justify-start">
<div <ToolCallCard message={message} className="max-w-[85%]" />
className={cn(
"inline-flex max-w-[85%] min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
isFailed
? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]"
: isInitiated
? "border-amber-300/34 bg-[linear-gradient(90deg,hsl(43_74%_30%_/_0.34),hsl(260_48%_13%_/_0.74))]"
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]"
)}
title={`${toolSummary}\n${toolLabel}${toolDetailLabel}`}
>
<span
className={cn(
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
isFailed
? "border-rose-400/34 bg-rose-400/13 text-rose-300"
: isInitiated
? "border-amber-300/34 bg-amber-300/13 text-amber-200"
: "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
)}
>
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1 space-y-1">
<span className={cn("block truncate text-sm leading-5", isFailed ? "text-rose-200" : "text-violet-50/95")}>
{toolSummary}
</span>
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4">
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : isInitiated ? "text-amber-200/90" : "text-cyan-200/90")}>
{toolLabel}
</span>
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>
</span>
</span>
</div>
</div> </div>
); );
} }

View File

@@ -140,6 +140,148 @@ textarea {
0 14px 36px hsl(240 80% 2% / 0.28); 0 14px 36px hsl(240 80% 2% / 0.28);
} }
.tool-call-stack-shell {
perspective: 900px;
transform-style: preserve-3d;
isolation: isolate;
}
.tool-call-stack-card {
transform: translate3d(var(--tool-stack-x, 0), var(--tool-stack-y, 0), var(--tool-stack-z, 0)) scale(var(--tool-stack-scale, 1));
transform-origin: top left;
opacity: var(--tool-stack-opacity, 1);
transition:
opacity 180ms ease,
transform 300ms cubic-bezier(0.2, 0.8, 0.22, 1);
will-change: transform, opacity;
}
.tool-call-stack-shell-layout-a {
animation: tool-call-stack-height-a 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
.tool-call-stack-shell-layout-b {
animation: tool-call-stack-height-b 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
.tool-call-stack-card-layout-a {
animation: tool-call-stack-layout-a 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
.tool-call-stack-card-layout-b {
animation: tool-call-stack-layout-b 340ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
.tool-call-stack-card-surface {
transform-origin: top left;
}
.tool-call-stack-card-glass {
backdrop-filter: blur(10px);
}
.tool-call-stack-card-enter {
animation: tool-call-stack-drop-in 320ms cubic-bezier(0.18, 0.95, 0.28, 1) backwards;
animation-delay: var(--tool-stack-delay, 0ms);
}
.tool-call-stack-toggle {
border: 1px solid hsl(188 82% 70% / 0.36);
background:
linear-gradient(180deg, hsl(230 36% 16% / 0.96), hsl(238 48% 7% / 0.96)),
hsl(236 48% 8%);
color: hsl(186 92% 86%);
box-shadow:
inset 0 1px 0 hsl(180 100% 88% / 0.08),
0 8px 22px hsl(235 72% 2% / 0.42);
transition:
border-color 160ms ease,
color 160ms ease,
transform 160ms ease,
filter 160ms ease;
}
.tool-call-stack-toggle:hover {
border-color: hsl(188 92% 74% / 0.62);
color: hsl(184 100% 92%);
filter: brightness(1.08);
}
.tool-call-stack-toggle:focus-visible {
outline: 2px solid hsl(188 92% 72% / 0.9);
outline-offset: 2px;
}
@keyframes tool-call-stack-height-a {
from {
height: var(--tool-stack-from-height);
}
to {
height: var(--tool-stack-to-height);
}
}
@keyframes tool-call-stack-height-b {
from {
height: var(--tool-stack-from-height);
}
to {
height: var(--tool-stack-to-height);
}
}
@keyframes tool-call-stack-layout-a {
from {
opacity: var(--tool-stack-from-opacity, 1);
transform: var(--tool-stack-from-transform);
}
to {
opacity: var(--tool-stack-to-opacity, 1);
transform: var(--tool-stack-to-transform);
}
}
@keyframes tool-call-stack-layout-b {
from {
opacity: var(--tool-stack-from-opacity, 1);
transform: var(--tool-stack-from-transform);
}
to {
opacity: var(--tool-stack-to-opacity, 1);
transform: var(--tool-stack-to-transform);
}
}
@keyframes tool-call-stack-drop-in {
from {
opacity: 0.72;
transform: translate3d(0, -0.65rem, 120px) scale(1.025) rotateX(3deg);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1) rotateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.tool-call-stack-card {
transition: none;
}
.tool-call-stack-shell-layout-a,
.tool-call-stack-shell-layout-b,
.tool-call-stack-card-layout-a,
.tool-call-stack-card-layout-b,
.tool-call-stack-card-enter {
animation: none;
}
}
.md-content { .md-content {
word-break: break-word; word-break: break-word;
} }