Bulk: Vertex AI & Better Memory

This commit is contained in:
Kaede Akino committed 2026-09-17 12:47:14 +08:00
1 parent c2d1bed4fe
commit 7cdb755d5f
51 files changed
+5202 -174

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,195 @@
{
"originHash" : "6aeb5447857612da7b6960feb17ce0fc14e8688afe3bd4e0b56c885ecc186b9f",
"pins" : [
{
"identity" : "agentkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/AkinoKaede/AgentKit.git",
"state" : {
"revision" : "bbc233cf6cf4dc28c3f6e7d9b7b06d096f7b27f7",
"version" : "0.13.1"
}
},
{
"identity" : "choreographer",
"kind" : "remoteSourceControl",
"location" : "https://github.com/unixzii/Choreographer",
"state" : {
"revision" : "04dc84bfe380b8c5910ed7d0607765fddedd0460",
"version" : "0.2.0"
}
},
{
"identity" : "glyphixtextfx",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/GlyphixTextFx.git",
"state" : {
"revision" : "e1d0aa71c1849363a1bbef061d3d92f10481e62c",
"version" : "2.3.6"
}
},
{
"identity" : "grdb.swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/groue/GRDB.swift.git",
"state" : {
"revision" : "b83108d10f42680d78f23fe4d4d80fc88dab3212",
"version" : "7.11.1"
}
},
{
"identity" : "highlightr",
"kind" : "remoteSourceControl",
"location" : "https://github.com/raspu/Highlightr",
"state" : {
"revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef",
"version" : "2.3.0"
}
},
{
"identity" : "libghostty-spm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/libghostty-spm",
"state" : {
"revision" : "7e45d27160f9b34aca9ca5c9820e9207482f9f04",
"version" : "1.6.20260909"
}
},
{
"identity" : "libssh2-spm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/libssh2-spm.git",
"state" : {
"revision" : "d211fdf8587532cd952927d9f9f3f5167ce6a77f",
"version" : "2.0.3"
}
},
{
"identity" : "listviewkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/ListViewKit",
"state" : {
"revision" : "c6a067ba837758a50612f88dd1d6bb025175df6c",
"version" : "4.3.2"
}
},
{
"identity" : "litext",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/Litext",
"state" : {
"revision" : "130b4eef642d76a3d2dcf07ab966f32f08e14b90",
"version" : "2.2.1"
}
},
{
"identity" : "lrucache",
"kind" : "remoteSourceControl",
"location" : "https://github.com/nicklockwood/LRUCache",
"state" : {
"revision" : "f0570e943ea4445d258113ba73f50cb2b7e564ac",
"version" : "1.3.0"
}
},
{
"identity" : "markdownview",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/MarkdownView",
"state" : {
"revision" : "4abc4817fa5211f05e25fbf7a6295fb8e9fcfd90",
"version" : "4.3.2"
}
},
{
"identity" : "msdisplaylink",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/MSDisplayLink.git",
"state" : {
"revision" : "87eb0af130744c8cbe2e31b6e1a5bcd659f1c220",
"version" : "2.2.0"
}
},
{
"identity" : "openssl-spm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/openssl-spm.git",
"state" : {
"revision" : "9f3b525d960fe71e534482310e96cd9c4f2faa17",
"version" : "3.6.2"
}
},
{
"identity" : "respring",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ktiays/Respring",
"state" : {
"revision" : "2148e8fce312b4dcf76b8f909226b19f3ae38186",
"version" : "1.1.0"
}
},
{
"identity" : "scrubberkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/ScrubberKit.git",
"state" : {
"revision" : "b69932dc44461dd4a73aafeb78d514222f68d3e1",
"version" : "0.2.0"
}
},
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-cmark",
"state" : {
"revision" : "924936d0427cb25a61169739a7660230bffa6ea6",
"version" : "0.8.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
"revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a",
"version" : "1.6.0"
}
},
{
"identity" : "swiftmath",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mgriebling/SwiftMath",
"state" : {
"revision" : "fa8244ed032f4a1ade4cb0571bf87d2f1a9fd2d7",
"version" : "1.7.3"
}
},
{
"identity" : "swiftsoup",
"kind" : "remoteSourceControl",
"location" : "https://github.com/scinfu/SwiftSoup",
"state" : {
"revision" : "18b80329749eca5ea29fc50211dca5c7eff5bfec",
"version" : "2.13.9"
}
},
{
"identity" : "with",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ktiays/With",
"state" : {
"revision" : "98edf55b91436b2c13a76fc2b66dc3b34627d8d4",
"version" : "2.1.5"
}
},
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/weichsel/ZIPFoundation.git",
"state" : {
"revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d",
"version" : "0.9.20"
}
}
],
"version" : 3
}
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2700"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1EAB6B47E04D61C8CFC54762"
BuildableName = "Termind.app"
BlueprintName = "TFI"
ReferencedContainer = "container:.TermindPresentationQA.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "NO">
<EnvironmentVariables>
<EnvironmentVariable key = "TERMIND_KNOWLEDGE_SCREENSHOTS" value = "1" isEnabled = "YES"/>
</EnvironmentVariables>
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "F815C7E534B763A0E88A5CBD"
BuildableName = "TFITests.xctest"
BlueprintName = "TFITests"
ReferencedContainer = "container:.TermindPresentationQA.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4E03E735D4992AEB794812A1"
BuildableName = "FrameworkTests.xctest"
BlueprintName = "FrameworkTests"
ReferencedContainer = "container:.TermindPresentationQA.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1EAB6B47E04D61C8CFC54762"
BuildableName = "Termind.app"
BlueprintName = "TFI"
ReferencedContainer = "container:.TermindPresentationQA.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1EAB6B47E04D61C8CFC54762"
BuildableName = "Termind.app"
BlueprintName = "TFI"
ReferencedContainer = "container:.TermindPresentationQA.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,136 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2700"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "TRMD00000000000000030050"
BuildableName = "termind-mcp"
BlueprintName = "TermindMCPBridge"
ReferencedContainer = "container:.TermindPresentationQA.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "TRMD00000000000000000050"
BuildableName = "Termind.app"
BlueprintName = "TFM"
ReferencedContainer = "container:.TermindPresentationQA.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "TRMD00000000000000020050"
BuildableName = "TFMTests.xctest"
BlueprintName = "TFMTests"
ReferencedContainer = "container:.TermindPresentationQA.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "NO">
<EnvironmentVariables>
<EnvironmentVariable key = "TERMIND_KNOWLEDGE_SCREENSHOTS" value = "1" isEnabled = "YES"/>
</EnvironmentVariables>
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "TRMD00000000000000020050"
BuildableName = "TFMTests.xctest"
BlueprintName = "TFMTests"
ReferencedContainer = "container:.TermindPresentationQA.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4E03E735D4992AEB794812A1"
BuildableName = "FrameworkTests.xctest"
BlueprintName = "FrameworkTests"
ReferencedContainer = "container:.TermindPresentationQA.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "TRMD00000000000000000050"
BuildableName = "Termind.app"
BlueprintName = "TFM"
ReferencedContainer = "container:.TermindPresentationQA.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<AdditionalOptions>
<AdditionalOption
key = "APP_DISTRIBUTOR_ID_OVERRIDE"
value = "com.apple.AppStore"
isEnabled = "YES">
</AdditionalOption>
</AdditionalOptions>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "TRMD00000000000000000050"
BuildableName = "Termind.app"
BlueprintName = "TFM"
ReferencedContainer = "container:.TermindPresentationQA.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -44,6 +44,7 @@ struct ModelProviderCredentialTests {
name: "Vertex", apiFormat: .generateContent, credentialRef: "ordinary", usesVertex: true)
secrets.values["ordinary"] = "wrong-key"
#expect(ai.modelSecret(for: provider) == nil)
provider.vertex.projectID = "test-project"
provider.vertex.credentialRef = "service-account"
#expect(ai.modelSecret(for: provider) == nil)
secrets.values["service-account"] = "service-account-json"
@@ -0,0 +1,65 @@
import AgentKit
import Foundation
import TermindApplication
import TermindDomain
import Testing
@Suite @MainActor
struct ModelProviderFormTests {
@Test(arguments: ModelAPIType.allCases)
func emptyInputsSaveConcreteDefaultsWithoutChangingTheDraft(_ type: ModelAPIType) {
let draft = ModelProvider(name: " \n")
let endpoint = ProviderEndpoint(apiType: type, url: " \n")
let ai = AIConfigurationModel(persistsSecrets: false)
#expect(ai.saveProvider(draft, endpoint: endpoint))
let stored = ai.modelProvider(draft.id)
#expect(stored?.name == type.defaultProviderName)
#expect(ai.endpoint(for: draft.id).url == type.defaultURL)
#expect(stored?.inferenceURL == ProviderEndpoint(apiType: type, url: type.defaultURL).requestURL)
#expect(endpoint.url == " \n")
#expect(draft.name == " \n")
}
@Test func automaticUsesResponsesOnlyForAnEmptyFormURL() {
let empty = ModelProviderForm.normalized(ModelProvider(name: ""), endpoint: .init())
#expect(empty.provider.name == "OpenAI")
#expect(empty.provider.apiFormat == .responses)
#expect(empty.endpoint.url == "https://api.openai.com/v1/responses")
#expect(empty.provider.resolvedModelsURL == "https://api.openai.com/v1/models")
#expect(ProviderEndpoint().requestURL.isEmpty)
#expect(ProviderEndpoint(url: "https://local.test/v1").format == .chatCompletions)
}
@Test func explicitValuesAreNeverReplacedEvenWhenTheyMatchAnotherDefault() {
let value = ModelProviderForm.normalized(
ModelProvider(name: "OpenAI"),
endpoint: .init(apiType: .messages, url: ModelAPIType.automatic.defaultURL))
#expect(value.provider.name == "OpenAI")
#expect(value.provider.apiFormat == .messages)
#expect(value.endpoint.url == ModelAPIType.automatic.defaultURL)
let bad = ModelProviderForm.normalized(ModelProvider(name: "Private"), endpoint: .init(url: "bad URL"))
#expect(!ModelProviderForm.hasValidAddress(bad.provider))
#expect(bad.endpoint.url == "bad URL")
}
@Test func vertexNormalizesLocationAndKeepsItsSeparateDestination() {
var provider = ModelProvider(name: "", usesVertex: true)
provider.vertex = VertexConfig(projectID: "project", location: " ", credentialRef: "account")
let value = ModelProviderForm.normalized(provider, endpoint: .init(apiType: .generateContent))
#expect(value.provider.name == "Google")
#expect(value.provider.vertex.location == "global")
#expect(ModelProviderForm.hasValidAddress(value.provider))
#expect(
value.provider.requestURL()
== "https://aiplatform.googleapis.com/v1/projects/project/locations/global/publishers/google/models/{model}:generateContent"
)
#expect(!value.provider.canFetchModels)
let switched = ModelProviderForm.normalized(provider, endpoint: .init(apiType: .messages)).provider
#expect(!switched.usesVertex)
#expect(switched.vertex.credentialRef == "account")
provider.vertex.projectID = "project/escape"
#expect(
!ModelProviderForm.hasValidAddress(
ModelProviderForm.normalized(provider, endpoint: .init(apiType: .generateContent)).provider))
}
}
@@ -0,0 +1,238 @@
import AgentKit
import Foundation
import Security
import SwiftData
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import Testing
@Suite @MainActor
struct ModelProviderVertexTests {
private func vertexProvider() -> ModelProvider {
ModelProvider(
name: "Google", apiFormat: .generateContent, credentialRef: "api-key", usesVertex: true,
vertex: VertexConfig(
projectID: "test-project", location: "us-central1", credentialRef: "service-account",
clientEmail: "fixture@example.invalid"), models: [AIModel(id: "gemini-test")])
}
@Test func vertexPersistsAlongsideTheFrozenProviderRowAndDeletesWithIt() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let persistence = AIPersistence(context: container.mainContext)
let ai = AIConfigurationModel(persistence: persistence, persistsSecrets: false)
let provider = vertexProvider()
#expect(ai.saveProvider(provider, endpoint: .init(apiType: .generateContent)))
let reopened = AIConfigurationModel(persistence: persistence, persistsSecrets: false)
let saved = try #require(reopened.modelProvider(provider.id))
#expect(saved.usesVertex)
#expect(saved.vertex == provider.vertex)
#expect(saved.models == provider.models)
#expect(saved.requestURL().hasPrefix("https://us-central1-aiplatform.googleapis.com/"))
#expect(reopened.endpoint(for: provider.id).url == ModelAPIType.generateContent.defaultURL)
reopened.delete(modelProviderID: provider.id)
#expect(try container.mainContext.fetch(FetchDescriptor<PersistentModelProviderVertexConfiguration>()).isEmpty)
}
@Test func v18MigratesWithoutChangingExistingProviderData() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("Store.sqlite")
let schema = Schema(versionedSchema: TermindSchemaV18.self)
let provider = ModelProvider(name: "Legacy", models: [AIModel(id: "private")])
let endpoint = ProviderEndpoint(apiType: .messages, url: "https://private.invalid/v1")
do {
let legacy = try ModelContainer(
for: schema,
configurations: [
ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none)
])
legacy.mainContext.insert(PersistentModelProvider(provider, endpoint: endpoint, sortIndex: 0))
legacy.mainContext.insert(PersistentAIModel(provider.models[0], providerID: provider.id, sortIndex: 0))
try legacy.mainContext.save()
}
let container = try TermindPersistence.makeContainer(url: url)
let snapshot = try AIPersistence(context: container.mainContext).load()
#expect(snapshot.providers.first?.name == "Legacy")
#expect(snapshot.providers.first?.models == provider.models)
#expect(snapshot.endpoints[provider.id] == endpoint)
#expect(snapshot.providers.first?.usesVertex == false)
}
@Test func syncRoundTripsVertexAndPreservesItWhenAnOlderWriterOmitsIt() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let defaults = UserDefaults(suiteName: "ProviderVertexTests.\(UUID())")!
let store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults)
try store.prepare()
let provider = vertexProvider()
let endpoint = ProviderEndpoint(apiType: .generateContent, url: ModelAPIType.generateContent.defaultURL)
var value = SyncedModelProvider(provider, endpoint: endpoint, sortIndex: 0)
func envelope(_ value: SyncedModelProvider, time: TimeInterval) -> SettingsEnvelope {
let payload = SettingsRecordPayload.provider(value)
return SettingsEnvelope(
key: payload.key,
version: .next(after: nil, writerID: UUID(), now: Date(timeIntervalSinceNow: time)), payload: payload)
}
let first = envelope(value, time: 10)
let roundTrip = try JSONDecoder().decode(SettingsEnvelope.self, from: JSONEncoder().encode(first))
#expect(roundTrip == first)
let received = try store.applyRemote([roundTrip]).snapshot.configuration.providers[0]
#expect(received.vertex == provider.vertex)
#expect(received.usesVertex)
value.name = "Old client rename"
value.vertex = nil
let legacyData = try JSONEncoder().encode(value)
#expect(try JSONDecoder().decode(SyncedModelProvider.self, from: legacyData).vertex == nil)
let legacy = try store.applyRemote([envelope(value, time: 20)])
#expect(legacy.snapshot.configuration.providers[0].name == "Old client rename")
#expect(legacy.snapshot.configuration.providers[0].vertex == provider.vertex)
#expect(legacy.snapshot.configuration.providers[0].usesVertex)
#expect(legacy.needsUpload.contains(first.key))
value.vertex = SyncedProviderVertex(provider)
value.vertex?.isEnabled = false
value.vertex?.credentialRef = ""
let cleared = try store.applyRemote([envelope(value, time: 30)]).snapshot.configuration.providers[0]
#expect(!cleared.usesVertex)
#expect(cleared.vertex.credentialRef.isEmpty)
}
@Test func failedMetadataSaveRollsBackBothCredentialsAndKeepsDraftSecrets() throws {
let provider = vertexProvider()
let secrets = VertexSecrets()
secrets.values = ["api-key": "old-key", "service-account": "old-json"]
let persistence = FailingProviderPersistence(provider: provider)
let ai = AIConfigurationModel(persistence: persistence, aiSecrets: secrets)
let edits: [ModelProviderCredentialEdit] = [
.init(kind: .apiKey, reference: "api-key", previous: "old-key", replacement: "new-key"),
.init(kind: .serviceAccount, reference: "service-account", previous: "old-json", replacement: "new-json"),
]
#expect(throws: (any Error).self) {
try ai.saveProvider(provider, endpoint: .init(apiType: .generateContent), credentials: edits)
}
#expect(secrets.values == ["api-key": "old-key", "service-account": "old-json"])
#expect(ai.modelProvider(provider.id) == provider)
#expect(edits[1].replacement == "new-json")
}
@Test func credentialConflictsFailBeforeWritingAndDeletionCleansBothReferences() throws {
let provider = vertexProvider()
let secrets = VertexSecrets()
secrets.values = ["api-key": "key", "service-account": "newer-json"]
let ai = AIConfigurationModel(modelProviders: [provider], aiSecrets: secrets)
#expect(throws: SettingsDraftMerge.Conflict.self) {
try ai.saveProvider(
provider, endpoint: .init(apiType: .generateContent),
credentials: [
.init(
kind: .serviceAccount, reference: "service-account", previous: "old-json", replacement: "stale")
])
}
#expect(secrets.writes.isEmpty)
ai.delete(modelProviderID: provider.id)
#expect(secrets.values.isEmpty)
#expect(Set(secrets.writes) == ["api-key", "service-account"])
}
@Test func fileAndPastedAccountsShareValidationAndRejectUntrustedTokenHosts() async throws {
// Ephemeral test-only key: never written to the repository, Keychain, or a real Google account.
let key = try #require(
SecKeyCreateRandomKey(
[
kSecAttrKeyType: kSecAttrKeyTypeRSA, kSecAttrKeySizeInBits: 1024,
] as CFDictionary, nil))
let rsa = try #require(SecKeyCopyExternalRepresentation(key, nil)) as Data
func der(_ tag: UInt8, _ data: Data) -> Data {
let length = data.count
return Data([tag, 0x82, UInt8(length >> 8), UInt8(length & 255)]) + data
}
let algorithm = Data([
0x02, 0x01, 0x00, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7,
0x0d, 0x01, 0x01, 0x01, 0x05, 0x00,
])
let pem =
"-----BEGIN PRIVATE KEY-----\n" + der(0x30, algorithm + der(0x04, rsa)).base64EncodedString()
+ "\n-----END PRIVATE KEY-----\n"
let object = [
"type": "service_account", "project_id": "test-project", "client_email": "fixture@example.invalid",
"private_key": pem, "token_uri": "https://oauth2.googleapis.com/token",
]
let json = String(decoding: try JSONEncoder().encode(object), as: UTF8.self)
let account = try ModelProviderServiceAccount(json: json)
#expect(account.projectID == "test-project")
#expect(account.clientEmail == "fixture@example.invalid")
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("fixture.json")
try Data(json.utf8).write(to: url)
#expect(try await ModelProviderServiceAccountFile.read(url) == account.json)
#expect(throws: (any Error).self) {
try ModelProviderServiceAccount(
json: json.replacingOccurrences(of: "oauth2.googleapis.com", with: "evil.invalid"))
}
try Data(repeating: 32, count: ModelProviderServiceAccount.maximumBytes + 1).write(to: url)
await #expect(throws: (any Error).self) { try await ModelProviderServiceAccountFile.read(url) }
}
@Test func clearingAnUncommittedCredentialDoesNotMintAKeychainReference() throws {
let secrets = VertexSecrets()
let ai = AIConfigurationModel(aiSecrets: secrets)
let provider = ModelProvider(name: "")
try ai.saveProvider(
provider, endpoint: .init(),
credentials: [
.init(kind: .serviceAccount, reference: "", previous: nil, replacement: nil)
])
#expect(secrets.writes.isEmpty)
#expect(ai.modelProvider(provider.id)?.vertex.credentialRef == "")
#expect(ai.modelProvider(provider.id)?.name == "OpenAI")
}
@Test func failedCredentialCleanupRemainsVisibleAfterTheOtherReferenceSucceeds() {
let provider = vertexProvider()
let secrets = VertexSecrets()
secrets.values = ["api-key": "key", "service-account": "json"]
secrets.failingReference = "service-account"
let ai = AIConfigurationModel(modelProviders: [provider], aiSecrets: secrets)
ai.delete(modelProviderID: provider.id)
#expect(ai.modelProvider(provider.id) == nil)
#expect(secrets.values["api-key"] == nil)
#expect(secrets.values["service-account"] == "json")
#expect(ai.secretStorageError != nil)
#expect(Set(secrets.writes) == ["api-key", "service-account"])
}
@Test func invalidServiceAccountErrorsNeverEchoInput() {
for json in ["private-secret", "{}", String(repeating: "private-secret", count: 100_000)] {
do {
_ = try ModelProviderServiceAccount(json: json)
Issue.record("Invalid service account accepted")
} catch {
#expect(!error.localizedDescription.contains("private-secret"))
}
}
}
}
@MainActor private final class VertexSecrets: AISecretStoring {
var values: [String: String] = [:]
var writes: [String] = []
var failingReference: String?
enum Failure: Error { case inaccessible }
func string(for reference: String) throws -> String? { values[reference] }
func setString(_ value: String?, for reference: String) throws {
writes.append(reference)
if reference == failingReference { throw Failure.inaccessible }
values[reference] = value
}
}
@MainActor private final class FailingProviderPersistence: AIConfigurationPersisting {
let provider: ModelProvider
init(provider: ModelProvider) { self.provider = provider }
func load() throws -> AIConfigurationSnapshot { .init(providers: [provider], roles: [:]) }
func save(_ snapshot: AIConfigurationSnapshot) throws { throw Failure.failed }
enum Failure: Error { case failed }
}
@@ -65,7 +65,8 @@ struct PersistenceCompatibilityTests {
try first.mainContext.save()
#expect(try second.mainContext.fetchCount(FetchDescriptor<PersistentHostGroup>()) == 0)
#expect(Set(first.schema.entities.map(\.name)) == Set(TermindPersistence.schema.entities.map(\.name)))
#expect(TermindMigrationPlan.schemas.count == 18)
#expect(TermindMigrationPlan.schemas.count == 19)
#expect(first.schema.entities.contains { $0.name == "PersistentModelProviderVertexConfiguration" })
}
@Test
+41 -36
View File
@@ -1,23 +1,25 @@
{
"sourceLanguage" : "en",
"strings" : {
"This skill name is reserved for a built-in skill.": {
"extractionState": "manual",
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "此名称保留给内置技能使用。"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "此名稱保留給內建技能使用。"
}
}
}
},
"Google Vertex AI" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"Google Vertex AI"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Google Vertex AI"}}}},
"Vertex AI" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"Vertex AI"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Vertex AI"}}}},
"Project ID" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"项目ID"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"專案ID"}}}},
"Location" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"区域"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"區域"}}}},
"Service Account" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"服务账号"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"服務帳戶"}}}},
"Remove Service Account" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"移除服务账号"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"移除服務帳戶"}}}},
"Paste Service Account JSON" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"粘贴服务账号JSON"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"貼上服務帳戶JSON"}}}},
"Enter a valid project and location to configure Vertex AI." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"输入有效的项目和区域以配置Vertex AI。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"輸入有效的專案與區域以設定Vertex AI。"}}}},
"Vertex AI requires a service account. Add model IDs manually; model discovery is unavailable." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"Vertex AI需要服务账号。不支持模型发现,请手动添加模型ID。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Vertex AI需要服務帳戶。不支援模型探索,請手動新增模型ID。"}}}},
"Enter a valid provider URL, or a Vertex project and location." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"请输入有效的提供方URL,或Vertex项目和区域。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"請輸入有效的供應商URL,或Vertex專案與區域。"}}}},
"Choose a valid Google service account JSON file smaller than 1 MiB." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"请选择小于1 MiB的有效Google服务账号JSON文件。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"請選擇小於1 MiB的有效Google服務帳戶JSON檔案。"}}}},
"Show more" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"显示更多"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"顯示更多"}}}},
"Show less" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"收起"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"收合"}}}},
"install" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"安装"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"安裝"}}}},
"skills" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"技能"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"技能"}}}},
"persistent memory" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"持久化记忆"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"持久化記憶"}}}},
"saved conversations" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"历史会话"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"歷史對話"}}}},
"skill files" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"技能文件"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"技能檔案"}}}},
"%@ (%lld operations)" : {"extractionState":"manual","localizations":{"en":{"variations":{"plural":{"one":{"stringUnit":{"state":"translated","value":"%1$@ (%2$lld operation)"}},"other":{"stringUnit":{"state":"translated","value":"%1$@ (%2$lld operations)"}}}}},"zh-Hans":{"variations":{"plural":{"other":{"stringUnit":{"state":"translated","value":"%1$@(%2$lld项操作)"}}}}},"zh-Hant":{"variations":{"plural":{"other":{"stringUnit":{"state":"translated","value":"%1$@(%2$lld項操作)"}}}}}}},
"" : {
"localizations" : {
"zh-Hans" : {
@@ -2100,6 +2102,7 @@
}
},
"Add a key first. This endpoint requires one to list models." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -2116,6 +2119,7 @@
}
},
"Add a key, then press Fetch" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -7466,22 +7470,6 @@
}
}
},
"Delete Provider" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "删除提供商"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "刪除供應商"
}
}
}
},
"Delete saved memory, snapshots, learning progress, and search indexes, then turn off Memory. Skills and chat history are kept. Re-enabling Memory can rebuild memories from old chats." : {
"extractionState" : "manual",
"localizations" : {
@@ -18403,6 +18391,7 @@
}
},
"Rebuild" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -19179,7 +19168,6 @@
}
}
},
"Replace Key" : {
"extractionState" : "manual",
"localizations" : {
@@ -19413,6 +19401,7 @@
},
"Reset" : {
"comment" : "A button that deletes all skills.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
@@ -21593,7 +21582,6 @@
}
}
},
"Skip" : {
"localizations" : {
"zh-Hans" : {
@@ -25654,6 +25642,23 @@
}
}
},
"This skill name is reserved for a built-in skill." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "此名称保留给内置技能使用。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "此名稱保留給內建技能使用。"
}
}
}
},
"This SSH record is not supported." : {
"extractionState" : "stale",
"localizations" : {
@@ -28429,4 +28434,4 @@
}
},
"version" : "1.2"
}
}
@@ -376,6 +376,7 @@ public final class AIConfigurationModel {
/// a configured credential is unavailable; callers must not send anonymously.
/// Vertex always requires a service account, even without a stored reference.
public func modelSecret(for provider: ModelProvider) -> String? {
if provider.usesVertexEndpoint && !ModelProviderForm.hasValidAddress(provider) { return nil }
let reference = provider.effectiveCredentialRef
if reference.isEmpty { return provider.usesVertexEndpoint ? nil : "" }
guard let value = secret(for: reference), !value.isEmpty else { return nil }
@@ -451,10 +452,7 @@ public final class AIConfigurationModel {
@discardableResult
public func saveProvider(_ draft: ModelProvider, endpoint: ProviderEndpoint) -> Bool {
mutatePersistedState {
var value = draft
value.inferenceURL = endpoint.requestURL
value.baseURL = endpoint.baseURL
value.apiFormat = endpoint.format
let (value, endpoint) = ModelProviderForm.normalized(draft, endpoint: endpoint)
let removed = Set((modelProvider(value.id)?.models ?? []).map(\.id)).subtracting(value.models.map(\.id))
for role in AIRole.allCases {
if let ref = roles[role], ref.modelProviderID == value.id, removed.contains(ref.modelID) {
@@ -500,7 +498,12 @@ public final class AIConfigurationModel {
endpoints[modelProviderID] = nil
}
guard deleted else { return }
setSecret(nil, for: provider.credentialRef)
var failures: [String] = []
for reference in Set([provider.credentialRef, provider.vertex.credentialRef]) where !reference.isEmpty {
setSecret(nil, for: reference)
if let error = secretStorageError { failures.append(error) }
}
secretStorageError = failures.isEmpty ? nil : failures.joined(separator: "\n")
}
public func setModelProviderEnabled(_ enabled: Bool, for id: ModelProvider.ID) {
@@ -0,0 +1,121 @@
import AgentKit
import Foundation
import TermindDomain
/// Validated transient input. Never put this value in a configuration snapshot or sync payload.
public nonisolated struct ModelProviderServiceAccount: Sendable {
public static let maximumBytes = 1_048_576
public let json: String
public let clientEmail: String
public let projectID: String
public init(json: String) throws {
guard json.utf8.count <= Self.maximumBytes,
let object = try? JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any],
object["type"] as? String == "service_account",
let tokenURI = object["token_uri"] as? String,
["https://oauth2.googleapis.com/token", "https://accounts.google.com/o/oauth2/token"].contains(tokenURI),
let key = object["private_key"] as? String,
key.hasPrefix("-----BEGIN PRIVATE KEY-----"),
key.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix("-----END PRIVATE KEY-----"),
let der = Data(
base64Encoded: key.replacingOccurrences(of: "-----BEGIN PRIVATE KEY-----", with: "")
.replacingOccurrences(of: "-----END PRIVATE KEY-----", with: "")
.components(separatedBy: .whitespacesAndNewlines).joined()),
der.count > 256, der.first == 0x30,
let email = GoogleServiceAccountAuth.clientEmail(in: json), email.contains("@"),
let project = GoogleServiceAccountAuth.projectID(in: json), !project.isEmpty
else { throw ValidationError.invalid }
self.json = json
clientEmail = email
projectID = project
}
public enum ValidationError: LocalizedError {
case invalid
public var errorDescription: String? {
String(localized: "Choose a valid Google service account JSON file smaller than 1 MiB.")
}
}
}
public nonisolated enum ModelProviderCredentialKind: Sendable {
case apiKey, serviceAccount
public func reference(in provider: ModelProvider) -> String {
switch self {
case .apiKey: provider.credentialRef
case .serviceAccount: provider.vertex.credentialRef
}
}
public func setReference(_ reference: String, in provider: inout ModelProvider) {
switch self {
case .apiKey: provider.credentialRef = reference
case .serviceAccount: provider.vertex.credentialRef = reference
}
}
}
/// A compare-and-swap edit held only for the duration of an editor transaction.
public nonisolated struct ModelProviderCredentialEdit {
public let kind: ModelProviderCredentialKind
public let reference: String
public let previous: String?
public let replacement: String?
public init(kind: ModelProviderCredentialKind, reference: String, previous: String?, replacement: String?) {
self.kind = kind
self.reference = reference
self.previous = previous
self.replacement = replacement
}
}
extension AIConfigurationModel {
/// Writes both secrets before metadata, rolling every changed secret back if any step fails.
public func saveProvider(
_ draft: ModelProvider, endpoint: ProviderEndpoint, credentials: [ModelProviderCredentialEdit]
) throws {
var value = draft
let current = modelProvider(draft.id)
for edit in credentials {
guard edit.kind.reference(in: current ?? draft) == edit.reference else {
throw SettingsDraftMerge.Conflict.changed
}
let previous = secret(for: edit.reference)
if let error = secretStorageError, !edit.reference.isEmpty { throw CredentialError.message(error) }
guard previous == edit.previous else { throw SettingsDraftMerge.Conflict.changed }
}
var rollback: [(String, String?)] = []
do {
for edit in credentials {
if edit.reference.isEmpty && edit.replacement == nil { continue }
let reference = edit.reference.isEmpty ? Self.makeCredentialRef("provider") : edit.reference
rollback.append((reference, edit.previous))
setSecret(edit.replacement, for: reference)
if let error = secretStorageError { throw CredentialError.message(error) }
edit.kind.setReference(edit.replacement == nil ? "" : reference, in: &value)
}
guard saveProvider(value, endpoint: endpoint) else {
throw CredentialError.message(storageError ?? String(localized: "Unable to Save"))
}
} catch {
var message = error.localizedDescription
for (reference, previous) in rollback.reversed() {
setSecret(previous, for: reference)
if let failure = secretStorageError { message += "\n" + failure }
}
throw CredentialError.message(message)
}
}
public enum CredentialError: LocalizedError {
case message(String)
public var errorDescription: String? {
switch self {
case .message(let message): message
}
}
}
}
@@ -0,0 +1,73 @@
import AgentKit
import Foundation
/// Form hints are also concrete save defaults, never implicit runtime destinations.
nonisolated extension ModelAPIType {
public var defaultProviderName: String {
switch self {
case .automatic, .chatCompletions, .responses: "OpenAI"
case .messages: "Anthropic"
case .generateContent: "Google"
}
}
public var defaultURL: String {
switch self {
case .automatic: "https://api.openai.com/v1/responses"
case .chatCompletions, .responses: "https://api.openai.com/v1"
case .messages: "https://api.anthropic.com/v1"
case .generateContent: "https://generativelanguage.googleapis.com/v1beta"
}
}
}
nonisolated extension ProviderEndpoint {
public var withDefaultURL: ProviderEndpoint {
var value = self
if value.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
value.url = apiType.defaultURL
}
return value
}
}
public nonisolated enum ModelProviderForm {
public static func normalized(_ provider: ModelProvider, endpoint: ProviderEndpoint) -> (
provider: ModelProvider, endpoint: ProviderEndpoint
) {
var provider = provider
let endpoint = endpoint.withDefaultURL
provider.name = provider.name.trimmingCharacters(in: .whitespacesAndNewlines)
if provider.name.isEmpty { provider.name = endpoint.apiType.defaultProviderName }
provider.inferenceURL = endpoint.requestURL
provider.baseURL = endpoint.baseURL
provider.apiFormat = endpoint.format
provider.usesVertex = provider.usesVertex && endpoint.apiType == .generateContent
provider.vertex.projectID = provider.vertex.projectID.trimmingCharacters(in: .whitespacesAndNewlines)
provider.vertex.location = provider.vertex.location.trimmingCharacters(in: .whitespacesAndNewlines)
if provider.vertex.location.isEmpty { provider.vertex.location = "global" }
return (provider, endpoint)
}
public static func hasValidAddress(_ provider: ModelProvider) -> Bool {
if provider.usesVertexEndpoint {
return isPathIdentifier(provider.vertex.projectID) && isPathIdentifier(provider.vertex.location)
}
return isValidURL(provider.inferenceURL)
}
public static func isValidURL(_ value: String) -> Bool {
guard let url = URL(string: value),
["http", "https"].contains(url.scheme?.lowercased() ?? ""),
let host = url.host, !host.isEmpty
else { return false }
return true
}
private static func isPathIdentifier(_ value: String) -> Bool {
!value.isEmpty
&& value.utf8.allSatisfy {
(48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) || $0 == 45
}
}
}
@@ -60,12 +60,34 @@ public nonisolated struct SyncedAIModel: Codable, Hashable, Sendable {
}
}
public nonisolated struct SyncedProviderVertex: Codable, Hashable, Sendable {
public var isEnabled: Bool
public var projectID: String
public var location: String
public var credentialRef: String
public var clientEmail: String
public init(_ provider: ModelProvider) {
isEnabled = provider.usesVertex
projectID = provider.vertex.projectID
location = provider.vertex.location
credentialRef = provider.vertex.credentialRef
clientEmail = provider.vertex.clientEmail
}
public var configuration: VertexConfig {
VertexConfig(projectID: projectID, location: location, credentialRef: credentialRef, clientEmail: clientEmail)
}
}
public nonisolated struct SyncedModelProvider: Codable, Hashable, Sendable {
public var id: UUID
public var name: String
public var endpoint: ProviderEndpoint
public var isEnabled: Bool
public var credentialRef: String
/// Absence identifies an older writer, not an explicit removal.
public var vertex: SyncedProviderVertex?
public var models: [SyncedAIModel]
public var modelsFetchedAt: Date?
public var sortIndex: Int
@@ -76,6 +98,7 @@ public nonisolated struct SyncedModelProvider: Codable, Hashable, Sendable {
self.endpoint = endpoint
isEnabled = provider.isEnabled
credentialRef = provider.credentialRef
vertex = SyncedProviderVertex(provider)
models = provider.models.map(SyncedAIModel.init)
modelsFetchedAt = provider.modelsFetchedAt
self.sortIndex = sortIndex
@@ -85,7 +108,9 @@ public nonisolated struct SyncedModelProvider: Codable, Hashable, Sendable {
ModelProvider(
id: id, name: name, apiFormat: endpoint.format, isEnabled: isEnabled,
inferenceURL: endpoint.requestURL, baseURL: endpoint.baseURL,
credentialRef: credentialRef, models: models.map(\.value), modelsFetchedAt: modelsFetchedAt)
credentialRef: credentialRef, usesVertex: vertex?.isEnabled ?? false,
vertex: vertex?.configuration ?? VertexConfig(),
models: models.map(\.value), modelsFetchedAt: modelsFetchedAt)
}
}
@@ -250,7 +250,10 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
private func migrateSecrets() throws {
let config = try persistence.load().configuration
try secrets.migrate(Set(config.providers.map(\.credentialRef) + config.mcpServers.map(\.credentialRef)))
try secrets.migrate(
Set(
config.providers.flatMap { [$0.credentialRef, $0.vertex.credentialRef] }
+ config.mcpServers.map(\.credentialRef)))
}
private func applyRemote(_ envelopes: [SettingsEnvelope]) throws
@@ -260,11 +263,11 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
)
{
let result = try persistence.applyRemote(envelopes)
let references = envelopes.filter { result.acceptedKeys.contains($0.key) }.compactMap { envelope -> String? in
let references = envelopes.filter { result.acceptedKeys.contains($0.key) }.flatMap { envelope -> [String] in
switch envelope.payload {
case .provider(let value): value.credentialRef
case .mcpServer(let value): value.credentialRef
default: nil
case .provider(let value): [value.credentialRef, value.vertex?.credentialRef ?? ""]
case .mcpServer(let value): [value.credentialRef]
default: []
}
}
try secrets.acceptRemoteReferences(Set(references))
@@ -0,0 +1,19 @@
import Foundation
import TermindApplication
public enum ModelProviderServiceAccountFile {
public static func read(_ url: URL) async throws -> String {
try await Task.detached {
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
try Task.checkCancellation()
let file = try FileHandle(forReadingFrom: url)
defer { try? file.close() }
let data = try file.read(upToCount: ModelProviderServiceAccount.maximumBytes + 1) ?? Data()
guard data.count <= ModelProviderServiceAccount.maximumBytes,
let json = String(data: data, encoding: .utf8)
else { throw ModelProviderServiceAccount.ValidationError.invalid }
return try ModelProviderServiceAccount(json: json).json
}.value
}
}
@@ -686,6 +686,13 @@ public enum TermindSchemaV18: VersionedSchema {
}
}
public enum TermindSchemaV19: VersionedSchema {
public static let versionIdentifier = Schema.Version(19, 0, 0)
public static var models: [any PersistentModel.Type] {
TermindSchemaV18.models + [PersistentModelProviderVertexConfiguration.self]
}
}
public enum TermindMigrationPlan: SchemaMigrationPlan {
public static var schemas: [any VersionedSchema.Type] {
[
@@ -694,7 +701,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
TermindSchemaV7.self, TermindSchemaV8.self, TermindSchemaV9.self,
TermindSchemaV10.self, TermindSchemaV11.self, TermindSchemaV12.self, TermindSchemaV13.self,
TermindSchemaV14.self, TermindSchemaV15.self, TermindSchemaV16.self, TermindSchemaV17.self,
TermindSchemaV18.self,
TermindSchemaV18.self, TermindSchemaV19.self,
]
}
@@ -754,6 +761,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
MigrationStage.lightweight(fromVersion: TermindSchemaV15.self, toVersion: TermindSchemaV16.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV16.self, toVersion: TermindSchemaV17.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV17.self, toVersion: TermindSchemaV18.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV18.self, toVersion: TermindSchemaV19.self),
]
}
}
@@ -0,0 +1,29 @@
import AgentKit
import Foundation
import SwiftData
/// Independent backing preserves every installed provider entity hash through V18.
@Model
public final class PersistentModelProviderVertexConfiguration {
@Attribute(.unique) public var providerID: UUID = UUID()
public var isEnabled = false
public var projectID = ""
public var location = "global"
public var credentialRef = ""
public var clientEmail = ""
public init(_ provider: ModelProvider) {
providerID = provider.id
isEnabled = provider.usesVertex
projectID = provider.vertex.projectID
location = provider.vertex.location
credentialRef = provider.vertex.credentialRef
clientEmail = provider.vertex.clientEmail
}
public func apply(to provider: inout ModelProvider) {
provider.usesVertex = isEnabled
provider.vertex = VertexConfig(
projectID: projectID, location: location, credentialRef: credentialRef, clientEmail: clientEmail)
}
}
@@ -132,8 +132,25 @@ public final class SettingsSyncPersistence {
uniqueKeysWithValues: try skills.load().compactMap { skill in
skill.package.map { (skill.id, $0) }
})
let existingProviders = Dictionary(
uniqueKeysWithValues: snapshot.configuration.providers.map { ($0.id, $0) })
let legacyProviderIDs = Set(
try context.fetch(FetchDescriptor<PersistentAIConfigurationSyncEntry>()).compactMap {
row -> UUID? in
guard let envelope = row.envelope, !envelope.isDeleted,
case .provider(let value) = envelope.payload, value.vertex == nil
else { return nil }
return value.id
})
snapshot = try materialize()
let remoteSnapshot = snapshot
for index in snapshot.configuration.providers.indices {
let id = snapshot.configuration.providers[index].id
if legacyProviderIDs.contains(id), let old = existingProviders[id] {
snapshot.configuration.providers[index].usesVertex = old.usesVertex
snapshot.configuration.providers[index].vertex = old.vertex
}
}
for index in snapshot.skills.indices where snapshot.skills[index].package == nil {
snapshot.skills[index].package = existingPackages[snapshot.skills[index].id]
}
@@ -24,6 +24,9 @@ public final class AIPersistence {
let providerRows = try context.fetch(
FetchDescriptor<PersistentModelProvider>(sortBy: [SortDescriptor(\.sortIndex)])
)
let vertexRows = try context.fetch(FetchDescriptor<PersistentModelProviderVertexConfiguration>())
let vertexByProvider = Dictionary(
vertexRows.map { ($0.providerID, $0) }, uniquingKeysWith: { first, _ in first })
var seenProviders: Set<UUID> = []
var endpoints: [ModelProvider.ID: ProviderEndpoint] = [:]
let providers = providerRows.compactMap { row -> ModelProvider? in
@@ -34,7 +37,9 @@ public final class AIPersistence {
return modelRow.value
}
endpoints[row.id] = row.endpoint
return row.value(models: models)
var provider = row.value(models: models)
vertexByProvider[row.id]?.apply(to: &provider)
return provider
}
func valid(_ ref: AIModelRef) -> Bool {
@@ -84,6 +89,7 @@ public final class AIPersistence {
public func replace(_ snapshot: AIConfigurationSnapshot) throws {
try context.delete(model: PersistentAIModel.self)
try context.delete(model: PersistentModelProvider.self)
try context.delete(model: PersistentModelProviderVertexConfiguration.self)
try context.delete(model: PersistentAIRoleAssignment.self)
try context.delete(model: PersistentAISelection.self)
try context.delete(model: PersistentMCPServer.self)
@@ -97,6 +103,7 @@ public final class AIPersistence {
context.insert(
PersistentModelProvider(provider, endpoint: endpoint, sortIndex: providerIndex)
)
context.insert(PersistentModelProviderVertexConfiguration(provider))
for (modelIndex, model) in provider.models.enumerated() {
context.insert(
PersistentAIModel(model, providerID: provider.id, sortIndex: modelIndex)
@@ -5,7 +5,7 @@ import SwiftData
/// choose storage and own the container lifetime, never the model set.
@MainActor
public enum TermindPersistence {
public static let schema = Schema(versionedSchema: TermindSchemaV18.self)
public static let schema = Schema(versionedSchema: TermindSchemaV19.self)
public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer {
precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL")
@@ -0,0 +1,89 @@
import AgentKit
import Foundation
import TermindApplication
/// Render-time repair also covers cards persisted with the original, incorrect skill fallback.
/// A remote tool or an unknown explicit presenter ID can never opt into this policy by name alone.
nonisolated enum AgentKnowledgeToolPresentation {
enum Kind: String {
case skillsList = "skills_list"
case skillRead = "skill_read_file"
case skillManage = "skill_manage"
case skillInstall = "skill_install"
case memory
case sessionSearch = "session_search"
case legacySkill = "load_skill"
}
struct Operation {
let action: AgentToolDescriptor.Presentation.ActionKind
let activity: String
let batchActivity: String
var isArgument = false
}
static func kind(for descriptor: AgentToolDescriptor) -> Kind? {
guard descriptor.target == .local, let kind = Kind(rawValue: descriptor.name) else { return nil }
if let id = descriptor.presentation?.presenterID, id != "builtin.\(kind.rawValue)" { return nil }
return kind
}
static func operation(for card: AgentToolCard, locale: Locale, approval: Bool = false) -> Operation? {
guard let kind = kind(for: card.descriptor) else { return nil }
let args = card.invocation.call.arguments.objectValue ?? [:]
func localized(_ key: String.LocalizationValue) -> String { AppLocalization.string(key, locale: locale) }
func string(_ key: String) -> String? {
AgentToolDetailFormatting.nonempty(args[key]?.stringValue)
}
let skills = localized("skills")
let memory = localized("persistent memory")
let sessions = localized("saved conversations")
switch kind {
case .skillsList:
return .init(action: .list, activity: skills, batchActivity: skills)
case .skillRead, .legacySkill:
let listing = string("path") == "."
let fallback = listing ? localized("skill files") : localized("a skill")
guard !approval, let name = string("name") else {
return .init(action: listing ? .list : .read, activity: fallback, batchActivity: skills)
}
let path = string("path") ?? "SKILL.md"
return .init(
action: listing ? .list : .read, activity: listing ? name : "\(name) / \(path)", batchActivity: skills,
isArgument: true)
case .skillManage:
let operations = args["operations"]?.arrayValue ?? []
let names = Set(
operations.compactMap { AgentToolDetailFormatting.nonempty($0.objectValue?["name"]?.stringValue) })
let name = !approval && names.count == 1 ? names.first : nil
let activity = name ?? skills
let counted = operations.count > 1 ? localized("\(activity) (\(operations.count) operations)") : activity
return .init(action: .update, activity: counted, batchActivity: skills, isArgument: name != nil)
case .skillInstall:
let name = approval ? nil : string("name")
return .init(
action: .install, activity: name ?? localized("a skill"), batchActivity: skills,
isArgument: name != nil
)
case .memory:
let operations = args["operations"]?.arrayValue
let reading = operations?.isEmpty == true
let count = operations?.count ?? 0
let activity = count > 1 ? localized("\(memory) (\(count) operations)") : memory
return .init(action: reading ? .read : .update, activity: activity, batchActivity: memory)
case .sessionSearch:
if let query = string("query") {
return .init(
action: .search, activity: approval ? sessions : query, batchActivity: sessions,
isArgument: !approval)
}
return .init(
action: string("conversation_id") == nil ? .list : .read, activity: sessions, batchActivity: sessions)
}
}
static func title(_ text: String) -> String {
let oneLine = text.split(whereSeparator: \.isWhitespace).joined(separator: " ")
return oneLine.count > 80 ? String(oneLine.prefix(79)) + "…" : oneLine
}
}
@@ -0,0 +1,119 @@
import AgentKit
import Foundation
/// A whole-card budget, not one budget per block. Full values remain untouched for expansion and Copy.
nonisolated public struct AgentToolDetailPreview: Equatable, Sendable {
public static let characterLimit = 600
public static let rowLimit = 5
public let items: [AgentToolDetail.Item]
public let fullItems: [AgentToolDetail.Item]
public var isShortened: Bool { items != fullItems }
public init(_ detail: AgentToolDetail) {
fullItems = detail.items
var budget = Budget()
items = budget.project(detail.items)
}
public var fullText: String { Self.text(fullItems) }
private static func text(_ items: [AgentToolDetail.Item]) -> String {
items.map { item in
switch item {
case .field(let field): return field.label + ": " + field.value
case .message(let value, _): return value
case .text(let block): return [block.title, block.text].compactMap { $0 }.joined(separator: "\n")
case .list(let list):
return
([list.title].compactMap { $0 } + list.rows.map(rowText)
+ (list.rows.isEmpty ? [list.emptyMessage].compactMap { $0 } : [])).joined(separator: "\n")
case .groupedList(let list):
return
([list.summary]
+ list.sections.map {
([$0.title] + $0.rows.map(rowText)).joined(separator: "\n")
}).joined(separator: "\n")
}
}.joined(separator: "\n\n")
}
private static func rowText(_ row: AgentToolDetail.ListRow) -> String {
([row.title, row.subtitle, row.detail].compactMap { $0 } + row.badges).joined(separator: "\n")
}
private struct Budget {
var characters = AgentToolDetailPreview.characterLimit
var rows = AgentToolDetailPreview.rowLimit
var hasRoom: Bool { characters > 0 && rows > 0 }
mutating func take(_ value: String) -> String {
guard characters > 0 else { return "" }
let result = value.count > characters ? String(value.prefix(max(0, characters - 1))) + "…" : value
characters -= result.count
return result
}
mutating func optional(_ value: String?) -> String? {
value.map { take($0) }
}
mutating func project(_ items: [AgentToolDetail.Item]) -> [AgentToolDetail.Item] {
var result: [AgentToolDetail.Item] = []
for item in items {
guard hasRoom else { break }
switch item {
case .field(var field):
rows -= 1
field.label = take(field.label)
field.value = take(field.value)
result.append(.field(field))
case .message(let value, let tint):
rows -= 1
result.append(.message(take(value), tint))
case .text(var block):
rows -= 1
block.title = optional(block.title)
block.text = take(block.text)
result.append(.text(block))
case .list(var list):
list.title = optional(list.title)
list.rows = projectRows(list.rows)
if list.rows.isEmpty {
rows -= 1
list.emptyMessage = optional(list.emptyMessage)
}
result.append(.list(list))
case .groupedList(var list):
list.summary = take(list.summary)
var sections: [AgentToolDetail.ListSection] = []
for var section in list.sections {
guard hasRoom else { break }
section.title = take(section.title)
section.rows = projectRows(section.rows)
sections.append(section)
}
list.sections = sections
result.append(.groupedList(list))
}
}
return result
}
mutating func projectRows(_ values: [AgentToolDetail.ListRow]) -> [AgentToolDetail.ListRow] {
var result: [AgentToolDetail.ListRow] = []
for var row in values {
guard hasRoom else { break }
rows -= 1
row.title = take(row.title)
row.subtitle = optional(row.subtitle)
row.detail = optional(row.detail)
row.badges = row.badges.compactMap { badge in
guard characters > 0 else { return nil }
return take(badge)
}
result.append(row)
}
return result
}
}
}
@@ -19,6 +19,11 @@ nonisolated public enum AgentToolPresentation {
public let omittedCharacters: Int
}
public static func usesCompactDetails(for descriptor: AgentToolDescriptor) -> Bool {
guard let kind = AgentKnowledgeToolPresentation.kind(for: descriptor) else { return false }
return kind != .legacySkill
}
public static func symbol(for card: AgentToolCard) -> String {
return card.descriptor.presentation?.symbol ?? "wrench.and.screwdriver"
}
@@ -59,6 +64,15 @@ nonisolated public enum AgentToolPresentation {
hostName: String?,
locale: Locale
) -> String {
if let operation = AgentKnowledgeToolPresentation.operation(for: card, locale: locale, approval: true) {
let action = localizedActionVerb(operation.action, locale: locale)
let title =
isChinese(locale)
? localized("Approval tool action: \(action) \(operation.activity)", locale: locale)
: uppercaseFirst(actionName(operation.action, form: .infinitive, locale: locale)) + " "
+ operation.activity
return AgentKnowledgeToolPresentation.title(title)
}
let activity = approvalActivity(for: card, locale: locale)
guard let kind = actionKind(for: card) else {
let action = localized("Run \(activity)", locale: locale)
@@ -147,6 +161,12 @@ nonisolated public enum AgentToolPresentation {
hostName: String? = nil,
locale: Locale = .current
) -> String {
if let operation = AgentKnowledgeToolPresentation.operation(for: card, locale: locale) {
return AgentKnowledgeToolPresentation.title(
actionSummary(
operation.action, state: card.state, activity: operation.activity,
isArgumentValue: operation.isArgument, target: nil, locale: locale))
}
let operation = operation(for: card, locale: locale)
let phase: String
if card.state == .running {
@@ -215,12 +235,15 @@ nonisolated public enum AgentToolPresentation {
struct Key: Hashable {
let name: String
let state: AgentToolCard.State
let knowledgeAction: AgentToolDescriptor.Presentation.ActionKind?
}
var order: [Key] = []
var grouped: [Key: [AgentToolCard]] = [:]
for card in cards {
let key = Key(name: card.descriptor.qualifiedName, state: card.state)
let key = Key(
name: card.descriptor.qualifiedName, state: card.state,
knowledgeAction: AgentKnowledgeToolPresentation.operation(for: card, locale: locale)?.action)
if grouped[key] == nil { order.append(key) }
grouped[key, default: []].append(card)
}
@@ -292,6 +315,16 @@ nonisolated public enum AgentToolPresentation {
])
}
// A failed/denied historical result may still contain success-shaped fields.
// Only successful execution is allowed to select a knowledge success presenter.
if usesCompactDetails(for: card.descriptor),
result.isError || card.state == .failed || card.state == .denied || card.state == .interrupted
{
if detailOutput(from: result, presentation: card.descriptor.presentation, review: card.review) == nil {
return nil
}
return AgentToolDetail(items: AgentToolDetailFormatting.genericItems(value, locale: locale))
}
guard let presenter else {
return AgentToolDetail(
items: AgentToolDetailFormatting.genericItems(value, locale: locale)
@@ -609,6 +642,14 @@ nonisolated public enum AgentToolPresentation {
_ cards: [AgentToolCard], locale: Locale
) -> String {
guard let first = cards.first else { return "" }
if let operation = AgentKnowledgeToolPresentation.operation(for: first, locale: locale) {
if cards.count == 1 { return summary(for: first, locale: locale) }
let activity = operation.batchActivity + " · " + toolCallCount(cards.count, locale: locale)
return AgentKnowledgeToolPresentation.title(
actionSummary(
operation.action, state: first.state, activity: activity,
isArgumentValue: false, target: nil, locale: locale))
}
guard let presentation = first.descriptor.presentation else {
let activity =
cards.count == 1
@@ -1012,6 +1053,7 @@ nonisolated public enum AgentToolPresentation {
case .edit: .init(infinitive: "edit", progressive: "Editing", past: "Edited")
case .fetch: .init(infinitive: "fetch", progressive: "Fetching", past: "Fetched")
case .inspect: .init(infinitive: "inspect", progressive: "Inspecting", past: "Inspected")
case .install: .init(infinitive: "install", progressive: "Installing", past: "Installed")
case .list: .init(infinitive: "list", progressive: "Listing", past: "Listed")
case .move: .init(infinitive: "move", progressive: "Moving", past: "Moved")
case .present: .init(infinitive: "present", progressive: "Writing", past: "Presented")
@@ -1056,6 +1098,7 @@ nonisolated public enum AgentToolPresentation {
case .edit: localized("edit", locale: locale)
case .fetch: localized("fetch", locale: locale)
case .inspect: localized("inspect", locale: locale)
case .install: localized("install", locale: locale)
case .list: localized("list", locale: locale)
case .move: localized("move", locale: locale)
case .present: localized("present", locale: locale)
@@ -1096,6 +1139,9 @@ nonisolated public enum AgentToolPresentation {
case .scratchWorkspace: localized("the scratch workspace", locale: locale)
case .secret: localized("a secret", locale: locale)
case .skill: localized("a skill", locale: locale)
case .skills: localized("skills", locale: locale)
case .memory: localized("persistent memory", locale: locale)
case .sessions: localized("saved conversations", locale: locale)
case .sshCommand: localized("the SSH command", locale: locale)
case .taskList: localized("the task list", locale: locale)
case .url: localized("a URL", locale: locale)
@@ -0,0 +1,45 @@
import AgentKit
import SwiftUI
/// The compact knowledge-tool body. Expansion belongs to the transcript, never to a recycled row.
public struct AssistantCompactToolDetails: View {
private let preview: AgentToolDetailPreview
private let isError: Bool
private let copy: (String) -> Void
@Binding private var expanded: Bool
@ScaledMetric(relativeTo: .caption) private var previewHeight: CGFloat = 180
@State private var contentHeight: CGFloat = 0
public init(detail: AgentToolDetail, isError: Bool, expanded: Binding<Bool>, copy: @escaping (String) -> Void) {
preview = AgentToolDetailPreview(detail)
self.isError = isError
_expanded = expanded
self.copy = copy
}
public var body: some View {
VStack(alignment: .leading, spacing: 8) {
AssistantToolDetails(items: expanded ? preview.fullItems : preview.items, isError: isError, copy: copy)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
.onGeometryChange(for: CGFloat.self) {
$0.size.height
} action: {
contentHeight = $0
}
.frame(maxHeight: expanded ? nil : previewHeight, alignment: .top)
.clipped()
if expanded || preview.isShortened || contentHeight > previewHeight {
HStack(spacing: 16) {
Button(expanded ? "Show less" : "Show more") { expanded.toggle() }
.accessibilityIdentifier("AssistantTool.More")
Button("Copy", systemImage: "doc.on.doc") { copy(preview.fullText) }
.accessibilityIdentifier("AssistantTool.Copy")
}
.font(.caption)
.frame(minHeight: 44)
}
}
.accessibilityIdentifier("AssistantTool.CompactDetails")
}
}
@@ -5,6 +5,7 @@ import TermindApplication
nonisolated public enum AssistantTranscriptDisclosureID: Hashable, Sendable {
case work(UUID)
case tool(UUID)
case toolDetail(UUID)
case toolBatch(UUID)
case reasoning(UUID)
case plan(UUID)
@@ -13,7 +14,7 @@ nonisolated public enum AssistantTranscriptDisclosureID: Hashable, Sendable {
fileprivate var sourceID: UUID {
switch self {
case .work(let id), .tool(let id), .toolBatch(let id), .reasoning(let id), .plan(let id),
case .work(let id), .tool(let id), .toolDetail(let id), .toolBatch(let id), .reasoning(let id), .plan(let id),
.compactionActivity(let id), .failure(let id):
id
}
@@ -0,0 +1,100 @@
#if DEBUG
import AgentKit
import Foundation
import TermindApplication
extension AssistantTranscriptPreview {
/// No live store, account, network, or model service is involved in these rendering fixtures.
public static func knowledgeCards(language: AppLanguage) -> [AgentToolCard] {
let prose: String
switch language {
case .simplifiedChinese: prose = "部署之前先检查状态,并保留可复用的验证步骤。"
case .traditionalChinese: prose = "部署之前先檢查狀態,並保留可重用的驗證步驟。"
case .english, .system: prose = "Check status before deploying and retain reusable verification steps."
}
let body = Array(repeating: prose, count: 80).joined(separator: "\n")
let operations: AgentJSONValue = .array(
(0..<8).map { index in
.object(["action": .string("update"), "name": .string("deploy-\(index)"), "content": .string(body)])
})
struct Sample {
let name: String
let symbol: String
let arguments: [String: AgentJSONValue]
let result: AgentJSONValue
init(_ name: String, _ symbol: String, _ arguments: [String: AgentJSONValue], _ result: AgentJSONValue)
{
self.name = name
self.symbol = symbol
self.arguments = arguments
self.result = result
}
}
let samples: [Sample] = [
.init(
"skills_list", "books.vertical", [:],
.object([
"skills": .array(
(0..<15).map { index in
.object([
"name": .string("deploy-\(index)"), "description": .string(prose),
"enabled": .bool(index.isMultiple(of: 2)), "read_only": .bool(index == 0),
])
})
])),
.init(
"skill_read_file", "book.closed", ["name": .string("deploy")],
.object([
"skill": .string("deploy"), "path": .string("SKILL.md"), "title": .string("Deploy"),
"content": .string(body),
])),
.init(
"skill_manage", "book.closed", ["operations": operations],
.object([
"saved": .bool(true), "available": .string("next_run"),
])),
.init(
"skill_install", "square.and.arrow.down", ["name": .string("deploy")],
.object([
"saved": .bool(true), "name": .string("deploy"),
"source": .string("https://github.com/example/skills/tree/main/deploy"),
"available": .string("next_run"),
])),
.init(
"memory", "brain",
[
"operations": .array([
.object([
"action": .string("add"), "target": .string("user"), "content": .string(prose),
])
])
], .object(["memory": .string("MEMORY\n" + body)])),
.init(
"session_search", "magnifyingglass", ["query": .string("deploy")],
.object([
"messages": .array(
(0..<8).map { index in
.object([
"title": .string("Deploy \(index)"),
"role": .string(index.isMultiple(of: 2) ? "user" : "assistant"),
"content": .string(body), "conversation_id": .string("not-visible"),
])
}), "next_offset": .number(8),
])),
]
return samples.map { sample in
let name = sample.name
var card = AgentToolCard(
invocation: .init(
runID: UUID(), call: .init(id: name, name: name, arguments: .object(sample.arguments))),
descriptor: .init(
name: name, summary: name, inputSchema: .object([:]), target: .local, approvalPolicy: .approve,
presentation: .init(
symbol: sample.symbol, activity: .semanticLabel(.skill),
presenterID: "builtin.\(name)", actionKind: .write)), state: .completed)
card.result = AgentToolResult(callID: name, content: sample.result.encodedString)
return card
}
}
}
#endif
@@ -6,38 +6,57 @@ public struct ModelProviderConnectionFields: View {
@Binding private var isEnabled: Bool
@Binding private var name: String
@Binding private var endpoint: ProviderEndpoint
private let showsEndpoint: Bool
public init(name: Binding<String>, endpoint: Binding<ProviderEndpoint>, isEnabled: Binding<Bool>) {
public init(
name: Binding<String>, endpoint: Binding<ProviderEndpoint>, isEnabled: Binding<Bool>,
showsEndpoint: Bool = true
) {
_isEnabled = isEnabled
_name = name
_endpoint = endpoint
self.showsEndpoint = showsEndpoint
}
private var preview: ProviderEndpoint { endpoint.withDefaultURL }
public var body: some View {
Section {
LabeledContent("Name") {
TextField("Name", text: $name.singleLineText, prompt: Text("New Provider"), axis: .horizontal)
.singleLinePaste().lineLimit(1).labelsHidden().multilineTextAlignment(.trailing)
TextField(
"Name", text: $name.singleLineText, prompt: Text(verbatim: endpoint.apiType.defaultProviderName),
axis: .horizontal
)
.singleLinePaste().lineLimit(1).labelsHidden().multilineTextAlignment(.trailing)
}
Toggle("Enabled", isOn: $isEnabled)
Picker("API Type", selection: $endpoint.apiType) {
ForEach(ModelAPIType.allCases) { Text($0.displayName).tag($0) }
}
}
Section("Endpoint") {
LabeledContent("URL") {
TextField("URL", text: $endpoint.url.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
.labelsHidden()
.autocorrectionDisabled()
.multilineTextAlignment(.trailing)
}
if endpoint.isConfigured {
LabeledContent("Full URL") {
Text(endpoint.requestURL).font(.caption.monospaced()).textSelection(.enabled)
.multilineTextAlignment(.trailing).fixedSize(horizontal: false, vertical: true)
if showsEndpoint {
Section("Endpoint") {
LabeledContent("URL") {
TextField(
"URL", text: $endpoint.url.singleLineText, prompt: Text(verbatim: endpoint.apiType.defaultURL),
axis: .horizontal
).singleLinePaste().lineLimit(1)
.labelsHidden()
.autocorrectionDisabled()
.multilineTextAlignment(.trailing)
}
if !ModelProviderForm.isValidURL(preview.requestURL) {
Text("Enter a valid provider URL, or a Vertex project and location.")
.font(.caption).foregroundStyle(.red)
}
if preview.isConfigured {
LabeledContent("Full URL") {
Text(preview.requestURL).font(.caption.monospaced()).textSelection(.enabled)
.multilineTextAlignment(.trailing).fixedSize(horizontal: false, vertical: true)
}
Text("Requests use the \(preview.format.displayName) format.")
.font(.caption).foregroundStyle(.secondary)
}
Text("Requests use the \(endpoint.format.displayName) format.")
.font(.caption).foregroundStyle(.secondary)
}
}
}
@@ -0,0 +1,157 @@
import SwiftUI
import UniformTypeIdentifiers
/// Platform-independent fields; hosts own file access, secret transactions, and request lifetimes.
public struct ModelProviderVertexFields: View {
@Binding private var isEnabled: Bool
@Binding private var projectID: String
@Binding private var location: String
private let clientEmail: String
private let hasCredential: Bool
private let requestURL: String?
private let readFile: (URL) async throws -> String
private let importJSON: (String) throws -> Void
private let remove: () throws -> Void
@State private var importingFile = false
@State private var pasting = false
@State private var json = ""
@State private var error: String?
@State private var importTask: Task<Void, Never>?
public init(
isEnabled: Binding<Bool>, projectID: Binding<String>, location: Binding<String>,
clientEmail: String, hasCredential: Bool, requestURL: String?,
readFile: @escaping (URL) async throws -> String,
importJSON: @escaping (String) throws -> Void, remove: @escaping () throws -> Void
) {
_isEnabled = isEnabled
_projectID = projectID
_location = location
self.clientEmail = clientEmail
self.hasCredential = hasCredential
self.requestURL = requestURL
self.readFile = readFile
self.importJSON = importJSON
self.remove = remove
}
public var body: some View {
Section("Google Vertex AI") {
Toggle("Vertex AI", isOn: $isEnabled)
if isEnabled {
LabeledContent("Project ID") {
TextField("Project ID", text: $projectID.singleLineText, axis: .horizontal)
.singleLinePaste().labelsHidden().multilineTextAlignment(.trailing)
.autocorrectionDisabled().lineLimit(1)
}
LabeledContent("Location") {
TextField(
"Location", text: $location.singleLineText, prompt: Text(verbatim: "global"), axis: .horizontal
)
.singleLinePaste().labelsHidden().multilineTextAlignment(.trailing)
.autocorrectionDisabled().lineLimit(1)
}
LabeledContent("Service Account") {
if hasCredential { Text(clientEmail).textSelection(.enabled) } else { Text("Required") }
}
ViewThatFits(in: .horizontal) {
HStack { credentialActions }
VStack(alignment: .leading, spacing: 12) { credentialActions }
}
.buttonStyle(.bordered)
if let requestURL {
LabeledContent("Full URL") {
Text(requestURL).font(.caption.monospaced()).textSelection(.enabled)
.multilineTextAlignment(.trailing).fixedSize(horizontal: false, vertical: true)
}
} else {
Text("Enter a valid project and location to configure Vertex AI.")
.font(.caption).foregroundStyle(.secondary)
}
Text("Vertex AI requires a service account. Add model IDs manually; model discovery is unavailable.")
.font(.caption).foregroundStyle(.secondary)
if let error { Text(error).foregroundStyle(.red) }
}
}
.fileImporter(isPresented: $importingFile, allowedContentTypes: [.json], allowsMultipleSelection: false) {
result in
switch result {
case .success(let urls):
guard let url = urls.first else { return }
importTask?.cancel()
importTask = Task {
do {
let value = try await readFile(url)
try Task.checkCancellation()
try importJSON(value)
} catch {
if !Task.isCancelled { self.error = error.localizedDescription }
}
}
case .failure(let error): self.error = error.localizedDescription
}
}
.sheet(isPresented: $pasting, onDismiss: { json = "" }) {
VStack(alignment: .leading, spacing: 12) {
Text("Paste Service Account JSON").font(.headline)
TextEditor(text: $json).font(.body.monospaced()).autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.frame(minHeight: 220)
if let error { Text(error).foregroundStyle(.red) }
HStack {
Button("Cancel") {
json = ""
pasting = false
}
Spacer()
Button("Import") {
perform {
try importJSON(json)
json = ""
pasting = false
}
}.disabled(json.isEmpty)
}
}
.padding().frame(minWidth: 300, idealWidth: 520)
}
.onDisappear {
importTask?.cancel()
importTask = nil
json = ""
}
.onChange(of: isEnabled) { _, _ in
importTask?.cancel()
importTask = nil
json = ""
pasting = false
}
}
@ViewBuilder private var credentialActions: some View {
Button("Import") {
importTask?.cancel()
error = nil
importingFile = true
}
Button("Paste") {
importTask?.cancel()
error = nil
json = ""
pasting = true
}
if hasCredential {
Button("Remove Service Account", role: .destructive) { perform(remove) }
}
}
private func perform(_ action: () throws -> Void) {
importTask?.cancel()
do {
try action()
error = nil
} catch { self.error = error.localizedDescription }
}
}
@@ -157,6 +157,6 @@ nonisolated public enum TermindAgentTools {
"sftp_move", "sftp_chmod", "sftp_delete", "fetch", "web_search",
"request_user_input", "request_user_secret", "scratch_list", "scratch_read",
"scratch_write", "scratch_edit", "scratch_diff", "scratch_delete",
"scratch_pull", "scratch_push", "present_plan", "manage_tasks",
"scratch_pull", "scratch_push", "present_plan", "manage_tasks", "load_skill",
]
}
+36 -4
View File
@@ -21,7 +21,7 @@ The agent runtime is the separately versioned `AgentKit` package.
- `TermindPresentation` owns reusable SwiftUI content, presentation policy,
Ghostty themes, and the `TerminalPreferences` bridge. It depends only on
Application and Domain; native surfaces, transports, and lifetimes stay in apps.
- `TermindPersistence` exclusively owns every SwiftData model, frozen V1-V17
- `TermindPersistence` exclusively owns every SwiftData model, frozen V1-V18
schema, migration plan, and container factory. It never depends on an app target.
- `TFM` and `TFI` own platform UI, adapters, and lifecycle orchestration. Both use
`App`, `Application`, `Design`, `DesignSystem`, `Features`, `Infrastructure`,
@@ -301,8 +301,31 @@ empty, or unreadable remains an error. `AIConfigurationModel.modelSecret(for:)`
shares this distinction across discovery, probes, Chat, Guardian, titles, compact,
Command Generator, and Memory review. Anonymous resolution never reads secret
storage or inherits another provider's storage error. Vertex still requires its
service account. AgentKit omits empty authentication headers for ordinary providers.
No authentication mode or persistence schema is added.
service account and a valid project/location. AgentKit omits empty authentication headers for ordinary providers.
Model-provider forms show protocol-specific Name and URL hints, not prefilled input.
A shared Domain policy materializes blank/whitespace fields at the Application save
boundary: Automatic stores OpenAI's full Responses URL; explicit protocols store
OpenAI/Anthropic v1 or Google v1beta bases. Nonempty input never falls back or changes
on a type switch. Runtime reads never infer public destinations for legacy empty rows.
Desktop keeps page-owned input buffers while saving concrete defaults immediately;
mobile previews a normalized copy and normalizes again after conflict merging at Save.
Reopening shows saved values. Merely viewing or editing a form does not contact a provider.
Explicit generateContent offers a Vertex AI toggle. Project, location (blank saves
as global), and service-account identity are independent of the retained ordinary
URL/API key. Leaving that type disables Vertex without deleting its configuration.
Missing Vertex credentials never fall back to an API key or anonymous access; Vertex
models are manually added. AgentKit owns address derivation and OAuth token exchange.
File/paste import validates bounded service-account JSON and trusted Google token URLs;
plaintext stays in transient drafts and AISecretStore. Two-reference credential edits
compare their original values and roll back all changed secrets on metadata failure.
Deletion removes both references; disabling Vertex removes neither.
V19 adds independent PersistentModelProviderVertexConfiguration backing without changing
V1-V18 entities. Settings sync carries only metadata/references, preserves existing
Vertex metadata when an older payload omits it, and respects explicit clears and deletes.
The existing Keychain migration covers both references; no private key enters CloudKit
configuration. Vertex data already discarded by V12 is not reconstructed.
Both apps accept user-selected HTTP and HTTPS model endpoints. Their ATS arbitrary
loads exception supports unknown user-configured domains, including local servers;
@@ -701,7 +724,7 @@ selection, menus, and clipboard behavior.
TermindPresentation owns tool summaries, batch status semantics, bounded result previews, timestamp formatting,
Markdown sanitization/soft-break policy and the shared disclosure/deadline state. Hosts inject a resolved
AgentToolDetailPresenter from TermindRemoteInfrastructure; Presentation never imports the registry's transport
module. Tool protocol results, AgentKit versions and persisted schemas are unchanged.
module. Tool protocol results and persisted schemas are unchanged.
TFM retains native AppKit rows, Markdown caches and message editing state. TFI retains SwiftUI content inside
its UIKit ListViewKit bridge. A row's presentation revision can change without its data changing: the mobile
@@ -714,3 +737,12 @@ after the whole row leaves the viewport, unless the reader has already chosen it
The direct approval callback stages a tool card idempotently before accepting a response. Queued tool-proposed
and approval-requested notifications update that identity without erasing an already recorded denial. This keeps
UI state independent of whether the event channel or the direct interaction reaches MainActor first.
Skill and memory tools own their UI-neutral result and approval projections in AgentKit.
Presentation repairs their historical summary semantics only for recognized local tool/presenter
identities. Their compact result preview shares a 600-character/five-row budget across the entire
card and a scaled 180-point viewport. Full-detail expansion is independent, window-owned transcript
state and survives row reuse; Copy retains the full already-sanitized projection. Unknown presenter
IDs and MCP tools never opt into this policy by name. Long approvals instead retain all live
arguments in a scaled 240-point scroll region with actions outside it. Neither preview path reads
live skill/memory storage or changes the original protocol result, authorization, or persisted data.
@@ -12,6 +12,7 @@ final class MobileProviderEditorModel {
var draft: ModelProvider
var endpoint: ProviderEndpoint
let key = MobileSecretDraft()
let serviceAccount = MobileSecretDraft()
var keyAction: KeyAction {
get { key.action }
set { key.action = newValue }
@@ -25,43 +26,71 @@ final class MobileProviderEditorModel {
init(ai: AIConfigurationModel, provider: ModelProvider) {
self.ai = ai
original = provider
draft = provider
wasPersisted = ai.modelProvider(provider.id) != nil
var initial = provider
if !wasPersisted && provider.vertex == VertexConfig() { initial.vertex.location = "" }
original = initial
draft = initial
originalEndpoint = wasPersisted ? ai.endpoint(for: provider.id) : ProviderEndpoint(url: provider.inferenceURL)
endpoint = originalEndpoint
key.begin(in: ai, reference: provider.credentialRef)
serviceAccount.begin(in: ai, reference: provider.vertex.credentialRef)
}
var hasExistingKey: Bool { wasPersisted && !original.credentialRef.isEmpty }
var isDirty: Bool { draft != original || endpoint != originalEndpoint || key.isDirty }
var isDirty: Bool { draft != original || endpoint != originalEndpoint || key.isDirty || serviceAccount.isDirty }
var hasServiceAccount: Bool {
serviceAccount.action == .replace || (serviceAccount.action == .preserve && !draft.vertex.credentialRef.isEmpty)
}
var canSave: Bool {
let url = URL(string: endpoint.requestURL)
return !draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& ["http", "https"].contains(url?.scheme?.lowercased() ?? "")
&& url?.host?.isEmpty == false && key.isValid
ModelProviderForm.hasValidAddress(requestProvider) && key.isValid && serviceAccount.isValid
}
func importServiceAccount(_ json: String) throws {
let account = try ModelProviderServiceAccount(json: json)
serviceAccount.text = account.json
serviceAccount.action = .replace
draft.vertex.clientEmail = account.clientEmail
if draft.vertex.projectID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
draft.vertex.projectID = account.projectID
}
}
func removeServiceAccount() {
serviceAccount.text = ""
serviceAccount.action = .clear
draft.vertex.clientEmail = ""
}
func discardPlaintext() {
key.discardPlaintext()
serviceAccount.discardPlaintext()
}
var requestProvider: ModelProvider {
var provider = draft
provider.inferenceURL = endpoint.requestURL
provider.baseURL = endpoint.baseURL
provider.apiFormat = endpoint.format
return provider
ModelProviderForm.normalized(draft, endpoint: endpoint).provider
}
func requestSecret() throws -> String {
guard !wasPersisted || ai.modelProvider(original.id) != nil else {
throw MobileEditorError.message(String(localized: "Provider Deleted"))
}
if key.action == .preserve {
guard let secret = ai.modelSecret(for: ai.modelProvider(original.id) ?? requestProvider) else {
throw MobileEditorError.message(
ai.secretStorageError ?? String(localized: "The provider's credential is unavailable."))
}
return secret
let provider = requestProvider
guard ModelProviderForm.hasValidAddress(provider) else {
throw MobileEditorError.message(
String(localized: "Enter a valid provider URL, or a Vertex project and location."))
}
return try key.effective(in: ai, reference: draft.effectiveCredentialRef)
let credential = provider.usesVertexEndpoint ? serviceAccount : key
let reference = provider.effectiveCredentialRef
let secret = try credential.effective(in: ai, reference: reference)
guard
!secret.isEmpty || (!provider.usesVertexEndpoint && reference.isEmpty && credential.action != .replace)
|| (!provider.usesVertexEndpoint && credential.action == .clear)
else {
throw MobileEditorError.message(
String(localized: "The provider's credential is unavailable."))
}
return secret
}
func add(_ models: [AIModel]) {
@@ -93,6 +122,10 @@ final class MobileProviderEditorModel {
var fields = SettingsFields(original, draft, current)
try fields.merge(\.name)
try fields.merge(\.isEnabled)
try fields.merge(\.usesVertex)
try fields.merge(\.vertex.projectID)
try fields.merge(\.vertex.location)
try fields.merge(\.vertex.clientEmail)
value = fields.result
value.models = try SettingsDraftMerge.collection(
original.models, draft.models, current.models, merge: Self.mergeModel)
@@ -103,11 +136,17 @@ final class MobileProviderEditorModel {
try connection.merge(\.apiType)
savedEndpoint = connection.result
}
value.name = value.name.trimmingCharacters(in: .whitespacesAndNewlines)
try key.commit(in: ai, reference: value.credentialRef) { reference in
value.credentialRef = reference
return ai.saveProvider(value, endpoint: savedEndpoint)
guard ModelProviderForm.hasValidAddress(ModelProviderForm.normalized(value, endpoint: savedEndpoint).provider)
else {
throw MobileEditorError.message(
String(localized: "Enter a valid provider URL, or a Vertex project and location."))
}
let edits = try [
key.providerEdit(reference: value.credentialRef, kind: .apiKey),
serviceAccount.providerEdit(reference: value.vertex.credentialRef, kind: .serviceAccount),
].compactMap { $0 }
try ai.saveProvider(value, endpoint: savedEndpoint, credentials: edits)
discardPlaintext()
}
static func mergeModel(_ original: AIModel, _ draft: AIModel, _ current: AIModel) throws -> AIModel {
@@ -35,6 +35,19 @@ final class MobileSecretDraft {
}
}
func providerEdit(reference: String, kind: ModelProviderCredentialKind) throws -> ModelProviderCredentialEdit? {
guard action != .preserve else { return nil }
if let readError { throw MobileEditorError.message(readError) }
guard reference == originalReference else { throw SettingsDraftMerge.Conflict.changed }
return .init(
kind: kind, reference: reference, previous: originalSecret, replacement: action == .clear ? nil : text)
}
func discardPlaintext() {
text = ""
originalSecret = nil
}
func commit(in ai: AIConfigurationModel, reference: String, kind: String = "key", save: (String) -> Bool) throws {
if action != .preserve {
if let readError { throw MobileEditorError.message(readError) }
@@ -5,6 +5,7 @@ import TermindPresentation
import TermindRemoteInfrastructure
struct MobileAssistantInteraction: View {
@ScaledMetric(relativeTo: .caption) private var approvalHeight: CGFloat = 240
@Bindable var conversation: ConversationModel
let hosts: HostLibraryModel
@FocusState private var focusedInput: InputFocus?
@@ -84,6 +85,7 @@ struct MobileAssistantInteraction: View {
}
.scrollBounceBehavior(.basedOnSize)
}
.frame(maxHeight: AgentToolPresentation.usesCompactDetails(for: approval.descriptor) ? approvalHeight : nil)
HStack {
Button("Deny", systemImage: "xmark", role: .destructive) {
+17 -3
View File
@@ -108,7 +108,7 @@ struct MobileAssistantToolRow: View {
)
)
.foregroundStyle(card.state == .running ? Color.secondary : .primary)
.lineLimit(expanded ? nil : 1)
.lineLimit(AgentToolPresentation.usesCompactDetails(for: card.descriptor) ? 1 : (expanded ? nil : 1))
.modifier(MobileAssistantScanning(active: card.state == .running))
if card.state == .reviewing {
Image(systemName: "checkmark.shield").foregroundStyle(.orange)
@@ -125,8 +125,22 @@ struct MobileAssistantToolRow: View {
if let detail = AgentToolPresentation.detail(
for: card, presenter: TermindAgentTools.presenter(for: card.descriptor), locale: locale
) {
AssistantToolDetails(items: detail.items, isError: card.result?.isError == true) {
UIPasteboard.general.string = $0
if AgentToolPresentation.usesCompactDetails(for: card.descriptor) {
AssistantCompactToolDetails(
detail: detail, isError: card.result?.isError == true,
expanded: Binding(
get: { presentation.isExpanded(.toolDetail(card.id)) },
set: { value in
if value != presentation.isExpanded(.toolDetail(card.id)) {
presentation.toggleDisclosure(.toolDetail(card.id))
}
}
)
) { UIPasteboard.general.string = $0 }
} else {
AssistantToolDetails(items: detail.items, isError: card.result?.isError == true) {
UIPasteboard.general.string = $0
}
}
}
if card.result?.isTruncated == true {
@@ -2,6 +2,7 @@ import AgentKit
import SwiftUI
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPresentation
struct MobileAISettingsView: View {
@@ -86,7 +87,7 @@ struct MobileModelsSettingsView: View {
Image(systemName: "brain").foregroundStyle(.secondary).accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text(provider.name).foregroundStyle(.primary)
Text(provider.inferenceURL).font(.caption).foregroundStyle(.secondary)
Text(provider.requestURL()).font(.caption).foregroundStyle(.secondary)
.lineLimit(1).truncationMode(.middle)
Text("\(provider.models.count) Models").font(.caption).foregroundStyle(.secondary)
if !provider.isEnabled {
@@ -120,11 +121,11 @@ struct MobileModelsSettingsView: View {
}
.sheet(item: $editing) { provider in MobileProviderEditor(ai: ai, provider: provider) }
.confirmationDialog(
"Delete Provider",
"Delete",
isPresented: Binding(
get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible
) {
Button("Delete Provider", role: .destructive) {
Button("Delete", role: .destructive) {
if let deleting { ai.delete(modelProviderID: deleting.id) }
deleting = nil
}
@@ -206,16 +207,27 @@ struct MobileProviderEditor: View {
var body: some View {
MobileEditorSheet(
title: String(localized: "Model Provider"), isDirty: model.isDirty, canSave: model.canSave,
onCancel: { model.discardPlaintext() },
save: { try model.save() },
content: {
ModelProviderConnectionFields(
name: $model.draft.name, endpoint: $model.endpoint, isEnabled: $model.draft.isEnabled
name: $model.draft.name, endpoint: $model.endpoint, isEnabled: $model.draft.isEnabled,
showsEndpoint: !model.requestProvider.usesVertexEndpoint
)
.textInputAutocapitalization(.never)
MobileSecretFields(
key: model.key,
hasExistingCredential: model.hasExistingKey
)
if !model.requestProvider.usesVertexEndpoint {
MobileSecretFields(key: model.key, hasExistingCredential: model.hasExistingKey)
}
if model.endpoint.apiType == .generateContent {
ModelProviderVertexFields(
isEnabled: $model.draft.usesVertex, projectID: $model.draft.vertex.projectID,
location: $model.draft.vertex.location, clientEmail: model.draft.vertex.clientEmail,
hasCredential: model.hasServiceAccount,
requestURL: ModelProviderForm.hasValidAddress(model.requestProvider)
? model.requestProvider.requestURL() : nil,
readFile: { try await ModelProviderServiceAccountFile.read($0) },
importJSON: model.importServiceAccount, remove: model.removeServiceAccount)
}
modelSection.buttonStyle(.borderless)
if let error { Text(error).foregroundStyle(.red) }
}
@@ -237,6 +249,11 @@ struct MobileProviderEditor: View {
.onChange(of: model.requestProvider) { _, _ in cancelRequests() }
.onChange(of: model.key.action) { _, _ in cancelRequests() }
.onChange(of: model.key.text) { _, _ in cancelRequests() }
.onChange(of: model.serviceAccount.action) { _, _ in cancelRequests() }
.onChange(of: model.serviceAccount.text) { _, _ in cancelRequests() }
.onChange(of: model.endpoint.apiType) { _, type in
if type != .generateContent { model.draft.usesVertex = false }
}
.onChange(of: ai.modelProvider(model.original.id)) { _, _ in cancelRequests() }
.onDisappear { cancelRequests() }
}
@@ -284,7 +301,7 @@ struct MobileProviderEditor: View {
presentedSheet = .fetch
} label: {
Label("Fetch", systemImage: "arrow.down.circle").frame(maxWidth: .infinity, minHeight: 44)
}.disabled(!model.canSave)
}.disabled(!model.canSave || !model.requestProvider.canFetchModels)
Button {
presentedSheet = .add
} label: {
@@ -18,7 +18,7 @@ struct MobileTerminalSettingsView: View {
}
themeSection(dark: false)
themeSection(dark: true)
Section { Toggle("Shell Integration", isOn: binding(\.shellIntegration)) }
Section("Integration") { Toggle("Shell Integration", isOn: binding(\.shellIntegration)) }
if let error = model.storageError { Text(error).foregroundStyle(.red) }
}
.navigationTitle("Terminal")
+23 -2
View File
@@ -2,11 +2,10 @@ import AgentKit
import Foundation
import SwiftUI
import TermindApplication
import TermindDomain
import Testing
import UIKit
import struct TermindDomain.Host
@testable import TermindIOS
/// Opt-in native snapshots: set TERMIND_AI_SCREENSHOTS in the test runner environment.
@@ -22,6 +21,25 @@ struct MobileAISettingsRenderingTests {
view: MobileProviderEditor(ai: ai, provider: provider).environment(\.locale, Locale(identifier: language)))
}
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
func newHintsAndVertexEditorRender(_ language: String) async throws {
let ai = AIConfigurationModel(persistsSecrets: false)
try await capture(
"provider-hints", language: language,
view: MobileProviderEditor(ai: ai, provider: ModelProvider(name: ""))
.environment(\.locale, Locale(identifier: language)))
let provider = ModelProvider(
name: "Google", usesVertex: true,
vertex: VertexConfig(
projectID: "fixture-project", credentialRef: "fixture-reference",
clientEmail: "fixture@example.invalid"))
#expect(ai.saveProvider(provider, endpoint: .init(apiType: .generateContent)))
try await capture(
"provider-vertex", language: language,
view: MobileProviderEditor(ai: ai, provider: try #require(ai.modelProvider(provider.id)))
.environment(\.locale, Locale(identifier: language)))
}
@Test func renderSettingsEditors() async throws {
guard let language = ProcessInfo.processInfo.environment["TERMIND_AI_SCREENSHOTS"] else { return }
let app = try MobileAppModel(inMemory: true)
@@ -207,6 +225,9 @@ struct MobileAISettingsRenderingTests {
let directory = FileManager.default.temporaryDirectory.appending(path: "ai-settings-screenshots")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let device = UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone"
if ["provider-hints", "provider-vertex"].contains(name) {
Attachment.record([UInt8](data), named: "\(device)-\(language)-\(name).png")
}
try data.write(to: directory.appending(path: "\(device)-\(language)-\(name).png"))
print("AI settings snapshot: \(directory.path)/\(device)-\(language)-\(name).png")
}
+1 -1
View File
@@ -41,7 +41,7 @@ struct MobileAISettingsTests {
#expect(probes.results["test"] == "OK")
}
@Test(arguments: ["", "file:///tmp/model", "ftp://example.com/model", "http:///", "example.com/v1"])
@Test(arguments: ["file:///tmp/model", "ftp://example.com/model", "http:///", "example.com/v1"])
func invalidProviderAddressesCannotBeSaved(_ url: String) {
let editor = MobileProviderEditorModel(ai: AIConfigurationModel(), provider: ModelProvider(name: "Invalid"))
editor.endpoint.url = url
@@ -0,0 +1,69 @@
import AgentKit
import Foundation
import SwiftUI
import TermindApplication
import TermindPresentation
import Testing
import UIKit
@testable import TermindIOS
@Suite(.serialized) @MainActor
struct MobileKnowledgeToolRenderingTests {
@Test func knowledgeRowsStayCompactAtPhoneWidthAndExpandOnDemand() async throws {
for language in [AppLanguage.english, .simplifiedChinese, .traditionalChinese] {
for card in AssistantTranscriptPreview.knowledgeCards(language: language) {
let state = AssistantTranscriptPresentationState()
state.followDefault(true, for: .tool(card.id))
let controller = UIHostingController(
rootView:
MobileAssistantToolRow(card: card, presentation: state, hostName: { _ in "Unused" })
.environment(\.locale, language.locale)
.environment(\.mobileListReduceMotion, true)
.padding(16)
.background(Color(uiColor: .systemBackground)))
let size = CGSize(width: 390, height: 30_000)
let compact = controller.sizeThatFits(in: size)
#expect(compact.height <= 360, "\(card.descriptor.name): \(compact.height)")
#expect(compact.height > 40)
controller.view.frame = CGRect(origin: .zero, size: compact)
controller.view.layoutIfNeeded()
if ProcessInfo.processInfo.environment["TERMIND_KNOWLEDGE_SCREENSHOTS"] == "1" {
try snapshot(controller.view, name: "ios-\(language.rawValue)-\(card.descriptor.name)")
}
state.toggleDisclosure(.toolDetail(card.id))
try await Task.sleep(for: .milliseconds(20))
controller.view.setNeedsLayout()
controller.view.layoutIfNeeded()
let expanded = controller.sizeThatFits(in: size)
#expect(expanded.height >= compact.height)
if card.descriptor.name != "skill_install" { #expect(expanded.height > compact.height) }
#expect(state.isExpanded(.toolDetail(card.id)))
}
}
}
@Test func largerTextScalesThePreviewRatherThanHidingItsControls() {
let detail = AgentToolDetail(items: [
.text(.init(text: String(repeating: "Readable memory\n", count: 100), style: .plain))
])
let controller = UIHostingController(
rootView:
AssistantCompactToolDetails(detail: detail, isError: false, expanded: .constant(false), copy: { _ in })
.environment(\.dynamicTypeSize, .accessibility2))
let size = controller.sizeThatFits(in: CGSize(width: 390, height: 30_000))
#expect(size.height > 180)
#expect(size.height < 800)
}
private func snapshot(_ view: UIView, name: String) throws {
let image = UIGraphicsImageRenderer(bounds: view.bounds).image { context in
view.layer.render(in: context.cgContext)
}
let directory = FileManager.default.temporaryDirectory.appending(path: "knowledge-screenshots")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appending(path: name + ".png")
try #require(image.pngData()).write(to: url)
print("Knowledge snapshot: \(url.path)")
}
}
+101
View File
@@ -0,0 +1,101 @@
import AgentKit
import Foundation
import TermindApplication
import TermindDomain
import Testing
@testable import TermindIOS
@Suite @MainActor
struct MobileProviderDefaultsTests {
@Test(arguments: ModelAPIType.allCases)
func blankDraftsPreviewDefaultsAndPersistConcreteValues(_ type: ModelAPIType) throws {
let ai = AIConfigurationModel(persistsSecrets: false)
let editor = MobileProviderEditorModel(ai: ai, provider: ModelProvider(name: ""))
#expect(!editor.isDirty)
editor.endpoint.apiType = type
#expect(editor.canSave)
#expect(editor.draft.name.isEmpty)
#expect(editor.endpoint.url.isEmpty)
#expect(editor.requestProvider.name == type.defaultProviderName)
#expect(try editor.requestSecret() == "")
#expect(ai.modelProviders.isEmpty)
try editor.save()
let saved = try #require(ai.modelProvider(editor.original.id))
#expect(saved.name == type.defaultProviderName)
#expect(ai.endpoint(for: saved.id).url == type.defaultURL)
let reopened = MobileProviderEditorModel(ai: ai, provider: saved)
#expect(reopened.draft.name == type.defaultProviderName)
#expect(reopened.endpoint.url == type.defaultURL)
#expect(!reopened.isDirty)
}
@Test func typeSwitchDoesNotReplaceExplicitValuesAndDefaultsUseMergedType() throws {
let ai = AIConfigurationModel(persistsSecrets: false)
let provider = ModelProvider(name: "Original")
#expect(ai.saveProvider(provider, endpoint: .init()))
let saved = try #require(ai.modelProvider(provider.id))
let editor = MobileProviderEditorModel(ai: ai, provider: saved)
editor.draft.name = ""
editor.endpoint.url = ""
ai.setEndpoint(.init(apiType: .messages, url: ModelAPIType.automatic.defaultURL), for: provider.id)
try editor.save()
#expect(ai.modelProvider(provider.id)?.name == "Anthropic")
#expect(ai.endpoint(for: provider.id).url == ModelAPIType.messages.defaultURL)
let explicit = MobileProviderEditorModel(ai: ai, provider: try #require(ai.modelProvider(provider.id)))
explicit.endpoint.apiType = .generateContent
#expect(explicit.draft.name == "Anthropic")
#expect(explicit.endpoint.url == ModelAPIType.messages.defaultURL)
}
@Test func vertexPreviewSelectsItsOwnCredentialAndCanSaveAfterClearingIt() throws {
let ai = AIConfigurationModel(persistsSecrets: false)
let provider = ModelProvider(name: "Google", credentialRef: "key")
#expect(ai.saveProvider(provider, endpoint: .init(apiType: .generateContent)))
ai.setSecret("ordinary-key", for: "key")
let editor = MobileProviderEditorModel(ai: ai, provider: try #require(ai.modelProvider(provider.id)))
editor.draft.usesVertex = true
#expect(!editor.canSave)
editor.draft.vertex.projectID = "project"
editor.draft.vertex.location = ""
#expect(editor.canSave)
#expect(throws: (any Error).self) { try editor.requestSecret() }
editor.serviceAccount.action = .replace
editor.serviceAccount.text = "draft-service-account-json"
editor.draft.vertex.clientEmail = "fixture@example.invalid"
#expect(try editor.requestSecret() == "draft-service-account-json")
#expect(ai.modelProvider(provider.id)?.usesVertex == false)
try editor.save()
let saved = try #require(ai.modelProvider(provider.id))
#expect(saved.vertex.location == "global")
#expect(saved.credentialRef == "key")
#expect(!saved.vertex.credentialRef.isEmpty)
#expect(ai.modelSecret(for: saved) == "draft-service-account-json")
let reopened = MobileProviderEditorModel(ai: ai, provider: saved)
reopened.removeServiceAccount()
#expect(reopened.canSave)
#expect(throws: (any Error).self) { try reopened.requestSecret() }
try reopened.save()
#expect(ai.modelProvider(provider.id)?.vertex.credentialRef == "")
#expect(ai.secret(for: saved.vertex.credentialRef) == nil)
#expect(ai.secret(for: "key") == "ordinary-key")
}
@Test func vertexProjectConflictsRejectSaveAndInvalidImportsDoNotAlterDraft() throws {
let ai = AIConfigurationModel(persistsSecrets: false)
let provider = ModelProvider(
name: "Google", usesVertex: true,
vertex: VertexConfig(projectID: "original", credentialRef: "service"))
#expect(ai.saveProvider(provider, endpoint: .init(apiType: .generateContent)))
let saved = try #require(ai.modelProvider(provider.id))
let editor = MobileProviderEditorModel(ai: ai, provider: saved)
#expect(throws: (any Error).self) { try editor.importServiceAccount("private-invalid") }
#expect(editor.draft == saved)
editor.draft.vertex.projectID = "local"
var remote = saved
remote.vertex.projectID = "remote"
ai.upsert(remote)
#expect(throws: SettingsDraftMerge.Conflict.self) { try editor.save() }
#expect(ai.modelProvider(provider.id)?.vertex.projectID == "remote")
}
}
@@ -532,6 +532,7 @@ struct AgentTaskCapsuleView: View {
/// transcript that is waiting on a person, and the de-emphasis the rest of this
/// view got would make it read like history.
private struct AgentApprovalCard: View {
@ScaledMetric(relativeTo: .caption) private var approvalHeight: CGFloat = 240
let request: AgentApprovalRequest
let conversation: ConversationModel
@@ -571,6 +572,7 @@ private struct AgentApprovalCard: View {
}
.scrollBounceBehavior(.basedOnSize)
}
.frame(maxHeight: AgentToolPresentation.usesCompactDetails(for: request.descriptor) ? approvalHeight : nil)
HStack {
Button("Deny", role: .destructive) {
+59 -11
View File
@@ -789,7 +789,7 @@ private enum NativeTranscriptFactory {
reviewing: card.state == .reviewing,
reduceMotion: context.configuration.reduceMotion,
measurement: context.measurement,
lineLimit: expanded ? 0 : 1
lineLimit: AgentToolPresentation.usesCompactDetails(for: card.descriptor) ? 1 : (expanded ? 0 : 1)
)
header.alphaValue = card.state == .interrupted ? 0.5 : 1
header.toolTip = expanded ? String(localized: "Hide details") : String(localized: "Show details")
@@ -819,10 +819,12 @@ private enum NativeTranscriptFactory {
for: card, presenter: TermindAgentTools.presenter(for: card.descriptor),
locale: context.configuration.locale
) {
for item in projected.items {
detail.addArrangedSubview(
toolDetail(item, isError: card.result?.isError == true)
)
if AgentToolPresentation.usesCompactDetails(for: card.descriptor) {
detail.addArrangedSubview(compactToolDetail(projected, card: card, context: context))
} else {
for item in projected.items {
detail.addArrangedSubview(toolDetail(item, isError: card.result?.isError == true))
}
}
}
if card.result?.isTruncated == true {
@@ -1392,6 +1394,46 @@ private enum NativeTranscriptFactory {
return card
}
private static func compactToolDetail(
_ detail: AgentToolDetail, card: AgentToolCard, context: NativeTranscriptContext
) -> NSView {
let preview = AgentToolDetailPreview(detail)
let id = AssistantTranscriptDisclosureID.toolDetail(card.id)
let expanded = context.presentation.disclosures.isExpanded(id)
let body = NativeStack.vertical()
for item in expanded ? preview.fullItems : preview.items {
body.addArrangedSubview(toolDetail(item, isError: card.result?.isError == true))
}
let scale = max(1, NSFont.preferredFont(forTextStyle: .caption1).pointSize / NSFont.smallSystemFontSize)
let height = 180 * scale
let width = body.widthAnchor.constraint(equalToConstant: max(1, context.contentWidth - 48))
width.isActive = true
body.layoutSubtreeIfNeeded()
let needsMore = preview.isShortened || body.fittingSize.height > height
width.isActive = false
let clipped = NativePlanPreview(content: body, expanded: expanded, previewHeight: height)
let toggle = {
context.presentation.disclosures.toggleDisclosure(id)
context.invalidate(context.entryID, false)
}
clipped.handler = needsMore ? toggle : nil
let stack = NativeStack.vertical()
stack.addArrangedSubview(clipped)
if expanded || needsMore {
let actions = NativeStack.horizontal()
let more = NativeButton(
title: AppLocalization.string(
expanded ? "Show less" : "Show more", locale: context.configuration.locale))
more.identifier = NSUserInterfaceItemIdentifier("AssistantTool.More")
more.handler = toggle
actions.addArrangedSubview(more)
actions.addArrangedSubview(NativeCopyButton(text: preview.fullText))
stack.addArrangedSubview(actions)
}
stack.identifier = NSUserInterfaceItemIdentifier("AssistantTool.CompactDetails")
return stack
}
private static func toolDetail(_ item: AgentToolDetail.Item, isError: Bool) -> NSView {
switch item {
case .field(let field):
@@ -2138,10 +2180,12 @@ private final class NativePlanPreview: NSControl {
var handler: (() -> Void)?
private let content: NSView
private let expanded: Bool
private let previewHeight: CGFloat
init(content: NSView, expanded: Bool) {
init(content: NSView, expanded: Bool, previewHeight: CGFloat = Metrics.Chat.planPreviewHeight) {
self.content = content
self.expanded = expanded
self.previewHeight = previewHeight
super.init(frame: .zero)
wantsLayer = true
layer?.masksToBounds = true
@@ -2155,7 +2199,7 @@ private final class NativePlanPreview: NSControl {
if expanded {
content.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
} else {
heightAnchor.constraint(lessThanOrEqualToConstant: Metrics.Chat.planPreviewHeight).isActive = true
heightAnchor.constraint(lessThanOrEqualToConstant: previewHeight).isActive = true
}
}
@@ -2164,15 +2208,16 @@ private final class NativePlanPreview: NSControl {
override func layout() {
super.layout()
guard !expanded else {
guard !expanded, content.fittingSize.height > previewHeight else {
layer?.mask = nil
return
}
let mask = CAGradientLayer()
mask.colors = [NSColor.black.cgColor, NSColor.black.cgColor, NSColor.clear.cgColor]
mask.locations = [0, 0.72, 1]
mask.startPoint = CGPoint(x: 0.5, y: 0)
mask.endPoint = CGPoint(x: 0.5, y: 1)
// AppKit layer coordinates run upwards even though the transcript view is flipped.
mask.startPoint = CGPoint(x: 0.5, y: 1)
mask.endPoint = CGPoint(x: 0.5, y: 0)
mask.frame = bounds
layer?.mask = mask
}
@@ -2185,7 +2230,7 @@ private final class NativePlanPreview: NSControl {
let size = content.fittingSize
return NSSize(
width: NSView.noIntrinsicMetric,
height: expanded ? size.height : min(size.height, Metrics.Chat.planPreviewHeight)
height: expanded ? size.height : min(size.height, previewHeight)
)
}
}
@@ -2339,6 +2384,9 @@ private final class NativeBadge: NSView {
font: .systemFont(ofSize: NSFont.smallSystemFontSize - 1),
color: .secondaryLabelColor
)
label.maximumNumberOfLines = 1
label.lineBreakMode = .byTruncatingTail
label.setContentCompressionResistancePriority(.required, for: .horizontal)
label.translatesAutoresizingMaskIntoConstraints = false
addSubview(label)
NSLayoutConstraint.activate([
@@ -2,16 +2,26 @@ import AgentKit
import SwiftUI
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPresentation
@Observable
final class ModelProviderEditorSession {
var secret = ""
var name = ""
var endpoint = ProviderEndpoint()
var projectID = ""
var location = ""
var error: String?
private var didBegin = false
func begin(with provider: ModelProvider, in store: AIConfigurationModel) {
func begin(with provider: ModelProvider, in store: AIConfigurationModel, isNew: Bool = false) {
guard !didBegin else { return }
didBegin = true
name = isNew ? "" : provider.name
endpoint = isNew ? ProviderEndpoint() : store.endpoint(for: provider.id)
projectID = provider.vertex.projectID
location = isNew ? "" : provider.vertex.location
secret = store.secret(for: provider.credentialRef) ?? ""
}
}
@@ -24,6 +34,7 @@ final class ModelProviderEditorSession {
struct ModelProviderEditorPage: View {
@Environment(\.locale) private var locale
let modelProviderID: ModelProvider.ID
var isNew = false
let showModels: () -> Void
let deleted: () -> Void
@@ -66,13 +77,15 @@ struct ModelProviderEditorPage: View {
private func settings(_ provider: ModelProvider) -> some View {
Form {
ModelProviderConnectionFields(
name: binding(\.name),
endpoint: Binding(
get: { endpoint }, set: { app.aiConfiguration.setEndpoint($0, for: modelProviderID) }),
name: Binding(get: { session.name }, set: saveName),
endpoint: Binding(get: { session.endpoint }, set: saveEndpoint),
isEnabled: Binding(
get: { app.aiConfiguration.modelProvider(modelProviderID)?.isEnabled ?? false },
set: { app.aiConfiguration.setModelProviderEnabled($0, for: modelProviderID) }))
credentialSection
set: { value in updateProvider { $0.isEnabled = value } }),
showsEndpoint: !provider.usesVertexEndpoint)
if !provider.usesVertexEndpoint { credentialSection }
if session.endpoint.apiType == .generateContent { vertexSection(provider) }
if let error = session.error { Text(error).foregroundStyle(.red) }
Section("Models") {
Button(action: showModels) {
@@ -92,7 +105,7 @@ struct ModelProviderEditorPage: View {
}
Section {
Button("Delete Provider", role: .destructive) {
Button("Delete", role: .destructive) {
app.aiConfiguration.delete(modelProviderID: modelProviderID)
deleted()
}
@@ -115,11 +128,14 @@ struct ModelProviderEditorPage: View {
SecureField(
"API Key",
text: Binding(
get: { session.secret }, set: { session.secret = $0 }
get: { session.secret },
set: {
session.secret = $0
store(secret: $0)
}
),
prompt: Text("Optional")
)
.onChange(of: session.secret) { _, value in store(secret: value) }
Text(
"The key is stored in this Mac's Data Protection Keychain."
@@ -134,7 +150,7 @@ struct ModelProviderEditorPage: View {
private func loadSecrets() {
guard let provider else { return }
session.begin(with: provider, in: app.aiConfiguration)
session.begin(with: provider, in: app.aiConfiguration, isNew: isNew)
}
/// Mints a reference the first time something is typed, and clears the
@@ -145,41 +161,82 @@ struct ModelProviderEditorPage: View {
/// entry that holds nothing is not credentialled, and it should not look
/// like it is.
private func store(secret value: String) {
guard let provider else { return }
do {
try saveCredential(provider, kind: .apiKey, replacement: value.isEmpty ? nil : value)
session.error = nil
} catch { session.error = error.localizedDescription }
}
private func saveName(_ value: String) {
session.name = value
guard var provider else { return }
if value.isEmpty {
app.aiConfiguration.setSecret(nil, for: provider.credentialRef)
provider.credentialRef = ""
} else {
if provider.credentialRef.isEmpty {
provider.credentialRef = AIConfigurationModel.makeCredentialRef()
}
app.aiConfiguration.setSecret(value, for: provider.credentialRef)
}
app.aiConfiguration.upsert(provider)
provider.name = value
app.aiConfiguration.saveProvider(provider, endpoint: app.aiConfiguration.endpoint(for: modelProviderID))
}
private var endpoint: ProviderEndpoint {
app.aiConfiguration.endpoint(for: modelProviderID)
private func saveEndpoint(_ value: ProviderEndpoint) {
session.endpoint = value
guard var provider else { return }
if session.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { provider.name = "" }
app.aiConfiguration.saveProvider(provider, endpoint: value)
}
/// A two-way binding into the stored provider.
///
/// Reads and writes go through `AIConfigurationModel` rather than through a local copy,
/// so the row behind the sheet updates as you type and there is no "apply"
/// step that could be skipped. The `?? ModelProvider(name: "")` is unreachable
/// while the sheet is up — the `if let` in `body` has already established
/// the provider exists.
private func binding<Value>(_ keyPath: WritableKeyPath<ModelProvider, Value>) -> Binding<Value> {
Binding(
get: {
app.aiConfiguration.modelProvider(modelProviderID)?[keyPath: keyPath]
?? ModelProvider(name: "")[keyPath: keyPath]
private func vertexSection(_ provider: ModelProvider) -> some View {
ModelProviderVertexFields(
isEnabled: Binding(
get: { provider.usesVertex },
set: { value in
updateProvider { $0.usesVertex = value }
}),
projectID: Binding(
get: { session.projectID },
set: { value in
session.projectID = value
updateProvider { $0.vertex.projectID = value }
}),
location: Binding(
get: { session.location },
set: { value in
session.location = value
updateProvider { $0.vertex.location = value }
}),
clientEmail: provider.vertex.clientEmail, hasCredential: !provider.vertex.credentialRef.isEmpty,
requestURL: ModelProviderForm.hasValidAddress(provider) ? provider.requestURL() : nil,
readFile: { try await ModelProviderServiceAccountFile.read($0) },
importJSON: { json in
let account = try ModelProviderServiceAccount(json: json)
guard var current = self.provider else { return }
current.vertex.clientEmail = account.clientEmail
if current.vertex.projectID.isEmpty { current.vertex.projectID = account.projectID }
try saveCredential(current, kind: .serviceAccount, replacement: account.json)
session.projectID = current.vertex.projectID
},
set: { newValue in
guard var provider = app.aiConfiguration.modelProvider(modelProviderID) else { return }
provider[keyPath: keyPath] = newValue
app.aiConfiguration.upsert(provider)
}
)
remove: {
guard var current = self.provider else { return }
current.vertex.clientEmail = ""
try saveCredential(current, kind: .serviceAccount, replacement: nil)
})
}
private func updateProvider(_ change: (inout ModelProvider) -> Void) {
guard var provider else { return }
change(&provider)
app.aiConfiguration.saveProvider(provider, endpoint: app.aiConfiguration.endpoint(for: modelProviderID))
}
private func saveCredential(_ value: ModelProvider, kind: ModelProviderCredentialKind, replacement: String?) throws
{
let ai = app.aiConfiguration
let reference = kind.reference(in: value)
let previous = ai.secret(for: reference)
if !reference.isEmpty, let error = ai.secretStorageError {
throw AIConfigurationModel.CredentialError.message(error)
}
try ai.saveProvider(
value, endpoint: ai.endpoint(for: value.id),
credentials: [
.init(kind: kind, reference: reference, previous: previous, replacement: replacement)
])
}
}
@@ -27,8 +27,9 @@ struct ModelProviderSettings: View {
// provider living in a view's `@State`. Cancelling takes it back
// out again — which is what `isNew` below is for.
let provider = ModelProvider(name: "")
app.aiConfiguration.upsert(provider)
edit(provider.id, true)
if app.aiConfiguration.saveProvider(provider, endpoint: ProviderEndpoint()) {
edit(provider.id, true)
}
}
Text(
@@ -70,7 +71,9 @@ private struct ModelProviderRow: View {
Text(provider.name)
.lineLimit(1)
Group {
if endpoint.isConfigured {
if provider.usesVertexEndpoint {
Text(ModelProviderForm.hasValidAddress(provider) ? provider.requestURL() : "Vertex AI")
} else if endpoint.isConfigured {
Text(endpoint.url)
} else {
Text("Needs a URL")
+5 -1
View File
@@ -93,12 +93,14 @@ enum SettingsRoute: Hashable {
struct SettingsPageView: View {
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
@State private var newlyCreatedProviderID: ModelProvider.ID?
var body: some View {
detail.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(TermindColor.windowSurface)
.onChange(of: window.settingsRoute) { oldRoute, newRoute in
cleanUpDraft(from: oldRoute, movingTo: newRoute)
if case .modelProvider = oldRoute { newlyCreatedProviderID = nil }
}
.onDisappear {
cleanUpDraft(from: window.settingsRoute, movingTo: .index)
@@ -118,6 +120,7 @@ struct SettingsPageView: View {
case .modelProvider(let id):
ModelProviderEditorPage(
modelProviderID: id,
isNew: newlyCreatedProviderID == id,
showModels: { navigate(to: .providerModels(id)) },
deleted: { navigate(to: .category(.ai)) }
)
@@ -162,7 +165,8 @@ struct SettingsPageView: View {
SettingsFormPage { SFTPSettings() }
case .ai:
SettingsFormPage {
ModelProviderSettings { id, _ in
ModelProviderSettings { id, isNew in
newlyCreatedProviderID = isNew ? id : nil
navigate(to: .modelProvider(id))
}
ModelRoleSettings()
@@ -0,0 +1,178 @@
import AgentKit
import Foundation
import TermindApplication
import TermindPresentation
import Testing
@Suite struct AgentKnowledgeToolPresentationTests {
private let english = Locale(identifier: "en")
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
func oldMemoryMetadataIsRepairedInEveryState(_ language: String) {
let locale = Locale(identifier: language)
for state: AgentToolCard.State in [
.proposed, .waitingForApproval, .waitingForUser, .reviewing, .running,
.completed, .failed, .denied, .interrupted,
] {
var value = card(
"memory",
arguments: [
"operations": .array([
.object([
"action": .string("add"), "target": .string("user"), "content": .string("private-body"),
])
])
])
value.state = state
let title = AgentToolPresentation.summary(for: value, locale: locale)
#expect(!title.contains("skill"))
#expect(!title.contains("技能"))
#expect(!title.contains("private-body"))
#expect(title.contains(language == "en" ? "memory" : language == "zh-Hans" ? "记忆" : "記憶"))
}
var value = card("memory", arguments: ["operations": .array([])])
value.state = .completed
if language == "en" {
#expect(AgentToolPresentation.summary(for: value, locale: locale) == "Read persistent memory")
}
}
@Test func eachKnowledgeModeHasTheCorrectActionAndTarget() {
let cases: [(String, [String: AgentJSONValue], String)] = [
("skills_list", [:], "Listed skills"),
("skill_read_file", ["name": .string("guide")], "Read guide / SKILL.md"),
("skill_read_file", ["name": .string("guide"), "path": .string(".")], "Listed guide"),
("skill_install", ["name": .string("guide")], "Installed guide"),
(
"skill_manage",
["operations": .array([.object(["action": .string("delete"), "name": .string("guide")])])],
"Updated guide"
),
("memory", ["operations": .array([.object(["action": .string("add")])])], "Updated persistent memory"),
("session_search", [:], "Listed saved conversations"),
("session_search", ["query": .string("deploy")], "Searched deploy"),
("session_search", ["conversation_id": .string("hidden-id")], "Read saved conversations"),
]
for (name, args, expected) in cases {
let value = card(name, arguments: args)
#expect(AgentToolPresentation.summary(for: value, locale: english) == expected)
}
}
@Test func mixedMemoryReadAndWriteBatchesKeepTheirActionsAndFailuresSeparate() {
let read = card("memory", arguments: ["operations": .array([])])
var write = card("memory", arguments: ["operations": .array([.object(["action": .string("add")])])])
write.state = .failed
let title = AgentToolPresentation.batchSummary(for: [read, read, write], locale: english)
#expect(title.contains("Read persistent memory"))
#expect(title.contains("2 tool calls"))
#expect(title.contains("failed to update persistent memory"))
#expect(!title.contains("skill"))
}
@Test func unknownPresenterOrRemoteIdentityCannotSelectKnowledgePolicy() {
var value = card("memory")
value.descriptor.presentation?.presenterID = "remote.memory"
#expect(!AgentToolPresentation.usesCompactDetails(for: value.descriptor))
value.descriptor.presentation?.presenterID = "builtin.memory"
value.descriptor.target = .mcp
#expect(!AgentToolPresentation.usesCompactDetails(for: value.descriptor))
}
@Test func approvalKeepsBodyOutOfItsTitleAndProjectsTheCompleteArguments() {
let body = String(repeating: "approval-only-content\n", count: 1_000)
let value = card(
"skill_manage",
arguments: [
"operations": .array([
.object([
"action": .string("create"), "name": .string("example"), "content": .string(body),
])
])
])
let request = AgentApprovalRequest(
invocation: value.invocation, descriptor: value.descriptor, userIntent: "Create")
let approval = AgentToolPresentation.approval(
for: request, presenter: SkillManageTool.presenter, locale: english)
#expect(approval.title == "Update skills")
#expect(approval.detail.items.contains(.text(.init(title: "Content", text: body, style: .monospaced))))
}
@Test func failedKnowledgeResultsCannotDrawSuccessMessages() throws {
var value = card("skill_install")
value.state = .failed
value.result = AgentToolResult(callID: "call", content: "{\"saved\":true}", isError: true)
let detail = try #require(
AgentToolPresentation.detail(for: value, presenter: SkillInstallTool.presenter, locale: english))
#expect(
!detail.items.contains {
if case .message(_, .success) = $0 { return true }
return false
})
}
@Test func titleLimitsDoNotChangeOriginalArguments() {
let query = "开始\n" + String(repeating: "🧭", count: 160)
let value = card("session_search", arguments: ["query": .string(query)])
let title = AgentToolPresentation.summary(for: value, locale: english)
#expect(title.count <= 80)
#expect(!title.contains("\n"))
#expect(title.hasSuffix("…"))
#expect(value.invocation.call.arguments.objectValue?["query"]?.stringValue == query)
}
@Test func wholeCardPreviewSharesItsBudgetAndPreservesFullCopy() {
let text = String(repeating: "中🧭", count: 900)
let detail = AgentToolDetail(
items: (0..<32).map { .text(.init(title: "Block \($0)", text: text, style: .plain)) })
let preview = AgentToolDetailPreview(detail)
#expect(preview.isShortened)
#expect(preview.fullItems == detail.items)
let rendered = AgentToolDetailPreview(AgentToolDetail(items: preview.items)).fullText
// Separators are formatting, not source text; all source strings share the 600-character budget.
#expect(rendered.count <= 610)
#expect(preview.fullText.contains("Block 31"))
#expect(preview.fullText.hasSuffix(text))
}
@Test func listsShareFiveRowsAcrossSectionsAndShortDetailsRemainUnchanged() {
let rows = (0..<4).map { AgentToolDetail.ListRow(title: "Skill \($0)") }
let detail = AgentToolDetail(items: [
.list(.init(title: "First", rows: rows)), .list(.init(title: "Second", rows: rows)),
])
let preview = AgentToolDetailPreview(detail)
let count = preview.items.reduce(0) { count, item in
if case .list(let list) = item { return count + list.rows.count }
return count
}
#expect(count == 5)
#expect(preview.isShortened)
let short = AgentToolDetail(items: [.list(.init(rows: [.init(title: "One")], emptyMessage: "None"))])
#expect(!AgentToolDetailPreview(short).isShortened)
}
@Test @MainActor func detailExpansionIsIndependentAndSurvivesReconfiguration() {
let state = AssistantTranscriptPresentationState()
let id = UUID()
state.toggleDisclosure(.toolDetail(id))
let revision = state.layoutRevision(for: [id])
state.followDefault(false, for: .tool(id))
state.prune(validTimelineIDs: [id])
#expect(state.isExpanded(.toolDetail(id)))
#expect(!state.isExpanded(.tool(id)))
#expect(state.layoutRevision(for: [id]) >= revision)
state.prune(validTimelineIDs: [])
#expect(!state.isExpanded(.toolDetail(id)))
}
private func card(_ name: String, arguments: [String: AgentJSONValue] = [:]) -> AgentToolCard {
AgentToolCard(
invocation: .init(runID: UUID(), call: .init(id: "call", name: name, arguments: .object(arguments))),
descriptor: .init(
name: name, summary: name, inputSchema: .object([:]), target: .local,
approvalPolicy: .ask,
presentation: .init(
symbol: "brain", activity: .semanticArgument(key: "target", fallback: .skill),
presenterID: "builtin.\(name)", actionKind: .write)), state: .completed)
}
}
@@ -0,0 +1,74 @@
import AgentKit
import AppKit
import Foundation
import SwiftUI
import TermindApplication
import TermindPresentation
import Testing
@testable import Termind
@Suite(.serialized) @MainActor
struct KnowledgeToolRenderingTests {
@Test(arguments: [380.0, 720.0])
func knowledgeRowsStayCompactAndExpandWithoutLosingReaderState(_ width: Double) throws {
for language in [AppLanguage.english, .simplifiedChinese, .traditionalChinese] {
for card in AssistantTranscriptPreview.knowledgeCards(language: language) {
let state = TranscriptPresentationState()
state.disclosures.followDefault(true, for: .tool(card.id))
let row = TranscriptNativeRow.make(for: .tool)
func configure() {
row.configure(
entry: .init(id: card.id, content: .tool(card)), proposedWidth: width,
configuration: .init(
isCompact: true, showsTerminalActions: false, isConversationStreaming: false,
locale: language.locale, reduceMotion: true, colorScheme: .light),
actions: .init(), presentation: state, markdownCache: TranscriptMarkdownCache(limit: 4),
measurement: false, invalidate: { _, _ in })
}
configure()
let compact = row.measuredHeight(for: width)
#expect(compact > 20)
#expect(compact <= 320, "\(card.descriptor.name): \(compact)")
if width == 720, ProcessInfo.processInfo.environment["TERMIND_KNOWLEDGE_SCREENSHOTS"] == "1" {
try snapshot(
row, width: width, height: compact, name: "mac-\(language.rawValue)-\(card.descriptor.name)")
}
state.disclosures.toggleDisclosure(.toolDetail(card.id))
configure()
let expanded = row.measuredHeight(for: width)
#expect(expanded >= compact)
if card.descriptor.name != "skill_install" { #expect(expanded > compact) }
configure()
#expect(state.disclosures.isExpanded(.toolDetail(card.id)))
}
}
}
private func snapshot(_ row: NSView, width: Double, height: Double, name: String) throws {
let canvas = NSView(frame: CGRect(x: 0, y: 0, width: width + 32, height: ceil(height) + 32))
canvas.wantsLayer = true
canvas.layer?.backgroundColor = NSColor.textBackgroundColor.cgColor
row.translatesAutoresizingMaskIntoConstraints = true
row.frame = CGRect(x: 16, y: 16, width: width, height: ceil(height))
canvas.addSubview(row)
let window = NSWindow(contentRect: canvas.frame, styleMask: [.borderless], backing: .buffered, defer: false)
window.appearance = NSAppearance(named: .aqua)
window.contentView = canvas
window.orderFront(nil)
defer {
window.orderOut(nil)
window.contentView = nil
}
canvas.layoutSubtreeIfNeeded()
let bitmap = try #require(canvas.bitmapImageRepForCachingDisplay(in: canvas.bounds))
canvas.cacheDisplay(in: canvas.bounds, to: bitmap)
let data = try #require(bitmap.representation(using: .png, properties: [:]))
let directory = FileManager.default.temporaryDirectory.appending(path: "knowledge-screenshots")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appending(path: name + ".png")
try data.write(to: url)
row.removeFromSuperview()
print("Knowledge snapshot: \(url.path)")
}
}
@@ -2,6 +2,7 @@ import AgentKit
import AppKit
import SwiftUI
import TermindApplication
import TermindDomain
import Testing
@testable import Termind
@@ -26,6 +27,43 @@ struct ModelProviderRenderingTests {
language: language, name: "provider-list")
}
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
func newProviderHintsAndVertexFieldsRender(_ language: String) async throws {
let ai = AIConfigurationModel(persistsSecrets: false)
let provider = ModelProvider(name: "")
#expect(ai.saveProvider(provider, endpoint: .init()))
let app = AppModel(hosts: HostLibraryModel(), ai: ai)
defer { app.shutdownForTermination() }
try await capture(
ModelProviderEditorPage(modelProviderID: provider.id, isNew: true, showModels: {}, deleted: {}).environment(
app),
language: language, name: "new-hints")
var vertex = ModelProvider(name: "Google", usesVertex: true)
vertex.vertex = VertexConfig(
projectID: "fixture-project", location: "global",
credentialRef: "fixture-reference", clientEmail: "fixture@example.invalid")
#expect(ai.saveProvider(vertex, endpoint: .init(apiType: .generateContent)))
try await capture(
ModelProviderEditorPage(modelProviderID: vertex.id, showModels: {}, deleted: {}).environment(app),
language: language, name: "vertex")
}
@Test func newEditorKeepsEmptyBuffersWhileReopeningReadsSavedDefaults() throws {
let ai = AIConfigurationModel(persistsSecrets: false)
let provider = ModelProvider(name: "")
#expect(ai.saveProvider(provider, endpoint: .init()))
let saved = try #require(ai.modelProvider(provider.id))
let newSession = ModelProviderEditorSession()
newSession.begin(with: saved, in: ai, isNew: true)
#expect(newSession.name.isEmpty)
#expect(newSession.endpoint.url.isEmpty)
let reopened = ModelProviderEditorSession()
reopened.begin(with: saved, in: ai)
#expect(reopened.name == "OpenAI")
#expect(reopened.endpoint.url == ModelAPIType.automatic.defaultURL)
#expect(!SettingsDraftPolicy.shouldDiscard(saved))
}
private func capture(_ view: some View, language: String, name: String) async throws {
let content = view.environment(\.locale, Locale(identifier: language)).frame(width: 680, height: 700)
let controller = NSHostingController(rootView: content)
+4 -3
View File
@@ -100,9 +100,10 @@ struct SettingsSyncTests {
var legacy: ModelContainer? = try ModelContainer(
for: oldSchema,
configurations: localModelConfiguration("Migration", schema: oldSchema, url: url))
try AIPersistence(context: legacy!.mainContext).save(
AIConfigurationSnapshot(
providers: [provider], endpoints: [provider.id: endpoint], roles: [:]))
legacy!.mainContext.insert(PersistentModelProvider(provider, endpoint: endpoint, sortIndex: 0))
for (index, model) in provider.models.enumerated() {
legacy!.mainContext.insert(PersistentAIModel(model, providerID: provider.id, sortIndex: index))
}
legacy!.mainContext.insert(PersistentAgentSkill(skill, sortIndex: 0))
try legacy!.mainContext.save()
legacy = nil
+1 -1
View File
@@ -2624,7 +2624,7 @@
repositoryURL = "https://github.com/AkinoKaede/AgentKit.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.13.1;
minimumVersion = 0.13.2;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -6,8 +6,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/AkinoKaede/AgentKit.git",
"state" : {
"revision" : "bbc233cf6cf4dc28c3f6e7d9b7b06d096f7b27f7",
"version" : "0.13.1"
"revision" : "3f293bfe76ea8693cb3dcf452a65d44421989cde",
"version" : "0.13.2"
}
},
{