Improve Chinese localization and plural handling
This commit is contained in:
1 parent
e3ee3fc62e
commit
8b44c98d3c
11 files changed
+722
-464
No files matched your search
@@ -57,9 +57,8 @@ nonisolated extension AgentBuiltInTools {
|
||||
], required: ["questions"], target: .user, safety: .locallyReadOnly,
|
||||
presentation: .init(
|
||||
symbol: "questionmark.circle",
|
||||
activity: .arrayCount(
|
||||
key: "questions", singular: "a question", plural: "questions",
|
||||
fallback: "a question"
|
||||
activity: .localizedArrayCount(
|
||||
key: "questions", unit: .question, fallback: "a question"
|
||||
),
|
||||
action: .init(infinitive: "ask", progressive: "Asking", past: "Asked")
|
||||
)
|
||||
|
||||
@@ -78,14 +78,21 @@ nonisolated struct AgentToolDescriptor: Identifiable, Hashable, Sendable, Codabl
|
||||
var past: String
|
||||
}
|
||||
|
||||
/// A semantic unit whose counted presentation is owned by the app's
|
||||
/// localization resources instead of English descriptor metadata.
|
||||
nonisolated enum CountedUnit: String, Hashable, Sendable, Codable {
|
||||
case question
|
||||
}
|
||||
|
||||
nonisolated enum Activity: Hashable, Sendable, Codable {
|
||||
case label(String)
|
||||
case argument(key: String, fallback: String)
|
||||
/// Names an activity from the number of entries in an array
|
||||
/// argument. The singular phrase includes its determiner (for
|
||||
/// example, "a question"); the plural phrase is prefixed by the
|
||||
/// count when rendered.
|
||||
/// Legacy persisted shape. New built-in descriptors must use
|
||||
/// `localizedArrayCount` so their grammar comes from the catalog.
|
||||
case arrayCount(key: String, singular: String, plural: String, fallback: String)
|
||||
/// Names an activity from the number of entries in an array
|
||||
/// argument. The unit selects a locale-aware plural resource.
|
||||
case localizedArrayCount(key: String, unit: CountedUnit, fallback: String)
|
||||
}
|
||||
|
||||
nonisolated enum Output: Hashable, Sendable, Codable {
|
||||
|
||||
@@ -946,15 +946,15 @@ nonisolated enum AgentToolPresentation {
|
||||
}
|
||||
|
||||
private static func hostCount(_ count: Int) -> String {
|
||||
count == 1 ? String(localized: "1 host") : String(localized: "\(count) hosts")
|
||||
String(localized: "\(count) hosts")
|
||||
}
|
||||
|
||||
private static func itemCount(_ count: Int) -> String {
|
||||
count == 1 ? String(localized: "1 item") : String(localized: "\(count) items")
|
||||
String(localized: "\(count) items")
|
||||
}
|
||||
|
||||
private static func searchResultCount(_ count: Int) -> String {
|
||||
count == 1 ? String(localized: "1 result") : String(localized: "\(count) results")
|
||||
String(localized: "\(count) results")
|
||||
}
|
||||
|
||||
private static func label(_ path: [String]) -> String {
|
||||
@@ -1097,6 +1097,11 @@ nonisolated enum AgentToolPresentation {
|
||||
let oneLine = value.split(whereSeparator: \.isNewline).joined(separator: " ")
|
||||
return oneLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? fallback : oneLine
|
||||
case .localizedArrayCount(let key, let unit, let fallback):
|
||||
guard let count = card.invocation.call.arguments.objectValue?[key]?.arrayValue?.count,
|
||||
count > 0
|
||||
else { return fallback }
|
||||
return countDescription(count, unit: unit)
|
||||
case .arrayCount(let key, let singular, let plural, let fallback):
|
||||
guard let count = card.invocation.call.arguments.objectValue?[key]?.arrayValue?.count,
|
||||
count > 0
|
||||
@@ -1108,9 +1113,8 @@ nonisolated enum AgentToolPresentation {
|
||||
private static func batchClause(_ cards: [AgentToolCard]) -> String {
|
||||
guard let first = cards.first else { return "" }
|
||||
guard let presentation = first.descriptor.presentation else {
|
||||
let operation = humanized(first.descriptor.qualifiedName)
|
||||
let activity = cards.count == 1
|
||||
? operation : "\(cards.count) \(operation) calls"
|
||||
? humanized(first.descriptor.qualifiedName) : toolCallCount(cards.count)
|
||||
return fallbackAction(for: first.state, activity: activity)
|
||||
}
|
||||
|
||||
@@ -1141,6 +1145,12 @@ nonisolated enum AgentToolPresentation {
|
||||
cards: [AgentToolCard]
|
||||
) -> String {
|
||||
switch activity {
|
||||
case .localizedArrayCount(let key, let unit, let fallback):
|
||||
let count = cards.reduce(0) { total, card in
|
||||
total + (card.invocation.call.arguments.objectValue?[key]?.arrayValue?.count ?? 0)
|
||||
}
|
||||
guard count > 0 else { return fallback }
|
||||
return countDescription(count, unit: unit)
|
||||
case .arrayCount(let key, let singular, let plural, let fallback):
|
||||
let count = cards.reduce(0) { total, card in
|
||||
total + (card.invocation.call.arguments.objectValue?[key]?.arrayValue?.count ?? 0)
|
||||
@@ -1148,12 +1158,24 @@ nonisolated enum AgentToolPresentation {
|
||||
guard count > 0 else { return fallback }
|
||||
return count == 1 ? singular : "\(count) \(plural)"
|
||||
case .argument(_, let fallback):
|
||||
return cards.count == 1 ? fallback : counted(fallback, count: cards.count)
|
||||
return cards.count == 1 ? fallback : toolCallCount(cards.count)
|
||||
case .label(let label):
|
||||
return cards.count == 1 ? label : counted(label, count: cards.count)
|
||||
return cards.count == 1 ? label : toolCallCount(cards.count)
|
||||
}
|
||||
}
|
||||
|
||||
private static func countDescription(
|
||||
_ count: Int, unit: AgentToolDescriptor.Presentation.CountedUnit
|
||||
) -> String {
|
||||
switch unit {
|
||||
case .question: String(localized: "\(count) questions")
|
||||
}
|
||||
}
|
||||
|
||||
private static func toolCallCount(_ count: Int) -> String {
|
||||
String(localized: "\(count) tool calls")
|
||||
}
|
||||
|
||||
private static func fallbackAction(
|
||||
for state: AgentToolCard.State, activity: String
|
||||
) -> String {
|
||||
@@ -1169,34 +1191,6 @@ nonisolated enum AgentToolPresentation {
|
||||
}
|
||||
}
|
||||
|
||||
private static func counted(_ phrase: String, count: Int) -> String {
|
||||
let prefixes = ["a ", "an ", "the "]
|
||||
let lower = phrase.lowercased()
|
||||
let nounPhrase = prefixes.first(where: { lower.hasPrefix($0) }).map {
|
||||
String(phrase.dropFirst($0.count))
|
||||
} ?? phrase
|
||||
return "\(count) \(pluralized(nounPhrase))"
|
||||
}
|
||||
|
||||
private static func pluralized(_ phrase: String) -> String {
|
||||
guard let space = phrase.lastIndex(of: " ") else {
|
||||
return pluralizedWord(phrase)
|
||||
}
|
||||
return String(phrase[...space]) + pluralizedWord(String(phrase[phrase.index(after: space)...]))
|
||||
}
|
||||
|
||||
private static func pluralizedWord(_ word: String) -> String {
|
||||
let lower = word.lowercased()
|
||||
if lower.hasSuffix("s") { return word }
|
||||
if lower.hasSuffix("y"), word.count > 1 {
|
||||
return String(word.dropLast()) + "ies"
|
||||
}
|
||||
if lower.hasSuffix("ch") || lower.hasSuffix("sh") || lower.hasSuffix("x") {
|
||||
return word + "es"
|
||||
}
|
||||
return word + "s"
|
||||
}
|
||||
|
||||
private static func lowercaseFirst(_ text: String) -> String {
|
||||
guard let first = text.first else { return text }
|
||||
return first.lowercased() + String(text.dropFirst())
|
||||
|
||||
@@ -277,7 +277,7 @@ private struct SSHSessionHistorySidebar: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
List {
|
||||
Section(query.isEmpty ? "Recent" : "Results") {
|
||||
Section(query.isEmpty ? "Recents" : "Results") {
|
||||
ForEach(allEntries) { entry in
|
||||
historyRow(entry)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ nonisolated enum HostChooserPurpose: Hashable, Sendable, Identifiable {
|
||||
/// connects, so it says which thing *and* where it lands.
|
||||
var workspaceConfirmLabel: LocalizedStringKey {
|
||||
switch self {
|
||||
case .connect, .batchStartup: "Connect in Workspace"
|
||||
case .connect, .batchStartup: "Connect in the same workspace"
|
||||
case .snippet: "Execute"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,8 +268,7 @@ struct HostsPageView: ManagedCardPage {
|
||||
private func deletionMessage(for group: HostGroup) -> String {
|
||||
let hosts = app.hostLibrary.hosts(inTreeOf: group.id).count
|
||||
let nested = app.hostLibrary.descendantGroups(of: group.id).count
|
||||
let hostCount = hosts == 1
|
||||
? String(localized: "1 host") : String(localized: "\(hosts) hosts")
|
||||
let hostCount = String(localized: "\(hosts) hosts")
|
||||
if nested == 0 {
|
||||
return String(localized: "This group contains \(hostCount).")
|
||||
}
|
||||
|
||||
@@ -626,9 +626,7 @@ final class SFTPSessionModel: Identifiable {
|
||||
let snapshot = try await client.listDirectory(at: requestedPath)
|
||||
guard status == .connected, path == requestedPath else { return }
|
||||
replaceDirectoryContents(with: snapshot)
|
||||
hint =
|
||||
snapshot.count == 1
|
||||
? String(localized: "1 item") : String(localized: "\(snapshot.count) items")
|
||||
hint = String(localized: "\(snapshot.count) items")
|
||||
} catch let error as SFTPError {
|
||||
if case .connectionLost(let message) = error {
|
||||
handleConnectionFailure(message, attemptID: connectionAttemptID)
|
||||
|
||||
@@ -267,9 +267,8 @@ struct MCPServerEditorPage: View {
|
||||
app.aiConfiguration.setTools(discovery.tools, version: discovery.protocolVersion, for: serverID)
|
||||
app.aiConfiguration.setMCPStatus(.connected, for: serverID)
|
||||
session.connection = .succeeded(
|
||||
discovery.tools.count == 1
|
||||
? String(localized: "\(discovery.serverName) — 1 tool")
|
||||
: String(localized: "\(discovery.serverName) — \(discovery.tools.count) tools"))
|
||||
String(localized: "\(discovery.serverName) — \(discovery.tools.count) tools")
|
||||
)
|
||||
} catch {
|
||||
app.aiConfiguration.setMCPStatus(.failed(error.localizedDescription), for: serverID)
|
||||
session.connection = .failed(error.localizedDescription)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -50,7 +50,7 @@ struct AgentToolPresentationTests {
|
||||
)
|
||||
#expect(
|
||||
AgentToolPresentation.batchSummary(for: [card, card])
|
||||
== "Ran 2 SSH commands"
|
||||
== "Ran 2 tool calls"
|
||||
)
|
||||
for state in [
|
||||
AgentToolCard.State.denied, .failed, .interrupted,
|
||||
@@ -568,6 +568,15 @@ struct AgentToolPresentationTests {
|
||||
|
||||
@Test
|
||||
func countedArrayActivityRoundTrips() throws {
|
||||
let activity = AgentToolDescriptor.Presentation.Activity.localizedArrayCount(
|
||||
key: "questions", unit: .question, fallback: "a question"
|
||||
)
|
||||
let data = try JSONEncoder().encode(activity)
|
||||
#expect(try JSONDecoder().decode(type(of: activity), from: data) == activity)
|
||||
}
|
||||
|
||||
@Test
|
||||
func legacyCountedArrayActivityStillDecodes() throws {
|
||||
let activity = AgentToolDescriptor.Presentation.Activity.arrayCount(
|
||||
key: "questions", singular: "a question", plural: "questions",
|
||||
fallback: "a question"
|
||||
|
||||
@@ -56,13 +56,83 @@ struct AppLanguageTests {
|
||||
if entry["shouldTranslate"] as? Bool == false { continue }
|
||||
let localizations = try #require(entry["localizations"] as? [String: Any])
|
||||
let chinese = try #require(localizations["zh-Hans"] as? [String: Any])
|
||||
let unit = try #require(chinese["stringUnit"] as? [String: Any])
|
||||
#expect(unit["state"] as? String == "translated", "Incomplete: \(key)")
|
||||
#expect(!(unit["value"] as? String ?? "").isEmpty || key.isEmpty)
|
||||
let units = localizedUnits(in: chinese)
|
||||
#expect(!units.isEmpty, "No translated values: \(key)")
|
||||
for unit in units {
|
||||
#expect(unit["state"] as? String == "translated", "Incomplete: \(key)")
|
||||
#expect(!(unit["value"] as? String ?? "").isEmpty || key.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func countedToolResourcesUseCatalogPluralRules() throws {
|
||||
let repository = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent().deletingLastPathComponent()
|
||||
let url = repository.appending(path: "Termind/Resources/Localizable.xcstrings")
|
||||
let root = try #require(
|
||||
JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]
|
||||
)
|
||||
let strings = try #require(root["strings"] as? [String: Any])
|
||||
|
||||
for key in [
|
||||
"%lld hosts", "%lld items", "%lld questions", "%lld results",
|
||||
"%lld tool calls", "%@ — %lld tools",
|
||||
] {
|
||||
let entry = try #require(strings[key] as? [String: Any])
|
||||
let localizations = try #require(entry["localizations"] as? [String: Any])
|
||||
let english = try #require(localizations["en"] as? [String: Any])
|
||||
let chinese = try #require(localizations["zh-Hans"] as? [String: Any])
|
||||
#expect(pluralForms(in: english).keys.sorted() == ["one", "other"])
|
||||
#expect(pluralForms(in: chinese).keys.sorted() == ["other"])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func coreChineseInterfaceTermsStayConsistent() throws {
|
||||
let repository = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent().deletingLastPathComponent()
|
||||
let url = repository.appending(path: "Termind/Resources/Localizable.xcstrings")
|
||||
let root = try #require(
|
||||
JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]
|
||||
)
|
||||
let strings = try #require(root["strings"] as? [String: Any])
|
||||
let expected = [
|
||||
"App": "App",
|
||||
"Version": "版本",
|
||||
"Welcome": "欢迎",
|
||||
"Snippets": "片段",
|
||||
"Approve for me": "帮我批准",
|
||||
"Full access": "完全访问权限",
|
||||
]
|
||||
|
||||
for (key, translation) in expected {
|
||||
let entry = try #require(strings[key] as? [String: Any])
|
||||
let localizations = try #require(entry["localizations"] as? [String: Any])
|
||||
let chinese = try #require(localizations["zh-Hans"] as? [String: Any])
|
||||
let unit = try #require(chinese["stringUnit"] as? [String: Any])
|
||||
#expect(unit["value"] as? String == translation)
|
||||
}
|
||||
}
|
||||
|
||||
private func localizedUnits(in value: [String: Any]) -> [[String: Any]] {
|
||||
if let unit = value["stringUnit"] as? [String: Any] { return [unit] }
|
||||
guard let variations = value["variations"] as? [String: Any] else { return [] }
|
||||
return variations.values.flatMap { variation -> [[String: Any]] in
|
||||
guard let forms = variation as? [String: Any] else { return [] }
|
||||
return forms.values.flatMap { form -> [[String: Any]] in
|
||||
guard let form = form as? [String: Any] else { return [] }
|
||||
return localizedUnits(in: form)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pluralForms(in localization: [String: Any]) -> [String: Any] {
|
||||
let variations = localization["variations"] as? [String: Any]
|
||||
return variations?["plural"] as? [String: Any] ?? [:]
|
||||
}
|
||||
|
||||
private func makeDefaults() throws -> UserDefaults {
|
||||
let suiteName = "AppLanguageTests.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
|
||||
Reference in new issue
Block a user