Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7436544a69 | |||
| 95796646b1 | |||
| d7214c88ad | |||
| 22aa652257 | |||
| 8f6e8c17a5 |
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
20
ios/.env.example
Normal file
20
ios/.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2
|
||||||
|
FASTLANE_TEAM_ID=DQQH5H6GBD
|
||||||
|
FASTLANE_USER=you@example.com
|
||||||
|
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
|
||||||
|
FASTLANE_SKIP_UPDATE_CHECK=1
|
||||||
|
FASTLANE_HIDE_CHANGELOG=1
|
||||||
|
SYBIL_APP_STORE_APPLE_ID=6759442828
|
||||||
|
SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
|
||||||
|
|
||||||
|
# Optional App Store Connect API key settings for non-interactive upload and
|
||||||
|
# TestFlight build-number lookup.
|
||||||
|
APP_STORE_CONNECT_API_KEY_ID=
|
||||||
|
APP_STORE_CONNECT_API_ISSUER_ID=
|
||||||
|
APP_STORE_CONNECT_API_KEY_PATH=
|
||||||
|
APP_STORE_CONNECT_API_KEY_CONTENT=
|
||||||
|
APP_STORE_CONNECT_API_KEY_CONTENT_BASE64=false
|
||||||
|
|
||||||
|
# Optional deployment overrides.
|
||||||
|
SYBIL_BUILD_NUMBER=
|
||||||
|
SYBIL_VERSION_TAG=
|
||||||
11
ios/.gitignore
vendored
11
ios/.gitignore
vendored
@@ -1,2 +1,11 @@
|
|||||||
*.xcodeproj
|
*.xcodeproj
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
build/
|
||||||
|
*.ipa
|
||||||
|
*.dSYM.zip
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots/
|
||||||
|
fastlane/test_output/
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ targets:
|
|||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
MARKETING_VERSION: 1.9
|
MARKETING_VERSION: "1.10"
|
||||||
CURRENT_PROJECT_VERSION: 10
|
CURRENT_PROJECT_VERSION: 11
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||||
|
|||||||
3
ios/Gemfile
Normal file
3
ios/Gemfile
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
gem "fastlane", "~> 2.227"
|
||||||
@@ -7,39 +7,107 @@ struct SybilChatTranscriptView: View {
|
|||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
var topContentInset: CGFloat = 0
|
var topContentInset: CGFloat = 0
|
||||||
var bottomContentInset: CGFloat = 0
|
var bottomContentInset: CGFloat = 0
|
||||||
|
var bottomPinRequestID: Int = 0
|
||||||
|
|
||||||
private var hasPendingAssistant: Bool {
|
private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
|
||||||
messages.contains { message in
|
private var renderItems: [TranscriptRenderItem] {
|
||||||
message.id.hasPrefix("temp-assistant-") && message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
buildTranscriptRenderItems(from: messages)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollViewReader { proxy in
|
||||||
LazyVStack(alignment: .leading, spacing: 26) {
|
ScrollView {
|
||||||
ForEach(messages.reversed()) { message in
|
LazyVStack(alignment: .leading, spacing: 26) {
|
||||||
MessageBubble(message: message, isSending: isSending)
|
if isLoading && messages.isEmpty {
|
||||||
.frame(maxWidth: .infinity)
|
Text("Loading messages…")
|
||||||
.scaleEffect(x: 1, y: -1)
|
.font(.sybil(.footnote))
|
||||||
}
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.padding(.top, 24)
|
||||||
|
}
|
||||||
|
|
||||||
if isLoading && messages.isEmpty {
|
ForEach(renderItems) { item in
|
||||||
Text("Loading messages…")
|
switch item {
|
||||||
.font(.sybil(.footnote))
|
case let .message(message):
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
MessageBubble(message: message, isSending: isSending)
|
||||||
.padding(.top, 24)
|
.frame(maxWidth: .infinity)
|
||||||
.scaleEffect(x: 1, y: -1)
|
case let .toolGroup(id, messages):
|
||||||
|
ToolCallStackView(groupID: id, messages: messages)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color.clear
|
||||||
|
.frame(height: 18 + bottomContentInset)
|
||||||
|
.id(bottomAnchorID)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.top, 18 + topContentInset)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 14)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.padding(.top, 18 + bottomContentInset)
|
.onAppear {
|
||||||
.padding(.bottom, 18 + topContentInset)
|
scrollToBottom(with: proxy, animated: false)
|
||||||
|
}
|
||||||
|
.onChange(of: bottomPinRequestID) { _, _ in
|
||||||
|
scrollToBottom(with: proxy, animated: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.scrollDismissesKeyboard(.interactively)
|
|
||||||
.scaleEffect(x: 1, y: -1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
|
||||||
|
let action = {
|
||||||
|
proxy.scrollTo(bottomAnchorID, anchor: .bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
if animated {
|
||||||
|
withAnimation(.easeOut(duration: 0.18), action)
|
||||||
|
} else {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -137,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
|
||||||
@@ -147,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 {
|
||||||
@@ -233,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)
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ final class SybilViewModel {
|
|||||||
var isLoadingCollections = false
|
var isLoadingCollections = false
|
||||||
var isLoadingSelection = false
|
var isLoadingSelection = false
|
||||||
var isCreatingSearchChat = false
|
var isCreatingSearchChat = false
|
||||||
|
var chatBottomPinRequestID = 0
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
|
||||||
var composer = ""
|
var composer = ""
|
||||||
@@ -1699,6 +1700,10 @@ final class SybilViewModel {
|
|||||||
isLoadingSelection = false
|
isLoadingSelection = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func requestChatBottomPin() {
|
||||||
|
chatBottomPinRequestID += 1
|
||||||
|
}
|
||||||
|
|
||||||
private func startSelectionRefreshTask() -> Task<Void, Never> {
|
private func startSelectionRefreshTask() -> Task<Void, Never> {
|
||||||
isLoadingSelection = true
|
isLoadingSelection = true
|
||||||
let task = Task { [weak self] in
|
let task = Task { [weak self] in
|
||||||
@@ -1752,6 +1757,7 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
selectedChat = chat
|
selectedChat = chat
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
|
requestChatBottomPin()
|
||||||
|
|
||||||
if let provider = chat.lastUsedProvider,
|
if let provider = chat.lastUsedProvider,
|
||||||
let model = chat.lastUsedModel,
|
let model = chat.lastUsedModel,
|
||||||
@@ -1824,6 +1830,7 @@ final class SybilViewModel {
|
|||||||
} else {
|
} else {
|
||||||
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
||||||
}
|
}
|
||||||
|
requestChatBottomPin()
|
||||||
|
|
||||||
if chatID == nil {
|
if chatID == nil {
|
||||||
let created = try await client.createChat(title: nil)
|
let created = try await client.createChat(title: nil)
|
||||||
@@ -1871,6 +1878,7 @@ final class SybilViewModel {
|
|||||||
if let draftPending = pendingDraftChatState {
|
if let draftPending = pendingDraftChatState {
|
||||||
pendingDraftChatState = nil
|
pendingDraftChatState = nil
|
||||||
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: draftPending.messages)
|
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: draftPending.messages)
|
||||||
|
requestChatBottomPin()
|
||||||
} else if pendingChatStates[chatID] == nil {
|
} else if pendingChatStates[chatID] == nil {
|
||||||
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages)
|
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -194,7 +194,8 @@ struct SybilWorkspaceView: View {
|
|||||||
isLoading: viewModel.isLoadingSelection,
|
isLoading: viewModel.isLoadingSelection,
|
||||||
isSending: viewModel.isSendingVisibleChat,
|
isSending: viewModel.isSendingVisibleChat,
|
||||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
|
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
||||||
|
bottomPinRequestID: viewModel.chatBottomPinRequestID
|
||||||
)
|
)
|
||||||
.id(transcriptScrollContextID)
|
.id(transcriptScrollContextID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)!
|
||||||
@@ -495,6 +559,7 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
#expect(snapshot.listSearches == 0)
|
#expect(snapshot.listSearches == 0)
|
||||||
#expect(snapshot.getChat == 1)
|
#expect(snapshot.getChat == 1)
|
||||||
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
||||||
|
#expect(viewModel.chatBottomPinRequestID == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -682,6 +747,37 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
await sendTask.value
|
await sendTask.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func chatBottomPinRequestDoesNotFollowAssistantStreaming() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_245)
|
||||||
|
let chat = makeChatSummary(id: "chat-pin", date: date)
|
||||||
|
let detail = makeChatDetail(id: "chat-pin", date: date, body: "existing transcript")
|
||||||
|
let client = MockSybilClient(
|
||||||
|
chatsResponse: [chat],
|
||||||
|
chatDetails: ["chat-pin": detail]
|
||||||
|
)
|
||||||
|
await client.setCompletionStreamEvents([
|
||||||
|
.delta(CompletionStreamDelta(text: "partial ")),
|
||||||
|
.delta(CompletionStreamDelta(text: "response")),
|
||||||
|
.done(CompletionStreamDone(text: "partial response"))
|
||||||
|
])
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.chats = [chat]
|
||||||
|
viewModel.workspaceItems = [WorkspaceItem(chat: chat)]
|
||||||
|
viewModel.selectedItem = .chat("chat-pin")
|
||||||
|
viewModel.selectedChat = detail
|
||||||
|
viewModel.composer = "continue"
|
||||||
|
|
||||||
|
let initialPinRequestID = viewModel.chatBottomPinRequestID
|
||||||
|
await viewModel.sendComposer()
|
||||||
|
|
||||||
|
let snapshot = await client.currentSnapshot()
|
||||||
|
#expect(snapshot.runCompletionStream == 1)
|
||||||
|
#expect(viewModel.chatBottomPinRequestID == initialPinRequestID + 1)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
|
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
|
||||||
let client = MockSybilClient()
|
let client = MockSybilClient()
|
||||||
|
|||||||
9
ios/fastlane/Appfile
Normal file
9
ios/fastlane/Appfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
require "dotenv"
|
||||||
|
|
||||||
|
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||||
|
|
||||||
|
app_identifier(ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2"))
|
||||||
|
team_id(ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD"))
|
||||||
|
|
||||||
|
apple_id(ENV["FASTLANE_USER"]) if ENV["FASTLANE_USER"].to_s.strip.length.positive?
|
||||||
|
itc_team_id(ENV["FASTLANE_ITC_TEAM_ID"]) if ENV["FASTLANE_ITC_TEAM_ID"].to_s.strip.length.positive?
|
||||||
177
ios/fastlane/Fastfile
Normal file
177
ios/fastlane/Fastfile
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
require "dotenv"
|
||||||
|
require "open3"
|
||||||
|
require "shellwords"
|
||||||
|
require "yaml"
|
||||||
|
|
||||||
|
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||||
|
|
||||||
|
default_platform(:ios)
|
||||||
|
|
||||||
|
APP_IDENTIFIER = ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2")
|
||||||
|
TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD")
|
||||||
|
APP_STORE_APPLE_ID = ENV.fetch("SYBIL_APP_STORE_APPLE_ID", "6759442828")
|
||||||
|
PROVIDER_PUBLIC_ID = ENV.fetch("SYBIL_PROVIDER_PUBLIC_ID", "c043d167-ad88-4036-84ea-76c223f1b1b2")
|
||||||
|
IOS_ROOT = File.expand_path("..", __dir__)
|
||||||
|
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
|
||||||
|
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
|
||||||
|
APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
|
||||||
|
SCHEME = "Sybil"
|
||||||
|
TARGET = "SybilApp"
|
||||||
|
|
||||||
|
def present?(value)
|
||||||
|
!value.to_s.strip.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
def capture(command)
|
||||||
|
stdout, stderr, status = Open3.capture3(command)
|
||||||
|
return stdout.strip if status.success?
|
||||||
|
|
||||||
|
UI.user_error!("Command failed: #{command}\n#{stderr.strip}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def app_project_settings
|
||||||
|
YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base")
|
||||||
|
end
|
||||||
|
|
||||||
|
def local_marketing_version
|
||||||
|
app_project_settings.fetch("MARKETING_VERSION").to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def local_build_number
|
||||||
|
app_project_settings.fetch("CURRENT_PROJECT_VERSION").to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_version_tag(tag)
|
||||||
|
version = tag.to_s.strip.sub(/\Av/, "")
|
||||||
|
unless version.match?(/\A\d+\.\d+(\.\d+)?\z/)
|
||||||
|
UI.user_error!("Release tag #{tag.inspect} must look like v1.10 or v1.10.0")
|
||||||
|
end
|
||||||
|
version
|
||||||
|
end
|
||||||
|
|
||||||
|
def release_version
|
||||||
|
tag = ENV["SYBIL_VERSION_TAG"]
|
||||||
|
tag = capture("git describe --tags --abbrev=0") unless present?(tag)
|
||||||
|
normalize_version_tag(tag)
|
||||||
|
end
|
||||||
|
|
||||||
|
def xcode_build_setting(key, value)
|
||||||
|
"#{key}=#{value.to_s.shellescape}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def app_store_connect_key_options
|
||||||
|
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
|
||||||
|
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
|
||||||
|
return nil unless present?(key_id) && present?(issuer_id)
|
||||||
|
|
||||||
|
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
|
||||||
|
key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
|
||||||
|
if present?(key_path)
|
||||||
|
{
|
||||||
|
key_id: key_id,
|
||||||
|
issuer_id: issuer_id,
|
||||||
|
key_filepath: key_path
|
||||||
|
}
|
||||||
|
elsif present?(key_content)
|
||||||
|
{
|
||||||
|
key_id: key_id,
|
||||||
|
issuer_id: issuer_id,
|
||||||
|
key_content: key_content,
|
||||||
|
is_key_content_base64: ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
platform :ios do
|
||||||
|
desc "Show the version Fastlane will stamp into the next TestFlight archive"
|
||||||
|
lane :version do
|
||||||
|
UI.message("Git tag version: #{release_version}")
|
||||||
|
UI.message("Checked-in app version: #{local_marketing_version}")
|
||||||
|
UI.message("Checked-in build number: #{local_build_number}")
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Build Sybil and upload it to TestFlight"
|
||||||
|
lane :beta do
|
||||||
|
version = release_version
|
||||||
|
build_number = ENV["SYBIL_BUILD_NUMBER"].to_s
|
||||||
|
api_key = nil
|
||||||
|
|
||||||
|
if app_store_connect_key_options
|
||||||
|
api_key = app_store_connect_api_key(app_store_connect_key_options)
|
||||||
|
end
|
||||||
|
|
||||||
|
unless present?(build_number)
|
||||||
|
build_number = (local_build_number + 1).to_s
|
||||||
|
|
||||||
|
if api_key
|
||||||
|
begin
|
||||||
|
latest = latest_testflight_build_number(
|
||||||
|
app_identifier: APP_IDENTIFIER,
|
||||||
|
version: version,
|
||||||
|
api_key: api_key,
|
||||||
|
initial_build_number: local_build_number
|
||||||
|
).to_i
|
||||||
|
build_number = [latest + 1, local_build_number + 1].max.to_s
|
||||||
|
rescue StandardError => e
|
||||||
|
UI.important("Could not look up TestFlight build number: #{e.message}")
|
||||||
|
UI.important("Using checked-in build number + 1: #{build_number}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
|
||||||
|
|
||||||
|
sh("xcodegen --spec #{PROJECT_SPEC.shellescape}")
|
||||||
|
|
||||||
|
xcode_args = [
|
||||||
|
"-allowProvisioningUpdates",
|
||||||
|
xcode_build_setting("MARKETING_VERSION", version),
|
||||||
|
xcode_build_setting("CURRENT_PROJECT_VERSION", build_number)
|
||||||
|
].join(" ")
|
||||||
|
|
||||||
|
ipa_path = build_app(
|
||||||
|
project: PROJECT_FILE,
|
||||||
|
scheme: SCHEME,
|
||||||
|
clean: true,
|
||||||
|
sdk: "iphoneos",
|
||||||
|
export_method: "app-store",
|
||||||
|
output_directory: File.join(IOS_ROOT, "build/fastlane"),
|
||||||
|
output_name: "Sybil-#{version}-#{build_number}.ipa",
|
||||||
|
xcargs: xcode_args,
|
||||||
|
export_xcargs: "-allowProvisioningUpdates",
|
||||||
|
export_options: {
|
||||||
|
method: "app-store-connect",
|
||||||
|
destination: "export",
|
||||||
|
signingStyle: "automatic",
|
||||||
|
teamID: TEAM_ID,
|
||||||
|
manageAppVersionAndBuildNumber: false,
|
||||||
|
uploadSymbols: true,
|
||||||
|
stripSwiftSymbols: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipa_path ||= lane_context[SharedValues::IPA_OUTPUT_PATH]
|
||||||
|
UI.user_error!("IPA export failed; no IPA path was returned") unless present?(ipa_path) && File.exist?(ipa_path)
|
||||||
|
|
||||||
|
password = ENV["FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD"]
|
||||||
|
UI.user_error!("FASTLANE_USER is required for altool upload") unless present?(ENV["FASTLANE_USER"])
|
||||||
|
UI.user_error!("FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD is required for altool upload") unless present?(password)
|
||||||
|
UI.user_error!("SYBIL_APP_STORE_APPLE_ID is required for altool upload") unless present?(APP_STORE_APPLE_ID)
|
||||||
|
UI.user_error!("SYBIL_PROVIDER_PUBLIC_ID is required for altool upload") unless present?(PROVIDER_PUBLIC_ID)
|
||||||
|
|
||||||
|
ENV["ITMS_TRANSPORTER_PASSWORD"] = password
|
||||||
|
sh([
|
||||||
|
"xcrun altool",
|
||||||
|
"--upload-package #{ipa_path.shellescape}",
|
||||||
|
"--platform ios",
|
||||||
|
"--apple-id #{APP_STORE_APPLE_ID.shellescape}",
|
||||||
|
"--bundle-id #{APP_IDENTIFIER.shellescape}",
|
||||||
|
"--bundle-version #{build_number.shellescape}",
|
||||||
|
"--bundle-short-version-string #{version.shellescape}",
|
||||||
|
"--provider-public-id #{PROVIDER_PUBLIC_ID.shellescape}",
|
||||||
|
"--username #{ENV.fetch("FASTLANE_USER").shellescape}",
|
||||||
|
"--password @env:ITMS_TRANSPORTER_PASSWORD",
|
||||||
|
"--show-progress"
|
||||||
|
].join(" "))
|
||||||
|
end
|
||||||
|
end
|
||||||
40
ios/fastlane/README.md
Normal file
40
ios/fastlane/README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
fastlane documentation
|
||||||
|
----
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
Make sure you have the latest version of the Xcode command line tools installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
xcode-select --install
|
||||||
|
```
|
||||||
|
|
||||||
|
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||||
|
|
||||||
|
# Available Actions
|
||||||
|
|
||||||
|
## iOS
|
||||||
|
|
||||||
|
### ios version
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane ios version
|
||||||
|
```
|
||||||
|
|
||||||
|
Show the version Fastlane will stamp into the next TestFlight archive
|
||||||
|
|
||||||
|
### ios beta
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane ios beta
|
||||||
|
```
|
||||||
|
|
||||||
|
Build Sybil and upload it to TestFlight
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||||
|
|
||||||
|
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||||
|
|
||||||
|
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||||
12
ios/justfile
12
ios/justfile
@@ -5,8 +5,10 @@ derived_data := "build/DerivedData"
|
|||||||
default:
|
default:
|
||||||
@just build
|
@just build
|
||||||
|
|
||||||
build:
|
generate:
|
||||||
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
xcodegen --spec project.yml
|
||||||
|
|
||||||
|
build: generate
|
||||||
if command -v xcbeautify >/dev/null 2>&1; then \
|
if command -v xcbeautify >/dev/null 2>&1; then \
|
||||||
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
||||||
else \
|
else \
|
||||||
@@ -16,13 +18,15 @@ build:
|
|||||||
test:
|
test:
|
||||||
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
|
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
|
||||||
|
|
||||||
run:
|
run: generate
|
||||||
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
|
||||||
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
|
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
|
||||||
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'
|
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'
|
||||||
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
||||||
xcrun simctl launch booted net.buzzert.sybil2
|
xcrun simctl launch booted net.buzzert.sybil2
|
||||||
|
|
||||||
|
beta:
|
||||||
|
fastlane ios beta
|
||||||
|
|
||||||
screenshot path="build/sybil-screenshot.png":
|
screenshot path="build/sybil-screenshot.png":
|
||||||
mkdir -p "$(dirname '{{path}}')"
|
mkdir -p "$(dirname '{{path}}')"
|
||||||
xcrun simctl io booted screenshot '{{path}}'
|
xcrun simctl io booted screenshot '{{path}}'
|
||||||
|
|||||||
26
server/src/browser-fetch-headers.ts
Normal file
26
server/src/browser-fetch-headers.ts
Normal 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user