feat: add Omega Mac native macOS SwiftUI chat app
Native macOS counterpart to the web Omega agent, connecting directly to the OpenAI API and TPMJS tool registry (1M+ AI-ready tools). - SwiftUI app targeting macOS 15+ with dark theme - Full agentic loop: auto-discover tools via BM25, stream OpenAI responses, execute tools via remote sandbox, loop up to 10x - SwiftData persistence for conversations, messages, tool runs - Keychain storage for API keys and environment variables - SSE streaming via URLSession.bytes with custom parser - Actor-based services (OpenAIService, TPMJSRegistryService) - NavigationSplitView layout with sidebar + chat detail - MarkdownUI for rendering assistant responses - Settings: API key, model picker, env vars, custom system prompt - Keyboard shortcuts: Cmd+N new chat, Cmd+, settings, Enter send Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c8e8a7d9b4
commit
1cd44b4e97
45 changed files with 4583 additions and 0 deletions
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"colors": [
|
||||
{
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": {
|
||||
"alpha": "1.000",
|
||||
"blue": "0.996",
|
||||
"green": "0.475",
|
||||
"red": "0.325"
|
||||
}
|
||||
},
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "16x16"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "16x16"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "32x32"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "32x32"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "128x128"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "128x128"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "256x256"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "256x256"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "512x512"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "512x512"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
6
apps/omega-mac/OmegaMac/Assets.xcassets/Contents.json
Normal file
6
apps/omega-mac/OmegaMac/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
41
apps/omega-mac/OmegaMac/Models/Conversation.swift
Normal file
41
apps/omega-mac/OmegaMac/Models/Conversation.swift
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class Conversation {
|
||||
var id: UUID
|
||||
var title: String?
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
var executionState: String // "idle" | "running"
|
||||
var inputTokensTotal: Int
|
||||
var outputTokensTotal: Int
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \Message.conversation)
|
||||
var messages: [Message]
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \ToolCallRecord.conversation)
|
||||
var toolRuns: [ToolCallRecord]
|
||||
|
||||
init(
|
||||
title: String? = nil
|
||||
) {
|
||||
self.id = UUID()
|
||||
self.title = title
|
||||
self.createdAt = Date()
|
||||
self.updatedAt = Date()
|
||||
self.executionState = "idle"
|
||||
self.inputTokensTotal = 0
|
||||
self.outputTokensTotal = 0
|
||||
self.messages = []
|
||||
self.toolRuns = []
|
||||
}
|
||||
|
||||
var displayTitle: String {
|
||||
title ?? "New Conversation"
|
||||
}
|
||||
|
||||
var sortedMessages: [Message] {
|
||||
messages.sorted { $0.createdAt < $1.createdAt }
|
||||
}
|
||||
}
|
||||
18
apps/omega-mac/OmegaMac/Models/EnvVar.swift
Normal file
18
apps/omega-mac/OmegaMac/Models/EnvVar.swift
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class EnvVar {
|
||||
var id: UUID
|
||||
var keyName: String
|
||||
/// Last 4 characters of the value (for display hint)
|
||||
var valueHint: String
|
||||
var createdAt: Date
|
||||
|
||||
init(keyName: String, valueHint: String) {
|
||||
self.id = UUID()
|
||||
self.keyName = keyName
|
||||
self.valueHint = valueHint
|
||||
self.createdAt = Date()
|
||||
}
|
||||
}
|
||||
141
apps/omega-mac/OmegaMac/Models/Message.swift
Normal file
141
apps/omega-mac/OmegaMac/Models/Message.swift
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
enum MessageRole: String, Codable {
|
||||
case user = "USER"
|
||||
case assistant = "ASSISTANT"
|
||||
case tool = "TOOL"
|
||||
case system = "SYSTEM"
|
||||
}
|
||||
|
||||
@Model
|
||||
final class Message {
|
||||
var id: UUID
|
||||
var role: MessageRole
|
||||
var content: String
|
||||
var createdAt: Date
|
||||
var inputTokens: Int?
|
||||
var outputTokens: Int?
|
||||
|
||||
/// JSON-encoded array of tool calls (for assistant messages)
|
||||
var toolCallsJSON: Data?
|
||||
|
||||
var conversation: Conversation?
|
||||
|
||||
init(
|
||||
role: MessageRole,
|
||||
content: String,
|
||||
conversation: Conversation? = nil,
|
||||
inputTokens: Int? = nil,
|
||||
outputTokens: Int? = nil,
|
||||
toolCalls: [ToolCallData]? = nil
|
||||
) {
|
||||
self.id = UUID()
|
||||
self.role = role
|
||||
self.content = content
|
||||
self.createdAt = Date()
|
||||
self.inputTokens = inputTokens
|
||||
self.outputTokens = outputTokens
|
||||
self.conversation = conversation
|
||||
if let toolCalls {
|
||||
self.toolCallsJSON = try? JSONEncoder().encode(toolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
var toolCalls: [ToolCallData] {
|
||||
get {
|
||||
guard let data = toolCallsJSON else { return [] }
|
||||
return (try? JSONDecoder().decode([ToolCallData].self, from: data)) ?? []
|
||||
}
|
||||
set {
|
||||
toolCallsJSON = try? JSONEncoder().encode(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializable tool call data stored in messages
|
||||
struct ToolCallData: Codable, Identifiable {
|
||||
var id: String { toolCallId }
|
||||
let toolCallId: String
|
||||
let toolName: String
|
||||
let args: JSONValue?
|
||||
let output: JSONValue?
|
||||
|
||||
init(toolCallId: String, toolName: String, args: JSONValue? = nil, output: JSONValue? = nil) {
|
||||
self.toolCallId = toolCallId
|
||||
self.toolName = toolName
|
||||
self.args = args
|
||||
self.output = output
|
||||
}
|
||||
}
|
||||
|
||||
/// A type-erased JSON value for encoding/decoding arbitrary JSON
|
||||
enum JSONValue: Codable, Equatable, Sendable {
|
||||
case string(String)
|
||||
case number(Double)
|
||||
case bool(Bool)
|
||||
case object([String: JSONValue])
|
||||
case array([JSONValue])
|
||||
case null
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
self = .null
|
||||
} else if let b = try? container.decode(Bool.self) {
|
||||
self = .bool(b)
|
||||
} else if let n = try? container.decode(Double.self) {
|
||||
self = .number(n)
|
||||
} else if let s = try? container.decode(String.self) {
|
||||
self = .string(s)
|
||||
} else if let arr = try? container.decode([JSONValue].self) {
|
||||
self = .array(arr)
|
||||
} else if let obj = try? container.decode([String: JSONValue].self) {
|
||||
self = .object(obj)
|
||||
} else {
|
||||
self = .null
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .string(let s): try container.encode(s)
|
||||
case .number(let n): try container.encode(n)
|
||||
case .bool(let b): try container.encode(b)
|
||||
case .object(let o): try container.encode(o)
|
||||
case .array(let a): try container.encode(a)
|
||||
case .null: try container.encodeNil()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert any Codable/Sendable value to JSONValue
|
||||
static func from(_ value: Any) -> JSONValue {
|
||||
if let s = value as? String { return .string(s) }
|
||||
if let n = value as? NSNumber {
|
||||
if CFBooleanGetTypeID() == CFGetTypeID(n) {
|
||||
return .bool(n.boolValue)
|
||||
}
|
||||
return .number(n.doubleValue)
|
||||
}
|
||||
if let b = value as? Bool { return .bool(b) }
|
||||
if let i = value as? Int { return .number(Double(i)) }
|
||||
if let d = value as? Double { return .number(d) }
|
||||
if let arr = value as? [Any] { return .array(arr.map { from($0) }) }
|
||||
if let obj = value as? [String: Any] {
|
||||
return .object(obj.mapValues { from($0) })
|
||||
}
|
||||
return .null
|
||||
}
|
||||
|
||||
/// Pretty-print JSON
|
||||
var prettyString: String {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
guard let data = try? encoder.encode(self),
|
||||
let str = String(data: data, encoding: .utf8) else {
|
||||
return "null"
|
||||
}
|
||||
return str
|
||||
}
|
||||
}
|
||||
51
apps/omega-mac/OmegaMac/Models/ToolCallRecord.swift
Normal file
51
apps/omega-mac/OmegaMac/Models/ToolCallRecord.swift
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class ToolCallRecord {
|
||||
var id: UUID
|
||||
var toolName: String
|
||||
var toolCallId: String
|
||||
var status: String // "running" | "success" | "error"
|
||||
var inputJSON: Data?
|
||||
var outputJSON: Data?
|
||||
var errorMessage: String?
|
||||
var executionTimeMs: Int?
|
||||
var createdAt: Date
|
||||
var completedAt: Date?
|
||||
|
||||
var conversation: Conversation?
|
||||
|
||||
init(
|
||||
toolName: String,
|
||||
toolCallId: String,
|
||||
conversation: Conversation? = nil
|
||||
) {
|
||||
self.id = UUID()
|
||||
self.toolName = toolName
|
||||
self.toolCallId = toolCallId
|
||||
self.status = "running"
|
||||
self.createdAt = Date()
|
||||
self.conversation = conversation
|
||||
}
|
||||
|
||||
var input: JSONValue? {
|
||||
get {
|
||||
guard let data = inputJSON else { return nil }
|
||||
return try? JSONDecoder().decode(JSONValue.self, from: data)
|
||||
}
|
||||
set {
|
||||
inputJSON = try? JSONEncoder().encode(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
var output: JSONValue? {
|
||||
get {
|
||||
guard let data = outputJSON else { return nil }
|
||||
return try? JSONDecoder().decode(JSONValue.self, from: data)
|
||||
}
|
||||
set {
|
||||
outputJSON = try? JSONEncoder().encode(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
17
apps/omega-mac/OmegaMac/Models/UserSettings.swift
Normal file
17
apps/omega-mac/OmegaMac/Models/UserSettings.swift
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class UserSettings {
|
||||
var id: UUID
|
||||
var systemPrompt: String?
|
||||
var selectedModel: String
|
||||
var pinnedToolIds: [String]
|
||||
|
||||
init() {
|
||||
self.id = UUID()
|
||||
self.systemPrompt = nil
|
||||
self.selectedModel = "gpt-4.1-mini"
|
||||
self.pinnedToolIds = []
|
||||
}
|
||||
}
|
||||
14
apps/omega-mac/OmegaMac/OmegaMac.entitlements
Normal file
14
apps/omega-mac/OmegaMac/OmegaMac.entitlements
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.tpmjs.omega-mac</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
44
apps/omega-mac/OmegaMac/OmegaMacApp.swift
Normal file
44
apps/omega-mac/OmegaMac/OmegaMacApp.swift
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct OmegaMacApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
.modelContainer(for: [
|
||||
Conversation.self,
|
||||
Message.self,
|
||||
ToolCallRecord.self,
|
||||
EnvVar.self,
|
||||
UserSettings.self,
|
||||
])
|
||||
.defaultSize(width: 1100, height: 750)
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Conversation") {
|
||||
NotificationCenter.default.post(
|
||||
name: .newConversation, object: nil)
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
Settings {
|
||||
SettingsView()
|
||||
.modelContainer(for: [
|
||||
EnvVar.self,
|
||||
UserSettings.self,
|
||||
])
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let newConversation = Notification.Name("newConversation")
|
||||
}
|
||||
613
apps/omega-mac/OmegaMac/Services/ChatOrchestrator.swift
Normal file
613
apps/omega-mac/OmegaMac/Services/ChatOrchestrator.swift
Normal file
|
|
@ -0,0 +1,613 @@
|
|||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Represents a live tool call being displayed during streaming
|
||||
struct LiveToolCall: Identifiable, Sendable {
|
||||
let id: String // toolCallId
|
||||
let toolName: String
|
||||
var arguments: String
|
||||
var status: String // "running" | "success" | "error"
|
||||
var output: JSONValue?
|
||||
}
|
||||
|
||||
/// Main orchestrator for the Omega agentic chat loop.
|
||||
/// Coordinates between OpenAI, TPMJS registry, and SwiftData persistence.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ChatOrchestrator {
|
||||
// MARK: - Published State
|
||||
|
||||
var streamingContent: String = ""
|
||||
var isStreaming: Bool = false
|
||||
var liveToolCalls: [LiveToolCall] = []
|
||||
var error: String?
|
||||
|
||||
// MARK: - Private State
|
||||
|
||||
private let openAI = OpenAIService()
|
||||
private let registry = TPMJSRegistryService()
|
||||
|
||||
/// Dynamically loaded tools for the current conversation (sanitizedName -> ToolMeta)
|
||||
private var loadedTools: [String: ToolMeta] = [:]
|
||||
|
||||
/// Maximum agentic loop iterations (search -> execute -> respond)
|
||||
private let maxIterations = 10
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Send a user message and run the full agentic loop.
|
||||
/// Streams the response, handles tool calls, and persists everything to SwiftData.
|
||||
func sendMessage(
|
||||
_ text: String,
|
||||
conversation: Conversation,
|
||||
modelContext: ModelContext
|
||||
) async {
|
||||
// Reset state
|
||||
streamingContent = ""
|
||||
isStreaming = true
|
||||
liveToolCalls = []
|
||||
error = nil
|
||||
|
||||
// Get API key
|
||||
guard let apiKey = KeychainService.load(key: "OPENAI_API_KEY"), !apiKey.isEmpty else {
|
||||
error = "No OpenAI API key set. Open Settings (Cmd+,) to add your key."
|
||||
isStreaming = false
|
||||
return
|
||||
}
|
||||
|
||||
// Load user settings
|
||||
let settingsDescriptor = FetchDescriptor<UserSettings>()
|
||||
let settings = (try? modelContext.fetch(settingsDescriptor))?.first
|
||||
|
||||
let model = settings?.selectedModel ?? "gpt-4.1-mini"
|
||||
let customPrompt = settings?.systemPrompt
|
||||
let pinnedToolIds = settings?.pinnedToolIds ?? []
|
||||
|
||||
// Save user message
|
||||
let userMessage = Message(role: .user, content: text, conversation: conversation)
|
||||
modelContext.insert(userMessage)
|
||||
conversation.updatedAt = Date()
|
||||
conversation.executionState = "running"
|
||||
try? modelContext.save()
|
||||
|
||||
// Load env vars from Keychain
|
||||
let envVarDescriptor = FetchDescriptor<EnvVar>()
|
||||
let envVarRecords = (try? modelContext.fetch(envVarDescriptor)) ?? []
|
||||
let envVars = KeychainService.loadAllEnvVars(keyNames: envVarRecords.map(\.keyName))
|
||||
|
||||
// Auto-discover tools via BM25 search
|
||||
do {
|
||||
let relevantTools = try await registry.searchTools(query: text, limit: 10)
|
||||
for toolMeta in relevantTools {
|
||||
let sanitized = sanitizeToolName(toolMeta.toolId)
|
||||
if loadedTools[sanitized] == nil {
|
||||
loadedTools[sanitized] = toolMeta
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: continue without auto-discovered tools
|
||||
print("Auto-discovery failed: \(error)")
|
||||
}
|
||||
|
||||
// Build messages array from conversation history
|
||||
var chatMessages = buildChatMessages(
|
||||
conversation: conversation,
|
||||
customPrompt: customPrompt,
|
||||
pinnedToolIds: pinnedToolIds
|
||||
)
|
||||
|
||||
// Add the new user message
|
||||
chatMessages.append(.user(text))
|
||||
|
||||
// Build tools list
|
||||
let tools = buildToolsList()
|
||||
|
||||
// Agentic loop
|
||||
var iteration = 0
|
||||
var allToolCallData: [ToolCallData] = []
|
||||
var allToolResultData: [ToolCallData] = []
|
||||
var totalInputTokens = 0
|
||||
var totalOutputTokens = 0
|
||||
|
||||
while iteration < maxIterations {
|
||||
iteration += 1
|
||||
|
||||
var currentContent = ""
|
||||
var pendingToolCalls: [ChatToolCall] = []
|
||||
var receivedDone = false
|
||||
|
||||
do {
|
||||
let stream = await openAI.streamCompletion(
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
messages: chatMessages,
|
||||
tools: tools.isEmpty ? nil : tools
|
||||
)
|
||||
|
||||
for try await event in stream {
|
||||
switch event {
|
||||
case .contentDelta(let delta):
|
||||
currentContent += delta
|
||||
streamingContent = currentContent
|
||||
|
||||
case .toolCallStarted(_, let id, let name):
|
||||
let liveTC = LiveToolCall(
|
||||
id: id,
|
||||
toolName: name,
|
||||
arguments: "",
|
||||
status: "running"
|
||||
)
|
||||
liveToolCalls.append(liveTC)
|
||||
|
||||
case .toolCallArgumentDelta(let index, let delta):
|
||||
if index < liveToolCalls.count {
|
||||
liveToolCalls[index].arguments += delta
|
||||
}
|
||||
|
||||
case .toolCallComplete(let toolCall):
|
||||
pendingToolCalls.append(toolCall)
|
||||
|
||||
case .usage(let input, let output):
|
||||
totalInputTokens += input
|
||||
totalOutputTokens += output
|
||||
|
||||
case .done:
|
||||
receivedDone = true
|
||||
|
||||
case .error(let msg):
|
||||
self.error = msg
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
break
|
||||
}
|
||||
|
||||
// If we got content with no tool calls, we're done
|
||||
if pendingToolCalls.isEmpty {
|
||||
streamingContent = currentContent
|
||||
break
|
||||
}
|
||||
|
||||
// Process tool calls
|
||||
// Add assistant message with tool calls to chat history
|
||||
chatMessages.append(.assistant(
|
||||
content: currentContent.isEmpty ? nil : currentContent,
|
||||
toolCalls: pendingToolCalls
|
||||
))
|
||||
|
||||
// Execute each tool call
|
||||
for toolCall in pendingToolCalls {
|
||||
let tcData = ToolCallData(
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.toolName,
|
||||
args: .object(toolCall.parsedArguments)
|
||||
)
|
||||
allToolCallData.append(tcData)
|
||||
|
||||
// Record tool run
|
||||
let record = ToolCallRecord(
|
||||
toolName: toolCall.toolName,
|
||||
toolCallId: toolCall.id,
|
||||
conversation: conversation
|
||||
)
|
||||
record.input = .object(toolCall.parsedArguments)
|
||||
modelContext.insert(record)
|
||||
|
||||
let result = await executeToolCall(
|
||||
toolCall: toolCall,
|
||||
envVars: envVars
|
||||
)
|
||||
|
||||
// Update live tool call status
|
||||
if let idx = liveToolCalls.firstIndex(where: { $0.id == toolCall.id }) {
|
||||
liveToolCalls[idx].status = result.isError ? "error" : "success"
|
||||
liveToolCalls[idx].output = result.output
|
||||
}
|
||||
|
||||
// Update record
|
||||
record.output = result.output
|
||||
record.status = result.isError ? "error" : "success"
|
||||
record.completedAt = Date()
|
||||
|
||||
// Add tool result to chat messages
|
||||
let resultJSON: String
|
||||
if let data = try? JSONEncoder().encode(result.output) {
|
||||
resultJSON = String(data: data, encoding: .utf8) ?? "{}"
|
||||
} else {
|
||||
resultJSON = "{}"
|
||||
}
|
||||
|
||||
chatMessages.append(.toolResult(
|
||||
toolCallId: toolCall.id,
|
||||
name: toolCall.toolName,
|
||||
content: resultJSON
|
||||
))
|
||||
|
||||
let trData = ToolCallData(
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.toolName,
|
||||
args: .object(toolCall.parsedArguments),
|
||||
output: result.output
|
||||
)
|
||||
allToolResultData.append(trData)
|
||||
}
|
||||
|
||||
// Reset streaming for next iteration
|
||||
streamingContent = ""
|
||||
liveToolCalls = []
|
||||
}
|
||||
|
||||
// Save assistant message
|
||||
let assistantMessage = Message(
|
||||
role: .assistant,
|
||||
content: streamingContent,
|
||||
conversation: conversation,
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
toolCalls: allToolCallData.isEmpty ? nil : allToolCallData
|
||||
)
|
||||
modelContext.insert(assistantMessage)
|
||||
|
||||
// Save tool results as a TOOL message if we had tool calls
|
||||
if !allToolResultData.isEmpty {
|
||||
let toolMessage = Message(
|
||||
role: .tool,
|
||||
content: "Tool results",
|
||||
conversation: conversation,
|
||||
toolCalls: allToolResultData
|
||||
)
|
||||
modelContext.insert(toolMessage)
|
||||
}
|
||||
|
||||
// Update conversation
|
||||
conversation.executionState = "idle"
|
||||
conversation.inputTokensTotal += totalInputTokens
|
||||
conversation.outputTokensTotal += totalOutputTokens
|
||||
conversation.updatedAt = Date()
|
||||
|
||||
// Auto-title from first message
|
||||
if conversation.title == nil {
|
||||
let title = text.count > 50 ? String(text.prefix(50)) + "..." : text
|
||||
conversation.title = title
|
||||
}
|
||||
|
||||
try? modelContext.save()
|
||||
|
||||
isStreaming = false
|
||||
}
|
||||
|
||||
/// Clear loaded tools (when switching conversations)
|
||||
func resetConversation() {
|
||||
loadedTools = [:]
|
||||
streamingContent = ""
|
||||
isStreaming = false
|
||||
liveToolCalls = []
|
||||
error = nil
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private struct ToolResult {
|
||||
let output: JSONValue
|
||||
let isError: Bool
|
||||
}
|
||||
|
||||
private func executeToolCall(
|
||||
toolCall: ChatToolCall,
|
||||
envVars: [String: String]
|
||||
) async -> ToolResult {
|
||||
let name = toolCall.toolName
|
||||
let args = toolCall.parsedArguments
|
||||
|
||||
// Handle registrySearch
|
||||
if name == "registrySearch" {
|
||||
return await handleRegistrySearch(args: args)
|
||||
}
|
||||
|
||||
// Handle registryExecute
|
||||
if name == "registryExecute" {
|
||||
return await handleRegistryExecute(args: args, envVars: envVars)
|
||||
}
|
||||
|
||||
// Handle dynamic tools (loaded from search)
|
||||
if let toolMeta = loadedTools[name] {
|
||||
return await handleDynamicTool(meta: toolMeta, args: args, envVars: envVars)
|
||||
}
|
||||
|
||||
// Also check by finding the tool ID from the sanitized name
|
||||
if let toolId = findToolId(sanitizedName: name, in: loadedTools),
|
||||
let toolMeta = loadedTools.values.first(where: { $0.toolId == toolId }) {
|
||||
return await handleDynamicTool(meta: toolMeta, args: args, envVars: envVars)
|
||||
}
|
||||
|
||||
return ToolResult(
|
||||
output: .object([
|
||||
"error": .bool(true),
|
||||
"message": .string("Unknown tool: \(name)"),
|
||||
]),
|
||||
isError: true
|
||||
)
|
||||
}
|
||||
|
||||
private func handleRegistrySearch(args: [String: JSONValue]) async -> ToolResult {
|
||||
guard case .string(let query) = args["query"] else {
|
||||
return ToolResult(
|
||||
output: .object(["error": .bool(true), "message": .string("Missing 'query' parameter")]),
|
||||
isError: true
|
||||
)
|
||||
}
|
||||
|
||||
let limit: Int
|
||||
if case .number(let n) = args["limit"] {
|
||||
limit = Int(n)
|
||||
} else {
|
||||
limit = 5
|
||||
}
|
||||
|
||||
do {
|
||||
let tools = try await registry.searchTools(query: query, limit: limit)
|
||||
|
||||
// Inject found tools into loaded tools
|
||||
for toolMeta in tools {
|
||||
let sanitized = sanitizeToolName(toolMeta.toolId)
|
||||
if loadedTools[sanitized] == nil {
|
||||
loadedTools[sanitized] = toolMeta
|
||||
}
|
||||
}
|
||||
|
||||
let toolsJSON: [JSONValue] = tools.map { t in
|
||||
.object([
|
||||
"toolId": .string(t.toolId),
|
||||
"name": .string(t.name),
|
||||
"package": .string(t.packageName),
|
||||
"description": .string(t.description),
|
||||
])
|
||||
}
|
||||
|
||||
return ToolResult(
|
||||
output: .object([
|
||||
"query": .string(query),
|
||||
"matchCount": .number(Double(tools.count)),
|
||||
"tools": .array(toolsJSON),
|
||||
]),
|
||||
isError: false
|
||||
)
|
||||
} catch {
|
||||
return ToolResult(
|
||||
output: .object([
|
||||
"error": .bool(true),
|
||||
"message": .string(error.localizedDescription),
|
||||
]),
|
||||
isError: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRegistryExecute(
|
||||
args: [String: JSONValue],
|
||||
envVars: [String: String]
|
||||
) async -> ToolResult {
|
||||
guard case .string(let toolId) = args["toolId"] else {
|
||||
return ToolResult(
|
||||
output: .object(["error": .bool(true), "message": .string("Missing 'toolId' parameter")]),
|
||||
isError: true
|
||||
)
|
||||
}
|
||||
|
||||
let params = args["params"] ?? .object([:])
|
||||
|
||||
do {
|
||||
let response = try await registry.executeByToolId(
|
||||
toolId: toolId,
|
||||
params: params,
|
||||
env: envVars
|
||||
)
|
||||
|
||||
if response.success {
|
||||
return ToolResult(
|
||||
output: .object([
|
||||
"toolId": .string(toolId),
|
||||
"executionTimeMs": .number(Double(response.executionTimeMs ?? 0)),
|
||||
"output": response.output ?? .null,
|
||||
]),
|
||||
isError: false
|
||||
)
|
||||
} else {
|
||||
return ToolResult(
|
||||
output: .object([
|
||||
"error": .bool(true),
|
||||
"message": .string(response.error ?? "Tool execution failed"),
|
||||
"toolId": .string(toolId),
|
||||
]),
|
||||
isError: true
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return ToolResult(
|
||||
output: .object([
|
||||
"error": .bool(true),
|
||||
"message": .string(error.localizedDescription),
|
||||
"toolId": .string(toolId),
|
||||
]),
|
||||
isError: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDynamicTool(
|
||||
meta: ToolMeta,
|
||||
args: [String: JSONValue],
|
||||
envVars: [String: String]
|
||||
) async -> ToolResult {
|
||||
do {
|
||||
let response = try await registry.executeTool(
|
||||
packageName: meta.packageName,
|
||||
name: meta.name,
|
||||
version: meta.version,
|
||||
importUrl: meta.importUrl,
|
||||
params: .object(args),
|
||||
env: envVars
|
||||
)
|
||||
|
||||
if response.success {
|
||||
return ToolResult(
|
||||
output: response.output ?? .null,
|
||||
isError: false
|
||||
)
|
||||
} else {
|
||||
return ToolResult(
|
||||
output: .object([
|
||||
"error": .bool(true),
|
||||
"message": .string(response.error ?? "Tool execution failed"),
|
||||
"toolId": .string(meta.toolId),
|
||||
]),
|
||||
isError: true
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return ToolResult(
|
||||
output: .object([
|
||||
"error": .bool(true),
|
||||
"message": .string(error.localizedDescription),
|
||||
"toolId": .string(meta.toolId),
|
||||
]),
|
||||
isError: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build chat messages from conversation history
|
||||
private func buildChatMessages(
|
||||
conversation: Conversation,
|
||||
customPrompt: String?,
|
||||
pinnedToolIds: [String]
|
||||
) -> [ChatMessage] {
|
||||
var messages: [ChatMessage] = []
|
||||
|
||||
// System prompt
|
||||
let systemPrompt = SystemPromptBuilder.build(
|
||||
customSystemPrompt: customPrompt,
|
||||
pinnedToolIds: pinnedToolIds,
|
||||
loadedTools: loadedTools
|
||||
)
|
||||
messages.append(.system(systemPrompt))
|
||||
|
||||
// Last 20 messages from conversation history
|
||||
let sorted = conversation.sortedMessages
|
||||
let recent = sorted.suffix(20)
|
||||
|
||||
for msg in recent {
|
||||
switch msg.role {
|
||||
case .user:
|
||||
messages.append(.user(msg.content))
|
||||
|
||||
case .assistant:
|
||||
let toolCalls = msg.toolCalls
|
||||
if !toolCalls.isEmpty {
|
||||
let chatToolCalls = toolCalls.map { tc in
|
||||
ChatToolCall(
|
||||
id: tc.toolCallId,
|
||||
type: "function",
|
||||
function: ChatToolCallFunction(
|
||||
name: tc.toolName,
|
||||
arguments: {
|
||||
if let args = tc.args,
|
||||
let data = try? JSONEncoder().encode(args) {
|
||||
return String(data: data, encoding: .utf8) ?? "{}"
|
||||
}
|
||||
return "{}"
|
||||
}()
|
||||
)
|
||||
)
|
||||
}
|
||||
messages.append(.assistant(content: msg.content, toolCalls: chatToolCalls))
|
||||
} else {
|
||||
messages.append(.assistant(content: msg.content, toolCalls: nil))
|
||||
}
|
||||
|
||||
case .tool:
|
||||
for tc in msg.toolCalls {
|
||||
let outputJSON: String
|
||||
if let output = tc.output,
|
||||
let data = try? JSONEncoder().encode(output) {
|
||||
outputJSON = String(data: data, encoding: .utf8) ?? "{}"
|
||||
} else {
|
||||
outputJSON = "{}"
|
||||
}
|
||||
messages.append(.toolResult(
|
||||
toolCallId: tc.toolCallId,
|
||||
name: tc.toolName,
|
||||
content: outputJSON
|
||||
))
|
||||
}
|
||||
|
||||
case .system:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
/// Build the OpenAI tools array from static + dynamic tools
|
||||
private func buildToolsList() -> [ChatTool] {
|
||||
var tools: [ChatTool] = []
|
||||
|
||||
// Static: registrySearch
|
||||
tools.append(ChatTool(
|
||||
function: ChatFunction(
|
||||
name: "registrySearch",
|
||||
description: "Search the TPMJS tool registry to find AI SDK tools. Use this to discover tools for any task. Returns toolIds that can be executed with registryExecute.",
|
||||
parameters: JSONSchemaObject(
|
||||
type: "object",
|
||||
properties: [
|
||||
"query": JSONSchemaProperty(
|
||||
type: "string",
|
||||
description: "Search query (keywords, tool names, descriptions)"
|
||||
),
|
||||
"limit": JSONSchemaProperty(
|
||||
type: "number",
|
||||
description: "Maximum number of results (1-20, default 5)",
|
||||
minimum: 1,
|
||||
maximum: 20
|
||||
),
|
||||
],
|
||||
required: ["query"],
|
||||
additionalProperties: false
|
||||
)
|
||||
)
|
||||
))
|
||||
|
||||
// Static: registryExecute
|
||||
tools.append(ChatTool(
|
||||
function: ChatFunction(
|
||||
name: "registryExecute",
|
||||
description: "Execute a tool from the TPMJS registry. Use registrySearch first to find the toolId. Tools run in a secure sandbox.",
|
||||
parameters: JSONSchemaObject(
|
||||
type: "object",
|
||||
properties: [
|
||||
"toolId": JSONSchemaProperty(
|
||||
type: "string",
|
||||
description: "Tool identifier from registrySearch (format: 'package::name')"
|
||||
),
|
||||
"params": JSONSchemaProperty(
|
||||
type: "object",
|
||||
description: "Parameters to pass to the tool",
|
||||
additionalProperties: .bool(true)
|
||||
),
|
||||
],
|
||||
required: ["toolId", "params"],
|
||||
additionalProperties: false
|
||||
)
|
||||
)
|
||||
))
|
||||
|
||||
// Dynamic tools
|
||||
for (_, meta) in loadedTools {
|
||||
tools.append(meta.toChatTool())
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
}
|
||||
124
apps/omega-mac/OmegaMac/Services/KeychainService.swift
Normal file
124
apps/omega-mac/OmegaMac/Services/KeychainService.swift
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Wrapper around macOS Keychain for storing API keys and env var values securely.
|
||||
enum KeychainService {
|
||||
private static let serviceName = "com.tpmjs.omega-mac"
|
||||
|
||||
/// Save or update a value in the Keychain
|
||||
static func save(key: String, value: String) throws {
|
||||
guard let data = value.data(using: .utf8) else {
|
||||
throw KeychainError.encodingFailed
|
||||
}
|
||||
|
||||
// Check if item exists
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: key,
|
||||
]
|
||||
|
||||
let status = SecItemCopyMatching(query as CFDictionary, nil)
|
||||
|
||||
if status == errSecSuccess {
|
||||
// Update existing
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
]
|
||||
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
guard updateStatus == errSecSuccess else {
|
||||
throw KeychainError.unhandledError(updateStatus)
|
||||
}
|
||||
} else if status == errSecItemNotFound {
|
||||
// Add new
|
||||
var addQuery = query
|
||||
addQuery[kSecValueData as String] = data
|
||||
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw KeychainError.unhandledError(addStatus)
|
||||
}
|
||||
} else {
|
||||
throw KeychainError.unhandledError(status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve a value from the Keychain
|
||||
static func load(key: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess,
|
||||
let data = result as? Data,
|
||||
let value = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/// Delete a value from the Keychain
|
||||
static func delete(key: String) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: key,
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw KeychainError.unhandledError(status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all stored keys (returns key names only, not values)
|
||||
static func allKeys() -> [String] {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecReturnAttributes as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess,
|
||||
let items = result as? [[String: Any]] else {
|
||||
return []
|
||||
}
|
||||
|
||||
return items.compactMap { $0[kSecAttrAccount as String] as? String }
|
||||
}
|
||||
|
||||
/// Convenience: load all env vars as a dictionary
|
||||
static func loadAllEnvVars(keyNames: [String]) -> [String: String] {
|
||||
var result: [String: String] = [:]
|
||||
for key in keyNames {
|
||||
if let value = load(key: key) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
enum KeychainError: LocalizedError {
|
||||
case encodingFailed
|
||||
case unhandledError(OSStatus)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .encodingFailed:
|
||||
return "Failed to encode value for Keychain"
|
||||
case .unhandledError(let status):
|
||||
return "Keychain error: \(status)"
|
||||
}
|
||||
}
|
||||
}
|
||||
141
apps/omega-mac/OmegaMac/Services/OpenAIService.swift
Normal file
141
apps/omega-mac/OmegaMac/Services/OpenAIService.swift
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import Foundation
|
||||
|
||||
/// Actor that handles all communication with the OpenAI Chat Completions API.
|
||||
/// Supports streaming via Server-Sent Events (SSE).
|
||||
actor OpenAIService {
|
||||
private let session: URLSession
|
||||
|
||||
init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 300
|
||||
config.timeoutIntervalForResource = 300
|
||||
self.session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
/// Stream a chat completion, yielding parsed events as they arrive.
|
||||
func streamCompletion(
|
||||
apiKey: String,
|
||||
model: String,
|
||||
messages: [ChatMessage],
|
||||
tools: [ChatTool]?
|
||||
) -> AsyncThrowingStream<StreamParser.StreamEvent, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
Task {
|
||||
do {
|
||||
let request = try buildRequest(
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
messages: messages,
|
||||
tools: tools,
|
||||
stream: true
|
||||
)
|
||||
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
continuation.yield(.error("Invalid response type"))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
// Try to read error body
|
||||
var errorBody = ""
|
||||
for try await line in bytes.lines {
|
||||
errorBody += line
|
||||
}
|
||||
continuation.yield(.error("API error \(httpResponse.statusCode): \(errorBody)"))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
|
||||
// Track accumulated tool calls
|
||||
var toolCallAccumulators: [Int: StreamParser.ToolCallAccumulator] = [:]
|
||||
|
||||
for try await line in bytes.lines {
|
||||
let events = StreamParser.parseLine(line)
|
||||
for event in events {
|
||||
switch event {
|
||||
case .toolCallStarted(let index, let id, let name):
|
||||
toolCallAccumulators[index] = StreamParser.ToolCallAccumulator(
|
||||
id: id,
|
||||
name: name,
|
||||
arguments: ""
|
||||
)
|
||||
continuation.yield(event)
|
||||
|
||||
case .toolCallArgumentDelta(let index, let delta):
|
||||
toolCallAccumulators[index]?.arguments += delta
|
||||
continuation.yield(event)
|
||||
|
||||
case .done:
|
||||
// Emit completed tool calls
|
||||
for (_, acc) in toolCallAccumulators.sorted(by: { $0.key < $1.key }) {
|
||||
let toolCall = ChatToolCall(
|
||||
id: acc.id,
|
||||
type: "function",
|
||||
function: ChatToolCallFunction(
|
||||
name: acc.name,
|
||||
arguments: acc.arguments
|
||||
)
|
||||
)
|
||||
continuation.yield(.toolCallComplete(toolCall))
|
||||
}
|
||||
continuation.yield(.done)
|
||||
continuation.finish()
|
||||
|
||||
default:
|
||||
continuation.yield(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here without [DONE], still emit completed tool calls
|
||||
if !toolCallAccumulators.isEmpty {
|
||||
for (_, acc) in toolCallAccumulators.sorted(by: { $0.key < $1.key }) {
|
||||
let toolCall = ChatToolCall(
|
||||
id: acc.id,
|
||||
type: "function",
|
||||
function: ChatToolCallFunction(
|
||||
name: acc.name,
|
||||
arguments: acc.arguments
|
||||
)
|
||||
)
|
||||
continuation.yield(.toolCallComplete(toolCall))
|
||||
}
|
||||
}
|
||||
continuation.finish()
|
||||
|
||||
} catch {
|
||||
continuation.yield(.error(error.localizedDescription))
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func buildRequest(
|
||||
apiKey: String,
|
||||
model: String,
|
||||
messages: [ChatMessage],
|
||||
tools: [ChatTool]?,
|
||||
stream: Bool
|
||||
) throws -> URLRequest {
|
||||
var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body = ChatCompletionRequest(
|
||||
model: model,
|
||||
messages: messages,
|
||||
tools: tools?.isEmpty == true ? nil : tools,
|
||||
stream: stream,
|
||||
maxTokens: 4096,
|
||||
streamOptions: stream ? StreamOptions(includeUsage: true) : nil
|
||||
)
|
||||
|
||||
request.httpBody = try JSONEncoder().encode(body)
|
||||
return request
|
||||
}
|
||||
}
|
||||
209
apps/omega-mac/OmegaMac/Services/OpenAITypes.swift
Normal file
209
apps/omega-mac/OmegaMac/Services/OpenAITypes.swift
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import Foundation
|
||||
|
||||
// MARK: - Request Types
|
||||
|
||||
struct ChatCompletionRequest: Encodable {
|
||||
let model: String
|
||||
let messages: [ChatMessage]
|
||||
let tools: [ChatTool]?
|
||||
let stream: Bool
|
||||
let maxTokens: Int?
|
||||
let streamOptions: StreamOptions?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case model, messages, tools, stream
|
||||
case maxTokens = "max_tokens"
|
||||
case streamOptions = "stream_options"
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamOptions: Encodable {
|
||||
let includeUsage: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case includeUsage = "include_usage"
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatMessage: Codable {
|
||||
let role: String
|
||||
let content: String?
|
||||
let toolCalls: [ChatToolCall]?
|
||||
let toolCallId: String?
|
||||
let name: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case role, content, name
|
||||
case toolCalls = "tool_calls"
|
||||
case toolCallId = "tool_call_id"
|
||||
}
|
||||
|
||||
static func system(_ content: String) -> ChatMessage {
|
||||
ChatMessage(role: "system", content: content, toolCalls: nil, toolCallId: nil, name: nil)
|
||||
}
|
||||
|
||||
static func user(_ content: String) -> ChatMessage {
|
||||
ChatMessage(role: "user", content: content, toolCalls: nil, toolCallId: nil, name: nil)
|
||||
}
|
||||
|
||||
static func assistant(content: String?, toolCalls: [ChatToolCall]?) -> ChatMessage {
|
||||
ChatMessage(role: "assistant", content: content, toolCalls: toolCalls, toolCallId: nil, name: nil)
|
||||
}
|
||||
|
||||
static func toolResult(toolCallId: String, name: String, content: String) -> ChatMessage {
|
||||
ChatMessage(role: "tool", content: content, toolCalls: nil, toolCallId: toolCallId, name: name)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatTool: Encodable {
|
||||
let type: String = "function"
|
||||
let function: ChatFunction
|
||||
}
|
||||
|
||||
struct ChatFunction: Encodable {
|
||||
let name: String
|
||||
let description: String
|
||||
let parameters: JSONSchemaObject
|
||||
}
|
||||
|
||||
struct JSONSchemaObject: Encodable {
|
||||
let type: String
|
||||
let properties: [String: JSONSchemaProperty]
|
||||
let required: [String]?
|
||||
let additionalProperties: Bool?
|
||||
}
|
||||
|
||||
struct JSONSchemaProperty: Encodable {
|
||||
let type: String
|
||||
let description: String?
|
||||
let minimum: Int?
|
||||
let maximum: Int?
|
||||
let additionalProperties: JSONSchemaAdditional?
|
||||
|
||||
init(type: String, description: String? = nil, minimum: Int? = nil, maximum: Int? = nil, additionalProperties: JSONSchemaAdditional? = nil) {
|
||||
self.type = type
|
||||
self.description = description
|
||||
self.minimum = minimum
|
||||
self.maximum = maximum
|
||||
self.additionalProperties = additionalProperties
|
||||
}
|
||||
}
|
||||
|
||||
enum JSONSchemaAdditional: Encodable {
|
||||
case bool(Bool)
|
||||
case typed(JSONSchemaProperty)
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .bool(let b): try container.encode(b)
|
||||
case .typed(let p): try container.encode(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatToolCall: Codable, Identifiable {
|
||||
var id: String
|
||||
let type: String?
|
||||
let function: ChatToolCallFunction?
|
||||
|
||||
var toolName: String { function?.name ?? "" }
|
||||
var arguments: String { function?.arguments ?? "{}" }
|
||||
|
||||
var parsedArguments: [String: JSONValue] {
|
||||
guard let data = arguments.data(using: .utf8),
|
||||
let obj = try? JSONDecoder().decode([String: JSONValue].self, from: data) else {
|
||||
return [:]
|
||||
}
|
||||
return obj
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatToolCallFunction: Codable {
|
||||
let name: String?
|
||||
let arguments: String?
|
||||
}
|
||||
|
||||
// MARK: - Response Types (non-streaming)
|
||||
|
||||
struct ChatCompletionResponse: Decodable {
|
||||
let id: String
|
||||
let choices: [ChatChoice]
|
||||
let usage: ChatUsage?
|
||||
}
|
||||
|
||||
struct ChatChoice: Decodable {
|
||||
let index: Int
|
||||
let message: ChatResponseMessage
|
||||
let finishReason: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case index, message
|
||||
case finishReason = "finish_reason"
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatResponseMessage: Decodable {
|
||||
let role: String
|
||||
let content: String?
|
||||
let toolCalls: [ChatToolCall]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case role, content
|
||||
case toolCalls = "tool_calls"
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatUsage: Decodable {
|
||||
let promptTokens: Int?
|
||||
let completionTokens: Int?
|
||||
let totalTokens: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case promptTokens = "prompt_tokens"
|
||||
case completionTokens = "completion_tokens"
|
||||
case totalTokens = "total_tokens"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Streaming Types
|
||||
|
||||
struct ChatCompletionChunk: Decodable {
|
||||
let id: String?
|
||||
let choices: [ChunkChoice]?
|
||||
let usage: ChatUsage?
|
||||
}
|
||||
|
||||
struct ChunkChoice: Decodable {
|
||||
let index: Int?
|
||||
let delta: ChunkDelta?
|
||||
let finishReason: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case index, delta
|
||||
case finishReason = "finish_reason"
|
||||
}
|
||||
}
|
||||
|
||||
struct ChunkDelta: Decodable {
|
||||
let role: String?
|
||||
let content: String?
|
||||
let toolCalls: [ChunkToolCall]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case role, content
|
||||
case toolCalls = "tool_calls"
|
||||
}
|
||||
}
|
||||
|
||||
struct ChunkToolCall: Decodable {
|
||||
let index: Int?
|
||||
let id: String?
|
||||
let type: String?
|
||||
let function: ChunkToolCallFunction?
|
||||
}
|
||||
|
||||
struct ChunkToolCallFunction: Decodable {
|
||||
let name: String?
|
||||
let arguments: String?
|
||||
}
|
||||
105
apps/omega-mac/OmegaMac/Services/StreamParser.swift
Normal file
105
apps/omega-mac/OmegaMac/Services/StreamParser.swift
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import Foundation
|
||||
|
||||
/// Parses Server-Sent Events (SSE) from OpenAI's streaming API.
|
||||
/// Handles `data: {...}` lines and `data: [DONE]` termination.
|
||||
struct StreamParser {
|
||||
|
||||
/// Accumulated tool call state during streaming
|
||||
struct ToolCallAccumulator {
|
||||
var id: String = ""
|
||||
var name: String = ""
|
||||
var arguments: String = ""
|
||||
}
|
||||
|
||||
/// Result of parsing the stream - yields content deltas and complete tool calls
|
||||
enum StreamEvent: Sendable {
|
||||
case contentDelta(String)
|
||||
case toolCallStarted(index: Int, id: String, name: String)
|
||||
case toolCallArgumentDelta(index: Int, delta: String)
|
||||
case toolCallComplete(ChatToolCall)
|
||||
case usage(inputTokens: Int, outputTokens: Int)
|
||||
case done
|
||||
case error(String)
|
||||
}
|
||||
|
||||
/// Parse a single SSE line and return events
|
||||
static func parseLine(_ line: String) -> [StreamEvent] {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Skip empty lines and comments
|
||||
guard !trimmed.isEmpty, !trimmed.hasPrefix(":") else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Must start with "data: "
|
||||
guard trimmed.hasPrefix("data: ") else {
|
||||
return []
|
||||
}
|
||||
|
||||
let payload = String(trimmed.dropFirst(6))
|
||||
|
||||
// Check for stream end
|
||||
if payload == "[DONE]" {
|
||||
return [.done]
|
||||
}
|
||||
|
||||
// Parse JSON chunk
|
||||
guard let data = payload.data(using: .utf8) else {
|
||||
return [.error("Invalid UTF-8 in SSE payload")]
|
||||
}
|
||||
|
||||
do {
|
||||
let chunk = try JSONDecoder().decode(ChatCompletionChunk.self, from: data)
|
||||
return processChunk(chunk)
|
||||
} catch {
|
||||
return [.error("Failed to parse chunk: \(error.localizedDescription)")]
|
||||
}
|
||||
}
|
||||
|
||||
private static func processChunk(_ chunk: ChatCompletionChunk) -> [StreamEvent] {
|
||||
var events: [StreamEvent] = []
|
||||
|
||||
if let choices = chunk.choices {
|
||||
for choice in choices {
|
||||
guard let delta = choice.delta else { continue }
|
||||
|
||||
// Content delta
|
||||
if let content = delta.content, !content.isEmpty {
|
||||
events.append(.contentDelta(content))
|
||||
}
|
||||
|
||||
// Tool calls
|
||||
if let toolCalls = delta.toolCalls {
|
||||
for tc in toolCalls {
|
||||
let idx = tc.index ?? 0
|
||||
if let id = tc.id, !id.isEmpty {
|
||||
events.append(.toolCallStarted(
|
||||
index: idx,
|
||||
id: id,
|
||||
name: tc.function?.name ?? ""
|
||||
))
|
||||
}
|
||||
if let args = tc.function?.arguments, !args.isEmpty {
|
||||
events.append(.toolCallArgumentDelta(index: idx, delta: args))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finish reason
|
||||
if choice.finishReason == "stop" || choice.finishReason == "tool_calls" {
|
||||
// Will be handled by [DONE]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage info (sometimes included in last chunk)
|
||||
if let usage = chunk.usage {
|
||||
events.append(.usage(
|
||||
inputTokens: usage.promptTokens ?? 0,
|
||||
outputTokens: usage.completionTokens ?? 0
|
||||
))
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
}
|
||||
156
apps/omega-mac/OmegaMac/Services/TPMJSRegistryService.swift
Normal file
156
apps/omega-mac/OmegaMac/Services/TPMJSRegistryService.swift
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import Foundation
|
||||
|
||||
/// Actor that handles communication with the TPMJS tool registry API
|
||||
/// and the remote executor service.
|
||||
actor TPMJSRegistryService {
|
||||
private let session: URLSession
|
||||
private let registryBaseURL: String
|
||||
private let executorBaseURL: String
|
||||
|
||||
init(
|
||||
registryBaseURL: String = "https://tpmjs.com",
|
||||
executorBaseURL: String = "https://executor.tpmjs.com"
|
||||
) {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 60
|
||||
self.session = URLSession(configuration: config)
|
||||
self.registryBaseURL = registryBaseURL
|
||||
self.executorBaseURL = executorBaseURL
|
||||
}
|
||||
|
||||
// MARK: - Search
|
||||
|
||||
/// Search for tools matching a query using BM25
|
||||
func searchTools(query: String, limit: Int = 10) async throws -> [ToolMeta] {
|
||||
var components = URLComponents(string: "\(registryBaseURL)/api/tools/search")!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "q", value: query),
|
||||
URLQueryItem(name: "limit", value: String(limit)),
|
||||
]
|
||||
|
||||
guard let url = components.url else {
|
||||
throw TPMJSError.invalidURL
|
||||
}
|
||||
|
||||
let (data, response) = try await session.data(from: url)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
return []
|
||||
}
|
||||
|
||||
let searchResponse = try JSONDecoder().decode(TPMJSSearchResponse.self, from: data)
|
||||
let tools = searchResponse.results?.tools ?? []
|
||||
|
||||
return tools.map { tool in
|
||||
ToolMeta(
|
||||
toolId: "\(tool.package.npmPackageName)::\(tool.name)",
|
||||
packageName: tool.package.npmPackageName,
|
||||
name: tool.name,
|
||||
description: tool.description ?? "Tool: \(tool.name)",
|
||||
version: tool.package.npmVersion,
|
||||
importUrl: "https://esm.sh/\(tool.package.npmPackageName)@\(tool.package.npmVersion)",
|
||||
inputSchema: tool.inputSchema,
|
||||
env: tool.package.env
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Execute via Executor
|
||||
|
||||
/// Execute a tool via the TPMJS remote sandbox executor
|
||||
func executeTool(
|
||||
packageName: String,
|
||||
name: String,
|
||||
version: String,
|
||||
importUrl: String,
|
||||
params: JSONValue,
|
||||
env: [String: String]
|
||||
) async throws -> TPMJSExecuteResponse {
|
||||
guard let url = URL(string: "\(executorBaseURL)/execute-tool") else {
|
||||
throw TPMJSError.invalidURL
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body = TPMJSExecuteRequest(
|
||||
packageName: packageName,
|
||||
name: name,
|
||||
version: version,
|
||||
importUrl: importUrl,
|
||||
params: params,
|
||||
env: env
|
||||
)
|
||||
|
||||
request.httpBody = try JSONEncoder().encode(body)
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
throw TPMJSError.httpError(statusCode)
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(TPMJSExecuteResponse.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Registry Execute (uses search first to find metadata)
|
||||
|
||||
/// Execute a tool by its toolId (package::name format).
|
||||
/// Fetches metadata first via search, then executes via executor.
|
||||
func executeByToolId(
|
||||
toolId: String,
|
||||
params: JSONValue,
|
||||
env: [String: String]
|
||||
) async throws -> TPMJSExecuteResponse {
|
||||
// Parse toolId format: "package::name"
|
||||
guard let separatorIndex = toolId.range(of: "::", options: .backwards) else {
|
||||
throw TPMJSError.invalidToolId(toolId)
|
||||
}
|
||||
|
||||
let packageName = String(toolId[toolId.startIndex..<separatorIndex.lowerBound])
|
||||
let name = String(toolId[separatorIndex.upperBound...])
|
||||
|
||||
guard !packageName.isEmpty, !name.isEmpty else {
|
||||
throw TPMJSError.invalidToolId(toolId)
|
||||
}
|
||||
|
||||
// Search for the tool to get version metadata
|
||||
let searchResults = try await searchTools(query: name, limit: 10)
|
||||
guard let toolMeta = searchResults.first(where: {
|
||||
$0.packageName == packageName && $0.name == name
|
||||
}) else {
|
||||
throw TPMJSError.toolNotFound(toolId)
|
||||
}
|
||||
|
||||
return try await executeTool(
|
||||
packageName: toolMeta.packageName,
|
||||
name: toolMeta.name,
|
||||
version: toolMeta.version,
|
||||
importUrl: toolMeta.importUrl,
|
||||
params: params,
|
||||
env: env
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum TPMJSError: LocalizedError {
|
||||
case invalidURL
|
||||
case httpError(Int)
|
||||
case invalidToolId(String)
|
||||
case toolNotFound(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: return "Invalid URL"
|
||||
case .httpError(let code): return "HTTP error: \(code)"
|
||||
case .invalidToolId(let id): return "Invalid tool ID format: \(id). Expected 'package::name'"
|
||||
case .toolNotFound(let id): return "Tool not found: \(id). Try using registrySearch to find available tools."
|
||||
}
|
||||
}
|
||||
}
|
||||
128
apps/omega-mac/OmegaMac/Services/TPMJSTypes.swift
Normal file
128
apps/omega-mac/OmegaMac/Services/TPMJSTypes.swift
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import Foundation
|
||||
|
||||
// MARK: - Search API Types
|
||||
|
||||
struct TPMJSSearchResponse: Decodable {
|
||||
let results: TPMJSSearchResults?
|
||||
}
|
||||
|
||||
struct TPMJSSearchResults: Decodable {
|
||||
let tools: [TPMJSToolResult]?
|
||||
}
|
||||
|
||||
struct TPMJSToolResult: Decodable {
|
||||
let name: String
|
||||
let description: String?
|
||||
let inputSchema: JSONValue?
|
||||
let qualityScore: Double?
|
||||
let executionHealth: String?
|
||||
let package: TPMJSPackageInfo
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name, description, inputSchema, qualityScore, executionHealth
|
||||
case package = "package"
|
||||
}
|
||||
}
|
||||
|
||||
struct TPMJSPackageInfo: Decodable {
|
||||
let npmPackageName: String
|
||||
let npmVersion: String
|
||||
let category: String?
|
||||
let env: [TPMJSEnvVarDef]?
|
||||
}
|
||||
|
||||
struct TPMJSEnvVarDef: Decodable, Sendable {
|
||||
let name: String
|
||||
let description: String?
|
||||
let required: Bool?
|
||||
}
|
||||
|
||||
// MARK: - Executor API Types
|
||||
|
||||
struct TPMJSExecuteRequest: Encodable {
|
||||
let packageName: String
|
||||
let name: String
|
||||
let version: String
|
||||
let importUrl: String
|
||||
let params: JSONValue
|
||||
let env: [String: String]
|
||||
}
|
||||
|
||||
struct TPMJSExecuteResponse: Decodable, Sendable {
|
||||
let success: Bool
|
||||
let output: JSONValue?
|
||||
let error: String?
|
||||
let executionTimeMs: Int?
|
||||
}
|
||||
|
||||
// MARK: - Tool Metadata (internal tracking)
|
||||
|
||||
struct ToolMeta: Sendable {
|
||||
let toolId: String
|
||||
let packageName: String
|
||||
let name: String
|
||||
let description: String
|
||||
let version: String
|
||||
let importUrl: String
|
||||
let inputSchema: JSONValue?
|
||||
let env: [TPMJSEnvVarDef]?
|
||||
|
||||
/// Convert to an OpenAI function tool definition
|
||||
func toChatTool() -> ChatTool {
|
||||
let properties: [String: JSONSchemaProperty]
|
||||
let required: [String]?
|
||||
|
||||
if case .object(let schemaObj) = inputSchema {
|
||||
// Extract properties from schema
|
||||
var props: [String: JSONSchemaProperty] = [:]
|
||||
var reqs: [String] = []
|
||||
|
||||
if case .object(let propsObj) = schemaObj["properties"] {
|
||||
for (key, value) in propsObj {
|
||||
if case .object(let propDef) = value {
|
||||
let typeStr: String
|
||||
if case .string(let t) = propDef["type"] {
|
||||
typeStr = t
|
||||
} else {
|
||||
typeStr = "string"
|
||||
}
|
||||
let desc: String?
|
||||
if case .string(let d) = propDef["description"] {
|
||||
desc = d
|
||||
} else {
|
||||
desc = nil
|
||||
}
|
||||
props[key] = JSONSchemaProperty(type: typeStr, description: desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if case .array(let reqArr) = schemaObj["required"] {
|
||||
for item in reqArr {
|
||||
if case .string(let s) = item {
|
||||
reqs.append(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
properties = props
|
||||
required = reqs.isEmpty ? nil : reqs
|
||||
} else {
|
||||
properties = [:]
|
||||
required = nil
|
||||
}
|
||||
|
||||
return ChatTool(
|
||||
function: ChatFunction(
|
||||
name: sanitizeToolName(toolId),
|
||||
description: description,
|
||||
parameters: JSONSchemaObject(
|
||||
type: "object",
|
||||
properties: properties,
|
||||
required: required,
|
||||
additionalProperties: true
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
40
apps/omega-mac/OmegaMac/Utilities/SanitizeToolName.swift
Normal file
40
apps/omega-mac/OmegaMac/Utilities/SanitizeToolName.swift
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import Foundation
|
||||
|
||||
/// Sanitize a tool ID to be a valid OpenAI function name.
|
||||
/// Port of the web's sanitizeToolName logic.
|
||||
/// OpenAI requires tool names to be <= 64 characters and match [a-zA-Z0-9_-].
|
||||
func sanitizeToolName(_ name: String) -> String {
|
||||
var sanitized = name
|
||||
.replacingOccurrences(of: "@", with: "")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "-", with: "_")
|
||||
.replacingOccurrences(of: "::", with: "_")
|
||||
|
||||
// Remove any remaining invalid characters
|
||||
sanitized = String(sanitized.unicodeScalars.filter { scalar in
|
||||
CharacterSet.alphanumerics.contains(scalar) || scalar == "_"
|
||||
})
|
||||
|
||||
// OpenAI API requires tool names <= 64 characters
|
||||
if sanitized.count <= 64 {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// Truncate but try to keep the meaningful part (tool name at the end)
|
||||
let last64 = String(sanitized.suffix(64))
|
||||
if let first = last64.first, first.isLetter {
|
||||
return last64
|
||||
}
|
||||
return String(sanitized.prefix(64))
|
||||
}
|
||||
|
||||
/// Reverse lookup: find the original toolId from a sanitized name
|
||||
/// by checking against loaded tool metadata.
|
||||
func findToolId(sanitizedName: String, in tools: [String: ToolMeta]) -> String? {
|
||||
for (_, meta) in tools {
|
||||
if sanitizeToolName(meta.toolId) == sanitizedName {
|
||||
return meta.toolId
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
134
apps/omega-mac/OmegaMac/Utilities/SystemPromptBuilder.swift
Normal file
134
apps/omega-mac/OmegaMac/Utilities/SystemPromptBuilder.swift
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import Foundation
|
||||
|
||||
/// Builds the system prompt for the Omega agent.
|
||||
/// Port of the web's buildSystemPrompt logic from system-prompt.ts.
|
||||
enum SystemPromptBuilder {
|
||||
|
||||
static let basePrompt = """
|
||||
You are Omega, an AI assistant powered by the TPMJS tool registry - a collection of 1M+ AI-ready tools.
|
||||
|
||||
## Core Tools
|
||||
|
||||
You have access to two powerful meta-tools that give you access to the entire TPMJS registry:
|
||||
|
||||
1. **registrySearch** - Search for tools by keyword, category, or description
|
||||
2. **registryExecute** - Execute any tool by its toolId
|
||||
|
||||
These tools are importable by users into their own AI agents via:
|
||||
```typescript
|
||||
import { registrySearchTool } from '@tpmjs/registry-search';
|
||||
import { registryExecuteTool } from '@tpmjs/registry-execute';
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. When the user asks for something, relevant tools are automatically discovered and loaded
|
||||
2. You can also explicitly search using registrySearch
|
||||
3. Once tools are found, you have two options:
|
||||
- Use registryExecute with the toolId to execute any tool
|
||||
- Call dynamically loaded tools directly by their sanitized name
|
||||
|
||||
## Workflow Examples
|
||||
|
||||
### Example 1: User wants weather data
|
||||
1. Call registrySearch({ query: "weather api" })
|
||||
2. Review the results (toolIds like "@weather-api/sdk::getWeather")
|
||||
3. Call registryExecute({ toolId: "@weather-api/sdk::getWeather", params: { city: "Tokyo" } })
|
||||
4. Explain the result to the user
|
||||
|
||||
### Example 2: Tool already loaded
|
||||
If you see a tool like "weatherapi_sdk_getWeather" in the dynamically loaded tools list, call it directly instead of using registryExecute.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Search first** - If you don't see a relevant tool loaded, use registrySearch
|
||||
- **Execute don't describe** - Actually call tools to get real results
|
||||
- **Handle errors** - If a tool fails, explain and try an alternative
|
||||
- **Be efficient** - If a tool is already loaded, call it directly
|
||||
|
||||
## Response Style
|
||||
|
||||
- Keep responses concise and helpful
|
||||
- Present tool outputs in a clear, readable format
|
||||
- Tell the user which tool you used
|
||||
- Offer to do more if the user might need it
|
||||
|
||||
Remember: Your value is in EXECUTING tools to get real results, not describing what tools could do.
|
||||
"""
|
||||
|
||||
/// Build the complete system prompt with tool listings and user customizations
|
||||
static func build(
|
||||
customSystemPrompt: String?,
|
||||
pinnedToolIds: [String],
|
||||
loadedTools: [String: ToolMeta]
|
||||
) -> String {
|
||||
var parts: [String] = [basePrompt]
|
||||
|
||||
// Pinned tools
|
||||
if !pinnedToolIds.isEmpty {
|
||||
let pinned = pinnedToolIds.map { "- Tool ID: \($0)" }.joined(separator: "\n")
|
||||
parts.append("""
|
||||
## Pinned Tools
|
||||
|
||||
The user has pinned the following tools as favorites. Consider using these first when they match the task:
|
||||
\(pinned)
|
||||
""")
|
||||
}
|
||||
|
||||
// Custom system prompt
|
||||
if let custom = customSystemPrompt, !custom.isEmpty {
|
||||
parts.append("""
|
||||
## User Instructions
|
||||
|
||||
The user has provided the following custom instructions:
|
||||
|
||||
\(custom)
|
||||
""")
|
||||
}
|
||||
|
||||
// Static tools
|
||||
let staticToolsList = """
|
||||
- registrySearch: Search the TPMJS registry to find AI SDK tools by keyword. Returns toolIds for registryExecute.
|
||||
- registryExecute: Execute any tool from the TPMJS registry by toolId. Use registrySearch first to find tools.
|
||||
"""
|
||||
|
||||
parts.append("""
|
||||
## Static Tools (Always Available)
|
||||
|
||||
These tools let you access the entire TPMJS registry of 1M+ tools:
|
||||
|
||||
\(staticToolsList)
|
||||
""")
|
||||
|
||||
// Dynamic tools
|
||||
let dynamicToolsList: String
|
||||
if loadedTools.isEmpty {
|
||||
dynamicToolsList = "No tools loaded yet. Use registrySearch to find tools, or they will be auto-loaded based on your requests."
|
||||
} else {
|
||||
dynamicToolsList = loadedTools.map { (name, meta) in
|
||||
"- \(name): \(meta.description)"
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
|
||||
parts.append("""
|
||||
## Dynamically Loaded Tools
|
||||
|
||||
These tools have been discovered and loaded for this conversation. Call them directly:
|
||||
|
||||
\(dynamicToolsList)
|
||||
""")
|
||||
|
||||
// Usage instructions
|
||||
parts.append("""
|
||||
## How to Use Tools
|
||||
|
||||
1. **To find a tool**: Use registrySearch with a keyword (e.g., "weather", "web scraping", "database")
|
||||
2. **To execute a found tool**: Use registryExecute with the toolId returned from search
|
||||
3. **Direct execution**: If a tool is already loaded above, call it directly by name
|
||||
|
||||
Remember: Your value is in EXECUTING tools to get real results, not just describing what tools could do.
|
||||
""")
|
||||
|
||||
return parts.joined(separator: "\n\n")
|
||||
}
|
||||
}
|
||||
54
apps/omega-mac/OmegaMac/Views/Chat/ChatInputBar.swift
Normal file
54
apps/omega-mac/OmegaMac/Views/Chat/ChatInputBar.swift
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import SwiftUI
|
||||
|
||||
struct ChatInputBar: View {
|
||||
@Binding var text: String
|
||||
let isStreaming: Bool
|
||||
let onSend: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Divider()
|
||||
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextEditor(text: $text)
|
||||
.font(.body)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.frame(minHeight: 40, maxHeight: 160)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.onKeyPress(.return, phases: .down) { keyPress in
|
||||
if keyPress.modifiers.contains(.shift) {
|
||||
return .ignored // Let shift+enter add newline
|
||||
}
|
||||
if canSend {
|
||||
onSend()
|
||||
return .handled
|
||||
}
|
||||
return .ignored
|
||||
}
|
||||
|
||||
Button(action: onSend) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(canSend ? .accent : .tertiary)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(!canSend)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Text("Enter to send, Shift+Enter for new line")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.background(.background)
|
||||
}
|
||||
|
||||
private var canSend: Bool {
|
||||
!isStreaming && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
68
apps/omega-mac/OmegaMac/Views/Chat/ChatView.swift
Normal file
68
apps/omega-mac/OmegaMac/Views/Chat/ChatView.swift
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct ChatView: View {
|
||||
@Bindable var conversation: Conversation
|
||||
var orchestrator: ChatOrchestrator
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@State private var inputText: String = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Messages
|
||||
MessageList(
|
||||
conversation: conversation,
|
||||
streamingContent: orchestrator.streamingContent,
|
||||
isStreaming: orchestrator.isStreaming,
|
||||
liveToolCalls: orchestrator.liveToolCalls
|
||||
)
|
||||
|
||||
// Error banner
|
||||
if let error = orchestrator.error {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
Text(error)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.red)
|
||||
Spacer()
|
||||
Button("Dismiss") {
|
||||
orchestrator.error = nil
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.font(.callout)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.red.opacity(0.1))
|
||||
}
|
||||
|
||||
// Input bar
|
||||
ChatInputBar(
|
||||
text: $inputText,
|
||||
isStreaming: orchestrator.isStreaming,
|
||||
onSend: sendMessage
|
||||
)
|
||||
}
|
||||
.navigationTitle(conversation.displayTitle)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .automatic) {
|
||||
if orchestrator.isStreaming {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendMessage() {
|
||||
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
inputText = ""
|
||||
|
||||
Task {
|
||||
await orchestrator.sendMessage(text, conversation: conversation, modelContext: modelContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
69
apps/omega-mac/OmegaMac/Views/Chat/MessageBubble.swift
Normal file
69
apps/omega-mac/OmegaMac/Views/Chat/MessageBubble.swift
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import SwiftUI
|
||||
|
||||
struct MessageBubble: View {
|
||||
let message: Message
|
||||
|
||||
var body: some View {
|
||||
switch message.role {
|
||||
case .user:
|
||||
userBubble
|
||||
case .assistant:
|
||||
assistantBubble
|
||||
case .tool:
|
||||
toolResultsBubble
|
||||
case .system:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var userBubble: some View {
|
||||
HStack(alignment: .top) {
|
||||
Spacer(minLength: 60)
|
||||
Text(message.content)
|
||||
.font(.body)
|
||||
.foregroundStyle(.white)
|
||||
.padding(12)
|
||||
.background(.accent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private var assistantBubble: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if !message.content.isEmpty {
|
||||
MarkdownView(content: message.content)
|
||||
}
|
||||
|
||||
// Token usage
|
||||
if let input = message.inputTokens, let output = message.outputTokens {
|
||||
Text("In: \(input) | Out: \(output)")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.textSelection(.enabled)
|
||||
|
||||
Spacer(minLength: 60)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private var toolResultsBubble: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(message.toolCalls) { tc in
|
||||
JSONToolResultView(toolCallData: tc)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 60)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
95
apps/omega-mac/OmegaMac/Views/Chat/MessageList.swift
Normal file
95
apps/omega-mac/OmegaMac/Views/Chat/MessageList.swift
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import SwiftUI
|
||||
|
||||
struct MessageList: View {
|
||||
let conversation: Conversation
|
||||
let streamingContent: String
|
||||
let isStreaming: Bool
|
||||
let liveToolCalls: [LiveToolCall]
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(conversation.sortedMessages) { message in
|
||||
MessageBubble(message: message)
|
||||
.id(message.id)
|
||||
}
|
||||
|
||||
// Live tool calls
|
||||
ForEach(liveToolCalls) { tc in
|
||||
ToolCallView(toolCall: tc)
|
||||
.id("live-tc-\(tc.id)")
|
||||
}
|
||||
|
||||
// Streaming content
|
||||
if !streamingContent.isEmpty {
|
||||
HStack(alignment: .top) {
|
||||
assistantBubble(content: streamingContent, isStreaming: true)
|
||||
Spacer(minLength: 60)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.id("streaming")
|
||||
}
|
||||
|
||||
// Thinking indicator
|
||||
if isStreaming && streamingContent.isEmpty && liveToolCalls.isEmpty {
|
||||
HStack {
|
||||
StreamingIndicator()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.id("thinking")
|
||||
}
|
||||
|
||||
// Bottom spacer for scroll padding
|
||||
Color.clear.frame(height: 8)
|
||||
.id("bottom")
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.onChange(of: streamingContent) {
|
||||
withAnimation(.easeOut(duration: 0.15)) {
|
||||
proxy.scrollTo("bottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
.onChange(of: conversation.messages.count) {
|
||||
withAnimation(.easeOut(duration: 0.15)) {
|
||||
proxy.scrollTo("bottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
.onChange(of: liveToolCalls.count) {
|
||||
withAnimation(.easeOut(duration: 0.15)) {
|
||||
proxy.scrollTo("bottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func assistantBubble(content: String, isStreaming: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
MarkdownView(content: content)
|
||||
|
||||
if isStreaming {
|
||||
Rectangle()
|
||||
.fill(.accent)
|
||||
.frame(width: 2, height: 16)
|
||||
.opacity(0.8)
|
||||
.modifier(PulseAnimation())
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
private struct PulseAnimation: ViewModifier {
|
||||
@State private var isAnimating = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.opacity(isAnimating ? 0.3 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true), value: isAnimating)
|
||||
.onAppear { isAnimating = true }
|
||||
}
|
||||
}
|
||||
24
apps/omega-mac/OmegaMac/Views/Chat/StreamingIndicator.swift
Normal file
24
apps/omega-mac/OmegaMac/Views/Chat/StreamingIndicator.swift
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import SwiftUI
|
||||
|
||||
struct StreamingIndicator: View {
|
||||
@State private var dotCount = 0
|
||||
private let timer = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundStyle(.accent)
|
||||
.font(.caption)
|
||||
|
||||
Text("Omega is thinking" + String(repeating: ".", count: dotCount))
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.onReceive(timer) { _ in
|
||||
dotCount = (dotCount + 1) % 4
|
||||
}
|
||||
}
|
||||
}
|
||||
39
apps/omega-mac/OmegaMac/Views/ContentView.swift
Normal file
39
apps/omega-mac/OmegaMac/Views/ContentView.swift
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@State private var selectedConversation: Conversation?
|
||||
@State private var orchestrator = ChatOrchestrator()
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
SidebarView(
|
||||
selectedConversation: $selectedConversation,
|
||||
onNewConversation: createNewConversation
|
||||
)
|
||||
.navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 340)
|
||||
} detail: {
|
||||
if let conversation = selectedConversation {
|
||||
ChatView(conversation: conversation, orchestrator: orchestrator)
|
||||
} else {
|
||||
EmptyStateView(onNewConversation: createNewConversation)
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.onChange(of: selectedConversation) {
|
||||
orchestrator.resetConversation()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .newConversation)) { _ in
|
||||
createNewConversation()
|
||||
}
|
||||
}
|
||||
|
||||
private func createNewConversation() {
|
||||
let conversation = Conversation()
|
||||
modelContext.insert(conversation)
|
||||
try? modelContext.save()
|
||||
selectedConversation = conversation
|
||||
orchestrator.resetConversation()
|
||||
}
|
||||
}
|
||||
129
apps/omega-mac/OmegaMac/Views/Settings/APIKeySettings.swift
Normal file
129
apps/omega-mac/OmegaMac/Views/Settings/APIKeySettings.swift
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct APIKeySettings: View {
|
||||
@State private var apiKey: String = ""
|
||||
@State private var hasKey: Bool = false
|
||||
@State private var showKey: Bool = false
|
||||
@State private var saveStatus: String?
|
||||
|
||||
@Query private var settingsArray: [UserSettings]
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
private var settings: UserSettings {
|
||||
if let existing = settingsArray.first {
|
||||
return existing
|
||||
}
|
||||
let s = UserSettings()
|
||||
modelContext.insert(s)
|
||||
try? modelContext.save()
|
||||
return s
|
||||
}
|
||||
|
||||
@State private var selectedModel: String = "gpt-4.1-mini"
|
||||
|
||||
private let availableModels = [
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"o4-mini",
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("OpenAI API Key") {
|
||||
HStack {
|
||||
if showKey {
|
||||
TextField("sk-...", text: $apiKey)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
} else {
|
||||
SecureField("sk-...", text: $apiKey)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
|
||||
Button {
|
||||
showKey.toggle()
|
||||
} label: {
|
||||
Image(systemName: showKey ? "eye.slash" : "eye")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Save Key") {
|
||||
saveAPIKey()
|
||||
}
|
||||
.disabled(apiKey.isEmpty)
|
||||
|
||||
if hasKey {
|
||||
Button("Remove Key", role: .destructive) {
|
||||
removeAPIKey()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let status = saveStatus {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(status.contains("Error") ? .red : .green)
|
||||
}
|
||||
}
|
||||
|
||||
if hasKey {
|
||||
Label("API key is stored securely in Keychain", systemImage: "lock.shield")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Model") {
|
||||
Picker("Model", selection: $selectedModel) {
|
||||
ForEach(availableModels, id: \.self) { model in
|
||||
Text(model).tag(model)
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedModel) { _, newValue in
|
||||
settings.selectedModel = newValue
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
.onAppear {
|
||||
hasKey = KeychainService.load(key: "OPENAI_API_KEY") != nil
|
||||
selectedModel = settings.selectedModel
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAPIKey() {
|
||||
do {
|
||||
try KeychainService.save(key: "OPENAI_API_KEY", value: apiKey)
|
||||
hasKey = true
|
||||
apiKey = ""
|
||||
saveStatus = "Saved"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
saveStatus = nil
|
||||
}
|
||||
} catch {
|
||||
saveStatus = "Error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func removeAPIKey() {
|
||||
do {
|
||||
try KeychainService.delete(key: "OPENAI_API_KEY")
|
||||
hasKey = false
|
||||
apiKey = ""
|
||||
saveStatus = "Removed"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
saveStatus = nil
|
||||
}
|
||||
} catch {
|
||||
saveStatus = "Error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
131
apps/omega-mac/OmegaMac/Views/Settings/EnvVarsSettings.swift
Normal file
131
apps/omega-mac/OmegaMac/Views/Settings/EnvVarsSettings.swift
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct EnvVarsSettings: View {
|
||||
@Query(sort: \EnvVar.keyName) private var envVars: [EnvVar]
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@State private var newKeyName: String = ""
|
||||
@State private var newKeyValue: String = ""
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Stored Environment Variables") {
|
||||
if envVars.isEmpty {
|
||||
Text("No environment variables configured.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
ForEach(envVars) { envVar in
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(envVar.keyName)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Text("....\(envVar.valueHint)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(role: .destructive) {
|
||||
deleteEnvVar(envVar)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Add New") {
|
||||
TextField("Key name (e.g., WEATHER_API_KEY)", text: $newKeyName)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
SecureField("Value", text: $newKeyValue)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
HStack {
|
||||
Button("Add") {
|
||||
addEnvVar()
|
||||
}
|
||||
.disabled(newKeyName.isEmpty || newKeyValue.isEmpty)
|
||||
|
||||
if let error = errorMessage {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Label(
|
||||
"Values are stored in macOS Keychain. Only key names are visible in the app.",
|
||||
systemImage: "lock.shield"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Label(
|
||||
"All environment variables are passed to tool executions automatically.",
|
||||
systemImage: "info.circle"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func addEnvVar() {
|
||||
let name = newKeyName.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
let value = newKeyValue
|
||||
|
||||
guard !name.isEmpty, !value.isEmpty else { return }
|
||||
|
||||
// Check for duplicates
|
||||
if envVars.contains(where: { $0.keyName == name }) {
|
||||
// Update existing
|
||||
do {
|
||||
try KeychainService.save(key: name, value: value)
|
||||
if let existing = envVars.first(where: { $0.keyName == name }) {
|
||||
existing.valueHint = String(value.suffix(4))
|
||||
}
|
||||
try? modelContext.save()
|
||||
newKeyName = ""
|
||||
newKeyValue = ""
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try KeychainService.save(key: name, value: value)
|
||||
|
||||
let hint = String(value.suffix(4))
|
||||
let envVar = EnvVar(keyName: name, valueHint: hint)
|
||||
modelContext.insert(envVar)
|
||||
try? modelContext.save()
|
||||
|
||||
newKeyName = ""
|
||||
newKeyValue = ""
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteEnvVar(_ envVar: EnvVar) {
|
||||
try? KeychainService.delete(key: envVar.keyName)
|
||||
modelContext.delete(envVar)
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
23
apps/omega-mac/OmegaMac/Views/Settings/SettingsView.swift
Normal file
23
apps/omega-mac/OmegaMac/Views/Settings/SettingsView.swift
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
APIKeySettings()
|
||||
.tabItem {
|
||||
Label("API Key", systemImage: "key")
|
||||
}
|
||||
|
||||
EnvVarsSettings()
|
||||
.tabItem {
|
||||
Label("Environment", systemImage: "server.rack")
|
||||
}
|
||||
|
||||
SystemPromptSettings()
|
||||
.tabItem {
|
||||
Label("System Prompt", systemImage: "text.bubble")
|
||||
}
|
||||
}
|
||||
.frame(width: 520, height: 420)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct SystemPromptSettings: View {
|
||||
@Query private var settingsArray: [UserSettings]
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@State private var promptText: String = ""
|
||||
@State private var saved: Bool = false
|
||||
|
||||
private var settings: UserSettings {
|
||||
if let existing = settingsArray.first {
|
||||
return existing
|
||||
}
|
||||
let s = UserSettings()
|
||||
modelContext.insert(s)
|
||||
try? modelContext.save()
|
||||
return s
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Custom System Prompt") {
|
||||
TextEditor(text: $promptText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(minHeight: 200)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(4)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
|
||||
HStack {
|
||||
Button("Save") {
|
||||
settings.systemPrompt = promptText.isEmpty ? nil : promptText
|
||||
try? modelContext.save()
|
||||
saved = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
saved = false
|
||||
}
|
||||
}
|
||||
|
||||
Button("Reset to Default") {
|
||||
promptText = ""
|
||||
settings.systemPrompt = nil
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if saved {
|
||||
Text("Saved")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Label(
|
||||
"Custom instructions are appended to the default Omega system prompt. Leave empty to use the default.",
|
||||
systemImage: "info.circle"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
.onAppear {
|
||||
promptText = settings.systemPrompt ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
52
apps/omega-mac/OmegaMac/Views/Shared/EmptyStateView.swift
Normal file
52
apps/omega-mac/OmegaMac/Views/Shared/EmptyStateView.swift
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import SwiftUI
|
||||
|
||||
struct EmptyStateView: View {
|
||||
let onNewConversation: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "star.circle.fill")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.accent)
|
||||
.symbolEffect(.pulse, options: .repeating)
|
||||
|
||||
Text("Omega")
|
||||
.font(.largeTitle.bold())
|
||||
|
||||
Text("AI assistant powered by 1M+ tools from the TPMJS registry")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 400)
|
||||
|
||||
Button(action: onNewConversation) {
|
||||
Label("New Conversation", systemImage: "plus")
|
||||
.font(.headline)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
featureRow(icon: "magnifyingglass", text: "Search 1M+ tools by keyword")
|
||||
featureRow(icon: "play.circle", text: "Execute tools in a secure sandbox")
|
||||
featureRow(icon: "bolt", text: "Auto-discovers relevant tools")
|
||||
featureRow(icon: "key", text: "Securely store API keys in Keychain")
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(.background)
|
||||
}
|
||||
|
||||
private func featureRow(icon: String, text: String) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 20)
|
||||
.foregroundStyle(.accent)
|
||||
Text(text)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
79
apps/omega-mac/OmegaMac/Views/Shared/MarkdownView.swift
Normal file
79
apps/omega-mac/OmegaMac/Views/Shared/MarkdownView.swift
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import MarkdownUI
|
||||
import SwiftUI
|
||||
|
||||
/// Renders markdown content from assistant responses using MarkdownUI.
|
||||
struct MarkdownView: View {
|
||||
let content: String
|
||||
|
||||
var body: some View {
|
||||
Markdown(content)
|
||||
.markdownTheme(.omega)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Custom Markdown Theme
|
||||
|
||||
extension MarkdownUI.Theme {
|
||||
static let omega = Theme()
|
||||
.text {
|
||||
ForegroundColor(.primary)
|
||||
FontSize(14)
|
||||
}
|
||||
.code {
|
||||
FontFamilyVariant(.monospaced)
|
||||
FontSize(12)
|
||||
ForegroundColor(.secondary)
|
||||
}
|
||||
.codeBlock { configuration in
|
||||
configuration.label
|
||||
.markdownTextStyle {
|
||||
FontFamilyVariant(.monospaced)
|
||||
FontSize(12)
|
||||
ForegroundColor(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.black.opacity(0.2))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.link {
|
||||
ForegroundColor(.accentColor)
|
||||
}
|
||||
.heading1 { configuration in
|
||||
configuration.label
|
||||
.markdownTextStyle {
|
||||
FontWeight(.bold)
|
||||
FontSize(20)
|
||||
}
|
||||
.markdownMargin(top: 16, bottom: 8)
|
||||
}
|
||||
.heading2 { configuration in
|
||||
configuration.label
|
||||
.markdownTextStyle {
|
||||
FontWeight(.semibold)
|
||||
FontSize(17)
|
||||
}
|
||||
.markdownMargin(top: 12, bottom: 6)
|
||||
}
|
||||
.heading3 { configuration in
|
||||
configuration.label
|
||||
.markdownTextStyle {
|
||||
FontWeight(.semibold)
|
||||
FontSize(15)
|
||||
}
|
||||
.markdownMargin(top: 10, bottom: 4)
|
||||
}
|
||||
.blockquote { configuration in
|
||||
HStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(Color.accentColor.opacity(0.4))
|
||||
.frame(width: 3)
|
||||
configuration.label
|
||||
.markdownTextStyle {
|
||||
ForegroundColor(.secondary)
|
||||
FontSize(13)
|
||||
}
|
||||
.padding(.leading, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
25
apps/omega-mac/OmegaMac/Views/Sidebar/ConversationRow.swift
Normal file
25
apps/omega-mac/OmegaMac/Views/Sidebar/ConversationRow.swift
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import SwiftUI
|
||||
|
||||
struct ConversationRow: View {
|
||||
let conversation: Conversation
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(conversation.displayTitle)
|
||||
.font(.system(.body, design: .default))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(timeAgo(conversation.updatedAt))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private func timeAgo(_ date: Date) -> String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
52
apps/omega-mac/OmegaMac/Views/Sidebar/SidebarView.swift
Normal file
52
apps/omega-mac/OmegaMac/Views/Sidebar/SidebarView.swift
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct SidebarView: View {
|
||||
@Binding var selectedConversation: Conversation?
|
||||
let onNewConversation: () -> Void
|
||||
|
||||
@Query(sort: \Conversation.updatedAt, order: .reverse)
|
||||
private var conversations: [Conversation]
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
var body: some View {
|
||||
List(selection: $selectedConversation) {
|
||||
ForEach(conversations) { conversation in
|
||||
ConversationRow(conversation: conversation)
|
||||
.tag(conversation)
|
||||
.contextMenu {
|
||||
Button("Delete", role: .destructive) {
|
||||
deleteConversation(conversation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .automatic) {
|
||||
Button(action: onNewConversation) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.help("New Conversation (Cmd+N)")
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if conversations.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No Conversations", systemImage: "bubble.left.and.bubble.right")
|
||||
} description: {
|
||||
Text("Press Cmd+N to start a new conversation")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteConversation(_ conversation: Conversation) {
|
||||
if selectedConversation == conversation {
|
||||
selectedConversation = nil
|
||||
}
|
||||
modelContext.delete(conversation)
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
98
apps/omega-mac/OmegaMac/Views/Tools/JSONToolResultView.swift
Normal file
98
apps/omega-mac/OmegaMac/Views/Tools/JSONToolResultView.swift
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Displays a persisted tool call result from a ToolCallData record.
|
||||
/// Collapsible card with monospaced JSON input/output.
|
||||
struct JSONToolResultView: View {
|
||||
let toolCallData: ToolCallData
|
||||
@State private var isExpanded: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Header
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: isError ? "xmark.circle.fill" : "checkmark.circle.fill")
|
||||
.foregroundStyle(isError ? .red : .green)
|
||||
.font(.caption)
|
||||
|
||||
Text(toolCallData.toolName)
|
||||
.font(.system(.caption, design: .monospaced).bold())
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if isExpanded {
|
||||
Divider()
|
||||
.padding(.horizontal, 10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Input
|
||||
if let args = toolCallData.args {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("INPUT")
|
||||
.font(.system(size: 9, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
Text(args.prettyString)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(6)
|
||||
.background(.black.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
|
||||
// Output
|
||||
if let output = toolCallData.output {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("OUTPUT")
|
||||
.font(.system(size: 9, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
Text(output.prettyString)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(6)
|
||||
.background(.black.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.lineLimit(20)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
}
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(isError ? .red.opacity(0.2) : .green.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var isError: Bool {
|
||||
if case .object(let obj) = toolCallData.output,
|
||||
case .bool(true) = obj["error"] {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
119
apps/omega-mac/OmegaMac/Views/Tools/ToolCallView.swift
Normal file
119
apps/omega-mac/OmegaMac/Views/Tools/ToolCallView.swift
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import SwiftUI
|
||||
|
||||
struct ToolCallView: View {
|
||||
let toolCall: LiveToolCall
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
statusIcon
|
||||
Text(toolCall.toolName)
|
||||
.font(.system(.callout, design: .monospaced).bold())
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
statusBadge
|
||||
}
|
||||
|
||||
// Input arguments
|
||||
if !toolCall.arguments.isEmpty {
|
||||
DisclosureGroup("Input") {
|
||||
Text(prettyJSON(toolCall.arguments))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(.black.opacity(0.2))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// Output
|
||||
if let output = toolCall.output {
|
||||
DisclosureGroup("Output") {
|
||||
Text(output.prettyString)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(.black.opacity(0.2))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(borderColor, lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
Spacer(minLength: 60)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusIcon: some View {
|
||||
switch toolCall.status {
|
||||
case "running":
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
case "success":
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
case "error":
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
default:
|
||||
Image(systemName: "questionmark.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusBadge: some View {
|
||||
Text(toolCall.status.capitalized)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(badgeColor.opacity(0.15))
|
||||
.foregroundStyle(badgeColor)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
private var badgeColor: Color {
|
||||
switch toolCall.status {
|
||||
case "running": return .orange
|
||||
case "success": return .green
|
||||
case "error": return .red
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
switch toolCall.status {
|
||||
case "running": return .orange.opacity(0.3)
|
||||
case "success": return .green.opacity(0.2)
|
||||
case "error": return .red.opacity(0.3)
|
||||
default: return .clear
|
||||
}
|
||||
}
|
||||
|
||||
private func prettyJSON(_ jsonString: String) -> String {
|
||||
guard let data = jsonString.data(using: .utf8),
|
||||
let obj = try? JSONSerialization.jsonObject(with: data),
|
||||
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]),
|
||||
let str = String(data: pretty, encoding: .utf8) else {
|
||||
return jsonString
|
||||
}
|
||||
return str
|
||||
}
|
||||
}
|
||||
19
apps/omega-mac/Package.swift
Normal file
19
apps/omega-mac/Package.swift
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// swift-tools-version: 5.10
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "OmegaMac",
|
||||
platforms: [.macOS(.v15)],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.0"),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "OmegaMac",
|
||||
dependencies: [
|
||||
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
|
||||
],
|
||||
path: "OmegaMac"
|
||||
),
|
||||
]
|
||||
)
|
||||
115
apps/omega-mac/README.md
Normal file
115
apps/omega-mac/README.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# Omega Mac
|
||||
|
||||
Native macOS chat app powered by the [TPMJS tool registry](https://tpmjs.com) — 1M+ AI-ready tools at your fingertips.
|
||||
|
||||
Omega Mac is the desktop counterpart to the web-based Omega agent. It connects directly to the OpenAI API and the TPMJS registry to search, discover, and execute tools in a secure remote sandbox — all from a native SwiftUI interface.
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS 15 (Sequoia) or later
|
||||
- Xcode 16+
|
||||
- An OpenAI API key
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Open `Package.swift` in Xcode
|
||||
2. Wait for Swift Package Manager to resolve dependencies
|
||||
3. Build and run (Cmd+R)
|
||||
4. Open Settings (Cmd+,) and enter your OpenAI API key
|
||||
5. Press Cmd+N to start a new conversation
|
||||
|
||||
## How It Works
|
||||
|
||||
Omega Mac implements a full **agentic tool-use loop**:
|
||||
|
||||
```
|
||||
User message
|
||||
→ Auto-discover relevant tools (BM25 search against tpmjs.com)
|
||||
→ Build tool list (registrySearch + registryExecute + discovered tools)
|
||||
→ Stream OpenAI response
|
||||
→ If tool calls returned:
|
||||
→ Execute tools via remote sandbox (executor.tpmjs.com)
|
||||
→ Feed results back to OpenAI
|
||||
→ Loop (up to 10 iterations)
|
||||
→ Display final response
|
||||
```
|
||||
|
||||
### Two Core Tools
|
||||
|
||||
Every conversation has access to two meta-tools that unlock the entire registry:
|
||||
|
||||
- **registrySearch** — Search 1M+ tools by keyword. Returns tool IDs and metadata.
|
||||
- **registryExecute** — Execute any tool by its ID. Runs in a secure remote sandbox.
|
||||
|
||||
When you send a message, Omega also auto-discovers relevant tools via BM25 search and injects them as directly-callable functions — so the AI can call them without going through registryExecute.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
OmegaMac/
|
||||
├── Models/ SwiftData persistence
|
||||
│ ├── Conversation Chat sessions with token tracking
|
||||
│ ├── Message User/assistant/tool messages with JSON tool call data
|
||||
│ ├── ToolCallRecord Individual tool execution records
|
||||
│ ├── EnvVar Environment variable metadata (values in Keychain)
|
||||
│ └── UserSettings Model selection, system prompt, pinned tools
|
||||
├── Services/ Actor-based networking
|
||||
│ ├── OpenAIService Streaming chat completions via SSE
|
||||
│ ├── StreamParser Server-Sent Events line parser
|
||||
│ ├── TPMJSRegistry Tool search + remote execution
|
||||
│ ├── KeychainService Secure storage for API keys and env vars
|
||||
│ └── ChatOrchestrator @Observable coordinator for the agentic loop
|
||||
├── Views/ SwiftUI interface
|
||||
│ ├── Sidebar/ Conversation list with @Query
|
||||
│ ├── Chat/ Messages, input bar, streaming indicator
|
||||
│ ├── Tools/ Tool call cards with collapsible JSON
|
||||
│ ├── Settings/ API key, env vars, system prompt, model picker
|
||||
│ └── Shared/ Markdown rendering, empty state
|
||||
└── Utilities/ Tool name sanitization, system prompt builder
|
||||
```
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
- **SwiftData** for local persistence — no server, no auth, everything on-device
|
||||
- **Keychain** for secrets — API keys and env var values are encrypted at rest
|
||||
- **Actors** for networking — `OpenAIService` and `TPMJSRegistryService` are actors for safe concurrent access
|
||||
- **@Observable** — `ChatOrchestrator` drives all UI state with zero Combine boilerplate
|
||||
- **Dark theme** by default — matches the web Omega aesthetic
|
||||
|
||||
## Settings
|
||||
|
||||
### API Key (required)
|
||||
|
||||
Your OpenAI API key is stored in the macOS Keychain. Omega Mac calls the OpenAI API directly — no proxy server.
|
||||
|
||||
### Model Selection
|
||||
|
||||
Choose from: `gpt-4.1-mini` (default), `gpt-4.1`, `gpt-4.1-nano`, `gpt-4o`, `gpt-4o-mini`, `o4-mini`.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Many tools in the TPMJS registry require API keys (e.g., `WEATHER_API_KEY`, `GITHUB_TOKEN`). Add them in Settings → Environment. Values are stored in Keychain; only key names and last-4-char hints are visible in the app.
|
||||
|
||||
All stored env vars are automatically passed to every tool execution.
|
||||
|
||||
### Custom System Prompt
|
||||
|
||||
Append custom instructions to Omega's default system prompt. Useful for constraining behavior, adding domain context, or specifying preferred tools.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| Cmd+N | New conversation |
|
||||
| Cmd+, | Open settings |
|
||||
| Enter | Send message |
|
||||
| Shift+Enter | New line in input |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [MarkdownUI](https://github.com/gonzalezreal/swift-markdown-ui) — GitHub-flavored markdown rendering
|
||||
- Everything else uses Apple frameworks (SwiftUI, SwiftData, Security, Foundation)
|
||||
|
||||
## Relationship to Web Omega
|
||||
|
||||
This app ports the core logic from the web implementation at `apps/web/src/app/api/omega/`. The agentic loop, system prompt, tool name sanitization, and search/execute flow are all faithful Swift translations of the TypeScript originals. The key difference is that web Omega uses server-side auth and a database, while Omega Mac stores everything locally with SwiftData and Keychain.
|
||||
Loading…
Add table
Add a link
Reference in a new issue