Improve Chinese localization and plural handling

This commit is contained in:
Kaede Akino committed 2026-08-27 12:11:29 +08:00
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")
)
+11 -4
View File
@@ -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 {
+29 -35
View File
@@ -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"
}
}
+1 -2
View File
@@ -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).")
}
+1 -3
View File
@@ -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
+10 -1
View File
@@ -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"
+73 -3
View File
@@ -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))