Bulk: Vertex AI & Better Memory
This commit is contained in:
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
+195
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user