Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
922fe05954 | ||
|
|
162ce066d5 |
@@ -388,7 +388,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 2.5.2;
|
MARKETING_VERSION = 2.5.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.Confab;
|
PRODUCT_BUNDLE_IDENTIFIER = com.oai.Confab;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -440,7 +440,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 2.5.2;
|
MARKETING_VERSION = 2.5.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.Confab;
|
PRODUCT_BUNDLE_IDENTIFIER = com.oai.Confab;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
+92
-4946
File diff suppressed because it is too large
Load Diff
@@ -1838,7 +1838,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p>© 2025 - <span id="year"></span> <script>document.getElementById('year').textContent = new Date().getFullYear();</script> Confab - Rune Olsen. For support or feedback, visit <a href="https://gitlab.pm/rune/oai-swift">gitlab.pm</a> or <a href="https://confab.no/#contact">Contact Us</a>.</p>
|
<p>© 2026 Confab - Rune Olsen. For support or feedback, visit <a href="https://gitlab.pm/rune/oai-swift">gitlab.pm</a> or <a href="mailto:support@fubar.pm?subject=Confab Support&body=What can I help you with?">Contact Us</a>.</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,247 +0,0 @@
|
|||||||
//
|
|
||||||
// CLIServerService.swift
|
|
||||||
// Confab
|
|
||||||
//
|
|
||||||
// Local Unix-socket server for one-shot shell/CLI access to a single fixed model
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
//
|
|
||||||
// This file is part of Confab.
|
|
||||||
//
|
|
||||||
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
|
|
||||||
// You may use, study, modify, and share it for any noncommercial
|
|
||||||
// purpose. Commercial use — including selling Confab or any part of
|
|
||||||
// it, standalone or bundled into another product or service —
|
|
||||||
// requires a separate commercial license from the copyright holder.
|
|
||||||
//
|
|
||||||
// See the LICENSE file or
|
|
||||||
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
|
||||||
// the full license text. For commercial licensing, contact Rune
|
|
||||||
// Olsen via <https://confab.no>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Network
|
|
||||||
import os
|
|
||||||
|
|
||||||
/// Local Unix-domain-socket server exposing one-shot, non-streaming text completions from a
|
|
||||||
/// single fixed model (Settings → MCP → CLI Access) — e.g. an `ai "prompt"` shell function.
|
|
||||||
/// Deliberately minimal for v1: no tool-calling, no streaming, no per-request model override.
|
|
||||||
/// Speaks a minimal HTTP/1.1 subset (just enough for `curl --unix-socket`) rather than a custom
|
|
||||||
/// protocol, so it stays curl-friendly and trivially extensible — new fields can be added to the
|
|
||||||
/// JSON request/response bodies later without changing the transport.
|
|
||||||
final class CLIServerService {
|
|
||||||
static let shared = CLIServerService()
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
private let log = Logger(subsystem: Log.subsystem, category: "cli")
|
|
||||||
|
|
||||||
/// All listener/connection callbacks run on this single serial queue, so `activeConnections`
|
|
||||||
/// never needs a lock — Network.framework callbacks don't run on the main thread.
|
|
||||||
private let queue = DispatchQueue(label: "com.oai.Confab.cliserver")
|
|
||||||
private var listener: NWListener?
|
|
||||||
private var activeConnections: [ObjectIdentifier: NWConnection] = [:]
|
|
||||||
|
|
||||||
static let socketPath: String = {
|
|
||||||
(("~/Library/Application Support/oAI" as NSString).expandingTildeInPath as NSString)
|
|
||||||
.appendingPathComponent("cli.sock")
|
|
||||||
}()
|
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
|
||||||
|
|
||||||
func start() {
|
|
||||||
guard SettingsService.shared.cliServerEnabled else {
|
|
||||||
log.info("CLI server disabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
startListening()
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
listener?.cancel()
|
|
||||||
listener = nil
|
|
||||||
for (_, connection) in activeConnections { connection.cancel() }
|
|
||||||
activeConnections.removeAll()
|
|
||||||
try? FileManager.default.removeItem(atPath: Self.socketPath)
|
|
||||||
log.info("CLI server stopped")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Call after toggling cliServerEnabled or changing the provider/model, so the change takes
|
|
||||||
/// effect immediately instead of requiring an app relaunch.
|
|
||||||
func restart() {
|
|
||||||
stop()
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startListening() {
|
|
||||||
// Remove a stale socket file left behind by an unclean shutdown (bind() fails on an
|
|
||||||
// existing path even if nothing is listening on it anymore).
|
|
||||||
try? FileManager.default.removeItem(atPath: Self.socketPath)
|
|
||||||
|
|
||||||
let params = NWParameters()
|
|
||||||
params.defaultProtocolStack.transportProtocol = NWProtocolTCP.Options()
|
|
||||||
params.requiredLocalEndpoint = NWEndpoint.unix(path: Self.socketPath)
|
|
||||||
params.allowLocalEndpointReuse = true
|
|
||||||
|
|
||||||
do {
|
|
||||||
let newListener = try NWListener(using: params)
|
|
||||||
newListener.newConnectionHandler = { [weak self] connection in
|
|
||||||
self?.handle(connection: connection)
|
|
||||||
}
|
|
||||||
newListener.stateUpdateHandler = { [weak self] state in
|
|
||||||
switch state {
|
|
||||||
case .ready:
|
|
||||||
self?.log.info("CLI server listening at \(Self.socketPath)")
|
|
||||||
case .failed(let error):
|
|
||||||
self?.log.error("CLI server listener failed: \(error.localizedDescription)")
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newListener.start(queue: queue)
|
|
||||||
listener = newListener
|
|
||||||
} catch {
|
|
||||||
log.error("Failed to start CLI server: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Connection handling
|
|
||||||
|
|
||||||
private func handle(connection: NWConnection) {
|
|
||||||
let id = ObjectIdentifier(connection)
|
|
||||||
activeConnections[id] = connection
|
|
||||||
connection.stateUpdateHandler = { [weak self] state in
|
|
||||||
switch state {
|
|
||||||
case .failed, .cancelled:
|
|
||||||
self?.activeConnections.removeValue(forKey: id)
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
connection.start(queue: queue)
|
|
||||||
readRequest(on: connection, buffer: Data())
|
|
||||||
}
|
|
||||||
|
|
||||||
private func readRequest(on connection: NWConnection, buffer: Data) {
|
|
||||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
|
|
||||||
guard let self else { return }
|
|
||||||
var buffer = buffer
|
|
||||||
if let data { buffer.append(data) }
|
|
||||||
|
|
||||||
if let body = Self.parseRequestBody(from: buffer) {
|
|
||||||
Task {
|
|
||||||
let responseData = await self.processRequest(body)
|
|
||||||
self.sendAndClose(responseData, on: connection)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if isComplete || error != nil {
|
|
||||||
connection.cancel()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.readRequest(on: connection, buffer: buffer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sendAndClose(_ data: Data, on connection: NWConnection) {
|
|
||||||
let id = ObjectIdentifier(connection)
|
|
||||||
connection.send(content: data, completion: .contentProcessed { [weak self] _ in
|
|
||||||
connection.cancel()
|
|
||||||
self?.activeConnections.removeValue(forKey: id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Request processing
|
|
||||||
|
|
||||||
private struct AskRequest: Decodable {
|
|
||||||
let prompt: String
|
|
||||||
}
|
|
||||||
|
|
||||||
private func processRequest(_ body: Data) async -> Data {
|
|
||||||
guard let req = try? JSONDecoder().decode(AskRequest.self, from: body),
|
|
||||||
!req.prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
||||||
return Self.errorResponse("Request body must be JSON: {\"prompt\": \"...\"}")
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings = SettingsService.shared
|
|
||||||
guard settings.cliServerEnabled else {
|
|
||||||
return Self.errorResponse("CLI server is disabled in Settings")
|
|
||||||
}
|
|
||||||
guard let providerType = Settings.Provider(rawValue: settings.cliServerProvider),
|
|
||||||
let provider = ProviderRegistry.shared.getProvider(for: providerType) else {
|
|
||||||
return Self.errorResponse("No provider configured — set one in Settings > MCP > CLI Access")
|
|
||||||
}
|
|
||||||
guard !settings.cliServerModel.isEmpty else {
|
|
||||||
return Self.errorResponse("No model configured — set one in Settings > MCP > CLI Access")
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let request = ChatRequest(
|
|
||||||
messages: [Message(role: .user, content: req.prompt)],
|
|
||||||
model: settings.cliServerModel,
|
|
||||||
stream: false
|
|
||||||
)
|
|
||||||
let response = try await provider.chat(request: request)
|
|
||||||
return Self.successResponse(response.content)
|
|
||||||
} catch {
|
|
||||||
log.error("CLI request failed: \(error.localizedDescription)")
|
|
||||||
return Self.errorResponse(error.localizedDescription, statusCode: 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Pure helpers (unit tested — see CLIServerServiceTests.swift)
|
|
||||||
|
|
||||||
struct AskResponseBody: Codable {
|
|
||||||
let response: String?
|
|
||||||
let error: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses a minimal HTTP/1.1 request out of `buffer`, returning the body once the full
|
|
||||||
/// header block and (per Content-Length) body have arrived, or nil if more data is needed.
|
|
||||||
/// Not a general-purpose HTTP parser — this server only ever talks to `curl --unix-socket`,
|
|
||||||
/// so the method/path/most headers are read past rather than validated.
|
|
||||||
static func parseRequestBody(from buffer: Data) -> Data? {
|
|
||||||
let headerTerminator = Data("\r\n\r\n".utf8)
|
|
||||||
guard let headerEndRange = buffer.range(of: headerTerminator) else { return nil }
|
|
||||||
|
|
||||||
let headerData = buffer.subdata(in: buffer.startIndex..<headerEndRange.lowerBound)
|
|
||||||
guard let headerString = String(data: headerData, encoding: .utf8) else { return nil }
|
|
||||||
|
|
||||||
var contentLength = 0
|
|
||||||
for line in headerString.split(separator: "\r\n") {
|
|
||||||
let parts = line.split(separator: ":", maxSplits: 1)
|
|
||||||
guard parts.count == 2,
|
|
||||||
parts[0].trimmingCharacters(in: .whitespaces).caseInsensitiveCompare("Content-Length") == .orderedSame,
|
|
||||||
let value = Int(parts[1].trimmingCharacters(in: .whitespaces)) else { continue }
|
|
||||||
contentLength = value
|
|
||||||
}
|
|
||||||
|
|
||||||
let bodyStart = headerEndRange.upperBound
|
|
||||||
let availableBodyLength = buffer.distance(from: bodyStart, to: buffer.endIndex)
|
|
||||||
guard availableBodyLength >= contentLength else { return nil }
|
|
||||||
|
|
||||||
let bodyEnd = buffer.index(bodyStart, offsetBy: contentLength)
|
|
||||||
return buffer.subdata(in: bodyStart..<bodyEnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func makeHTTPResponse(statusCode: Int, statusText: String, jsonBody: Data) -> Data {
|
|
||||||
let header = "HTTP/1.1 \(statusCode) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(jsonBody.count)\r\nConnection: close\r\n\r\n"
|
|
||||||
var data = Data(header.utf8)
|
|
||||||
data.append(jsonBody)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
static func successResponse(_ text: String) -> Data {
|
|
||||||
let body = (try? JSONEncoder().encode(AskResponseBody(response: text, error: nil))) ?? Data()
|
|
||||||
return makeHTTPResponse(statusCode: 200, statusText: "OK", jsonBody: body)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func errorResponse(_ message: String, statusCode: Int = 400) -> Data {
|
|
||||||
let body = (try? JSONEncoder().encode(AskResponseBody(response: nil, error: message))) ?? Data()
|
|
||||||
let statusText = statusCode == 400 ? "Bad Request" : "Internal Server Error"
|
|
||||||
return makeHTTPResponse(statusCode: statusCode, statusText: statusText, jsonBody: body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1043,36 +1043,6 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CLI Server
|
|
||||||
|
|
||||||
/// Local Unix-socket server (CLIServerService) for one-shot, non-streaming shell access to a
|
|
||||||
/// single fixed model — deliberately separate from the chat UI's defaultProvider/defaultModel
|
|
||||||
/// so switching models in the GUI never changes shell-tool behavior, same reasoning as the
|
|
||||||
/// email handler's own dedicated provider/model pair above.
|
|
||||||
var cliServerEnabled: Bool {
|
|
||||||
get { cache["cliServerEnabled"] == "true" }
|
|
||||||
set {
|
|
||||||
cache["cliServerEnabled"] = String(newValue)
|
|
||||||
DatabaseService.shared.setSetting(key: "cliServerEnabled", value: String(newValue))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var cliServerProvider: String {
|
|
||||||
get { cache["cliServerProvider"] ?? "openrouter" }
|
|
||||||
set {
|
|
||||||
cache["cliServerProvider"] = newValue
|
|
||||||
DatabaseService.shared.setSetting(key: "cliServerProvider", value: newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var cliServerModel: String {
|
|
||||||
get { cache["cliServerModel"] ?? "" }
|
|
||||||
set {
|
|
||||||
cache["cliServerModel"] = newValue
|
|
||||||
DatabaseService.shared.setSetting(key: "cliServerModel", value: newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var emailSubjectIdentifier: String {
|
var emailSubjectIdentifier: String {
|
||||||
get { cache["emailSubjectIdentifier"] ?? "[OAIBOT]" }
|
get { cache["emailSubjectIdentifier"] ?? "[OAIBOT]" }
|
||||||
set {
|
set {
|
||||||
|
|||||||
@@ -23,44 +23,14 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Marked `nonisolated` at the type level (not per-member) since this project builds with
|
struct ThinkingVerbs {
|
||||||
/// `-default-isolation=MainActor` — without it, every member (including the static `let` verb
|
/// Get a random thinking verb with ellipsis
|
||||||
/// arrays) defaults to MainActor-isolated, which then conflicts with `nonisolated` funcs trying
|
|
||||||
/// to reference them. See CLAUDE.md's Swift 6 Compatibility section / MEMORY.md's Common Gotchas.
|
|
||||||
nonisolated struct ThinkingVerbs {
|
|
||||||
/// Get a random thinking verb with ellipsis, in the app's active display language.
|
|
||||||
/// Each language has its own hand-written set (not a translation of the English one) —
|
|
||||||
/// literal translations of English wordplay ("Waking up the hamsters") often land flat
|
|
||||||
/// or sound plain odd in another language, so every list was written to be funny/natural
|
|
||||||
/// on its own terms while covering the same rough categories (classic, technical, mystical,
|
|
||||||
/// quirky, etc).
|
|
||||||
static func random() -> String {
|
static func random() -> String {
|
||||||
verbs(for: currentLanguageCode).randomElement()! + "..."
|
verbs.randomElement()! + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mirrors how Text()/String(localized:) resolve the active language: the first of the
|
/// Collection of fun thinking verbs and phrases
|
||||||
/// user's preferred languages that this bundle actually ships a localization for (falls
|
private static let verbs = [
|
||||||
/// back to English otherwise). See CLAUDE.md's supported-language list (en, nb, sv, da, de, fr).
|
|
||||||
private static var currentLanguageCode: String {
|
|
||||||
Bundle.main.preferredLocalizations.first ?? "en"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Internal (not private) so tests can verify the language→list mapping without depending
|
|
||||||
/// on Bundle.main's runtime localization state.
|
|
||||||
static func verbs(for languageCode: String) -> [String] {
|
|
||||||
switch languageCode {
|
|
||||||
case "nb": return nbVerbs
|
|
||||||
case "sv": return svVerbs
|
|
||||||
case "da": return daVerbs
|
|
||||||
case "de": return deVerbs
|
|
||||||
case "fr": return frVerbs
|
|
||||||
default: return enVerbs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - English
|
|
||||||
|
|
||||||
private static let enVerbs = [
|
|
||||||
// Classic thinking
|
// Classic thinking
|
||||||
"Thinking",
|
"Thinking",
|
||||||
"Pondering",
|
"Pondering",
|
||||||
@@ -174,589 +144,4 @@ nonisolated struct ThinkingVerbs {
|
|||||||
"Loading brilliance",
|
"Loading brilliance",
|
||||||
"Unfurling wisdom"
|
"Unfurling wisdom"
|
||||||
]
|
]
|
||||||
|
|
||||||
// MARK: - Norwegian Bokmål
|
|
||||||
|
|
||||||
private static let nbVerbs = [
|
|
||||||
// Classic thinking
|
|
||||||
"Tenker",
|
|
||||||
"Grunner",
|
|
||||||
"Fundere",
|
|
||||||
"Reflekterer",
|
|
||||||
"Mediterer",
|
|
||||||
"Spekulerer",
|
|
||||||
"Overveier",
|
|
||||||
|
|
||||||
// Fancy/sophisticated
|
|
||||||
"Kontemplerer",
|
|
||||||
"Filosoferer",
|
|
||||||
"Resonnerer",
|
|
||||||
"Analyserer dypt",
|
|
||||||
"Bryner hjernen",
|
|
||||||
|
|
||||||
// Technical/AI themed
|
|
||||||
"Beregner",
|
|
||||||
"Prosesserer",
|
|
||||||
"Analyserer",
|
|
||||||
"Syntetiserer",
|
|
||||||
"Kalkulerer",
|
|
||||||
"Utleder",
|
|
||||||
"Kompilerer tanker",
|
|
||||||
"Kjører algoritmer",
|
|
||||||
"Knuser data",
|
|
||||||
"Parser nevroner",
|
|
||||||
|
|
||||||
// Creative/playful
|
|
||||||
"Dagdrømmer",
|
|
||||||
"Idémyldrer",
|
|
||||||
"Koker sammen tanker",
|
|
||||||
"Rører i tankegryta",
|
|
||||||
"Lar det godgjøre seg",
|
|
||||||
"Spinner nevroner",
|
|
||||||
"Varmer opp knollen",
|
|
||||||
"Klekker ut idéer",
|
|
||||||
|
|
||||||
// Mystical/fun
|
|
||||||
"Rådfører orakelet",
|
|
||||||
"Leser kaffegrut",
|
|
||||||
"Maner frem visdom",
|
|
||||||
"Trollbinder et svar",
|
|
||||||
"Kaster nevrale garn",
|
|
||||||
"Spår svaret",
|
|
||||||
|
|
||||||
// Quirky/silly
|
|
||||||
"Gjør greia",
|
|
||||||
"Trylle frem et svar",
|
|
||||||
"Aktiverer hjerneceller",
|
|
||||||
"Bøyer nevronene",
|
|
||||||
"Varmer opp transistorene",
|
|
||||||
"Rever opp synapsene",
|
|
||||||
"Kiler hjernebarken",
|
|
||||||
"Vekker hamsterne",
|
|
||||||
"Rådfører tomrommet",
|
|
||||||
"Spør den magiske 8-ballen",
|
|
||||||
|
|
||||||
// Self-aware/meta
|
|
||||||
"Later som om jeg tenker",
|
|
||||||
"Ser opptatt ut",
|
|
||||||
"Trekker ut tiden",
|
|
||||||
"Teller sauer",
|
|
||||||
"Tvinner tomlene",
|
|
||||||
"Ordner tankene",
|
|
||||||
"Leter etter riktige ord",
|
|
||||||
|
|
||||||
// Speed variations
|
|
||||||
"Tenker fort",
|
|
||||||
"Lynraskt tankearbeid",
|
|
||||||
"Dyptenkende",
|
|
||||||
"Hypertenker",
|
|
||||||
|
|
||||||
// Action-oriented
|
|
||||||
"Snekrer sammen et svar",
|
|
||||||
"Vever ord",
|
|
||||||
"Setter sammen tanker",
|
|
||||||
"Konstruerer svar",
|
|
||||||
"Formulerer idéer",
|
|
||||||
"Dirigerer nevroner",
|
|
||||||
"Koreograferer bits",
|
|
||||||
|
|
||||||
// Whimsical
|
|
||||||
"Får en åpenbaring",
|
|
||||||
"Kobler prikkene",
|
|
||||||
"Følger tråden",
|
|
||||||
"Jager tanker",
|
|
||||||
"Gjeter idéer",
|
|
||||||
"Reder ut nevronfloken",
|
|
||||||
|
|
||||||
// Time-based
|
|
||||||
"Tar en tenkepause",
|
|
||||||
"Puster rolig",
|
|
||||||
"Tar fem",
|
|
||||||
"Samler tankene",
|
|
||||||
"Henter pusten",
|
|
||||||
|
|
||||||
// Just plain weird
|
|
||||||
"Piper og beregner",
|
|
||||||
"Aktiverer hjernemodus",
|
|
||||||
"Slår på smartheten",
|
|
||||||
"Laster ned tanker",
|
|
||||||
"Bufrer intelligens",
|
|
||||||
"Laster inn genialitet",
|
|
||||||
"Folder ut visdom",
|
|
||||||
"Roter i idébanken",
|
|
||||||
"Sorterer tankene",
|
|
||||||
"Venter på et lyn av genialitet",
|
|
||||||
"Plager hjernen med det",
|
|
||||||
"Kokende av idéer",
|
|
||||||
"Grubler høyt",
|
|
||||||
"Filosoferer over saken"
|
|
||||||
]
|
|
||||||
|
|
||||||
// MARK: - Swedish
|
|
||||||
|
|
||||||
private static let svVerbs = [
|
|
||||||
// Classic thinking
|
|
||||||
"Tänker",
|
|
||||||
"Funderar",
|
|
||||||
"Grubblar",
|
|
||||||
"Reflekterar",
|
|
||||||
"Mediterar",
|
|
||||||
"Spekulerar",
|
|
||||||
"Överväger",
|
|
||||||
|
|
||||||
// Fancy/sophisticated
|
|
||||||
"Kontemplerar",
|
|
||||||
"Filosoferar",
|
|
||||||
"Resonerar",
|
|
||||||
"Djupanalyserar",
|
|
||||||
"Vässar hjärnan",
|
|
||||||
|
|
||||||
// Technical/AI themed
|
|
||||||
"Beräknar",
|
|
||||||
"Processar",
|
|
||||||
"Analyserar",
|
|
||||||
"Syntetiserar",
|
|
||||||
"Kalkylerar",
|
|
||||||
"Härleder",
|
|
||||||
"Kompilerar tankar",
|
|
||||||
"Kör algoritmer",
|
|
||||||
"Mosar data",
|
|
||||||
"Parsar nervceller",
|
|
||||||
|
|
||||||
// Creative/playful
|
|
||||||
"Dagdrömmer",
|
|
||||||
"Idékläcker",
|
|
||||||
"Kokar ihop tankar",
|
|
||||||
"Rör i tankegrytan",
|
|
||||||
"Låter det mogna",
|
|
||||||
"Snurrar nervceller",
|
|
||||||
"Värmer upp knoppen",
|
|
||||||
"Ruvar på idéer",
|
|
||||||
|
|
||||||
// Mystical/fun
|
|
||||||
"Rådfrågar oraklet",
|
|
||||||
"Läser i kaffesumpen",
|
|
||||||
"Frammanar visdom",
|
|
||||||
"Trollbinder ett svar",
|
|
||||||
"Kastar neurala nät",
|
|
||||||
"Spår svaret",
|
|
||||||
|
|
||||||
// Quirky/silly
|
|
||||||
"Gör grejen",
|
|
||||||
"Trollar fram ett svar",
|
|
||||||
"Aktiverar hjärnceller",
|
|
||||||
"Böjer nervcellerna",
|
|
||||||
"Värmer upp transistorerna",
|
|
||||||
"Varvar upp synapserna",
|
|
||||||
"Kittlar hjärnbarken",
|
|
||||||
"Väcker hamstrarna",
|
|
||||||
"Rådfrågar tomrummet",
|
|
||||||
"Frågar magiska åttan",
|
|
||||||
|
|
||||||
// Self-aware/meta
|
|
||||||
"Låtsas tänka",
|
|
||||||
"Ser upptagen ut",
|
|
||||||
"Drar ut på tiden",
|
|
||||||
"Räknar får",
|
|
||||||
"Tvinnar tummarna",
|
|
||||||
"Ordnar tankarna",
|
|
||||||
"Letar rätt ord",
|
|
||||||
|
|
||||||
// Speed variations
|
|
||||||
"Tänker snabbt",
|
|
||||||
"Blixtsnabb tankeverksamhet",
|
|
||||||
"Djuptänkande",
|
|
||||||
"Hypertänker",
|
|
||||||
|
|
||||||
// Action-oriented
|
|
||||||
"Snickrar ihop ett svar",
|
|
||||||
"Väver ord",
|
|
||||||
"Sätter ihop tankar",
|
|
||||||
"Konstruerar svar",
|
|
||||||
"Formulerar idéer",
|
|
||||||
"Dirigerar nervceller",
|
|
||||||
"Koreograferar bitar",
|
|
||||||
|
|
||||||
// Whimsical
|
|
||||||
"Får en aha-upplevelse",
|
|
||||||
"Kopplar ihop punkterna",
|
|
||||||
"Följer tråden",
|
|
||||||
"Jagar tankar",
|
|
||||||
"Vallar idéer",
|
|
||||||
"Reder ut nervtrasslet",
|
|
||||||
|
|
||||||
// Time-based
|
|
||||||
"Tar en tankepaus",
|
|
||||||
"Andas lugnt",
|
|
||||||
"Tar fem",
|
|
||||||
"Samlar tankarna",
|
|
||||||
"Hämtar andan",
|
|
||||||
|
|
||||||
// Just plain weird
|
|
||||||
"Piper och beräknar",
|
|
||||||
"Aktiverar hjärnläge",
|
|
||||||
"Slår på smartheten",
|
|
||||||
"Laddar ner tankar",
|
|
||||||
"Buffrar intelligens",
|
|
||||||
"Laddar in briljans",
|
|
||||||
"Vecklar ut visdom",
|
|
||||||
"Rotar i idébanken",
|
|
||||||
"Sorterar tankarna",
|
|
||||||
"Väntar på ett geniblixt",
|
|
||||||
"Bryr hjärnan med det",
|
|
||||||
"Kokar av idéer",
|
|
||||||
"Grubblar högt",
|
|
||||||
"Filosoferar över saken"
|
|
||||||
]
|
|
||||||
|
|
||||||
// MARK: - Danish
|
|
||||||
|
|
||||||
private static let daVerbs = [
|
|
||||||
// Classic thinking
|
|
||||||
"Tænker",
|
|
||||||
"Grunder",
|
|
||||||
"Grubler",
|
|
||||||
"Reflekterer",
|
|
||||||
"Mediterer",
|
|
||||||
"Spekulerer",
|
|
||||||
"Overvejer",
|
|
||||||
|
|
||||||
// Fancy/sophisticated
|
|
||||||
"Kontemplerer",
|
|
||||||
"Filosoferer",
|
|
||||||
"Ræsonnerer",
|
|
||||||
"Dybdeanalyserer",
|
|
||||||
"Skærper hjernen",
|
|
||||||
|
|
||||||
// Technical/AI themed
|
|
||||||
"Beregner",
|
|
||||||
"Processerer",
|
|
||||||
"Analyserer",
|
|
||||||
"Syntetiserer",
|
|
||||||
"Kalkulerer",
|
|
||||||
"Udleder",
|
|
||||||
"Kompilerer tanker",
|
|
||||||
"Kører algoritmer",
|
|
||||||
"Knuser data",
|
|
||||||
"Parser neuroner",
|
|
||||||
|
|
||||||
// Creative/playful
|
|
||||||
"Dagdrømmer",
|
|
||||||
"Idémylrer",
|
|
||||||
"Koger tanker sammen",
|
|
||||||
"Rører i tankegryden",
|
|
||||||
"Lader det simre",
|
|
||||||
"Snurrer neuroner",
|
|
||||||
"Varmer knolden op",
|
|
||||||
"Udruger idéer",
|
|
||||||
|
|
||||||
// Mystical/fun
|
|
||||||
"Rådspørger orakelet",
|
|
||||||
"Læser kaffegrums",
|
|
||||||
"Fremmaner visdom",
|
|
||||||
"Tryller et svar frem",
|
|
||||||
"Kaster neurale net",
|
|
||||||
"Spår svaret",
|
|
||||||
|
|
||||||
// Quirky/silly
|
|
||||||
"Gør tingen",
|
|
||||||
"Trylle-fremkalder et svar",
|
|
||||||
"Aktiverer hjerneceller",
|
|
||||||
"Bøjer neuronerne",
|
|
||||||
"Varmer transistorerne op",
|
|
||||||
"Ruller synapserne op",
|
|
||||||
"Kilder hjernebarken",
|
|
||||||
"Vækker hamsterne",
|
|
||||||
"Rådspørger tomrummet",
|
|
||||||
"Spørger den magiske 8-tal",
|
|
||||||
|
|
||||||
// Self-aware/meta
|
|
||||||
"Lader som om jeg tænker",
|
|
||||||
"Ser optaget ud",
|
|
||||||
"Trækker tiden ud",
|
|
||||||
"Tæller får",
|
|
||||||
"Snor tommelfingrene",
|
|
||||||
"Ordner tankerne",
|
|
||||||
"Leder efter de rette ord",
|
|
||||||
|
|
||||||
// Speed variations
|
|
||||||
"Tænker hurtigt",
|
|
||||||
"Lynhurtig tænkning",
|
|
||||||
"Dybttænkende",
|
|
||||||
"Hypertænker",
|
|
||||||
|
|
||||||
// Action-oriented
|
|
||||||
"Snedkererer et svar",
|
|
||||||
"Væver ord",
|
|
||||||
"Samler tanker",
|
|
||||||
"Konstruerer svar",
|
|
||||||
"Formulerer idéer",
|
|
||||||
"Dirigerer neuroner",
|
|
||||||
"Koreograferer bits",
|
|
||||||
|
|
||||||
// Whimsical
|
|
||||||
"Får en åbenbaring",
|
|
||||||
"Forbinder prikkerne",
|
|
||||||
"Følger tråden",
|
|
||||||
"Jagter tanker",
|
|
||||||
"Vogter idéer",
|
|
||||||
"Reder neurontrådene ud",
|
|
||||||
|
|
||||||
// Time-based
|
|
||||||
"Tager en tænkepause",
|
|
||||||
"Trækker vejret roligt",
|
|
||||||
"Tager fem",
|
|
||||||
"Samler tankerne",
|
|
||||||
"Henter vejret",
|
|
||||||
|
|
||||||
// Just plain weird
|
|
||||||
"Bipper og beregner",
|
|
||||||
"Aktiverer hjernemodus",
|
|
||||||
"Tænder for kløgt",
|
|
||||||
"Downloader tanker",
|
|
||||||
"Bufrer intelligens",
|
|
||||||
"Indlæser genialitet",
|
|
||||||
"Folder visdom ud",
|
|
||||||
"Roder i idébanken",
|
|
||||||
"Sorterer tankerne",
|
|
||||||
"Venter på et lyn af genialitet",
|
|
||||||
"Plager hjernen med det",
|
|
||||||
"Kogende af idéer",
|
|
||||||
"Grunder højt",
|
|
||||||
"Filosoferer over sagen"
|
|
||||||
]
|
|
||||||
|
|
||||||
// MARK: - German
|
|
||||||
|
|
||||||
private static let deVerbs = [
|
|
||||||
// Classic thinking
|
|
||||||
"Denkt nach",
|
|
||||||
"Grübelt",
|
|
||||||
"Sinniert",
|
|
||||||
"Reflektiert",
|
|
||||||
"Meditiert",
|
|
||||||
"Spekuliert",
|
|
||||||
"Überlegt",
|
|
||||||
|
|
||||||
// Fancy/sophisticated
|
|
||||||
"Kontempliert",
|
|
||||||
"Philosophiert",
|
|
||||||
"Räsoniert",
|
|
||||||
"Analysiert tiefgründig",
|
|
||||||
"Schärft das Denkvermögen",
|
|
||||||
|
|
||||||
// Technical/AI themed
|
|
||||||
"Berechnet",
|
|
||||||
"Verarbeitet",
|
|
||||||
"Analysiert",
|
|
||||||
"Synthetisiert",
|
|
||||||
"Kalkuliert",
|
|
||||||
"Leitet ab",
|
|
||||||
"Kompiliert Gedanken",
|
|
||||||
"Lässt Algorithmen laufen",
|
|
||||||
"Zermalmt Daten",
|
|
||||||
"Parst Neuronen",
|
|
||||||
|
|
||||||
// Creative/playful
|
|
||||||
"Tagträumt",
|
|
||||||
"Brainstormt",
|
|
||||||
"Braut Gedanken zusammen",
|
|
||||||
"Rührt im Gedankentopf",
|
|
||||||
"Lässt es köcheln",
|
|
||||||
"Dreht Neuronen",
|
|
||||||
"Wärmt die Denkerstube auf",
|
|
||||||
"Brütet Ideen aus",
|
|
||||||
|
|
||||||
// Mystical/fun
|
|
||||||
"Befragt das Orakel",
|
|
||||||
"Liest im Kaffeesatz",
|
|
||||||
"Beschwört Weisheit",
|
|
||||||
"Zaubert eine Antwort",
|
|
||||||
"Wirft neuronale Netze aus",
|
|
||||||
"Weissagt die Antwort",
|
|
||||||
|
|
||||||
// Quirky/silly
|
|
||||||
"Macht sein Ding",
|
|
||||||
"Zaubert eine Antwort herbei",
|
|
||||||
"Aktiviert Gehirnzellen",
|
|
||||||
"Biegt Neuronen",
|
|
||||||
"Wärmt die Transistoren auf",
|
|
||||||
"Dreht die Synapsen hoch",
|
|
||||||
"Kitzelt die Großhirnrinde",
|
|
||||||
"Weckt die Hamster",
|
|
||||||
"Befragt die Leere",
|
|
||||||
"Fragt die magische Kugel",
|
|
||||||
|
|
||||||
// Self-aware/meta
|
|
||||||
"Tut nur so, als würde es denken",
|
|
||||||
"Sieht beschäftigt aus",
|
|
||||||
"Zieht Zeit",
|
|
||||||
"Zählt Schafe",
|
|
||||||
"Dreht Däumchen",
|
|
||||||
"Ordnet die Gedanken",
|
|
||||||
"Sucht die richtigen Worte",
|
|
||||||
|
|
||||||
// Speed variations
|
|
||||||
"Denkt schnell",
|
|
||||||
"Blitzschnelles Denken",
|
|
||||||
"Tiefes Nachdenken",
|
|
||||||
"Hyperdenken",
|
|
||||||
|
|
||||||
// Action-oriented
|
|
||||||
"Zimmert eine Antwort",
|
|
||||||
"Webt Worte",
|
|
||||||
"Fügt Gedanken zusammen",
|
|
||||||
"Konstruiert Antworten",
|
|
||||||
"Formuliert Ideen",
|
|
||||||
"Dirigiert Neuronen",
|
|
||||||
"Choreografiert Bits",
|
|
||||||
|
|
||||||
// Whimsical
|
|
||||||
"Hat eine Eingebung",
|
|
||||||
"Verbindet die Punkte",
|
|
||||||
"Folgt dem roten Faden",
|
|
||||||
"Jagt Gedanken",
|
|
||||||
"Hütet Ideen",
|
|
||||||
"Entwirrt die Neuronen",
|
|
||||||
|
|
||||||
// Time-based
|
|
||||||
"Nimmt sich einen Moment",
|
|
||||||
"Atmet tief durch",
|
|
||||||
"Macht kurz Pause",
|
|
||||||
"Sammelt die Gedanken",
|
|
||||||
"Holt Luft",
|
|
||||||
|
|
||||||
// Just plain weird
|
|
||||||
"Piept und rechnet",
|
|
||||||
"Aktiviert den Gehirnmodus",
|
|
||||||
"Schaltet auf Schlaumodus",
|
|
||||||
"Lädt Gedanken herunter",
|
|
||||||
"Puffert Intelligenz",
|
|
||||||
"Lädt Genialität",
|
|
||||||
"Entfaltet Weisheit",
|
|
||||||
"Wühlt in der Ideenkiste",
|
|
||||||
"Sortiert die Gedanken",
|
|
||||||
"Wartet auf einen Geistesblitz",
|
|
||||||
"Quält das Gehirn damit",
|
|
||||||
"Kocht vor Ideen",
|
|
||||||
"Grübelt laut",
|
|
||||||
"Philosophiert über die Sache"
|
|
||||||
]
|
|
||||||
|
|
||||||
// MARK: - French
|
|
||||||
|
|
||||||
private static let frVerbs = [
|
|
||||||
// Classic thinking
|
|
||||||
"Réfléchit",
|
|
||||||
"Songe",
|
|
||||||
"Médite",
|
|
||||||
"Contemple",
|
|
||||||
"Spécule",
|
|
||||||
"Délibère",
|
|
||||||
"Rumine",
|
|
||||||
|
|
||||||
// Fancy/sophisticated
|
|
||||||
"Philosophise",
|
|
||||||
"Raisonne",
|
|
||||||
"Cogite",
|
|
||||||
"Analyse en profondeur",
|
|
||||||
"Aiguise ses neurones",
|
|
||||||
|
|
||||||
// Technical/AI themed
|
|
||||||
"Calcule",
|
|
||||||
"Traite",
|
|
||||||
"Analyse",
|
|
||||||
"Synthétise",
|
|
||||||
"Déduit",
|
|
||||||
"Compile des pensées",
|
|
||||||
"Fait tourner des algorithmes",
|
|
||||||
"Broie des données",
|
|
||||||
"Parse des neurones",
|
|
||||||
"Chiffre les possibilités",
|
|
||||||
|
|
||||||
// Creative/playful
|
|
||||||
"Rêvasse",
|
|
||||||
"Brainstorme",
|
|
||||||
"Mijote des idées",
|
|
||||||
"Remue la marmite à idées",
|
|
||||||
"Laisse mijoter",
|
|
||||||
"Fait tourner ses neurones",
|
|
||||||
"Chauffe la matière grise",
|
|
||||||
"Couve une idée",
|
|
||||||
|
|
||||||
// Mystical/fun
|
|
||||||
"Consulte l'oracle",
|
|
||||||
"Lit dans le marc de café",
|
|
||||||
"Invoque la sagesse",
|
|
||||||
"Conjure une réponse",
|
|
||||||
"Lance des filets neuronaux",
|
|
||||||
"Prédit la réponse",
|
|
||||||
|
|
||||||
// Quirky/silly
|
|
||||||
"Fait son truc",
|
|
||||||
"Fait apparaître une réponse par magie",
|
|
||||||
"Active ses neurones",
|
|
||||||
"Étire ses neurones",
|
|
||||||
"Chauffe les transistors",
|
|
||||||
"Emballe les synapses",
|
|
||||||
"Chatouille le cortex",
|
|
||||||
"Réveille les hamsters",
|
|
||||||
"Consulte le vide",
|
|
||||||
"Interroge la boule magique",
|
|
||||||
|
|
||||||
// Self-aware/meta
|
|
||||||
"Fait semblant de réfléchir",
|
|
||||||
"A l'air occupé",
|
|
||||||
"Fait durer le suspense",
|
|
||||||
"Compte les moutons",
|
|
||||||
"Se tourne les pouces",
|
|
||||||
"Met de l'ordre dans ses pensées",
|
|
||||||
"Cherche les mots justes",
|
|
||||||
|
|
||||||
// Speed variations
|
|
||||||
"Réfléchit vite",
|
|
||||||
"Pensée éclair",
|
|
||||||
"Réflexion profonde",
|
|
||||||
"Hyper-réflexion",
|
|
||||||
|
|
||||||
// Action-oriented
|
|
||||||
"Bricole une réponse",
|
|
||||||
"Tisse des mots",
|
|
||||||
"Assemble des idées",
|
|
||||||
"Construit une réponse",
|
|
||||||
"Formule des idées",
|
|
||||||
"Dirige ses neurones",
|
|
||||||
"Chorégraphie des bits",
|
|
||||||
|
|
||||||
// Whimsical
|
|
||||||
"A une révélation",
|
|
||||||
"Relie les points",
|
|
||||||
"Suit le fil",
|
|
||||||
"Traque une pensée",
|
|
||||||
"Rassemble ses idées",
|
|
||||||
"Démêle ses neurones",
|
|
||||||
|
|
||||||
// Time-based
|
|
||||||
"Prend un instant",
|
|
||||||
"Respire un grand coup",
|
|
||||||
"Fait une pause",
|
|
||||||
"Rassemble ses pensées",
|
|
||||||
"Reprend son souffle",
|
|
||||||
|
|
||||||
// Just plain weird
|
|
||||||
"Bip bip, ça calcule",
|
|
||||||
"Active le mode cerveau",
|
|
||||||
"Passe en mode intelligent",
|
|
||||||
"Télécharge des pensées",
|
|
||||||
"Met l'intelligence en cache",
|
|
||||||
"Charge du génie",
|
|
||||||
"Déploie sa sagesse",
|
|
||||||
"Fouille sa boîte à idées",
|
|
||||||
"Trie ses pensées",
|
|
||||||
"Attend un éclair de génie",
|
|
||||||
"Torture son cerveau",
|
|
||||||
"Bouillonne d'idées",
|
|
||||||
"Rumine à voix haute",
|
|
||||||
"Philosophise sur la question"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,5 +152,4 @@ enum Log {
|
|||||||
nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui")
|
nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui")
|
||||||
nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
|
nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
|
||||||
nonisolated static let extMcp = AppLogger(subsystem: subsystem, category: "ext-mcp")
|
nonisolated static let extMcp = AppLogger(subsystem: subsystem, category: "ext-mcp")
|
||||||
nonisolated static let cli = AppLogger(subsystem: subsystem, category: "cli")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,13 +120,6 @@ class ChatViewModel {
|
|||||||
var messages: [Message] = []
|
var messages: [Message] = []
|
||||||
var inputText: String = ""
|
var inputText: String = ""
|
||||||
var isGenerating: Bool = false
|
var isGenerating: Bool = false
|
||||||
/// Live "what's happening right now" line shown under ProcessingIndicator's thinking verb
|
|
||||||
/// while a tool-calling loop is running — e.g. "🔧 Calling: read_file". Replaced in place each
|
|
||||||
/// round rather than appending a new chat message, so a long multi-round tool chain doesn't
|
|
||||||
/// stack up a growing list of rows. Not persisted; nil whenever no tool round is in flight. The
|
|
||||||
/// full chain is still recorded, just collapsed into one expandable summary message once the
|
|
||||||
/// loop finishes — see generateAIResponseWithTools's use of allToolCallDetails.
|
|
||||||
var currentToolActivity: String? = nil
|
|
||||||
var sessionStats = SessionStats()
|
var sessionStats = SessionStats()
|
||||||
var selectedModel: ModelInfo?
|
var selectedModel: ModelInfo?
|
||||||
var currentProvider: Settings.Provider = .openrouter
|
var currentProvider: Settings.Provider = .openrouter
|
||||||
@@ -1596,10 +1589,6 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
streamingTask = Task {
|
streamingTask = Task {
|
||||||
let startTime = Date()
|
let startTime = Date()
|
||||||
var wasCancelled = false
|
var wasCancelled = false
|
||||||
// Accumulates ToolCallDetail entries across every round of this tool-calling loop —
|
|
||||||
// collapsed into a single expandable summary message once the loop exits (success,
|
|
||||||
// cancellation, or error), instead of one persisted message per round.
|
|
||||||
var allToolCallDetails: [ToolCallDetail] = []
|
|
||||||
do {
|
do {
|
||||||
// Include web_search tool when online mode is on (not needed for OpenRouter — it handles search via :online suffix)
|
// Include web_search tool when online mode is on (not needed for OpenRouter — it handles search via :online suffix)
|
||||||
let tools = mcp.getToolSchemas(onlineMode: onlineMode && currentProvider != .openrouter)
|
let tools = mcp.getToolSchemas(onlineMode: onlineMode && currentProvider != .openrouter)
|
||||||
@@ -1752,16 +1741,15 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show what tools the model is calling as a transient status line rather than
|
// Show what tools the model is calling
|
||||||
// a new chat message — see currentToolActivity's doc comment.
|
|
||||||
let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ")
|
let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ")
|
||||||
currentToolActivity = String(localized: "🔧 Calling: \(toolNames)")
|
let toolMsgId = showSystemMessage("🔧 Calling: \(toolNames)")
|
||||||
|
|
||||||
// Initialise detail entries with inputs (results fill in below); appended to
|
// Initialise detail entries with inputs (results fill in below)
|
||||||
// allToolCallDetails once this round finishes executing.
|
|
||||||
var toolDetails: [ToolCallDetail] = toolCalls.map { tc in
|
var toolDetails: [ToolCallDetail] = toolCalls.map { tc in
|
||||||
ToolCallDetail(name: tc.functionName, input: tc.arguments, result: nil)
|
ToolCallDetail(name: tc.functionName, input: tc.arguments, result: nil)
|
||||||
}
|
}
|
||||||
|
updateToolCallMessage(id: toolMsgId, details: toolDetails)
|
||||||
|
|
||||||
let usingTextCalls = !textCalls.isEmpty
|
let usingTextCalls = !textCalls.isEmpty
|
||||||
|
|
||||||
@@ -1812,9 +1800,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
resultJSON = "{\"error\": \"Failed to serialize result\"}"
|
resultJSON = "{\"error\": \"Failed to serialize result\"}"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the result so the collapsed summary message can show it once
|
// Update the detail entry with the result so the UI can show it
|
||||||
// the whole tool-calling loop finishes.
|
|
||||||
toolDetails[i].result = resultJSON
|
toolDetails[i].result = resultJSON
|
||||||
|
updateToolCallMessage(id: toolMsgId, details: toolDetails)
|
||||||
|
|
||||||
if usingTextCalls {
|
if usingTextCalls {
|
||||||
// Inject results as a user message for text-call models
|
// Inject results as a user message for text-call models
|
||||||
@@ -1834,8 +1822,6 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
apiMessages.append(["role": "user", "content": combined])
|
apiMessages.append(["role": "user", "content": combined])
|
||||||
}
|
}
|
||||||
|
|
||||||
allToolCallDetails.append(contentsOf: toolDetails)
|
|
||||||
|
|
||||||
// If this was the last iteration, note it
|
// If this was the last iteration, note it
|
||||||
if iteration == maxIterations - 1 {
|
if iteration == maxIterations - 1 {
|
||||||
hitIterationLimit = true // We're exiting with pending tool calls
|
hitIterationLimit = true // We're exiting with pending tool calls
|
||||||
@@ -1848,8 +1834,6 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
wasCancelled = true
|
wasCancelled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
flushToolCallSummary(allToolCallDetails)
|
|
||||||
|
|
||||||
// If we hit the iteration limit or the model returned no text at all, silently
|
// If we hit the iteration limit or the model returned no text at all, silently
|
||||||
// nudge a follow-up turn instead of showing a placeholder/blank bubble.
|
// nudge a follow-up turn instead of showing a placeholder/blank bubble.
|
||||||
let willAutoContinue = (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled
|
let willAutoContinue = (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled
|
||||||
@@ -1907,10 +1891,6 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
} catch {
|
} catch {
|
||||||
let responseTime = Date().timeIntervalSince(startTime)
|
let responseTime = Date().timeIntervalSince(startTime)
|
||||||
|
|
||||||
// Same collapse as the success path — any tool rounds that completed before the
|
|
||||||
// error/cancellation are still worth keeping a record of.
|
|
||||||
flushToolCallSummary(allToolCallDetails)
|
|
||||||
|
|
||||||
// Check if this was a cancellation
|
// Check if this was a cancellation
|
||||||
let isCancellation = Task.isCancelled || wasCancelled || error is CancellationError
|
let isCancellation = Task.isCancelled || wasCancelled || error is CancellationError
|
||||||
|
|
||||||
@@ -1956,16 +1936,6 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears the live tool-activity status line and, if any tool calls actually ran, collapses
|
|
||||||
/// them into a single persisted, expandable summary message — used on every exit path of
|
|
||||||
/// generateAIResponseWithTools's loop (success, cancellation, or error).
|
|
||||||
private func flushToolCallSummary(_ details: [ToolCallDetail]) {
|
|
||||||
currentToolActivity = nil
|
|
||||||
guard !details.isEmpty else { return }
|
|
||||||
let summaryId = showSystemMessage("🔧 Used ^[\(details.count) tool call](inflect: true)")
|
|
||||||
updateToolCallMessage(id: summaryId, details: details)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Error Helpers
|
// MARK: - Error Helpers
|
||||||
|
|
||||||
private func friendlyErrorMessage(from error: Error) -> String {
|
private func friendlyErrorMessage(from error: Error) -> String {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ struct ChatView: View {
|
|||||||
|
|
||||||
// Processing indicator
|
// Processing indicator
|
||||||
if viewModel.isGenerating && viewModel.messages.last?.isStreaming != true {
|
if viewModel.isGenerating && viewModel.messages.last?.isStreaming != true {
|
||||||
ProcessingIndicator(toolActivity: viewModel.currentToolActivity)
|
ProcessingIndicator()
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,43 +156,30 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ProcessingIndicator: View {
|
struct ProcessingIndicator: View {
|
||||||
/// Current tool round's status (e.g. "🔧 Calling: read_file"), replaced in place each round
|
|
||||||
/// rather than the chat accumulating a new row per round — see ChatViewModel.currentToolActivity.
|
|
||||||
let toolActivity: String?
|
|
||||||
@State private var animating = false
|
@State private var animating = false
|
||||||
@State private var thinkingText = ThinkingVerbs.random()
|
@State private var thinkingText = ThinkingVerbs.random()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
HStack(spacing: 8) {
|
||||||
HStack(spacing: 8) {
|
Text(thinkingText)
|
||||||
Text(thinkingText)
|
.font(.system(size: 14, weight: .medium))
|
||||||
.font(.system(size: 14, weight: .medium))
|
.foregroundColor(.confabSecondary)
|
||||||
.foregroundColor(.confabSecondary)
|
|
||||||
|
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
ForEach(0..<3) { index in
|
ForEach(0..<3) { index in
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Color.confabSecondary)
|
.fill(Color.confabSecondary)
|
||||||
.frame(width: 6, height: 6)
|
.frame(width: 6, height: 6)
|
||||||
.scaleEffect(animating ? 1.0 : 0.5)
|
.scaleEffect(animating ? 1.0 : 0.5)
|
||||||
.animation(
|
.animation(
|
||||||
.easeInOut(duration: 0.6)
|
.easeInOut(duration: 0.6)
|
||||||
.repeatForever()
|
.repeatForever()
|
||||||
.delay(Double(index) * 0.2),
|
.delay(Double(index) * 0.2),
|
||||||
value: animating
|
value: animating
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let toolActivity {
|
|
||||||
Text(toolActivity)
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundColor(.confabSecondary.opacity(0.75))
|
|
||||||
.transition(.opacity)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.15), value: toolActivity)
|
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(Color.confabSecondary.opacity(0.05))
|
.background(Color.confabSecondary.opacity(0.05))
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ enum SyncState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var tooltipText: LocalizedStringKey {
|
var tooltipText: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .disabled:
|
case .disabled:
|
||||||
return "Auto-sync disabled"
|
return "Auto-sync disabled"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ struct ModelInfoView: View {
|
|||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@Bindable private var settings = SettingsService.shared
|
@Bindable private var settings = SettingsService.shared
|
||||||
|
@State private var isDescriptionExpanded = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -72,13 +73,6 @@ struct ModelInfoView: View {
|
|||||||
infoRow("Released", releaseDate.formatted(date: .abbreviated, time: .omitted))
|
infoRow("Released", releaseDate.formatted(date: .abbreviated, time: .omitted))
|
||||||
}
|
}
|
||||||
if let desc = model.description {
|
if let desc = model.description {
|
||||||
// Always shown in full, no truncate/expand toggle — Text with a lineLimit
|
|
||||||
// nested inside this view's ScrollView doesn't reliably compute wrapping/
|
|
||||||
// truncation (a well-documented SwiftUI/AppKit quirk: without a fixedSize
|
|
||||||
// hint it hard-clips mid-word with no ellipsis; with one, sibling views in
|
|
||||||
// the same VStack — like the former "More…" button — can silently fail to
|
|
||||||
// lay out). The modal itself already scrolls, so a long description just
|
|
||||||
// means more scrolling, which sidesteps the whole bug class.
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Description")
|
Text("Description")
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.subheadline.weight(.medium))
|
||||||
@@ -86,7 +80,18 @@ struct ModelInfoView: View {
|
|||||||
Text(desc)
|
Text(desc)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
|
.lineLimit(isDescriptionExpanded ? nil : 4)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
|
if desc.count > 250 {
|
||||||
|
Button(isDescriptionExpanded ? "Less" : "More…") {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
isDescriptionExpanded.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,11 +113,6 @@ struct SettingsView: View {
|
|||||||
@State private var isTestingEmailConnection = false
|
@State private var isTestingEmailConnection = false
|
||||||
@State private var emailConnectionTestResult: String?
|
@State private var emailConnectionTestResult: String?
|
||||||
|
|
||||||
// CLI server state
|
|
||||||
@State private var showCLIModelSelector = false
|
|
||||||
@State private var cliAvailableModels: [ModelInfo] = []
|
|
||||||
@State private var isLoadingCLIModels = false
|
|
||||||
|
|
||||||
private let labelWidth: CGFloat = 160
|
private let labelWidth: CGFloat = 160
|
||||||
|
|
||||||
// Default system prompt - generic for all models
|
// Default system prompt - generic for all models
|
||||||
@@ -916,10 +911,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
Divider()
|
Divider()
|
||||||
externalMCPSection
|
externalMCPSection
|
||||||
|
|
||||||
// MARK: CLI Access
|
|
||||||
Divider()
|
|
||||||
cliServerSection
|
|
||||||
|
|
||||||
// MARK: Personal Data
|
// MARK: Personal Data
|
||||||
if !PersonalDataTools.isHiddenPendingAppleFix {
|
if !PersonalDataTools.isHiddenPendingAppleFix {
|
||||||
Divider()
|
Divider()
|
||||||
@@ -1104,160 +1095,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CLI Access Section
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var cliServerSection: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "terminal")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(.green)
|
|
||||||
Text("CLI Access")
|
|
||||||
.font(.system(size: 18, weight: .semibold))
|
|
||||||
}
|
|
||||||
Text("Expose a local socket so a shell command (like a zsh \"ai\" function) can get a one-shot text reply from a single fixed model, without opening the app window. Confab must be running.")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
.padding(.bottom, 4)
|
|
||||||
.onAppear {
|
|
||||||
Task { await loadCLIModels() }
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showCLIModelSelector) {
|
|
||||||
ModelSelectorView(
|
|
||||||
models: cliAvailableModels.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending },
|
|
||||||
selectedModel: cliAvailableModels.first(where: { $0.id == settingsService.cliServerModel })
|
|
||||||
) { selectedModel in
|
|
||||||
settingsService.cliServerModel = selectedModel.id
|
|
||||||
showCLIModelSelector = false
|
|
||||||
CLIServerService.shared.restart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
sectionHeader("Status")
|
|
||||||
formSection {
|
|
||||||
row("Enable CLI Access") {
|
|
||||||
Toggle("", isOn: $settingsService.cliServerEnabled)
|
|
||||||
.toggleStyle(.switch)
|
|
||||||
.onChange(of: settingsService.cliServerEnabled) {
|
|
||||||
CLIServerService.shared.restart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if settingsService.cliServerEnabled {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
sectionHeader("Model")
|
|
||||||
formSection {
|
|
||||||
row("Provider") {
|
|
||||||
Picker("", selection: $settingsService.cliServerProvider) {
|
|
||||||
ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { provider in
|
|
||||||
Text(provider.displayName).tag(provider.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
.frame(width: 250)
|
|
||||||
.onChange(of: settingsService.cliServerProvider) {
|
|
||||||
Task { await loadCLIModels() }
|
|
||||||
CLIServerService.shared.restart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rowDivider()
|
|
||||||
row("Model") {
|
|
||||||
if isLoadingCLIModels {
|
|
||||||
ProgressView().scaleEffect(0.7).frame(width: 250, alignment: .leading)
|
|
||||||
} else if cliAvailableModels.isEmpty {
|
|
||||||
Text("No models available")
|
|
||||||
.font(.system(size: settingsService.guiTextSize))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.frame(width: 250, alignment: .leading)
|
|
||||||
} else {
|
|
||||||
Button(action: { showCLIModelSelector = true }) {
|
|
||||||
HStack {
|
|
||||||
Text(cliAvailableModels.first(where: { $0.id == settingsService.cliServerModel })?.name ?? "Select model...")
|
|
||||||
.font(.system(size: settingsService.guiTextSize))
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: "chevron.up.chevron.down")
|
|
||||||
.font(.system(size: 10))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.frame(width: 250)
|
|
||||||
.background(Color.secondary.opacity(0.1))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
sectionHeader("Shell Function")
|
|
||||||
Text("Add this to your ~/.zshrc, then run `ai \"your prompt\"` in Terminal:")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text(Self.cliShellFunctionSnippet)
|
|
||||||
.font(.system(size: 11, design: .monospaced))
|
|
||||||
.foregroundStyle(.primary)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
.padding(10)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(Color.secondary.opacity(0.08))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
Text("Requires jq (brew install jq).")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let cliShellFunctionSnippet = """
|
|
||||||
ai() {
|
|
||||||
curl -s --unix-socket "$HOME/Library/Application Support/oAI/cli.sock" \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-d "$(jq -n --arg p "$*" '{prompt: $p}')" \\
|
|
||||||
http://localhost/ | jq -r 'if .error then "Error: " + .error else .response end'
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
private func loadCLIModels() async {
|
|
||||||
guard settingsService.cliServerEnabled else {
|
|
||||||
cliAvailableModels = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let providerRawValue = settingsService.cliServerProvider
|
|
||||||
guard let providerType = Settings.Provider(rawValue: providerRawValue),
|
|
||||||
let provider = ProviderRegistry.shared.getProvider(for: providerType) else {
|
|
||||||
cliAvailableModels = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoadingCLIModels = true
|
|
||||||
defer { isLoadingCLIModels = false }
|
|
||||||
|
|
||||||
do {
|
|
||||||
let models = try await provider.listModels()
|
|
||||||
cliAvailableModels = models
|
|
||||||
|
|
||||||
if !models.contains(where: { $0.id == settingsService.cliServerModel }) {
|
|
||||||
if let firstModel = models.first {
|
|
||||||
settingsService.cliServerModel = firstModel.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Log.ui.error("Failed to load CLI server models: \(error.localizedDescription)")
|
|
||||||
cliAvailableModels = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var addExternalMCPServerSheet: some View {
|
private var addExternalMCPServerSheet: some View {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"strings" : {
|
"strings" : {
|
||||||
"CFBundleName" : {
|
"CFBundleName" : {
|
||||||
"comment" : "Bundle name",
|
"comment" : "Bundle name",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "extracted_with_value",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"da" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -36,6 +36,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"NSCalendarsFullAccessUsageDescription" : {
|
||||||
|
"comment" : "Privacy - Calendars Full Access Usage Description",
|
||||||
|
"extractionState" : "extracted_with_value",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "Confab can read and create calendar events when you ask it to, if you enable Calendar access in Settings."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"NSContactsUsageDescription" : {
|
||||||
|
"comment" : "Privacy - Contacts Usage Description",
|
||||||
|
"extractionState" : "extracted_with_value",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "Confab can search your contacts when you ask it to, if you enable Contacts access in Settings."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"NSLocationWhenInUseUsageDescription" : {
|
||||||
|
"comment" : "Privacy - Location When In Use Usage Description",
|
||||||
|
"extractionState" : "extracted_with_value",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "Confab can use your current location to answer questions, if you enable Location & Maps access in Settings."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"NSRemindersFullAccessUsageDescription" : {
|
||||||
|
"comment" : "Privacy - Reminders Full Access Usage Description",
|
||||||
|
"extractionState" : "extracted_with_value",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "Confab can read and create reminders when you ask it to, if you enable Reminders access in Settings."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.1"
|
"version" : "1.1"
|
||||||
|
|||||||
@@ -73,9 +73,6 @@ struct oAIApp: App {
|
|||||||
// Start email handler on app launch
|
// Start email handler on app launch
|
||||||
EmailHandlerService.shared.start()
|
EmailHandlerService.shared.start()
|
||||||
|
|
||||||
// Start the local CLI server (Settings > Advanced > CLI Access) — no-op if disabled
|
|
||||||
CLIServerService.shared.start()
|
|
||||||
|
|
||||||
// Start external MCP servers
|
// Start external MCP servers
|
||||||
Task { @MainActor in ExternalMCPManager.shared.startAll() }
|
Task { @MainActor in ExternalMCPManager.shared.startAll() }
|
||||||
|
|
||||||
@@ -109,7 +106,6 @@ struct oAIApp: App {
|
|||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
||||||
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
|
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
|
||||||
CLIServerService.shared.stop()
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
//
|
|
||||||
// CLIServerServiceTests.swift
|
|
||||||
// oAITests
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import Confab
|
|
||||||
|
|
||||||
@Suite("CLIServerService pure HTTP helpers")
|
|
||||||
struct CLIServerServiceTests {
|
|
||||||
|
|
||||||
// MARK: - parseRequestBody
|
|
||||||
|
|
||||||
@Test("Returns the body once headers and full Content-Length body have arrived")
|
|
||||||
func parsesCompleteRequest() {
|
|
||||||
let body = "{\"prompt\":\"hi\"}"
|
|
||||||
let raw = "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)"
|
|
||||||
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
|
|
||||||
#expect(result.map { String(data: $0, encoding: .utf8) } == body)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Returns nil when the header block hasn't fully arrived yet")
|
|
||||||
func returnsNilForIncompleteHeaders() {
|
|
||||||
let raw = "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5"
|
|
||||||
#expect(CLIServerService.parseRequestBody(from: Data(raw.utf8)) == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Returns nil when the body hasn't fully arrived yet")
|
|
||||||
func returnsNilForIncompleteBody() {
|
|
||||||
let raw = "POST / HTTP/1.1\r\nContent-Length: 20\r\n\r\n{\"prompt\":\"hi\"}"
|
|
||||||
#expect(CLIServerService.parseRequestBody(from: Data(raw.utf8)) == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Content-Length header name match is case-insensitive")
|
|
||||||
func headerNameIsCaseInsensitive() {
|
|
||||||
let body = "abc"
|
|
||||||
let raw = "POST / HTTP/1.1\r\ncontent-length: 3\r\n\r\n\(body)"
|
|
||||||
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
|
|
||||||
#expect(result.map { String(data: $0, encoding: .utf8) } == body)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Missing Content-Length is treated as a zero-length body")
|
|
||||||
func missingContentLengthIsEmptyBody() {
|
|
||||||
let raw = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"
|
|
||||||
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
|
|
||||||
#expect(result?.isEmpty == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Extra trailing bytes beyond Content-Length don't prevent extraction (pipelined data ignored)")
|
|
||||||
func extraTrailingBytesStillExtractsBody() {
|
|
||||||
let body = "abc"
|
|
||||||
let raw = "POST / HTTP/1.1\r\nContent-Length: 3\r\n\r\n\(body)EXTRA"
|
|
||||||
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
|
|
||||||
#expect(result.map { String(data: $0, encoding: .utf8) } == body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Response encoding
|
|
||||||
|
|
||||||
@Test("successResponse produces a 200 with the response field set")
|
|
||||||
func successResponseShape() throws {
|
|
||||||
let data = CLIServerService.successResponse("hello world")
|
|
||||||
let text = String(data: data, encoding: .utf8)!
|
|
||||||
#expect(text.hasPrefix("HTTP/1.1 200 OK\r\n"))
|
|
||||||
#expect(text.contains("Content-Type: application/json"))
|
|
||||||
|
|
||||||
let bodyStart = text.range(of: "\r\n\r\n")!.upperBound
|
|
||||||
let bodyJSON = Data(text[bodyStart...].utf8)
|
|
||||||
let decoded = try JSONDecoder().decode(CLIServerService.AskResponseBody.self, from: bodyJSON)
|
|
||||||
#expect(decoded.response == "hello world")
|
|
||||||
#expect(decoded.error == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("errorResponse defaults to 400 Bad Request")
|
|
||||||
func errorResponseDefaultStatus() throws {
|
|
||||||
let data = CLIServerService.errorResponse("bad input")
|
|
||||||
let text = String(data: data, encoding: .utf8)!
|
|
||||||
#expect(text.hasPrefix("HTTP/1.1 400 Bad Request\r\n"))
|
|
||||||
|
|
||||||
let bodyStart = text.range(of: "\r\n\r\n")!.upperBound
|
|
||||||
let bodyJSON = Data(text[bodyStart...].utf8)
|
|
||||||
let decoded = try JSONDecoder().decode(CLIServerService.AskResponseBody.self, from: bodyJSON)
|
|
||||||
#expect(decoded.error == "bad input")
|
|
||||||
#expect(decoded.response == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("errorResponse supports a custom status code")
|
|
||||||
func errorResponseCustomStatus() {
|
|
||||||
let data = CLIServerService.errorResponse("provider failed", statusCode: 500)
|
|
||||||
let text = String(data: data, encoding: .utf8)!
|
|
||||||
#expect(text.hasPrefix("HTTP/1.1 500 Internal Server Error\r\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Content-Length in the response header matches the actual JSON body byte count")
|
|
||||||
func responseContentLengthMatchesBody() {
|
|
||||||
let data = CLIServerService.successResponse("hello")
|
|
||||||
let text = String(data: data, encoding: .utf8)!
|
|
||||||
let headerEnd = text.range(of: "\r\n\r\n")!.upperBound
|
|
||||||
let bodyByteCount = Data(text[headerEnd...].utf8).count
|
|
||||||
|
|
||||||
let lengthLine = text.split(separator: "\r\n").first { $0.hasPrefix("Content-Length:") }!
|
|
||||||
let declaredLength = Int(lengthLine.split(separator: " ")[1])!
|
|
||||||
#expect(declaredLength == bodyByteCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
//
|
|
||||||
// ThinkingVerbsTests.swift
|
|
||||||
// oAITests
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import Confab
|
|
||||||
|
|
||||||
@Suite("ThinkingVerbs language selection")
|
|
||||||
struct ThinkingVerbsTests {
|
|
||||||
|
|
||||||
private let supportedCodes = ["en", "nb", "sv", "da", "de", "fr"]
|
|
||||||
|
|
||||||
@Test("Every supported language has its own non-empty verb list")
|
|
||||||
func perLanguageListsAreNonEmpty() {
|
|
||||||
for code in supportedCodes {
|
|
||||||
#expect(!ThinkingVerbs.verbs(for: code).isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Unknown language codes fall back to English")
|
|
||||||
func unknownCodeFallsBackToEnglish() {
|
|
||||||
#expect(ThinkingVerbs.verbs(for: "xx") == ThinkingVerbs.verbs(for: "en"))
|
|
||||||
#expect(ThinkingVerbs.verbs(for: "") == ThinkingVerbs.verbs(for: "en"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Each supported language's list is genuinely distinct, not a fallback copy of English")
|
|
||||||
func perLanguageListsAreDistinctFromEnglish() {
|
|
||||||
let english = ThinkingVerbs.verbs(for: "en")
|
|
||||||
for code in supportedCodes where code != "en" {
|
|
||||||
#expect(ThinkingVerbs.verbs(for: code) != english)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Language lists are comparable in size — none is a token stub")
|
|
||||||
func perLanguageListsAreComparablyMore() {
|
|
||||||
let minimumCount = 40
|
|
||||||
for code in supportedCodes {
|
|
||||||
#expect(ThinkingVerbs.verbs(for: code).count >= minimumCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("No duplicate entries within a single language's list")
|
|
||||||
func noDuplicatesWithinLanguage() {
|
|
||||||
for code in supportedCodes {
|
|
||||||
let list = ThinkingVerbs.verbs(for: code)
|
|
||||||
#expect(Set(list).count == list.count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("random() returns a non-empty string ending in an ellipsis")
|
|
||||||
func randomProducesEllipsisSuffixedString() {
|
|
||||||
let result = ThinkingVerbs.random()
|
|
||||||
#expect(!result.isEmpty)
|
|
||||||
#expect(result.hasSuffix("..."))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user