feat(dictionary): add iCloud KVS sync, cloud ASR, and lexicon expansion
Mirror the personal dictionary through iCloud Key-Value Store with deterministic merge rules, main-app-only sync UI, and App Group as the keyboard runtime cache. Add cloud-engine ASR with dictionary bias and expand the bundled custom language model lexicon.
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)com.osgkeyboard.ios</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.osgkeyboard.shared</string>
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"name": "ai-tech-brands",
|
||||
"generated_at": "2026-07-05T11:34:08.462301+00:00",
|
||||
"generated_at": "2026-07-06T13:10:52.972532+00:00",
|
||||
"locale": "zh-Hans",
|
||||
"entry_count": 1259,
|
||||
"entry_count": 3040,
|
||||
"seed_file": "Scripts/lexicon/seeds/ai_tech_brands_seed.tsv",
|
||||
"license": "MIT (curated seed; OSGKeyboard contributors)",
|
||||
"categories": {
|
||||
"ai_brand": 169,
|
||||
"ai_model": 90,
|
||||
"ai_platform": 53,
|
||||
"ai_term": 197,
|
||||
"dev_tool": 283,
|
||||
"fintech": 7,
|
||||
"tech_company": 314,
|
||||
"tech_leader": 37,
|
||||
"tech_term": 109
|
||||
"ai_brand": 284,
|
||||
"ai_model": 152,
|
||||
"ai_platform": 116,
|
||||
"ai_term": 270,
|
||||
"athlete": 73,
|
||||
"celebrity": 251,
|
||||
"consumer_brand": 215,
|
||||
"dev_tool": 330,
|
||||
"fintech": 109,
|
||||
"game_ip": 70,
|
||||
"tech_company": 601,
|
||||
"tech_leader": 202,
|
||||
"tech_term": 367
|
||||
},
|
||||
"notes": [
|
||||
"Curated bilingual AI brands, tech companies, terminology, and hot words.",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,7 +39,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
private var sessionASRWarmedLocaleID: String?
|
||||
private var asr: ASRService {
|
||||
if let sessionASR { return sessionASR }
|
||||
let service = ASRServiceFactory.make()
|
||||
let service = ASRServiceFactory.make(store: store)
|
||||
sessionASR = service
|
||||
return service
|
||||
}
|
||||
@@ -342,7 +342,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
return
|
||||
}
|
||||
sessionASR?.cancel()
|
||||
sessionASR = ASRServiceFactory.make()
|
||||
sessionASR = ASRServiceFactory.make(store: store)
|
||||
sessionASREngineMode = engineMode
|
||||
sessionASRWarmedLocaleID = nil
|
||||
}
|
||||
|
||||
@@ -140,7 +140,8 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
private var headerGradientColors: [Color] {
|
||||
if sessionIsLive {
|
||||
// 云端引擎未配置(缺 API Key)时不算就绪,保持中性灰渐变。
|
||||
if sessionIsLive, !needsCloudSetup {
|
||||
return [
|
||||
palette.accent.opacity(0.28),
|
||||
palette.accent.opacity(0.10),
|
||||
@@ -175,7 +176,14 @@ struct HomeView: View {
|
||||
.fill(flowStatusColor)
|
||||
.frame(width: 6, height: 6)
|
||||
|
||||
if flowManager.isActive,
|
||||
if needsCloudSetup {
|
||||
// 云端引擎缺 API Key:不显示就绪 / 计时 / 结束按钮。
|
||||
Text("home.flow.notReady")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.warning)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
} else if flowManager.isActive,
|
||||
let expires = flowManager.sessionExpiresAt {
|
||||
Text("home.flow.label")
|
||||
.font(TypeStyle.caption2)
|
||||
@@ -195,7 +203,10 @@ struct HomeView: View {
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
|
||||
if flowManager.isActive {
|
||||
if needsCloudSetup {
|
||||
// 无按钮:引导卡片已提示去设置填 API Key。
|
||||
EmptyView()
|
||||
} else if flowManager.isActive {
|
||||
Button {
|
||||
flowManager.endSession()
|
||||
} label: {
|
||||
@@ -306,6 +317,7 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
private var flowStatusColor: Color {
|
||||
if needsCloudSetup { return palette.warning }
|
||||
if flowManager.isActive { return palette.accent }
|
||||
if flowManager.isStarting { return palette.accent }
|
||||
if needsPermissionSetup { return palette.warning }
|
||||
|
||||
@@ -38,6 +38,10 @@ struct MainAppRoot: View {
|
||||
.onAppear {
|
||||
flowManager.setAppForeground(scenePhase == .active)
|
||||
flowManager.activateOnForeground()
|
||||
PersonalDictionaryCloudSync.shared.startObservingExternalChanges()
|
||||
Task {
|
||||
await PersonalDictionaryCloudSync.shared.pullAndMergeIfEnabled()
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .osgKeyboardOpenURL)) { notification in
|
||||
guard let url = notification.userInfo?["url"] as? URL else { return }
|
||||
@@ -50,8 +54,13 @@ struct MainAppRoot: View {
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
flowManager.handleScenePhase(phase)
|
||||
guard phase == .active, config.hasCompletedOnboarding else { return }
|
||||
flowManager.activateOnForeground()
|
||||
guard phase == .active else { return }
|
||||
if config.hasCompletedOnboarding {
|
||||
flowManager.activateOnForeground()
|
||||
}
|
||||
Task {
|
||||
await PersonalDictionaryCloudSync.shared.pullAndMergeIfEnabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// PersonalDictionaryICloudSyncRow.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Settings-row toggle for mirroring the personal dictionary through
|
||||
// iCloud Key-Value Store. Lives in Settings → 词库与润色.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
@MainActor
|
||||
struct PersonalDictionaryICloudSyncRow: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@State private var isEnabled: Bool = AppGroupStore().personalDictionaryICloudSyncEnabled
|
||||
@State private var syncErrorMessage: String?
|
||||
@State private var isApplyingToggle = false
|
||||
|
||||
private let store = AppGroupStore()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Toggle(isOn: toggleBinding) {
|
||||
Text("settings.personalDictionary.iCloudSync.settingsTitle")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.disabled(isApplyingToggle)
|
||||
|
||||
if let syncErrorMessage {
|
||||
Text(syncErrorMessage)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, Spacing.xxs)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .leading)
|
||||
.onAppear { reloadFromStore() }
|
||||
.onReceive(
|
||||
NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)
|
||||
) { _ in
|
||||
reloadFromStore()
|
||||
}
|
||||
}
|
||||
|
||||
private var toggleBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { isEnabled },
|
||||
set: { newValue in
|
||||
guard newValue != isEnabled else { return }
|
||||
if newValue {
|
||||
enableSync()
|
||||
} else {
|
||||
disableSync()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func reloadFromStore() {
|
||||
isEnabled = store.personalDictionaryICloudSyncEnabled
|
||||
}
|
||||
|
||||
private func enableSync() {
|
||||
isApplyingToggle = true
|
||||
syncErrorMessage = nil
|
||||
Task {
|
||||
do {
|
||||
try await PersonalDictionaryCloudSync.shared.enableSync()
|
||||
reloadFromStore()
|
||||
} catch let error as PersonalDictionaryCloudSyncError {
|
||||
isEnabled = false
|
||||
syncErrorMessage = localizedSyncError(error)
|
||||
} catch {
|
||||
isEnabled = false
|
||||
syncErrorMessage = error.localizedDescription
|
||||
}
|
||||
isApplyingToggle = false
|
||||
}
|
||||
}
|
||||
|
||||
private func disableSync() {
|
||||
PersonalDictionaryCloudSync.shared.disableSync()
|
||||
isEnabled = false
|
||||
syncErrorMessage = nil
|
||||
}
|
||||
|
||||
private func localizedSyncError(_ error: PersonalDictionaryCloudSyncError) -> String {
|
||||
switch error {
|
||||
case .payloadTooLarge:
|
||||
return AppL10n.string("settings.personalDictionary.iCloudSync.error.tooLarge")
|
||||
case .encodeFailed, .decodeFailed:
|
||||
return AppL10n.string("settings.personalDictionary.iCloudSync.error.generic")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,60 +80,48 @@ struct PersonalDictionaryView: View {
|
||||
saveManualEntry(term: term, editingID: editingEntry?.id)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
reloadFromStore()
|
||||
await PersonalDictionaryCloudSync.shared.pullAndMergeIfEnabled()
|
||||
reloadFromStore()
|
||||
}
|
||||
.onReceive(
|
||||
NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)
|
||||
) { _ in
|
||||
reloadFromStore()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - List
|
||||
|
||||
private var list: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: Spacing.lg) {
|
||||
introBanner
|
||||
ForEach(filteredSections, id: \.0) { category, items in
|
||||
section(for: category, items: items)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.vertical, Spacing.md)
|
||||
.tabBarScrollBottomPadding()
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "settings.personalDictionary.search.prompt")
|
||||
}
|
||||
|
||||
private var introBanner: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text("settings.personalDictionary.intro.title")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.personalDictionary.intro.body")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
private func section(for _: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry]) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(items.enumerated()), id: \.element.id) { index, entry in
|
||||
entryRow(entry)
|
||||
if index < items.count - 1 {
|
||||
Divider().background(palette.divider)
|
||||
List {
|
||||
ForEach(filteredSections, id: \.0) { _, items in
|
||||
Section {
|
||||
ForEach(items) { entry in
|
||||
entryRow(entry)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.listRowBackground(palette.surface)
|
||||
.listRowSeparatorTint(palette.divider)
|
||||
}
|
||||
.onDelete { offsets in
|
||||
delete(items: items, at: offsets)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.listSectionSpacing(Spacing.lg)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
// 让搜索栏与首个词条之间留出呼吸空间,视觉更透气。
|
||||
.contentMargins(.top, Spacing.lg, for: .scrollContent)
|
||||
.searchable(
|
||||
text: $searchText,
|
||||
placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "settings.personalDictionary.search.prompt"
|
||||
)
|
||||
.tabBarScrollBottomPadding()
|
||||
}
|
||||
|
||||
private func entryRow(_ entry: PersonalDictionary.Entry) -> some View {
|
||||
@@ -187,18 +175,10 @@ struct PersonalDictionaryView: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
delete(entry)
|
||||
} label: {
|
||||
Label("common.delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Spacer()
|
||||
Image(systemName: "square.stack.3d.down.right.fill")
|
||||
.font(.system(size: 36, weight: .regular))
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.5))
|
||||
@@ -219,8 +199,11 @@ struct PersonalDictionaryView: View {
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(palette.accent)
|
||||
.padding(.top, Spacing.sm)
|
||||
Spacer()
|
||||
}
|
||||
// 内容按自身高度居中,再上移抵消 large title 占用的顶部空间,
|
||||
// 让空状态在整屏视觉上真正垂直居中。
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
.padding(.bottom, 72)
|
||||
}
|
||||
|
||||
// MARK: - Derived data
|
||||
@@ -285,6 +268,12 @@ struct PersonalDictionaryView: View {
|
||||
persist()
|
||||
}
|
||||
|
||||
private func delete(items: [PersonalDictionary.Entry], at offsets: IndexSet) {
|
||||
for index in offsets {
|
||||
delete(items[index])
|
||||
}
|
||||
}
|
||||
|
||||
private func clearAll() {
|
||||
dictionary = .empty
|
||||
generatingAliasEntryIDs = []
|
||||
@@ -294,6 +283,13 @@ struct PersonalDictionaryView: View {
|
||||
private func persist() {
|
||||
dictionary.version += 1
|
||||
store.setPersonalDictionary(dictionary)
|
||||
Task {
|
||||
try? await PersonalDictionaryCloudSync.shared.pushLocalIfEnabled(dictionary)
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadFromStore() {
|
||||
dictionary = store.personalDictionary
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,10 @@ struct ProviderPickerSection: View {
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
|
||||
if provider.supportsPersonalDictionaryCloudASR {
|
||||
personalDictionaryBadge
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
|
||||
if selected {
|
||||
@@ -80,6 +84,16 @@ struct ProviderPickerSection: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 通义千问 / 智谱 GLM 等支持云端 ASR 热词 API 的提供商。
|
||||
private var personalDictionaryBadge: some View {
|
||||
Text("settings.provider.personalDictionaryBadge")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.accent)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 4)
|
||||
.background(palette.accentMuted, in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
enum ProviderLogo {
|
||||
|
||||
@@ -40,6 +40,7 @@ struct SettingsView: View {
|
||||
ScrollView {
|
||||
VStack(spacing: Spacing.md) {
|
||||
languageAndPolishSection
|
||||
dictionaryAndPolishSection
|
||||
flowSessionSection
|
||||
engineSection
|
||||
// v0.2.1: hide provider/api card when the
|
||||
@@ -180,15 +181,6 @@ struct SettingsView: View {
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
polishIntensityPreferenceRows
|
||||
|
||||
if config.isTranslationRowVisible {
|
||||
Divider().background(palette.divider)
|
||||
TranslationPickerRow(config: config, isVisible: true)
|
||||
}
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
cursorDragNavigationToggleRow
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
@@ -220,6 +212,30 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dictionary & polish
|
||||
|
||||
private var dictionaryAndPolishSection: some View {
|
||||
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||
sectionHeader("settings.dictionaryAndPolish.title")
|
||||
VStack(spacing: 0) {
|
||||
polishIntensityPreferenceRows
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
TranslationPickerRow(config: config, isVisible: config.isTranslationRowVisible)
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
PersonalDictionaryICloudSyncRow()
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// v0.2.1 follow-up: dedicated section for the local engine's
|
||||
/// settings (cloud-polish toggle + translation row). Renders only
|
||||
/// when `engineMode == "local"` so the cloud-engine user doesn't
|
||||
|
||||
@@ -98,8 +98,10 @@
|
||||
"settings.engine.local.ios26" = "Always on-device, no network.";
|
||||
"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish.";
|
||||
"settings.engine.cloud.title" = "Cloud recognition & polish";
|
||||
"settings.engine.cloud.subtitle" = "On-device ASR + polish via your API. Text is sent to the endpoint you configure.";
|
||||
"settings.engine.cloud.subtitle" = "Cloud ASR (with your dictionary) + API polish. Audio is sent to your provider.";
|
||||
"settings.engine.cloud.badge" = "Cloud engine";
|
||||
"settings.provider.title" = "Provider";
|
||||
"settings.provider.personalDictionaryBadge" = "Personal dictionary";
|
||||
"settings.provider.subtitle" = "Pick the LLM that polishes your dictation.";
|
||||
"provider.openai" = "OpenAI";
|
||||
"provider.deepseek" = "DeepSeek";
|
||||
@@ -141,6 +143,7 @@
|
||||
"settings.systemPrompt.hint" = "Used by the cloud LLM when polishing your dictation. Local engine skips this step.";
|
||||
"settings.about.title" = "About";
|
||||
"settings.preferences.title" = "Preferences";
|
||||
"settings.dictionaryAndPolish.title" = "Dictionary & polish";
|
||||
"settings.handedness.title" = "Handedness";
|
||||
"settings.handedness.left" = "Left hand";
|
||||
"settings.handedness.right" = "Right hand";
|
||||
@@ -283,6 +286,7 @@
|
||||
"home.flow.active" = "Voice session active";
|
||||
"home.flow.label" = "Ready";
|
||||
"home.flow.inactive" = "Voice session inactive";
|
||||
"home.flow.notReady" = "Not ready";
|
||||
"home.flow.hint" = "Switch to any app and tap the keyboard mic to dictate.";
|
||||
"home.setup.permission.mic" = "Microphone access is off — voice input won't work.";
|
||||
"home.setup.permission.speech" = "Speech recognition is off — voice input won't work.";
|
||||
@@ -346,7 +350,7 @@
|
||||
"settings.personalDictionary.intro.title" = "About your dictionary";
|
||||
"settings.personalDictionary.intro.body" = "Add words manually. Common speech-recognition mishearings are generated automatically after you save. Tap a word to edit.";
|
||||
"settings.personalDictionary.empty.title" = "No words yet";
|
||||
"settings.personalDictionary.empty.body" = "Add words you want protected during speech correction.";
|
||||
"settings.personalDictionary.empty.body" = "Add your personal dictionary to improve recognition accuracy\nSyncs across devices via iCloud";
|
||||
"settings.personalDictionary.search.prompt" = "Search words";
|
||||
"settings.personalDictionary.usageCount" = "%lld uses";
|
||||
"settings.personalDictionary.add.title" = "Add word";
|
||||
@@ -358,6 +362,11 @@
|
||||
"settings.personalDictionary.clearAll.confirmTitle" = "Clear all dictionary words?";
|
||||
"settings.personalDictionary.clearAll.message" = "This removes every word the LLM was protecting. This cannot be undone.";
|
||||
"settings.personalDictionary.clearAll.confirm" = "Clear all";
|
||||
"settings.personalDictionary.iCloudSync.title" = "Sync via iCloud";
|
||||
"settings.personalDictionary.iCloudSync.settingsTitle" = "Personal dictionary iCloud sync";
|
||||
"settings.personalDictionary.iCloudSync.subtitle" = "Keep your dictionary in sync across iPhone, iPad, and Mac. Data stays in your private iCloud account and is not used for training.";
|
||||
"settings.personalDictionary.iCloudSync.error.tooLarge" = "Dictionary is too large to sync via iCloud. Remove some entries and try again.";
|
||||
"settings.personalDictionary.iCloudSync.error.generic" = "Could not sync your dictionary with iCloud. Try again later.";
|
||||
|
||||
/* v0.3.0: Polish intensity */
|
||||
"settings.polishIntensity.title" = "Polish intensity";
|
||||
|
||||
@@ -98,8 +98,10 @@
|
||||
"settings.engine.local.ios26" = "全程在手机本地,不用联网";
|
||||
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
|
||||
"settings.engine.cloud.title" = "云端识别与润色";
|
||||
"settings.engine.cloud.subtitle" = "本地转写 + 你配置的 API 润色,文字发往该第三方服务";
|
||||
"settings.engine.cloud.subtitle" = "云端 ASR(含个性词库)+ API 润色,音频将发往第三方服务";
|
||||
"settings.engine.cloud.badge" = "云端引擎";
|
||||
"settings.provider.title" = "云端引擎";
|
||||
"settings.provider.personalDictionaryBadge" = "个性词库";
|
||||
"settings.provider.subtitle" = "选择 LLM 提供商。";
|
||||
"provider.openai" = "OpenAI";
|
||||
"provider.deepseek" = "DeepSeek";
|
||||
@@ -141,6 +143,7 @@
|
||||
"settings.systemPrompt.hint" = "云端润色时使用。本地识别模式不会用到此提示词。";
|
||||
"settings.about.title" = "关于";
|
||||
"settings.preferences.title" = "偏好设置";
|
||||
"settings.dictionaryAndPolish.title" = "词库与润色";
|
||||
"settings.handedness.title" = "握持偏好";
|
||||
"settings.handedness.left" = "左手";
|
||||
"settings.handedness.right" = "右手";
|
||||
@@ -282,6 +285,7 @@
|
||||
"home.flow.active" = "语音会话进行中";
|
||||
"home.flow.label" = "就绪";
|
||||
"home.flow.inactive" = "语音会话未启动";
|
||||
"home.flow.notReady" = "未就绪";
|
||||
"home.flow.hint" = "切到别的 App,点键盘麦克风就能说。";
|
||||
"home.setup.permission.mic" = "麦克风还没授权,语音输入用不了。";
|
||||
"home.setup.permission.speech" = "语音识别还没授权,语音输入用不了。";
|
||||
@@ -345,7 +349,7 @@
|
||||
"settings.personalDictionary.intro.title" = "关于词库";
|
||||
"settings.personalDictionary.intro.body" = "手动添加词条;保存后会自动生成常见误识别写法。点击词条可编辑。";
|
||||
"settings.personalDictionary.empty.title" = "还没有词条";
|
||||
"settings.personalDictionary.empty.body" = "添加希望在语音纠错时保护的词汇。";
|
||||
"settings.personalDictionary.empty.body" = "添加个性化词库,提升识别准确性\n词库将通过 iCloud 实现跨设备同步";
|
||||
"settings.personalDictionary.search.prompt" = "搜索词条";
|
||||
"settings.personalDictionary.usageCount" = "使用 %lld 次";
|
||||
"settings.personalDictionary.add.title" = "添加词条";
|
||||
@@ -357,9 +361,14 @@
|
||||
"settings.personalDictionary.clearAll.confirmTitle" = "清空全部词条?";
|
||||
"settings.personalDictionary.clearAll.message" = "这会移除所有受保护的词汇,且无法撤销。";
|
||||
"settings.personalDictionary.clearAll.confirm" = "全部清空";
|
||||
"settings.personalDictionary.iCloudSync.title" = "通过 iCloud 同步";
|
||||
"settings.personalDictionary.iCloudSync.settingsTitle" = "个性词库 iCloud 同步";
|
||||
"settings.personalDictionary.iCloudSync.subtitle" = "在 iPhone、iPad 和 Mac 之间同步词库。数据仅保存在你的私人 iCloud 账户中,不会用于训练。";
|
||||
"settings.personalDictionary.iCloudSync.error.tooLarge" = "词库过大,无法通过 iCloud 同步。请删除部分词条后重试。";
|
||||
"settings.personalDictionary.iCloudSync.error.generic" = "无法与 iCloud 同步词库,请稍后重试。";
|
||||
|
||||
/* v0.3.0: 润色档位 */
|
||||
"settings.polishIntensity.title" = "润色档位";
|
||||
/* v0.3.0: 润色强度 */
|
||||
"settings.polishIntensity.title" = "润色强度";
|
||||
|
||||
/* Flow 会话策略 */
|
||||
"settings.flow.title" = "语音会话";
|
||||
|
||||
@@ -30,6 +30,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public static let detectedAppContext = "config.detectedAppContext"
|
||||
public static let detectedAppContextAt = "config.detectedAppContextAt"
|
||||
public static let personalDictionary = "config.personalDictionary.v1"
|
||||
/// When true, the main app mirrors the personal dictionary via iCloud KVS.
|
||||
public static let personalDictionaryICloudSyncEnabled = "config.personalDictionary.iCloudSyncEnabled"
|
||||
/// When true, the host app auto-returns to the source app after a cold-start handoff.
|
||||
public static let flowSkipAppSwitch = "config.flowSkipAppSwitch"
|
||||
/// Raw `FlowInactivityDuration` value; session expires after this idle window.
|
||||
@@ -53,6 +55,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public var cursorDragNavigationEnabled: Bool
|
||||
public var polishIntensity: PolishIntensity
|
||||
public var personalDictionary: PersonalDictionary
|
||||
/// Opt-in iCloud KVS sync for the personal dictionary (main app only).
|
||||
public var personalDictionaryICloudSyncEnabled: Bool
|
||||
/// Auto-return to the host app after `startflow` cold start (default on).
|
||||
public var flowSkipAppSwitch: Bool
|
||||
/// Idle timeout before the Flow session ends; resets on each utterance.
|
||||
@@ -154,6 +158,12 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
}(),
|
||||
polishIntensity: resolvePolishIntensity(from: defaults),
|
||||
personalDictionary: decodePersonalDictionary(from: defaults),
|
||||
personalDictionaryICloudSyncEnabled: {
|
||||
if defaults.object(forKey: Keys.personalDictionaryICloudSyncEnabled) == nil {
|
||||
return true
|
||||
}
|
||||
return defaults.bool(forKey: Keys.personalDictionaryICloudSyncEnabled)
|
||||
}(),
|
||||
flowSkipAppSwitch: {
|
||||
if defaults.object(forKey: Keys.flowSkipAppSwitch) == nil {
|
||||
return true
|
||||
@@ -212,6 +222,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
|
||||
defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
|
||||
defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration)
|
||||
defaults.set(personalDictionaryICloudSyncEnabled, forKey: Keys.personalDictionaryICloudSyncEnabled)
|
||||
Self.encodePersonalDictionary(personalDictionary, to: defaults)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// CloudASRModels.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Cloud-engine ASR routing: which provider uses official hotwords /
|
||||
// vocabulary APIs vs. a transcription prompt bias.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// How a cloud provider applies the user's personal dictionary during ASR.
|
||||
public enum CloudASRStrategy: String, Sendable, Equatable {
|
||||
/// 智谱 GLM-ASR — `hotwords` + optional `prompt`.
|
||||
case zhipuHotwords
|
||||
/// 阿里百炼 Fun-ASR — managed `vocabulary_id` + context text.
|
||||
case alibabaVocabulary
|
||||
/// OpenAI / 小米 MiMo / 自定义端点 — `prompt` on transcription APIs.
|
||||
case prompt
|
||||
/// Moonshot 托管 API 暂无音频转写;云端引擎回退端侧 ASR。
|
||||
case localFallback
|
||||
}
|
||||
|
||||
public enum CloudASRError: Error, LocalizedError, Sendable, Equatable {
|
||||
case noAPIKey
|
||||
case invalidURL
|
||||
case http(status: Int, message: String?)
|
||||
case decoding(String)
|
||||
case transport(String)
|
||||
case emptyTranscript
|
||||
case audioTooLong
|
||||
case providerUnsupported
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .noAPIKey:
|
||||
return SharedL10n.string("error.cloudASR.noAPIKey")
|
||||
case .invalidURL:
|
||||
return SharedL10n.string("error.cloudASR.invalidURL")
|
||||
case .http(let status, let message):
|
||||
if let message, !message.isEmpty {
|
||||
return SharedL10n.format("error.cloudASR.httpWithMessage", status, message)
|
||||
}
|
||||
return SharedL10n.format("error.cloudASR.http", status)
|
||||
case .decoding(let detail):
|
||||
return SharedL10n.format("error.cloudASR.decoding", detail)
|
||||
case .transport(let detail):
|
||||
return SharedL10n.format("error.cloudASR.transport", detail)
|
||||
case .emptyTranscript:
|
||||
return SharedL10n.string("error.cloudASR.emptyTranscript")
|
||||
case .audioTooLong:
|
||||
return SharedL10n.string("error.cloudASR.audioTooLong")
|
||||
case .providerUnsupported:
|
||||
return SharedL10n.string("error.cloudASR.providerUnsupported")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum CloudASRModelCatalog {
|
||||
/// Sync Fun-ASR Flash — base64 upload, ≤ 5 min, supports context + vocabulary.
|
||||
public static let alibabaFunASRFlash = "fun-asr-flash-2026-06-15"
|
||||
/// Must match the ASR model used at recognition time.
|
||||
public static let alibabaVocabularyTargetModel = alibabaFunASRFlash
|
||||
|
||||
public static let zhipuGLMASR = "glm-asr-2512"
|
||||
public static let openAITranscribe = "gpt-4o-mini-transcribe"
|
||||
public static let openAIWhisper = "whisper-1"
|
||||
public static let mimoASR = "mimo-v2.5-asr"
|
||||
|
||||
public static let alibabaAPIBase = "https://dashscope.aliyuncs.com/api/v1"
|
||||
public static let alibabaCustomizationPath = "/services/audio/asr/customization"
|
||||
public static let alibabaMultimodalPath = "/services/aigc/multimodal-generation/generation"
|
||||
public static let zhipuTranscriptionPath = "/audio/transcriptions"
|
||||
|
||||
public static func strategy(for providerId: String) -> CloudASRStrategy {
|
||||
switch providerId {
|
||||
case "zhipu":
|
||||
return .zhipuHotwords
|
||||
case "qwen":
|
||||
return .alibabaVocabulary
|
||||
case "moonshot":
|
||||
return .localFallback
|
||||
case "openai", "mimo", "custom":
|
||||
return .prompt
|
||||
default:
|
||||
return .prompt
|
||||
}
|
||||
}
|
||||
|
||||
public static func defaultModel(for providerId: String) -> String {
|
||||
switch providerId {
|
||||
case "zhipu":
|
||||
return zhipuGLMASR
|
||||
case "qwen":
|
||||
return alibabaFunASRFlash
|
||||
case "mimo":
|
||||
return mimoASR
|
||||
case "openai", "custom":
|
||||
return openAITranscribe
|
||||
default:
|
||||
return openAITranscribe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LLMProvider {
|
||||
public var cloudASRStrategy: CloudASRStrategy {
|
||||
CloudASRModelCatalog.strategy(for: id)
|
||||
}
|
||||
|
||||
public var defaultCloudASRModel: String {
|
||||
CloudASRModelCatalog.defaultModel(for: id)
|
||||
}
|
||||
|
||||
/// Official hotwords / vocabulary APIs during cloud ASR (not prompt-only bias).
|
||||
public var supportsPersonalDictionaryCloudASR: Bool {
|
||||
switch cloudASRStrategy {
|
||||
case .zhipuHotwords, .alibabaVocabulary:
|
||||
return true
|
||||
case .prompt, .localFallback:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// PersonalDictionary+ASRBias.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Formats the user dictionary for cloud ASR bias (hotwords, Alibaba
|
||||
// vocabulary entries, or Whisper-style prompt fragments).
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
public struct AlibabaHotwordEntry: Codable, Sendable, Equatable {
|
||||
public let text: String
|
||||
public let weight: Int
|
||||
public let lang: String?
|
||||
|
||||
public init(text: String, weight: Int = 4, lang: String? = nil) {
|
||||
self.text = text
|
||||
self.weight = weight
|
||||
self.lang = lang
|
||||
}
|
||||
}
|
||||
|
||||
extension PersonalDictionary {
|
||||
/// Stable fingerprint used to decide when to refresh Alibaba vocabulary.
|
||||
public func vocabularySyncFingerprint() -> String {
|
||||
let payload = effectiveEntries
|
||||
.sorted { $0.term.localizedCaseInsensitiveCompare($1.term) == .orderedAscending }
|
||||
.map { entry in
|
||||
let aliases = entry.aliases
|
||||
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||
.joined(separator: ",")
|
||||
return "\(entry.term.lowercased())|\(aliases)"
|
||||
}
|
||||
.joined(separator: ";")
|
||||
let digest = SHA256.hash(data: Data(payload.utf8))
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// 智谱 `hotwords` — canonical terms only (aliases go to `asrPromptBias`).
|
||||
public func asrHotwords(maxCount: Int = 100) -> [String] {
|
||||
var seen = Set<String>()
|
||||
var words: [String] = []
|
||||
for entry in effectiveEntries {
|
||||
let term = entry.term.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !term.isEmpty else { continue }
|
||||
let key = term.lowercased()
|
||||
guard seen.insert(key).inserted else { continue }
|
||||
words.append(term)
|
||||
if words.count >= maxCount { break }
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
/// 阿里百炼热词表 — `text` + `weight` (+ optional `lang`).
|
||||
public func alibabaHotwordEntries(maxCount: Int = 500, defaultWeight: Int = 4) -> [AlibabaHotwordEntry] {
|
||||
var seen = Set<String>()
|
||||
var entries: [AlibabaHotwordEntry] = []
|
||||
for entry in effectiveEntries {
|
||||
let term = entry.term.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !term.isEmpty, term.count <= 15 else { continue }
|
||||
let key = term.lowercased()
|
||||
guard seen.insert(key).inserted else { continue }
|
||||
entries.append(
|
||||
AlibabaHotwordEntry(
|
||||
text: term,
|
||||
weight: defaultWeight,
|
||||
lang: Self.inferAlibabaLang(for: term)
|
||||
)
|
||||
)
|
||||
if entries.count >= maxCount { break }
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/// Whisper / OpenAI-style short prompt bias (also used by MiMo text hint).
|
||||
public func asrPromptBias(maxCharacters: Int = 800) -> String {
|
||||
let entries = effectiveEntries
|
||||
guard !entries.isEmpty else { return "" }
|
||||
|
||||
var lines: [String] = []
|
||||
for entry in entries {
|
||||
if entry.aliases.isEmpty {
|
||||
lines.append(entry.term)
|
||||
} else {
|
||||
let aliasHint = entry.aliases.prefix(4).joined(separator: ", ")
|
||||
lines.append("\(entry.term)(常见误识别:\(aliasHint))")
|
||||
}
|
||||
let joined = lines.joined(separator: ";")
|
||||
if joined.count > maxCharacters {
|
||||
if lines.count == 1 {
|
||||
return String(joined.prefix(maxCharacters))
|
||||
}
|
||||
lines.removeLast()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard !lines.isEmpty else { return "" }
|
||||
let body = lines.joined(separator: ";")
|
||||
return "用户专有词汇,转写时请优先使用以下标准写法:\(body)"
|
||||
}
|
||||
|
||||
/// Compact domain context for Alibaba Fun-ASR Flash `input_text`.
|
||||
public func alibabaContextText(maxCharacters: Int = 1200) -> String {
|
||||
let prompt = asrPromptBias(maxCharacters: maxCharacters)
|
||||
guard !prompt.isEmpty else { return "" }
|
||||
return prompt
|
||||
}
|
||||
|
||||
private static func inferAlibabaLang(for term: String) -> String? {
|
||||
let hasNonASCII = term.unicodeScalars.contains { !$0.isASCII }
|
||||
if hasNonASCII { return "zh" }
|
||||
return "en"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// PersonalDictionary+Merging.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Deterministic merge rules for iCloud KVS sync. Pure logic — no
|
||||
// NSUbiquitousKeyValueStore dependency so unit tests stay hermetic.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension PersonalDictionary {
|
||||
/// Merges two dictionary snapshots for cross-device sync.
|
||||
///
|
||||
/// Rules:
|
||||
/// - Same `id`: keep the entry with the newer `updatedAt`.
|
||||
/// - Same canonical term (case-insensitive) but different `id`: union
|
||||
/// aliases, take max `usageCount`, keep the newer entry's fields.
|
||||
public static func merge(local: PersonalDictionary, remote: PersonalDictionary) -> PersonalDictionary {
|
||||
var mergedByID: [UUID: Entry] = [:]
|
||||
var canonicalOwner: [String: UUID] = [:]
|
||||
|
||||
func insertOrMerge(_ candidate: Entry) {
|
||||
let key = candidate.term.lowercased()
|
||||
if let existingID = canonicalOwner[key], var existing = mergedByID[existingID] {
|
||||
if candidate.id == existingID {
|
||||
mergedByID[existingID] = resolveEntryConflict(existing: existing, incoming: candidate)
|
||||
return
|
||||
}
|
||||
existing = mergeSameTerm(existing: existing, incoming: candidate)
|
||||
mergedByID[existingID] = existing
|
||||
return
|
||||
}
|
||||
|
||||
if let existing = mergedByID[candidate.id] {
|
||||
mergedByID[candidate.id] = resolveEntryConflict(existing: existing, incoming: candidate)
|
||||
canonicalOwner[key] = candidate.id
|
||||
return
|
||||
}
|
||||
|
||||
mergedByID[candidate.id] = candidate
|
||||
canonicalOwner[key] = candidate.id
|
||||
}
|
||||
|
||||
for entry in local.entries { insertOrMerge(entry) }
|
||||
for entry in remote.entries { insertOrMerge(entry) }
|
||||
|
||||
let mergedEntries = mergedByID.values.sorted {
|
||||
if $0.updatedAt != $1.updatedAt {
|
||||
return $0.updatedAt > $1.updatedAt
|
||||
}
|
||||
return $0.term.localizedCaseInsensitiveCompare($1.term) == .orderedAscending
|
||||
}
|
||||
|
||||
let lastSyncedAt = [local.lastSyncedAt, remote.lastSyncedAt]
|
||||
.compactMap { $0 }
|
||||
.max()
|
||||
|
||||
return PersonalDictionary(
|
||||
entries: mergedEntries,
|
||||
version: max(local.version, remote.version) + 1,
|
||||
lastSyncedAt: lastSyncedAt
|
||||
)
|
||||
}
|
||||
|
||||
private static func resolveEntryConflict(existing: Entry, incoming: Entry) -> Entry {
|
||||
incoming.updatedAt >= existing.updatedAt ? incoming : existing
|
||||
}
|
||||
|
||||
private static func mergeSameTerm(existing: Entry, incoming: Entry) -> Entry {
|
||||
let winner = incoming.updatedAt >= existing.updatedAt ? incoming : existing
|
||||
let loser = winner.id == existing.id ? incoming : existing
|
||||
var merged = winner
|
||||
merged.aliases = unionAliases(
|
||||
existing: winner.aliases,
|
||||
incoming: loser.aliases,
|
||||
excludingTerm: winner.term
|
||||
)
|
||||
merged.usageCount = max(winner.usageCount, loser.usageCount)
|
||||
merged.updatedAt = max(winner.updatedAt, loser.updatedAt)
|
||||
return merged
|
||||
}
|
||||
|
||||
private static func unionAliases(
|
||||
existing: [String],
|
||||
incoming: [String],
|
||||
excludingTerm: String
|
||||
) -> [String] {
|
||||
let termLower = excludingTerm.lowercased()
|
||||
return Array(
|
||||
Set((existing + incoming).filter { $0.lowercased() != termLower })
|
||||
).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,33 @@ import Foundation
|
||||
public struct PersonalDictionary: Codable, Sendable, Equatable {
|
||||
public var entries: [Entry]
|
||||
public var version: Int
|
||||
/// When this dictionary blob was last successfully pushed to iCloud KVS.
|
||||
public var lastSyncedAt: Date?
|
||||
|
||||
public init(entries: [Entry] = [], version: Int = 1) {
|
||||
public init(entries: [Entry] = [], version: Int = 1, lastSyncedAt: Date? = nil) {
|
||||
self.entries = entries
|
||||
self.version = version
|
||||
self.lastSyncedAt = lastSyncedAt
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case entries
|
||||
case version
|
||||
case lastSyncedAt
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
entries = try container.decodeIfPresent([Entry].self, forKey: .entries) ?? []
|
||||
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
|
||||
lastSyncedAt = try container.decodeIfPresent(Date.self, forKey: .lastSyncedAt)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(entries, forKey: .entries)
|
||||
try container.encode(version, forKey: .version)
|
||||
try container.encodeIfPresent(lastSyncedAt, forKey: .lastSyncedAt)
|
||||
}
|
||||
|
||||
public struct Entry: Codable, Sendable, Equatable, Identifiable {
|
||||
@@ -34,6 +57,8 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
|
||||
public var category: Category
|
||||
public var source: Source
|
||||
public var createdAt: Date
|
||||
/// Last mutation time — used for iCloud merge conflict resolution.
|
||||
public var updatedAt: Date
|
||||
public var usageCount: Int
|
||||
|
||||
public init(
|
||||
@@ -43,6 +68,7 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
|
||||
category: Category,
|
||||
source: Source,
|
||||
createdAt: Date = Date(),
|
||||
updatedAt: Date? = nil,
|
||||
usageCount: Int = 0
|
||||
) {
|
||||
self.id = id
|
||||
@@ -51,9 +77,45 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
|
||||
self.category = category
|
||||
self.source = source
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt ?? createdAt
|
||||
self.usageCount = usageCount
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case term
|
||||
case aliases
|
||||
case category
|
||||
case source
|
||||
case createdAt
|
||||
case updatedAt
|
||||
case usageCount
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
term = try container.decode(String.self, forKey: .term)
|
||||
aliases = try container.decodeIfPresent([String].self, forKey: .aliases) ?? []
|
||||
category = try container.decode(Category.self, forKey: .category)
|
||||
source = try container.decode(Source.self, forKey: .source)
|
||||
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
|
||||
updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt
|
||||
usageCount = try container.decodeIfPresent(Int.self, forKey: .usageCount) ?? 0
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(term, forKey: .term)
|
||||
try container.encode(aliases, forKey: .aliases)
|
||||
try container.encode(category, forKey: .category)
|
||||
try container.encode(source, forKey: .source)
|
||||
try container.encode(createdAt, forKey: .createdAt)
|
||||
try container.encode(updatedAt, forKey: .updatedAt)
|
||||
try container.encode(usageCount, forKey: .usageCount)
|
||||
}
|
||||
|
||||
public enum Category: String, Codable, Sendable, CaseIterable {
|
||||
/// Person / place / brand / organization.
|
||||
case properNoun
|
||||
@@ -140,6 +202,7 @@ extension PersonalDictionary {
|
||||
category: .productName,
|
||||
source: .manual,
|
||||
createdAt: Date(timeIntervalSince1970: 0),
|
||||
updatedAt: Date(timeIntervalSince1970: 0),
|
||||
usageCount: 0
|
||||
),
|
||||
]
|
||||
@@ -182,6 +245,7 @@ extension PersonalDictionary {
|
||||
if termChanged || regenerateAliases {
|
||||
entry.aliases = []
|
||||
}
|
||||
entry.updatedAt = Date()
|
||||
entries[idx] = entry
|
||||
return entry
|
||||
}
|
||||
@@ -193,15 +257,19 @@ extension PersonalDictionary {
|
||||
entry.term = trimmed
|
||||
entry.category = category
|
||||
entry.source = .manual
|
||||
entry.updatedAt = Date()
|
||||
entries[idx] = entry
|
||||
return entry
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
let entry = Entry(
|
||||
term: trimmed,
|
||||
aliases: [],
|
||||
category: category,
|
||||
source: .manual
|
||||
source: .manual,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
)
|
||||
entries.append(entry)
|
||||
return entry
|
||||
@@ -216,6 +284,7 @@ extension PersonalDictionary {
|
||||
entries[idx].aliases = Array(
|
||||
Set(cleaned.filter { $0.lowercased() != termLower })
|
||||
).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||
entries[idx].updatedAt = Date()
|
||||
}
|
||||
|
||||
/// Renders the entire dictionary as a prompt fragment. Entries
|
||||
|
||||
@@ -64,7 +64,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
/// "local" → on-device ASR + built-in DeepSeek polish.
|
||||
/// "cloud" → on-device ASR + user's cloud LLM polish.
|
||||
/// "cloud" → provider cloud ASR (with personal dictionary) + user's cloud LLM polish.
|
||||
@Published public var engineMode: String {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return }
|
||||
|
||||
Binary file not shown.
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"bin_bytes" : 174285,
|
||||
"bin_bytes" : 198494,
|
||||
"bin_file" : "OSGKeyboardCLM.bin",
|
||||
"export_seconds" : 0.029847979545593262,
|
||||
"generated_at" : "2026-07-05T11:34:09Z",
|
||||
"export_seconds" : 0.035165071487426758,
|
||||
"generated_at" : "2026-07-06T13:16:27Z",
|
||||
"identifier" : "com.osgkeyboard.custom-lm.v1",
|
||||
"locale" : "zh_CN",
|
||||
"phrase_count" : 11550,
|
||||
"phrase_count" : 13329,
|
||||
"sources" : {
|
||||
"ai_tech_seed" : 1259,
|
||||
"ai_tech_seed" : 3040,
|
||||
"computer_terms" : 10300
|
||||
},
|
||||
"version" : "1.0.0"
|
||||
|
||||
@@ -121,9 +121,13 @@ public enum ASREvent: Sendable, Equatable {
|
||||
// MARK: - Factory
|
||||
|
||||
public enum ASRServiceFactory {
|
||||
/// Returns the on-device `SpeechAnalyzer` + `DictationTranscriber` backend.
|
||||
public static func make() -> ASRService {
|
||||
SpeechAnalyzerASR()
|
||||
/// Returns on-device SpeechAnalyzer for `local`, or the user's cloud
|
||||
/// ASR provider when `engineMode == "cloud"`.
|
||||
public static func make(store: AppGroupStore = AppGroupStore()) -> ASRService {
|
||||
if store.engineMode == "cloud" {
|
||||
return CloudASRService(store: store)
|
||||
}
|
||||
return SpeechAnalyzerASR()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -155,6 +155,16 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
|
||||
public func setPersonalDictionary(_ dictionary: PersonalDictionary) {
|
||||
mutateConfiguration { $0.personalDictionary = dictionary }
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public var personalDictionaryICloudSyncEnabled: Bool {
|
||||
get { configuration.personalDictionaryICloudSyncEnabled }
|
||||
set { setPersonalDictionaryICloudSyncEnabled(newValue) }
|
||||
}
|
||||
|
||||
public func setPersonalDictionaryICloudSyncEnabled(_ enabled: Bool) {
|
||||
mutateConfiguration { $0.personalDictionaryICloudSyncEnabled = enabled }
|
||||
}
|
||||
|
||||
// MARK: - Client
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// AlibabaVocabularySync.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Syncs PersonalDictionary → DashScope custom vocabulary (Fun-ASR Flash).
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AlibabaVocabularySync {
|
||||
public enum Keys {
|
||||
public static let vocabularyId = "config.alibabaASRVocabularyId"
|
||||
public static let fingerprint = "config.alibabaASRVocabularyFingerprint"
|
||||
}
|
||||
|
||||
private static let vocabularyPrefix = "osgkb"
|
||||
|
||||
/// Returns a ready `vocabulary_id`, creating or updating the remote list as needed.
|
||||
public static func ensureVocabularyID(
|
||||
dictionary: PersonalDictionary,
|
||||
apiKey: String,
|
||||
targetModel: String = CloudASRModelCatalog.alibabaVocabularyTargetModel,
|
||||
defaults: UserDefaults,
|
||||
session: URLSession = .shared
|
||||
) async throws -> String? {
|
||||
let entries = dictionary.alibabaHotwordEntries()
|
||||
guard !entries.isEmpty else {
|
||||
clearCache(defaults: defaults)
|
||||
return nil
|
||||
}
|
||||
|
||||
let fingerprint = dictionary.vocabularySyncFingerprint()
|
||||
if let cachedID = defaults.string(forKey: Keys.vocabularyId),
|
||||
defaults.string(forKey: Keys.fingerprint) == fingerprint,
|
||||
!cachedID.isEmpty {
|
||||
return cachedID
|
||||
}
|
||||
|
||||
let url = try customizationURL()
|
||||
if let existingID = defaults.string(forKey: Keys.vocabularyId), !existingID.isEmpty {
|
||||
try await updateVocabulary(
|
||||
id: existingID,
|
||||
entries: entries,
|
||||
apiKey: apiKey,
|
||||
url: url,
|
||||
session: session
|
||||
)
|
||||
cache(id: existingID, fingerprint: fingerprint, defaults: defaults)
|
||||
return existingID
|
||||
}
|
||||
|
||||
let createdID = try await createVocabulary(
|
||||
entries: entries,
|
||||
targetModel: targetModel,
|
||||
apiKey: apiKey,
|
||||
url: url,
|
||||
session: session
|
||||
)
|
||||
cache(id: createdID, fingerprint: fingerprint, defaults: defaults)
|
||||
return createdID
|
||||
}
|
||||
|
||||
public static func clearCache(defaults: UserDefaults) {
|
||||
defaults.removeObject(forKey: Keys.vocabularyId)
|
||||
defaults.removeObject(forKey: Keys.fingerprint)
|
||||
}
|
||||
|
||||
private static func cache(id: String, fingerprint: String, defaults: UserDefaults) {
|
||||
defaults.set(id, forKey: Keys.vocabularyId)
|
||||
defaults.set(fingerprint, forKey: Keys.fingerprint)
|
||||
}
|
||||
|
||||
private static func customizationURL() throws -> URL {
|
||||
let raw = CloudASRModelCatalog.alibabaAPIBase + CloudASRModelCatalog.alibabaCustomizationPath
|
||||
guard let url = URL(string: raw) else { throw CloudASRError.invalidURL }
|
||||
return url
|
||||
}
|
||||
|
||||
private static func createVocabulary(
|
||||
entries: [AlibabaHotwordEntry],
|
||||
targetModel: String,
|
||||
apiKey: String,
|
||||
url: URL,
|
||||
session: URLSession
|
||||
) async throws -> String {
|
||||
let vocabulary = entries.map { entry -> [String: Any] in
|
||||
var item: [String: Any] = ["text": entry.text, "weight": entry.weight]
|
||||
if let lang = entry.lang { item["lang"] = lang }
|
||||
return item
|
||||
}
|
||||
let body: [String: Any] = [
|
||||
"model": "speech-biasing",
|
||||
"input": [
|
||||
"action": "create_vocabulary",
|
||||
"target_model": targetModel,
|
||||
"prefix": vocabularyPrefix,
|
||||
"vocabulary": vocabulary,
|
||||
] as [String: Any],
|
||||
]
|
||||
let data = try await postJSON(body, to: url, apiKey: apiKey, session: session)
|
||||
guard let id = parseVocabularyID(from: data) else {
|
||||
throw CloudASRError.decoding("missing vocabulary_id")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
private static func updateVocabulary(
|
||||
id: String,
|
||||
entries: [AlibabaHotwordEntry],
|
||||
apiKey: String,
|
||||
url: URL,
|
||||
session: URLSession
|
||||
) async throws {
|
||||
let vocabulary = entries.map { entry -> [String: Any] in
|
||||
var item: [String: Any] = ["text": entry.text, "weight": entry.weight]
|
||||
if let lang = entry.lang { item["lang"] = lang }
|
||||
return item
|
||||
}
|
||||
let body: [String: Any] = [
|
||||
"model": "speech-biasing",
|
||||
"input": [
|
||||
"action": "update_vocabulary",
|
||||
"vocabulary_id": id,
|
||||
"vocabulary": vocabulary,
|
||||
] as [String: Any],
|
||||
]
|
||||
_ = try await postJSON(body, to: url, apiKey: apiKey, session: session)
|
||||
}
|
||||
|
||||
private static func postJSON(
|
||||
_ body: [String: Any],
|
||||
to url: URL,
|
||||
apiKey: String,
|
||||
session: URLSession
|
||||
) async throws -> [String: Any] {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw CloudASRError.transport("non-HTTP response")
|
||||
}
|
||||
let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let message = parseAPIErrorMessage(from: json)
|
||||
throw CloudASRError.http(status: http.statusCode, message: message)
|
||||
}
|
||||
return json ?? [:]
|
||||
}
|
||||
|
||||
private static func parseVocabularyID(from json: [String: Any]) -> String? {
|
||||
if let output = json["output"] as? [String: Any],
|
||||
let id = output["vocabulary_id"] as? String {
|
||||
return id
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func parseAPIErrorMessage(from json: [String: Any]?) -> String? {
|
||||
guard let json else { return nil }
|
||||
if let message = json["message"] as? String { return message }
|
||||
if let error = json["error"] as? [String: Any],
|
||||
let message = error["message"] as? String {
|
||||
return message
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
// CloudASRClients.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Provider-specific cloud ASR backends with personal-dictionary bias.
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol CloudASRTranscribing: Sendable {
|
||||
func prepare(dictionary: PersonalDictionary) async throws
|
||||
func transcribe(
|
||||
samples: [Float],
|
||||
sampleRate: Int,
|
||||
locale: Locale,
|
||||
dictionary: PersonalDictionary
|
||||
) async throws -> String
|
||||
}
|
||||
|
||||
public enum CloudASRClientFactory {
|
||||
public static func make(store: AppGroupStore, session: URLSession = .shared) -> CloudASRTranscribing {
|
||||
let strategy = CloudASRModelCatalog.strategy(for: store.providerId)
|
||||
switch strategy {
|
||||
case .zhipuHotwords:
|
||||
return ZhipuCloudASRClient(
|
||||
apiKey: store.apiKey,
|
||||
model: CloudASRModelCatalog.defaultModel(for: store.providerId),
|
||||
session: session
|
||||
)
|
||||
case .alibabaVocabulary:
|
||||
return AlibabaFunASRClient(
|
||||
apiKey: store.apiKey,
|
||||
model: CloudASRModelCatalog.defaultModel(for: store.providerId),
|
||||
store: store,
|
||||
session: session
|
||||
)
|
||||
case .prompt:
|
||||
return PromptCloudASRClient(
|
||||
providerId: store.providerId,
|
||||
baseURL: store.baseURL,
|
||||
apiKey: store.apiKey,
|
||||
model: CloudASRModelCatalog.defaultModel(for: store.providerId),
|
||||
session: session
|
||||
)
|
||||
case .localFallback:
|
||||
return UnsupportedCloudASRClient(providerId: store.providerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Zhipu (hotwords + prompt)
|
||||
|
||||
struct ZhipuCloudASRClient: CloudASRTranscribing {
|
||||
let apiKey: String
|
||||
let model: String
|
||||
let session: URLSession
|
||||
|
||||
private static let maxDurationSeconds: TimeInterval = 30
|
||||
|
||||
func prepare(dictionary: PersonalDictionary) async throws {}
|
||||
|
||||
func transcribe(
|
||||
samples: [Float],
|
||||
sampleRate: Int,
|
||||
locale: Locale,
|
||||
dictionary: PersonalDictionary
|
||||
) async throws -> String {
|
||||
guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey }
|
||||
let duration = Double(samples.count) / Double(sampleRate)
|
||||
guard duration <= Self.maxDurationSeconds else { throw CloudASRError.audioTooLong }
|
||||
|
||||
let wav = PCMSampleWavEncoder.encode(samples: samples, sampleRate: sampleRate)
|
||||
let urlString = "https://open.bigmodel.cn/api/paas/v4\(CloudASRModelCatalog.zhipuTranscriptionPath)"
|
||||
guard let url = URL(string: urlString) else { throw CloudASRError.invalidURL }
|
||||
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
var body = Data()
|
||||
func appendField(_ name: String, _ value: String) {
|
||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!)
|
||||
body.append("\(value)\r\n".data(using: .utf8)!)
|
||||
}
|
||||
|
||||
appendField("model", model)
|
||||
appendField("stream", "false")
|
||||
|
||||
let hotwords = dictionary.asrHotwords()
|
||||
if !hotwords.isEmpty,
|
||||
let hotwordsJSON = try? JSONSerialization.data(withJSONObject: hotwords),
|
||||
let hotwordsString = String(data: hotwordsJSON, encoding: .utf8) {
|
||||
appendField("hotwords", hotwordsString)
|
||||
}
|
||||
|
||||
let prompt = dictionary.asrPromptBias()
|
||||
if !prompt.isEmpty {
|
||||
appendField("prompt", prompt)
|
||||
}
|
||||
|
||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"chunk.wav\"\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Type: audio/wav\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(wav)
|
||||
body.append("\r\n".data(using: .utf8)!)
|
||||
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = body
|
||||
request.timeoutInterval = 60
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try Self.validateHTTP(response: response, data: data)
|
||||
guard let text = Self.parseZhipuText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty else {
|
||||
throw CloudASRError.emptyTranscript
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private static func parseZhipuText(from data: Data) -> String? {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
return json["text"] as? String
|
||||
}
|
||||
|
||||
fileprivate static func validateHTTP(response: URLResponse, data: Data) throws {
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw CloudASRError.transport("non-HTTP response")
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let message = parseErrorMessage(from: data)
|
||||
throw CloudASRError.http(status: http.statusCode, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate static func parseErrorMessage(from data: Data) -> String? {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
if let message = json["message"] as? String { return message }
|
||||
if let error = json["error"] as? [String: Any],
|
||||
let message = error["message"] as? String {
|
||||
return message
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Alibaba Fun-ASR Flash (vocabulary_id + context)
|
||||
|
||||
struct AlibabaFunASRClient: CloudASRTranscribing {
|
||||
let apiKey: String
|
||||
let model: String
|
||||
// Hold the (@unchecked Sendable) AppGroupStore rather than a raw
|
||||
// UserDefaults so this struct stays Sendable under strict concurrency.
|
||||
let store: AppGroupStore
|
||||
let session: URLSession
|
||||
|
||||
func prepare(dictionary: PersonalDictionary) async throws {
|
||||
_ = try await AlibabaVocabularySync.ensureVocabularyID(
|
||||
dictionary: dictionary,
|
||||
apiKey: apiKey,
|
||||
targetModel: CloudASRModelCatalog.alibabaVocabularyTargetModel,
|
||||
defaults: store.defaults,
|
||||
session: session
|
||||
)
|
||||
}
|
||||
|
||||
func transcribe(
|
||||
samples: [Float],
|
||||
sampleRate: Int,
|
||||
locale: Locale,
|
||||
dictionary: PersonalDictionary
|
||||
) async throws -> String {
|
||||
guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey }
|
||||
|
||||
let vocabularyID = try await AlibabaVocabularySync.ensureVocabularyID(
|
||||
dictionary: dictionary,
|
||||
apiKey: apiKey,
|
||||
targetModel: CloudASRModelCatalog.alibabaVocabularyTargetModel,
|
||||
defaults: store.defaults,
|
||||
session: session
|
||||
)
|
||||
|
||||
let dataURI = PCMSampleWavEncoder.dataURI(samples: samples, sampleRate: sampleRate)
|
||||
let urlString = CloudASRModelCatalog.alibabaAPIBase + CloudASRModelCatalog.alibabaMultimodalPath
|
||||
guard let url = URL(string: urlString) else { throw CloudASRError.invalidURL }
|
||||
|
||||
var messages: [[String: Any]] = []
|
||||
let context = dictionary.alibabaContextText()
|
||||
if !context.isEmpty {
|
||||
messages.append([
|
||||
"role": "user",
|
||||
"content": [
|
||||
["type": "input_text", "text": context],
|
||||
],
|
||||
])
|
||||
}
|
||||
messages.append([
|
||||
"role": "user",
|
||||
"content": [
|
||||
[
|
||||
"type": "input_audio",
|
||||
"input_audio": ["data": dataURI],
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
var parameters: [String: Any] = [
|
||||
"format": "wav",
|
||||
"sample_rate": "\(sampleRate)",
|
||||
]
|
||||
if let vocabularyID, !vocabularyID.isEmpty {
|
||||
parameters["vocabulary_id"] = vocabularyID
|
||||
}
|
||||
|
||||
let body: [String: Any] = [
|
||||
"model": model,
|
||||
"input": ["messages": messages],
|
||||
"parameters": parameters,
|
||||
]
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("disable", forHTTPHeaderField: "X-DashScope-SSE")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
request.timeoutInterval = 90
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try ZhipuCloudASRClient.validateHTTP(response: response, data: data)
|
||||
guard let text = Self.parseText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty else {
|
||||
throw CloudASRError.emptyTranscript
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private static func parseText(from data: Data) -> String? {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let output = json["output"] as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
if let text = output["text"] as? String { return text }
|
||||
if let sentence = output["sentence"] as? [String: Any],
|
||||
let text = sentence["text"] as? String {
|
||||
return text
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prompt-biased transcription (OpenAI / MiMo / custom)
|
||||
|
||||
struct PromptCloudASRClient: CloudASRTranscribing {
|
||||
let providerId: String
|
||||
let baseURL: String
|
||||
let apiKey: String
|
||||
let model: String
|
||||
let session: URLSession
|
||||
|
||||
func prepare(dictionary: PersonalDictionary) async throws {}
|
||||
|
||||
func transcribe(
|
||||
samples: [Float],
|
||||
sampleRate: Int,
|
||||
locale: Locale,
|
||||
dictionary: PersonalDictionary
|
||||
) async throws -> String {
|
||||
guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey }
|
||||
if providerId == "mimo" {
|
||||
return try await transcribeMiMo(
|
||||
samples: samples,
|
||||
sampleRate: sampleRate,
|
||||
dictionary: dictionary
|
||||
)
|
||||
}
|
||||
return try await transcribeOpenAIStyle(
|
||||
samples: samples,
|
||||
sampleRate: sampleRate,
|
||||
dictionary: dictionary
|
||||
)
|
||||
}
|
||||
|
||||
private func transcribeOpenAIStyle(
|
||||
samples: [Float],
|
||||
sampleRate: Int,
|
||||
dictionary: PersonalDictionary
|
||||
) async throws -> String {
|
||||
let wav = PCMSampleWavEncoder.encode(samples: samples, sampleRate: sampleRate)
|
||||
let trimmedBase = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL
|
||||
let urlString = "\(trimmedBase)/audio/transcriptions"
|
||||
guard let url = URL(string: urlString) else { throw CloudASRError.invalidURL }
|
||||
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
var body = Data()
|
||||
func appendField(_ name: String, _ value: String) {
|
||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!)
|
||||
body.append("\(value)\r\n".data(using: .utf8)!)
|
||||
}
|
||||
|
||||
appendField("model", model)
|
||||
let prompt = dictionary.asrPromptBias(maxCharacters: 600)
|
||||
if !prompt.isEmpty {
|
||||
appendField("prompt", prompt)
|
||||
}
|
||||
|
||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"chunk.wav\"\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Type: audio/wav\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(wav)
|
||||
body.append("\r\n".data(using: .utf8)!)
|
||||
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = body
|
||||
request.timeoutInterval = 90
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try ZhipuCloudASRClient.validateHTTP(response: response, data: data)
|
||||
guard let text = Self.parseOpenAIText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty else {
|
||||
throw CloudASRError.emptyTranscript
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private func transcribeMiMo(
|
||||
samples: [Float],
|
||||
sampleRate: Int,
|
||||
dictionary: PersonalDictionary
|
||||
) async throws -> String {
|
||||
let dataURI = PCMSampleWavEncoder.dataURI(samples: samples, sampleRate: sampleRate)
|
||||
let trimmedBase = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL
|
||||
let urlString = "\(trimmedBase)/chat/completions"
|
||||
guard let url = URL(string: urlString) else { throw CloudASRError.invalidURL }
|
||||
|
||||
var userContent: [[String: Any]] = []
|
||||
let prompt = dictionary.asrPromptBias()
|
||||
if !prompt.isEmpty {
|
||||
userContent.append(["type": "text", "text": prompt])
|
||||
}
|
||||
userContent.append([
|
||||
"type": "input_audio",
|
||||
"input_audio": ["data": dataURI],
|
||||
])
|
||||
|
||||
let body: [String: Any] = [
|
||||
"model": model,
|
||||
"messages": [
|
||||
["role": "user", "content": userContent],
|
||||
],
|
||||
"asr_options": ["language": "auto"],
|
||||
]
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(apiKey, forHTTPHeaderField: "api-key")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
request.timeoutInterval = 90
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try ZhipuCloudASRClient.validateHTTP(response: response, data: data)
|
||||
guard let text = Self.parseChatCompletionText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty else {
|
||||
throw CloudASRError.emptyTranscript
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private static func parseOpenAIText(from data: Data) -> String? {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
return json["text"] as? String
|
||||
}
|
||||
|
||||
private static func parseChatCompletionText(from data: Data) -> String? {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let choices = json["choices"] as? [[String: Any]],
|
||||
let first = choices.first,
|
||||
let message = first["message"] as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
return message["content"] as? String
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Unsupported hosted ASR (Moonshot)
|
||||
|
||||
struct UnsupportedCloudASRClient: CloudASRTranscribing {
|
||||
let providerId: String
|
||||
|
||||
func prepare(dictionary: PersonalDictionary) async throws {}
|
||||
|
||||
func transcribe(
|
||||
samples: [Float],
|
||||
sampleRate: Int,
|
||||
locale: Locale,
|
||||
dictionary: PersonalDictionary
|
||||
) async throws -> String {
|
||||
throw CloudASRError.providerUnsupported
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// CloudASRService.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Cloud-engine ASR: uploads PCM chunks to the user's configured provider
|
||||
// with personal-dictionary bias. Moonshot falls back to on-device ASR.
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
public final class CloudASRService: ASRService, @unchecked Sendable {
|
||||
private let store: AppGroupStore
|
||||
private let session: URLSession
|
||||
private let localFallback: ASRService
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
private var client: CloudASRTranscribing?
|
||||
private var usesLocalFallback = false
|
||||
private var boundProviderId: String?
|
||||
private var cancelled = false
|
||||
|
||||
public init(
|
||||
store: AppGroupStore = AppGroupStore(),
|
||||
session: URLSession = .shared,
|
||||
localFallback: ASRService? = nil
|
||||
) {
|
||||
self.store = store
|
||||
self.session = session
|
||||
// `SpeechAnalyzerASR` is internal, so it can't appear in a public
|
||||
// default argument value — resolve the fallback in the body instead.
|
||||
self.localFallback = localFallback ?? SpeechAnalyzerASR()
|
||||
}
|
||||
|
||||
public func resetForNewUtterance() {
|
||||
lock.withLock { cancelled = false }
|
||||
if usesLocalFallback {
|
||||
localFallback.resetForNewUtterance()
|
||||
}
|
||||
}
|
||||
|
||||
public func warmup(locale: Locale) async {
|
||||
bindClientIfNeeded()
|
||||
if usesLocalFallback {
|
||||
await localFallback.warmup(locale: locale)
|
||||
return
|
||||
}
|
||||
guard let client = lock.withLock({ client }) else { return }
|
||||
do {
|
||||
try await client.prepare(dictionary: store.personalDictionary)
|
||||
} catch {
|
||||
OSGLog.asr.warning("cloud ASR vocabulary prepare failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
public func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
||||
guard !samples.isEmpty else { return .success("") }
|
||||
if Task.isCancelled || lock.withLock({ cancelled }) { return .cancelled }
|
||||
|
||||
bindClientIfNeeded()
|
||||
if usesLocalFallback {
|
||||
return await localFallback.transcribeChunk(samples: samples, locale: locale)
|
||||
}
|
||||
|
||||
guard let client = lock.withLock({ client }) else {
|
||||
return .failure(CloudASRError.providerUnsupported.localizedDescription)
|
||||
}
|
||||
|
||||
do {
|
||||
let text = try await client.transcribe(
|
||||
samples: samples,
|
||||
sampleRate: 16_000,
|
||||
locale: locale,
|
||||
dictionary: store.personalDictionary
|
||||
)
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? .success("") : .success(trimmed)
|
||||
} catch is CancellationError {
|
||||
return .cancelled
|
||||
} catch {
|
||||
return .failure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
public func transcribe(
|
||||
stream: AsyncStream<AudioBufferSnapshot>,
|
||||
locale: Locale
|
||||
) -> AsyncStream<ASREvent> {
|
||||
bindClientIfNeeded()
|
||||
if usesLocalFallback {
|
||||
return localFallback.transcribe(stream: stream, locale: locale)
|
||||
}
|
||||
|
||||
return AsyncStream { continuation in
|
||||
continuation.yield(.capability(onDeviceSupported: false))
|
||||
let task = Task {
|
||||
var samples: [Float] = []
|
||||
for await snap in stream {
|
||||
if Task.isCancelled { break }
|
||||
samples.append(contentsOf: snap.samples)
|
||||
}
|
||||
guard !Task.isCancelled, !self.lock.withLock({ self.cancelled }) else {
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
guard !samples.isEmpty else {
|
||||
continuation.yield(.error(SharedL10n.string("error.asr.noSpeech")))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
|
||||
switch await self.transcribeChunk(samples: samples, locale: locale) {
|
||||
case .success(let text):
|
||||
if text.isEmpty {
|
||||
continuation.yield(.error(SharedL10n.string("error.asr.noSpeech")))
|
||||
} else {
|
||||
continuation.yield(.final(text))
|
||||
}
|
||||
case .failure(let message):
|
||||
continuation.yield(.error(message))
|
||||
case .cancelled:
|
||||
break
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
task.cancel()
|
||||
self.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func cancel() {
|
||||
lock.withLock { cancelled = true }
|
||||
localFallback.cancel()
|
||||
}
|
||||
|
||||
private func bindClientIfNeeded() {
|
||||
let providerId = store.providerId
|
||||
let strategy = CloudASRModelCatalog.strategy(for: providerId)
|
||||
lock.withLock {
|
||||
guard boundProviderId != providerId else { return }
|
||||
boundProviderId = providerId
|
||||
usesLocalFallback = strategy == .localFallback
|
||||
client = usesLocalFallback
|
||||
? nil
|
||||
: CloudASRClientFactory.make(store: store, session: session)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,7 @@ public final class LiveDictationController: ObservableObject {
|
||||
private var didInstallTap = false
|
||||
|
||||
public init(asr: ASRService? = nil) {
|
||||
self.asr = asr ?? ASRServiceFactory.make()
|
||||
self.asr = asr ?? ASRServiceFactory.make(store: AppGroupStore())
|
||||
}
|
||||
|
||||
/// Start dictation using a persisted settings locale id (`auto`, `zh-Hans`, …).
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
// PersonalDictionaryCloudSync.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Mirrors the personal dictionary through iCloud Key-Value Store while
|
||||
// keeping App Group UserDefaults as the keyboard extension's runtime
|
||||
// source of truth. Intended for main-app call sites only.
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Notification.Name {
|
||||
/// Posted after a remote KVS pull updates the App Group dictionary.
|
||||
static let personalDictionaryDidSyncFromCloud = Notification.Name(
|
||||
"com.osgkeyboard.personalDictionary.didSyncFromCloud"
|
||||
)
|
||||
}
|
||||
|
||||
public enum PersonalDictionaryCloudSyncError: Error, Equatable, Sendable {
|
||||
case payloadTooLarge(byteCount: Int)
|
||||
case encodeFailed
|
||||
case decodeFailed
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class PersonalDictionaryCloudSync {
|
||||
public static let shared = PersonalDictionaryCloudSync()
|
||||
|
||||
public static let kvsKey = "personalDictionary.v1"
|
||||
/// Stay below the ~1 MB per-key KVS limit.
|
||||
public static let maxPayloadBytes = 900_000
|
||||
|
||||
private let kvs: UbiquitousKeyValueStoreing
|
||||
private let makeStore: () -> AppGroupStore
|
||||
private var externalChangeObserver: NSObjectProtocol?
|
||||
|
||||
public init(
|
||||
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
|
||||
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() }
|
||||
) {
|
||||
self.kvs = kvs
|
||||
self.makeStore = makeStore
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
public func startObservingExternalChanges() {
|
||||
guard externalChangeObserver == nil else { return }
|
||||
externalChangeObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
await self.pullAndMergeIfEnabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func stopObservingExternalChanges() {
|
||||
if let externalChangeObserver {
|
||||
NotificationCenter.default.removeObserver(externalChangeObserver)
|
||||
self.externalChangeObserver = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull remote changes on launch / foreground when sync is enabled.
|
||||
public func pullAndMergeIfEnabled() async {
|
||||
let store = makeStore()
|
||||
guard store.personalDictionaryICloudSyncEnabled else { return }
|
||||
await pullAndMerge(store: store)
|
||||
}
|
||||
|
||||
/// Push the current local dictionary when sync is enabled.
|
||||
public func pushLocalIfEnabled(_ dictionary: PersonalDictionary) async throws {
|
||||
let store = makeStore()
|
||||
guard store.personalDictionaryICloudSyncEnabled else { return }
|
||||
try push(dictionary)
|
||||
}
|
||||
|
||||
/// Enable sync: merge local + remote, persist locally, then upload.
|
||||
public func enableSync() async throws {
|
||||
let store = makeStore()
|
||||
store.setPersonalDictionaryICloudSyncEnabled(true)
|
||||
|
||||
let local = store.personalDictionary
|
||||
let remote = loadRemote() ?? .empty
|
||||
let merged = PersonalDictionary.merge(local: local, remote: remote)
|
||||
store.setPersonalDictionary(merged)
|
||||
try push(merged)
|
||||
}
|
||||
|
||||
public func disableSync() {
|
||||
makeStore().setPersonalDictionaryICloudSyncEnabled(false)
|
||||
}
|
||||
|
||||
// MARK: - Core operations
|
||||
|
||||
public func pullAndMerge(store: AppGroupStore) async {
|
||||
guard store.personalDictionaryICloudSyncEnabled else { return }
|
||||
|
||||
let local = store.personalDictionary
|
||||
guard let remote = loadRemote() else { return }
|
||||
|
||||
let merged = PersonalDictionary.merge(local: local, remote: remote)
|
||||
guard merged != local else { return }
|
||||
|
||||
store.setPersonalDictionary(merged)
|
||||
NotificationCenter.default.post(name: .personalDictionaryDidSyncFromCloud, object: nil)
|
||||
}
|
||||
|
||||
public func push(_ dictionary: PersonalDictionary) throws {
|
||||
var payload = dictionary
|
||||
payload.lastSyncedAt = Date()
|
||||
let data = try encode(payload)
|
||||
kvs.set(data, forKey: Self.kvsKey)
|
||||
_ = kvs.synchronize()
|
||||
}
|
||||
|
||||
public func loadRemote() -> PersonalDictionary? {
|
||||
guard let data = kvs.data(forKey: Self.kvsKey) else { return nil }
|
||||
return try? decode(data)
|
||||
}
|
||||
|
||||
// MARK: - Encoding
|
||||
|
||||
public func encode(_ dictionary: PersonalDictionary) throws -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
guard let data = try? encoder.encode(dictionary) else {
|
||||
throw PersonalDictionaryCloudSyncError.encodeFailed
|
||||
}
|
||||
guard data.count <= Self.maxPayloadBytes else {
|
||||
throw PersonalDictionaryCloudSyncError.payloadTooLarge(byteCount: data.count)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
public func decode(_ data: Data) throws -> PersonalDictionary {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
guard let dictionary = try? decoder.decode(PersonalDictionary.self, from: data) else {
|
||||
throw PersonalDictionaryCloudSyncError.decodeFailed
|
||||
}
|
||||
return dictionary
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
// UbiquitousKeyValueStoreing.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Test seam around `NSUbiquitousKeyValueStore`.
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol UbiquitousKeyValueStoreing: AnyObject {
|
||||
func data(forKey key: String) -> Data?
|
||||
func set(_ value: Data?, forKey key: String)
|
||||
@discardableResult
|
||||
func synchronize() -> Bool
|
||||
}
|
||||
|
||||
extension NSUbiquitousKeyValueStore: UbiquitousKeyValueStoreing {}
|
||||
@@ -10,8 +10,8 @@
|
||||
// English dictation while halving the network round-trip.
|
||||
//
|
||||
// Engine matrix:
|
||||
// - `engineMode == "cloud"` → on-device ASR, then user's cloud LLM
|
||||
// - `engineMode == "local"` → on-device ASR, then built-in DeepSeek
|
||||
// - `engineMode == "cloud"` → provider cloud ASR + user's cloud LLM
|
||||
// - `engineMode == "local"` → on-device ASR + built-in DeepSeek
|
||||
// - Ultra-short, structure-free utterances skip the LLM entirely
|
||||
// - Cloud without API key → raw + `.missingAPIKey` warning
|
||||
// - Local without build key → raw + `.missingAPIKey` warning
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// or a safety timeout elapses (symmetric to pre-roll at utterance start).
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Tunable tail-drain policy shared by Flow capture and preview dictation.
|
||||
public struct FlowCaptureTailDrainPolicy: Sendable, Equatable {
|
||||
|
||||
@@ -9,8 +9,7 @@ import os
|
||||
public enum FlowPipelineDiagnostics {
|
||||
public static func logDrain(_ report: FlowCaptureDrainReport) {
|
||||
OSGLog.flow.info(
|
||||
"tailDrain duration=\(report.drainDurationSeconds, format: .fixed(precision: 2))s " +
|
||||
"silenceEnd=\(report.endedBySilence) tailSamples=\(report.tailSampleCount)"
|
||||
"tailDrain duration=\(report.drainDurationSeconds, format: .fixed(precision: 2))s silenceEnd=\(report.endedBySilence) tailSamples=\(report.tailSampleCount)"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,8 +20,7 @@ public enum FlowPipelineDiagnostics {
|
||||
chunkWarnings: Int
|
||||
) {
|
||||
OSGLog.flow.info(
|
||||
"chunkPipeline chunks=\(chunkCount) lastChunkSamples=\(lastChunkSamples) " +
|
||||
"stitchedLen=\(stitchedLength) warnings=\(chunkWarnings)"
|
||||
"chunkPipeline chunks=\(chunkCount) lastChunkSamples=\(lastChunkSamples) stitchedLen=\(stitchedLength) warnings=\(chunkWarnings)"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// PCMSampleWavEncoder.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Encodes mono Float32 PCM (@ 16 kHz) into a minimal WAV byte stream for
|
||||
// cloud ASR multipart / base64 uploads.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum PCMSampleWavEncoder {
|
||||
public static func encode(samples: [Float], sampleRate: Int = 16_000) -> Data {
|
||||
guard !samples.isEmpty else {
|
||||
return encode(pcm16: [], sampleRate: sampleRate)
|
||||
}
|
||||
var pcm16 = [Int16]()
|
||||
pcm16.reserveCapacity(samples.count)
|
||||
for sample in samples {
|
||||
let scaled = sample * 32_767.0
|
||||
let clipped = Swift.max(-32_768.0, Swift.min(32_767.0, scaled))
|
||||
pcm16.append(Int16(clipped.rounded()))
|
||||
}
|
||||
return encode(pcm16: pcm16, sampleRate: sampleRate)
|
||||
}
|
||||
|
||||
public static func encode(pcm16: [Int16], sampleRate: Int) -> Data {
|
||||
let byteRate = sampleRate * 2
|
||||
let dataSize = pcm16.count * MemoryLayout<Int16>.size
|
||||
var data = Data()
|
||||
data.reserveCapacity(44 + dataSize)
|
||||
|
||||
func appendASCII(_ string: String) {
|
||||
data.append(contentsOf: string.utf8)
|
||||
}
|
||||
func appendLE32(_ value: UInt32) {
|
||||
var le = value.littleEndian
|
||||
withUnsafeBytes(of: &le) { data.append(contentsOf: $0) }
|
||||
}
|
||||
func appendLE16(_ value: UInt16) {
|
||||
var le = value.littleEndian
|
||||
withUnsafeBytes(of: &le) { data.append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
appendASCII("RIFF")
|
||||
appendLE32(UInt32(36 + dataSize))
|
||||
appendASCII("WAVE")
|
||||
appendASCII("fmt ")
|
||||
appendLE32(16)
|
||||
appendLE16(1) // PCM
|
||||
appendLE16(1) // mono
|
||||
appendLE32(UInt32(sampleRate))
|
||||
appendLE32(UInt32(byteRate))
|
||||
appendLE16(2) // block align
|
||||
appendLE16(16) // bits per sample
|
||||
appendASCII("data")
|
||||
appendLE32(UInt32(dataSize))
|
||||
|
||||
pcm16.withUnsafeBufferPointer { buffer in
|
||||
guard let base = buffer.baseAddress else { return }
|
||||
data.append(UnsafeBufferPointer(start: base, count: buffer.count))
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
public static func dataURI(samples: [Float], sampleRate: Int = 16_000) -> String {
|
||||
let wav = encode(samples: samples, sampleRate: sampleRate)
|
||||
return "data:audio/wav;base64,\(wav.base64EncodedString())"
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,17 @@
|
||||
"error.asr.noSpeech" = "No speech detected. Please try again.";
|
||||
"error.asr.chunkFailed" = "Segment %lld failed: %@";
|
||||
|
||||
/* Cloud ASR errors */
|
||||
"error.cloudASR.noAPIKey" = "API Key is missing.";
|
||||
"error.cloudASR.invalidURL" = "Invalid cloud ASR endpoint.";
|
||||
"error.cloudASR.http" = "Cloud ASR returned HTTP %lld.";
|
||||
"error.cloudASR.httpWithMessage" = "Cloud ASR returned HTTP %lld: %@";
|
||||
"error.cloudASR.decoding" = "Failed to parse cloud ASR response: %@";
|
||||
"error.cloudASR.transport" = "Cloud ASR network error: %@";
|
||||
"error.cloudASR.emptyTranscript" = "Cloud ASR returned an empty transcript.";
|
||||
"error.cloudASR.audioTooLong" = "Audio segment is too long for this cloud ASR provider.";
|
||||
"error.cloudASR.providerUnsupported" = "This provider does not support cloud speech recognition yet.";
|
||||
|
||||
/* Polish scenarios */
|
||||
"polishScenario.daily_chat" = "Daily Chat";
|
||||
"polishScenario.social_lifestyle" = "Social Network";
|
||||
|
||||
@@ -33,6 +33,17 @@
|
||||
"error.asr.noSpeech" = "未识别到语音内容,请重试。";
|
||||
"error.asr.chunkFailed" = "第 %lld 段识别失败:%@";
|
||||
|
||||
/* Cloud ASR errors */
|
||||
"error.cloudASR.noAPIKey" = "未填写 API Key。";
|
||||
"error.cloudASR.invalidURL" = "云端识别接口地址无效。";
|
||||
"error.cloudASR.http" = "云端识别返回 HTTP %lld。";
|
||||
"error.cloudASR.httpWithMessage" = "云端识别返回 HTTP %lld:%@";
|
||||
"error.cloudASR.decoding" = "解析云端识别响应失败:%@";
|
||||
"error.cloudASR.transport" = "云端识别网络错误:%@";
|
||||
"error.cloudASR.emptyTranscript" = "云端识别返回了空文本。";
|
||||
"error.cloudASR.audioTooLong" = "音频片段超过该云端识别服务的时长限制。";
|
||||
"error.cloudASR.providerUnsupported" = "该服务商暂不支持云端语音识别。";
|
||||
|
||||
/* 润色场景 */
|
||||
"polishScenario.daily_chat" = "日常聊天";
|
||||
"polishScenario.social_lifestyle" = "小红书";
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// CloudASRTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class CloudASRTests: XCTestCase {
|
||||
|
||||
func testCloudASRStrategyRouting() {
|
||||
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "zhipu"), .zhipuHotwords)
|
||||
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "qwen"), .alibabaVocabulary)
|
||||
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "openai"), .prompt)
|
||||
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "mimo"), .prompt)
|
||||
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "moonshot"), .localFallback)
|
||||
}
|
||||
|
||||
func testCloudASRModelDefaults() {
|
||||
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "qwen"), "fun-asr-flash-2026-06-15")
|
||||
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "zhipu"), "glm-asr-2512")
|
||||
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "mimo"), "mimo-v2.5-asr")
|
||||
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "openai"), "gpt-4o-mini-transcribe")
|
||||
}
|
||||
|
||||
func testPersonalDictionaryCloudASRBadgeProviders() {
|
||||
XCTAssertTrue(LLMProvider.provider(id: "zhipu").supportsPersonalDictionaryCloudASR)
|
||||
XCTAssertTrue(LLMProvider.provider(id: "qwen").supportsPersonalDictionaryCloudASR)
|
||||
XCTAssertFalse(LLMProvider.provider(id: "openai").supportsPersonalDictionaryCloudASR)
|
||||
XCTAssertFalse(LLMProvider.provider(id: "moonshot").supportsPersonalDictionaryCloudASR)
|
||||
}
|
||||
|
||||
func testPersonalDictionaryASRHotwordsDedupesTerms() {
|
||||
let dict = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(term: "Kubernetes", category: .technical, source: .manual),
|
||||
PersonalDictionary.Entry(term: "kubernetes", category: .technical, source: .manual),
|
||||
PersonalDictionary.Entry(term: "OSGKeyboard", category: .productName, source: .manual),
|
||||
])
|
||||
let hotwords = dict.asrHotwords()
|
||||
XCTAssertEqual(hotwords.count, 2)
|
||||
XCTAssertTrue(hotwords.contains("Kubernetes"))
|
||||
XCTAssertTrue(hotwords.contains("OSGKeyboard"))
|
||||
}
|
||||
|
||||
func testPersonalDictionaryASRPromptIncludesAliases() {
|
||||
var dict = PersonalDictionary.empty
|
||||
_ = dict.upsertManual(term: "Kubernetes")
|
||||
dict.updateAliases(
|
||||
for: dict.entries[0].id,
|
||||
aliases: ["k8s", "库伯内特斯"]
|
||||
)
|
||||
let prompt = dict.asrPromptBias()
|
||||
XCTAssertTrue(prompt.contains("Kubernetes"))
|
||||
XCTAssertTrue(prompt.contains("k8s"))
|
||||
}
|
||||
|
||||
func testPersonalDictionaryAlibabaHotwordEntries() {
|
||||
let dict = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(term: "Cursor", category: .productName, source: .manual),
|
||||
])
|
||||
let entries = dict.alibabaHotwordEntries()
|
||||
XCTAssertEqual(entries.count, 1)
|
||||
XCTAssertEqual(entries[0].text, "Cursor")
|
||||
XCTAssertEqual(entries[0].weight, 4)
|
||||
}
|
||||
|
||||
func testPCMSampleWavEncoderProducesHeader() {
|
||||
let wav = PCMSampleWavEncoder.encode(samples: [0.0, 0.5, -0.5], sampleRate: 16_000)
|
||||
XCTAssertGreaterThan(wav.count, 44)
|
||||
XCTAssertEqual(String(data: wav.prefix(4), encoding: .ascii), "RIFF")
|
||||
XCTAssertEqual(String(data: wav.dropFirst(8).prefix(4), encoding: .ascii), "WAVE")
|
||||
}
|
||||
|
||||
func testVocabularyFingerprintChangesWhenDictionaryChanges() {
|
||||
var dict = PersonalDictionary.empty
|
||||
let emptyFP = dict.vocabularySyncFingerprint()
|
||||
_ = dict.upsertManual(term: "OSGKeyboard")
|
||||
XCTAssertNotEqual(emptyFP, dict.vocabularySyncFingerprint())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// PersonalDictionaryCloudSyncTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// Hermetic tests for iCloud KVS dictionary merge + sync service.
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
// MARK: - Fake KVS
|
||||
|
||||
private final class FakeUbiquitousKeyValueStore: UbiquitousKeyValueStoreing, @unchecked Sendable {
|
||||
private var storage: [String: Data] = [:]
|
||||
|
||||
func data(forKey key: String) -> Data? {
|
||||
storage[key]
|
||||
}
|
||||
|
||||
func set(_ value: Data?, forKey key: String) {
|
||||
if let value {
|
||||
storage[key] = value
|
||||
} else {
|
||||
storage.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
func synchronize() -> Bool { true }
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@MainActor
|
||||
final class PersonalDictionaryCloudSyncTests: XCTestCase {
|
||||
|
||||
private var suiteName: String!
|
||||
private var defaults: UserDefaults!
|
||||
private var store: AppGroupStore!
|
||||
private var kvs: FakeUbiquitousKeyValueStore!
|
||||
private var sync: PersonalDictionaryCloudSync!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
suiteName = "group.com.osgkeyboard.shared.tests.sync.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
store = AppGroupStore(defaults: defaults)
|
||||
kvs = FakeUbiquitousKeyValueStore()
|
||||
sync = PersonalDictionaryCloudSync(kvs: kvs) { [unowned self] in store }
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Merge
|
||||
|
||||
func testMergeKeepsNewerEntryForSameID() {
|
||||
let id = UUID()
|
||||
let older = PersonalDictionary.Entry(
|
||||
id: id,
|
||||
term: "Kubernetes",
|
||||
category: .technical,
|
||||
source: .manual,
|
||||
createdAt: Date(timeIntervalSince1970: 100),
|
||||
updatedAt: Date(timeIntervalSince1970: 100),
|
||||
usageCount: 1
|
||||
)
|
||||
let newer = PersonalDictionary.Entry(
|
||||
id: id,
|
||||
term: "Kubernetes",
|
||||
aliases: ["k8s"],
|
||||
category: .technical,
|
||||
source: .manual,
|
||||
createdAt: Date(timeIntervalSince1970: 100),
|
||||
updatedAt: Date(timeIntervalSince1970: 200),
|
||||
usageCount: 3
|
||||
)
|
||||
|
||||
let merged = PersonalDictionary.merge(
|
||||
local: PersonalDictionary(entries: [older]),
|
||||
remote: PersonalDictionary(entries: [newer])
|
||||
)
|
||||
|
||||
XCTAssertEqual(merged.entries.count, 1)
|
||||
XCTAssertEqual(merged.entries[0].aliases, ["k8s"])
|
||||
XCTAssertEqual(merged.entries[0].usageCount, 3)
|
||||
}
|
||||
|
||||
func testMergeUnionsAliasesForSameCanonicalTerm() {
|
||||
let local = PersonalDictionary.Entry(
|
||||
term: "Cursor",
|
||||
aliases: ["cursor ide"],
|
||||
category: .productName,
|
||||
source: .manual,
|
||||
createdAt: Date(timeIntervalSince1970: 100),
|
||||
updatedAt: Date(timeIntervalSince1970: 150)
|
||||
)
|
||||
let remote = PersonalDictionary.Entry(
|
||||
term: "cursor",
|
||||
aliases: ["光标"],
|
||||
category: .productName,
|
||||
source: .manual,
|
||||
createdAt: Date(timeIntervalSince1970: 120),
|
||||
updatedAt: Date(timeIntervalSince1970: 200)
|
||||
)
|
||||
|
||||
let merged = PersonalDictionary.merge(
|
||||
local: PersonalDictionary(entries: [local]),
|
||||
remote: PersonalDictionary(entries: [remote])
|
||||
)
|
||||
|
||||
XCTAssertEqual(merged.entries.count, 1)
|
||||
XCTAssertEqual(Set(merged.entries[0].aliases), Set(["cursor ide", "光标"]))
|
||||
XCTAssertEqual(merged.entries[0].term, "cursor")
|
||||
}
|
||||
|
||||
func testMergeCombinesDistinctTerms() {
|
||||
let local = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(term: "Alpha", category: .custom, source: .manual),
|
||||
])
|
||||
let remote = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(term: "Beta", category: .custom, source: .manual),
|
||||
])
|
||||
|
||||
let merged = PersonalDictionary.merge(local: local, remote: remote)
|
||||
XCTAssertEqual(Set(merged.entries.map(\.term)), Set(["Alpha", "Beta"]))
|
||||
}
|
||||
|
||||
// MARK: - Backward-compatible decode
|
||||
|
||||
func testEntryDecodesWithoutUpdatedAt() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": "A0000000-0000-4000-8000-000000000099",
|
||||
"term": "Legacy",
|
||||
"aliases": [],
|
||||
"category": "custom",
|
||||
"source": "manual",
|
||||
"createdAt": 1_700_000_000,
|
||||
"usageCount": 2
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let entry = try JSONDecoder().decode(PersonalDictionary.Entry.self, from: json)
|
||||
XCTAssertEqual(entry.term, "Legacy")
|
||||
XCTAssertEqual(entry.updatedAt.timeIntervalSince1970, 1_700_000_000, accuracy: 1)
|
||||
}
|
||||
|
||||
// MARK: - Sync service
|
||||
|
||||
func testEnableSyncMergesLocalAndRemoteThenUploads() async throws {
|
||||
let remoteEntry = PersonalDictionary.Entry(
|
||||
term: "RemoteTerm",
|
||||
category: .custom,
|
||||
source: .manual,
|
||||
updatedAt: Date(timeIntervalSince1970: 500)
|
||||
)
|
||||
let remoteData = try sync.encode(PersonalDictionary(entries: [remoteEntry]))
|
||||
kvs.set(remoteData, forKey: PersonalDictionaryCloudSync.kvsKey)
|
||||
|
||||
store.personalDictionary = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(
|
||||
term: "LocalTerm",
|
||||
category: .custom,
|
||||
source: .manual,
|
||||
updatedAt: Date(timeIntervalSince1970: 400)
|
||||
),
|
||||
])
|
||||
|
||||
try await sync.enableSync()
|
||||
|
||||
XCTAssertTrue(store.personalDictionaryICloudSyncEnabled)
|
||||
XCTAssertEqual(Set(store.personalDictionary.entries.map(\.term)), Set(["LocalTerm", "RemoteTerm"]))
|
||||
XCTAssertNotNil(sync.loadRemote())
|
||||
}
|
||||
|
||||
func testPushLocalIfEnabledSkipsWhenDisabled() async throws {
|
||||
store.personalDictionary = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(term: "OnlyLocal", category: .custom, source: .manual),
|
||||
])
|
||||
store.setPersonalDictionaryICloudSyncEnabled(false)
|
||||
|
||||
try await sync.pushLocalIfEnabled(store.personalDictionary)
|
||||
|
||||
XCTAssertNil(sync.loadRemote())
|
||||
}
|
||||
|
||||
func testPullAndMergeWritesMergedDictionaryToAppGroup() async {
|
||||
store.setPersonalDictionaryICloudSyncEnabled(true)
|
||||
store.personalDictionary = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(term: "LocalOnly", category: .custom, source: .manual),
|
||||
])
|
||||
|
||||
let remote = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(
|
||||
term: "CloudOnly",
|
||||
category: .custom,
|
||||
source: .manual,
|
||||
updatedAt: Date(timeIntervalSince1970: 900)
|
||||
),
|
||||
])
|
||||
kvs.set(try! sync.encode(remote), forKey: PersonalDictionaryCloudSync.kvsKey)
|
||||
|
||||
await sync.pullAndMerge(store: store)
|
||||
|
||||
XCTAssertEqual(Set(store.personalDictionary.entries.map(\.term)), Set(["LocalOnly", "CloudOnly"]))
|
||||
}
|
||||
|
||||
func testEncodeRejectsOversizedPayload() {
|
||||
let hugeTerm = String(repeating: "x", count: PersonalDictionaryCloudSync.maxPayloadBytes)
|
||||
let dictionary = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(term: hugeTerm, category: .custom, source: .manual),
|
||||
])
|
||||
|
||||
XCTAssertThrowsError(try sync.encode(dictionary)) { error in
|
||||
guard case .payloadTooLarge = error as? PersonalDictionaryCloudSyncError else {
|
||||
return XCTFail("Expected payloadTooLarge, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testAppGroupConfigurationDefaultsICloudSyncToOn() {
|
||||
let config = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
XCTAssertTrue(config.personalDictionaryICloudSyncEnabled)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,896 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Append batch-2 expansion rows to ai_tech_brands_seed.tsv with deduplication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SEED_PATH = REPO_ROOT / "Scripts/lexicon/seeds/ai_tech_brands_seed.tsv"
|
||||
|
||||
# word, pinyin, aliases (| separated), category, weight
|
||||
EXPANSION: list[tuple[str, str, str, str, int]] = [
|
||||
# --- AI brands / models / agents ---
|
||||
("NanoClaw", "", "nano claw|Nano Claw", "ai_brand", 86),
|
||||
("ZeroClaw", "", "zero claw|Zero Claw", "ai_brand", 82),
|
||||
("Hermes Agent", "", "hermes agent|Hermes", "ai_brand", 84),
|
||||
("Fugu", "", "sakana fugu|Fugu AI", "ai_brand", 84),
|
||||
("Sakana AI", "", "sakana ai|Sakana", "ai_brand", 82),
|
||||
("Agents-A1", "", "agents a1|Agents A1", "ai_model", 82),
|
||||
("NemoClaw", "", "nemo claw|Nemo Claw", "ai_brand", 80),
|
||||
("Moltworker", "", "molt worker|Moltworker", "ai_platform", 78),
|
||||
("Perplexity Computer", "", "perplexity computer|Personal Computer", "ai_brand", 84),
|
||||
("Personal Computer", "", "perplexity personal computer", "ai_brand", 82),
|
||||
("Simular", "", "simular ai|Simular AI", "ai_brand", 78),
|
||||
("Vellum", "", "vellum ai|Vellum AI", "ai_platform", 78),
|
||||
("Cloudflare Agents", "", "cloudflare agents", "ai_platform", 78),
|
||||
("NVIDIA NIM", "", "nvidia nim|NIM inference", "ai_platform", 82),
|
||||
("Fireworks AI", "", "fireworks ai|Fireworks", "ai_platform", 82),
|
||||
("Together AI", "", "together ai|Together", "ai_platform", 82),
|
||||
("Cerebras", "", "cerebras systems|Cerebras Systems", "tech_company", 82),
|
||||
("SambaNova", "", "samba nova|Samba Nova", "tech_company", 80),
|
||||
("Grokipedia", "", "grok ipedia|Grok pedia", "ai_brand", 76),
|
||||
("Grok Imagine", "", "grok imagine", "ai_brand", 78),
|
||||
("Imagen", "", "google imagen|Imagen AI", "ai_model", 82),
|
||||
("Veo", "", "google veo|Veo AI", "ai_model", 82),
|
||||
("Suno", "", "suno ai|Suno AI", "ai_brand", 84),
|
||||
("Udio", "", "udio ai|Udio AI", "ai_brand", 82),
|
||||
("Flux", "", "black forest flux|FLUX", "ai_model", 84),
|
||||
("Black Forest Labs", "", "black forest labs|BFL", "ai_brand", 82),
|
||||
("Ideogram", "", "ideogram ai|Ideogram AI", "ai_brand", 80),
|
||||
("Recraft", "", "recraft ai|Recraft AI", "ai_brand", 78),
|
||||
("Leonardo AI", "", "leonardo ai|Leonardo", "ai_brand", 80),
|
||||
("Krea", "", "krea ai|Krea AI", "ai_brand", 76),
|
||||
("PixVerse", "", "pix verse|Pixverse", "ai_brand", 80),
|
||||
("Vidu", "", "vidu ai|Vidu AI", "ai_brand", 80),
|
||||
("可灵AI", "ke ling AI", "Kling AI|可灵", "ai_brand", 84),
|
||||
("通义万相", "tong yi wan xiang", "Wanxiang|万相", "ai_brand", 84),
|
||||
("Wanxiang", "", "通义万相|wan xiang", "ai_brand", 82),
|
||||
("海螺视频", "hai luo shi pin", "Hailuo Video", "ai_brand", 80),
|
||||
("Skywork", "", "skywork ai|Skywork", "ai_brand", 78),
|
||||
("天工AI", "tian gong AI", "天工", "ai_brand", 78),
|
||||
("纳米搜索", "na mi sou suo", "Nami Search", "ai_brand", 76),
|
||||
("Flowith", "", "flowith ai|Flowith AI", "ai_platform", 76),
|
||||
("FlowGPT", "", "flow gpt|Flow GPT", "ai_platform", 74),
|
||||
("Coze", "", "coze ai|扣子", "ai_platform", 86),
|
||||
("扣子", "kou zi", "Coze|coze", "ai_platform", 84),
|
||||
("Dify", "", "dify ai|Dify AI", "ai_platform", 84),
|
||||
("FastGPT", "", "fast gpt|FastGPT", "ai_platform", 78),
|
||||
("MaxKB", "", "max kb|MaxKB", "ai_platform", 76),
|
||||
("RAGFlow", "", "rag flow|RAGFlow", "ai_platform", 82),
|
||||
("Mem0", "", "mem zero|Mem0 AI", "ai_platform", 80),
|
||||
("Letta", "", "letta ai|Letta AI", "ai_platform", 78),
|
||||
("Zep", "", "zep ai|Zep AI", "ai_platform", 76),
|
||||
("GraphRAG", "", "graph rag|Graph RAG", "ai_term", 84),
|
||||
("LightRAG", "", "light rag|Light RAG", "ai_term", 82),
|
||||
("Devstral", "", "devstral|mistral devstral", "ai_model", 82),
|
||||
("Pixtral", "", "pixtral|mistral pixtral", "ai_model", 82),
|
||||
("Mistral Small", "", "mistral small", "ai_model", 80),
|
||||
("Mistral Codestral", "", "mistral codestral", "ai_model", 82),
|
||||
("Codestral", "", "codestral|mistral codestral", "ai_model", 84),
|
||||
("Command R", "", "command r|cohere command", "ai_model", 80),
|
||||
("Cohere Command", "", "cohere command|Command R", "ai_model", 78),
|
||||
("Phi", "", "phi model|microsoft phi", "ai_model", 78),
|
||||
("Phi模型", "phi mo xing", "Phi|microsoft phi", "ai_model", 76),
|
||||
("Granite", "", "ibm granite|Granite model", "ai_model", 80),
|
||||
("IBM Granite", "", "granite|IBM Granite", "ai_model", 78),
|
||||
("Amazon Nova", "", "amazon nova|Nova model", "ai_model", 82),
|
||||
("Bedrock", "", "aws bedrock|Amazon Bedrock", "ai_platform", 82),
|
||||
("Gemma开源", "gemma kai yuan", "Gemma open", "ai_model", 76),
|
||||
("Claude Mythos", "", "claude mythos|Mythos", "ai_model", 82),
|
||||
("Whisper", "", "openai whisper|Whisper ASR", "ai_model", 82),
|
||||
("NotebookLM", "", "notebook lm|Notebook LM", "ai_brand", 84),
|
||||
("Google AI Studio", "", "google ai studio|AI Studio", "dev_tool", 82),
|
||||
("AI Studio", "", "google ai studio", "dev_tool", 80),
|
||||
("Gemini Live", "", "gemini live", "ai_brand", 80),
|
||||
("Gemini Robotics", "", "gemini robotics", "ai_brand", 78),
|
||||
("Apple Intelligence", "", "apple intelligence|苹果智能", "ai_brand", 86),
|
||||
("苹果智能", "ping guo zhi neng", "Apple Intelligence", "ai_brand", 84),
|
||||
("Siri", "", "apple siri|Siri AI", "ai_brand", 82),
|
||||
("Writing Tools", "", "apple writing tools", "ai_term", 76),
|
||||
("Image Playground", "", "image playground|苹果图像游乐场", "ai_term", 74),
|
||||
("Copilot Studio", "", "copilot studio|Microsoft Copilot Studio", "ai_platform", 80),
|
||||
("Microsoft Agent", "", "microsoft agent", "ai_platform", 78),
|
||||
("Semantic Kernel", "", "semantic kernel|SK", "ai_platform", 80),
|
||||
("AutoGen Studio", "", "autogen studio|AutoGen Studio", "ai_platform", 78),
|
||||
("Windsurf Cascade", "", "windsurf cascade|Cascade", "dev_tool", 82),
|
||||
("Cascade", "", "windsurf cascade", "dev_tool", 80),
|
||||
("Tabnine", "", "tab nine|Tabnine", "dev_tool", 80),
|
||||
("Sourcegraph Cody", "", "sourcegraph cody|Cody", "dev_tool", 82),
|
||||
("Cody", "", "sourcegraph cody", "dev_tool", 78),
|
||||
("GitHub Spark", "", "github spark|Spark", "dev_tool", 78),
|
||||
("GitHub Models", "", "github models", "ai_platform", 78),
|
||||
("Browser Use", "", "browser use|browser-use", "ai_term", 84),
|
||||
("Augment", "", "augment code|Augment Code", "dev_tool", 80),
|
||||
("JetBrains Junie", "", "jetbrains junie|Junie", "dev_tool", 78),
|
||||
("Kilo Code", "", "kilo code|KiloCode", "dev_tool", 78),
|
||||
("Zencoder", "", "zen coder|Zencoder", "dev_tool", 76),
|
||||
("Goose", "", "goose ai|Block Goose", "dev_tool", 76),
|
||||
("Omnigent", "", "omnigent ai|Omnigent", "dev_tool", 74),
|
||||
# --- Entrepreneurs / internet figures ---
|
||||
("俞敏洪", "yu min hong", "Yu Minhong|新东方俞敏洪", "tech_leader", 86),
|
||||
("Yu Minhong", "", "yu minhong|俞敏洪", "tech_leader", 84),
|
||||
("New Oriental", "", "new oriental|新东方", "tech_company", 80),
|
||||
("新东方", "xin dong fang", "New Oriental", "tech_company", 80),
|
||||
("卢伟冰", "lu wei bing", "Lu Weibing", "tech_leader", 84),
|
||||
("Lu Weibing", "", "lu weibing|卢伟冰", "tech_leader", 84),
|
||||
("影视飓风", "ying shi ju feng", "影视飓风Tim", "tech_leader", 82),
|
||||
("影视飓风Tim", "", "ying shi ju feng tim|影视飓风", "tech_leader", 82),
|
||||
("曹德旺", "cao de wang", "Cao Dewang", "tech_leader", 80),
|
||||
("Cao Dewang", "", "cao dewang|曹德旺", "tech_leader", 80),
|
||||
("福耀玻璃", "fu yao bo li", "Fuyao Glass", "tech_company", 76),
|
||||
("宗庆后", "zong qing hou", "Zong Qinghou", "tech_leader", 78),
|
||||
("钟睒睒", "zhong shan shan", "Zhong Shanshan", "tech_leader", 80),
|
||||
("Nongfu Spring", "", "nongfu spring|农夫山泉", "consumer_brand", 82),
|
||||
("农夫山泉", "nong fu shan quan", "Nongfu Spring", "consumer_brand", 82),
|
||||
("王健林", "wang jian lin", "Wang Jianlin", "tech_leader", 78),
|
||||
("Wang Jianlin", "", "wang jianlin|王健林", "tech_leader", 78),
|
||||
("许家印", "xu jia yin", "Xu Jiayin", "tech_leader", 74),
|
||||
("Xu Jiayin", "", "xu jiayin|许家印", "tech_leader", 74),
|
||||
("孙正义", "sun zheng yi", "Masayoshi Son", "tech_leader", 82),
|
||||
("Masayoshi Son", "", "masayoshi son|孙正义", "tech_leader", 82),
|
||||
("张朝阳", "zhang chao yang", "Charles Zhang", "tech_leader", 80),
|
||||
("Charles Zhang", "", "charles zhang|张朝阳", "tech_leader", 80),
|
||||
("搜狐", "sou hu", "Sohu", "tech_company", 78),
|
||||
("Sohu", "", "sohu|搜狐", "tech_company", 78),
|
||||
("丁磊", "ding lei", "William Ding", "tech_leader", 84),
|
||||
("William Ding", "", "william ding|丁磊", "tech_leader", 84),
|
||||
("陈天桥", "chen tian qiao", "Chen Tianqiao", "tech_leader", 76),
|
||||
("沈南鹏", "shen nan peng", "Neil Shen", "tech_leader", 82),
|
||||
("Neil Shen", "", "neil shen|沈南鹏", "tech_leader", 82),
|
||||
("红杉中国", "hong shan zhong guo", "Sequoia China", "fintech", 80),
|
||||
("Sequoia China", "", "sequoia china|红杉中国", "fintech", 80),
|
||||
("徐新", "xu xin", "Xu Xin", "tech_leader", 76),
|
||||
("今日资本", "jin ri zi ben", "Capital Today", "fintech", 74),
|
||||
("朱啸虎", "zhu xiao hu", "Zhu Xiaohu", "tech_leader", 78),
|
||||
("李开复", "li kai fu", "Kai-Fu Lee", "tech_leader", 86),
|
||||
("Kai-Fu Lee", "", "kai fu lee|李开复", "tech_leader", 86),
|
||||
("创新工场", "chuang xin gong chang", "Innovation Works", "tech_company", 78),
|
||||
("Innovation Works", "", "innovation works|创新工场", "tech_company", 78),
|
||||
("傅盛", "fu sheng", "Fu Sheng", "tech_leader", 78),
|
||||
("Fu Sheng", "", "fu sheng|傅盛", "tech_leader", 78),
|
||||
("猎豹移动", "lie bao yi dong", "Cheetah Mobile", "tech_company", 76),
|
||||
("王小川", "wang xiao chuan", "Wang Xiaochuan", "tech_leader", 82),
|
||||
("Wang Xiaochuan", "", "wang xiaochuan|王小川", "tech_leader", 82),
|
||||
("周航", "zhou hang", "Zhou Hang", "tech_leader", 72),
|
||||
("陈睿", "chen rui", "Chen Rui|B站陈睿", "tech_leader", 80),
|
||||
("Chen Rui", "", "chen rui|陈睿", "tech_leader", 80),
|
||||
("宿华", "su hua", "Su Hua", "tech_leader", 78),
|
||||
("Su Hua", "", "su hua|宿华", "tech_leader", 78),
|
||||
("程一笑", "cheng yi xiao", "Cheng Yixiao", "tech_leader", 76),
|
||||
("Cheng Yixiao", "", "cheng yixiao|程一笑", "tech_leader", 76),
|
||||
("陈欧", "chen ou", "Chen Ou", "tech_leader", 72),
|
||||
("聚美优品", "ju mei you pin", "Jumei", "tech_company", 70),
|
||||
("江南春", "jiang nan chun", "Jiang Nanchun", "tech_leader", 76),
|
||||
("分众传媒", "fen zhong chuan mei", "Focus Media", "tech_company", 76),
|
||||
("沈晖", "shen hui", "William Feng", "tech_leader", 74),
|
||||
("李想", "li xiang", "Li Xiang|理想李想", "tech_leader", 86),
|
||||
("Li Xiang", "", "li xiang|李想", "tech_leader", 86),
|
||||
("王传福", "wang chuan fu", "Wang Chuanfu", "tech_leader", 86),
|
||||
("Wang Chuanfu", "", "wang chuanfu|王传福", "tech_leader", 86),
|
||||
("曾毓群", "zeng yu qun", "Robin Zeng", "tech_leader", 84),
|
||||
("Robin Zeng", "", "robin zeng|曾毓群", "tech_leader", 84),
|
||||
("郭台铭", "guo tai ming", "Terry Gou", "tech_leader", 80),
|
||||
("Terry Gou", "", "terry gou|郭台铭", "tech_leader", 80),
|
||||
("富士康", "fu shi kang", "Foxconn", "tech_company", 82),
|
||||
("Foxconn", "", "foxconn|富士康", "tech_company", 82),
|
||||
("刘慈欣", "liu ci xin", "Liu Cixin", "celebrity", 82),
|
||||
("Liu Cixin", "", "liu cixin|刘慈欣", "celebrity", 82),
|
||||
("三体", "san ti", "Three Body Problem|Three-Body", "tech_term", 80),
|
||||
# --- Celebrities ---
|
||||
("刘宇宁", "liu yu ning", "Liu Yuning", "celebrity", 86),
|
||||
("Liu Yuning", "", "liu yuning|刘宇宁", "celebrity", 86),
|
||||
("梓渝", "zi yu", "Ziyu", "celebrity", 82),
|
||||
("Ziyu", "", "zi yu|梓渝", "celebrity", 82),
|
||||
("周深", "zhou shen", "Zhou Shen", "celebrity", 86),
|
||||
("Zhou Shen", "", "zhou shen|周深", "celebrity", 86),
|
||||
("白鹿", "bai lu", "Bai Lu", "celebrity", 84),
|
||||
("Bai Lu", "", "bai lu|白鹿", "celebrity", 84),
|
||||
("孟子义", "meng zi yi", "Meng Ziyi", "celebrity", 82),
|
||||
("Meng Ziyi", "", "meng ziyi|孟子义", "celebrity", 82),
|
||||
("虞书欣", "yu shu xin", "Esther Yu", "celebrity", 84),
|
||||
("Esther Yu", "", "esther yu|虞书欣", "celebrity", 84),
|
||||
("赵丽颖", "zhao li ying", "Zhao Liying", "celebrity", 86),
|
||||
("Zhao Liying", "", "zhao liying|赵丽颖", "celebrity", 86),
|
||||
("杨紫", "yang zi", "Yang Zi", "celebrity", 86),
|
||||
("成毅", "cheng yi", "Cheng Yi", "celebrity", 84),
|
||||
("任嘉伦", "ren jia lun", "Ren Jialun", "celebrity", 82),
|
||||
("Ren Jialun", "", "ren jialun|任嘉伦", "celebrity", 82),
|
||||
("李现", "li xian", "Li Xian", "celebrity", 84),
|
||||
("Li Xian", "", "li xian|李现", "celebrity", 84),
|
||||
("杨洋", "yang yang", "Yang Yang", "celebrity", 84),
|
||||
("吴磊", "wu lei", "Wu Lei", "celebrity", 82),
|
||||
("Wu Lei", "", "wu lei|吴磊", "celebrity", 82),
|
||||
("胡歌", "hu ge", "Hu Ge", "celebrity", 86),
|
||||
("Hu Ge", "", "hu ge|胡歌", "celebrity", 86),
|
||||
("黄晓明", "huang xiao ming", "Huang Xiaoming", "celebrity", 82),
|
||||
("Huang Xiaoming", "", "huang xiaoming|黄晓明", "celebrity", 82),
|
||||
("陈坤", "chen kun", "Chen Kun", "celebrity", 80),
|
||||
("章子怡", "zhang zi yi", "Zhang Ziyi", "celebrity", 82),
|
||||
("Zhang Ziyi", "", "zhang ziyi|章子怡", "celebrity", 82),
|
||||
("巩俐", "gong li", "Gong Li", "celebrity", 82),
|
||||
("周迅", "zhou xun", "Zhou Xun", "celebrity", 82),
|
||||
("汤唯", "tang wei", "Tang Wei", "celebrity", 80),
|
||||
("舒淇", "shu qi", "Shu Qi", "celebrity", 80),
|
||||
("黄渤", "huang bo", "Huang Bo", "celebrity", 86),
|
||||
("Huang Bo", "", "huang bo|黄渤", "celebrity", 86),
|
||||
("沈腾", "shen teng", "Shen Teng", "celebrity", 86),
|
||||
("Shen Teng", "", "shen teng|沈腾", "celebrity", 86),
|
||||
("贾玲", "jia ling", "Jia Ling", "celebrity", 84),
|
||||
("Jia Ling", "", "jia ling|贾玲", "celebrity", 84),
|
||||
("马丽", "ma li", "Ma Li", "celebrity", 80),
|
||||
("张小斐", "zhang xiao fei", "Zhang Xiaofei", "celebrity", 80),
|
||||
("吴京", "wu jing", "Wu Jing", "celebrity", 84),
|
||||
("Wu Jing", "", "wu jing|吴京", "celebrity", 84),
|
||||
("张译", "zhang yi yan", "Zhang Yi actor", "celebrity", 82),
|
||||
("雷佳音", "lei jia yin", "Lei Jiayin", "celebrity", 80),
|
||||
("孙俪", "sun li", "Sun Li", "celebrity", 82),
|
||||
("靳东", "jin dong", "Jin Dong", "celebrity", 78),
|
||||
("那英", "na ying", "Na Ying", "celebrity", 82),
|
||||
("汪峰", "wang feng", "Wang Feng", "celebrity", 80),
|
||||
("王菲", "wang fei", "Faye Wong", "celebrity", 84),
|
||||
("张学友", "zhang xue you", "Jacky Cheung", "celebrity", 82),
|
||||
("Jacky Cheung", "", "jacky cheung|张学友", "celebrity", 82),
|
||||
("林俊杰", "lin jun jie", "JJ Lin", "celebrity", 84),
|
||||
("JJ Lin", "", "jj lin|林俊杰", "celebrity", 84),
|
||||
("蔡依林", "cai yi lin", "Jolin Tsai", "celebrity", 82),
|
||||
("Jolin Tsai", "", "jolin tsai|蔡依林", "celebrity", 82),
|
||||
("王力宏", "wang li hong", "Wang Leehom", "celebrity", 82),
|
||||
("陈奕迅", "chen yi xun", "Eason Chan", "celebrity", 84),
|
||||
("Eason Chan", "", "eason chan|陈奕迅", "celebrity", 84),
|
||||
("毛不易", "mao bu yi", "Mao Buyi", "celebrity", 82),
|
||||
("华晨宇", "hua chen yu", "Hua Chenyu", "celebrity", 80),
|
||||
("时代少年团", "shi dai shao nian tuan", "TNT|Teens in Times", "celebrity", 80),
|
||||
("TFBOYS", "", "tf boys|TF Boys", "celebrity", 82),
|
||||
("蔡徐坤", "cai xu kun", "Cai Xukun", "celebrity", 82),
|
||||
("Cai Xukun", "", "cai xukun|蔡徐坤", "celebrity", 82),
|
||||
("黄子韬", "huang zi tao", "Huang Zitao", "celebrity", 80),
|
||||
("Huang Zitao", "", "huang zitao|黄子韬", "celebrity", 80),
|
||||
("BLACKPINK", "", "black pink|Black Pink", "celebrity", 84),
|
||||
("Jennie", "", "blackpink jennie", "celebrity", 80),
|
||||
("Jisoo", "", "blackpink jisoo", "celebrity", 78),
|
||||
("Rosé", "", "blackpink rose|Rose", "celebrity", 78),
|
||||
("BTS", "", "bts|防弹少年团", "celebrity", 84),
|
||||
("防弹少年团", "fang dan shao nian tuan", "BTS", "celebrity", 84),
|
||||
("NewJeans", "", "new jeans|New Jeans", "celebrity", 82),
|
||||
("aespa", "", "aespa组合", "celebrity", 80),
|
||||
("IVE", "", "ive组合", "celebrity", 78),
|
||||
("Rihanna", "", "rihanna|蕾哈娜", "celebrity", 82),
|
||||
("蕾哈娜", "lei ha na", "Rihanna", "celebrity", 80),
|
||||
("Drake", "", "drake rapper", "celebrity", 82),
|
||||
("Kanye West", "", "kanye west|Ye", "celebrity", 80),
|
||||
("Kendrick Lamar", "", "kendrick lamar", "celebrity", 80),
|
||||
("Billie Eilish", "", "billie eilish", "celebrity", 82),
|
||||
("Ariana Grande", "", "ariana grande", "celebrity", 82),
|
||||
("Lady Gaga", "", "lady gaga", "celebrity", 82),
|
||||
("Tom Hanks", "", "tom hanks", "celebrity", 80),
|
||||
("Leonardo DiCaprio", "", "leonardo dicaprio|小李子", "celebrity", 82),
|
||||
("小李子", "xiao li zi", "Leonardo DiCaprio", "celebrity", 80),
|
||||
("Brad Pitt", "", "brad pitt", "celebrity", 80),
|
||||
("Angelina Jolie", "", "angelina jolie", "celebrity", 78),
|
||||
("Jennifer Lawrence", "", "jennifer lawrence", "celebrity", 78),
|
||||
("Scarlett Johansson", "", "scarlett johansson", "celebrity", 78),
|
||||
("Robert Downey Jr", "", "robert downey jr|钢铁侠", "celebrity", 80),
|
||||
("钢铁侠", "gang tie xia", "Iron Man|Robert Downey Jr", "celebrity", 78),
|
||||
("漫威", "man wei", "Marvel", "tech_term", 80),
|
||||
("Marvel", "", "marvel|漫威", "tech_term", 80),
|
||||
("蝙蝠侠", "bian fu xia", "Batman", "celebrity", 76),
|
||||
("Batman", "", "batman|蝙蝠侠", "celebrity", 76),
|
||||
# --- Athletes ---
|
||||
("王楚钦", "wang chu qin", "Wang Chuqin", "athlete", 88),
|
||||
("Wang Chuqin", "", "wang chuqin|王楚钦", "athlete", 88),
|
||||
("孙颖莎", "sun ying sha", "Sun Yingsha", "athlete", 88),
|
||||
("Sun Yingsha", "", "sun yingsha|孙颖莎", "athlete", 88),
|
||||
("樊振东", "fan zhen dong", "Fan Zhendong", "athlete", 86),
|
||||
("Fan Zhendong", "", "fan zhendong|樊振东", "athlete", 86),
|
||||
("王曼昱", "wang man yu", "Wang Manyu", "athlete", 84),
|
||||
("Wang Manyu", "", "wang manyu|王曼昱", "athlete", 84),
|
||||
("郑钦文", "zheng qin wen", "Zheng Qinwen", "athlete", 86),
|
||||
("Zheng Qinwen", "", "zheng qinwen|郑钦文", "athlete", 86),
|
||||
("林诗栋", "lin shi dong", "Lin Shidong", "athlete", 80),
|
||||
("马龙", "ma long", "Ma Long", "athlete", 86),
|
||||
("Ma Long", "", "ma long|马龙", "athlete", 86),
|
||||
("全红婵", "quan hong chan", "Quan Hongchan", "athlete", 86),
|
||||
("Quan Hongchan", "", "quan hongchan|全红婵", "athlete", 86),
|
||||
("蒯曼", "kuai man", "Kuai Man", "athlete", 76),
|
||||
("林高远", "lin gao yuan", "Lin Gaoyuan", "athlete", 76),
|
||||
("苏炳添", "su bing tian", "Su Bingtian", "athlete", 80),
|
||||
("谷爱凌", "gu ai ling", "Eileen Gu", "athlete", 86),
|
||||
("Eileen Gu", "", "eileen gu|谷爱凌", "athlete", 86),
|
||||
("姚明", "yao ming", "Yao Ming", "athlete", 86),
|
||||
("Yao Ming", "", "yao ming|姚明", "athlete", 86),
|
||||
("李娜", "li na", "Li Na tennis", "athlete", 80),
|
||||
("刘翔", "liu xiang", "Liu Xiang", "athlete", 80),
|
||||
("易建联", "yi jian lian", "Yi Jianlian", "athlete", 78),
|
||||
("C罗", "C luo", "Cristiano Ronaldo", "athlete", 86),
|
||||
("Cristiano Ronaldo", "", "cristiano ronaldo|C罗", "athlete", 86),
|
||||
("梅西", "mei xi", "Lionel Messi", "athlete", 88),
|
||||
("Lionel Messi", "", "lionel messi|梅西", "athlete", 88),
|
||||
("姆巴佩", "mu ba pei", "Kylian Mbappé", "athlete", 84),
|
||||
("Kylian Mbappé", "", "kylian mbappe|姆巴佩", "athlete", 84),
|
||||
("哈兰德", "ha lan de", "Erling Haaland", "athlete", 82),
|
||||
("Erling Haaland", "", "erling haaland|哈兰德", "athlete", 82),
|
||||
("詹姆斯", "zhan mu si", "LeBron James", "athlete", 86),
|
||||
("LeBron James", "", "lebron james|詹姆斯", "athlete", 86),
|
||||
("库里", "ku li", "Stephen Curry", "athlete", 84),
|
||||
("Stephen Curry", "", "stephen curry|库里", "athlete", 84),
|
||||
("科比", "ke bi", "Kobe Bryant", "athlete", 82),
|
||||
("Kobe Bryant", "", "kobe bryant|科比", "athlete", 82),
|
||||
("乔丹", "qiao dan", "Michael Jordan", "athlete", 84),
|
||||
("Michael Jordan", "", "michael jordan|乔丹", "athlete", 84),
|
||||
("费德勒", "fei de le", "Roger Federer", "athlete", 80),
|
||||
("纳达尔", "na da er", "Rafael Nadal", "athlete", 80),
|
||||
("德约科维奇", "de yue ke wei qi", "Novak Djokovic", "athlete", 82),
|
||||
("Novak Djokovic", "", "novak djokovic|德约", "athlete", 82),
|
||||
# --- Consumer brands ---
|
||||
("Burberry", "", "burberry|博柏利", "consumer_brand", 80),
|
||||
("博柏利", "bo bai li", "Burberry", "consumer_brand", 80),
|
||||
("Balenciaga", "", "balenciaga|巴黎世家", "consumer_brand", 82),
|
||||
("巴黎世家", "ba li shi jia", "Balenciaga", "consumer_brand", 82),
|
||||
("Versace", "", "versace|范思哲", "consumer_brand", 78),
|
||||
("范思哲", "fan si zhe", "Versace", "consumer_brand", 78),
|
||||
("Armani", "", "armani|阿玛尼", "consumer_brand", 80),
|
||||
("阿玛尼", "a ma ni", "Armani", "consumer_brand", 80),
|
||||
("Cartier", "", "cartier|卡地亚", "consumer_brand", 82),
|
||||
("卡地亚", "ka di ya", "Cartier", "consumer_brand", 82),
|
||||
("Bulgari", "", "bulgari|宝格丽", "consumer_brand", 78),
|
||||
("宝格丽", "bao ge li", "Bulgari", "consumer_brand", 78),
|
||||
("Van Cleef", "", "van cleef|梵克雅宝", "consumer_brand", 76),
|
||||
("梵克雅宝", "fan ke ya bao", "Van Cleef", "consumer_brand", 76),
|
||||
("Moncler", "", "moncler|盟可睐", "consumer_brand", 78),
|
||||
("盟可睐", "meng ke lai", "Moncler", "consumer_brand", 78),
|
||||
("Canada Goose", "", "canada goose|加拿大鹅", "consumer_brand", 78),
|
||||
("加拿大鹅", "jia na da e", "Canada Goose", "consumer_brand", 78),
|
||||
("New Balance", "", "new balance|新百伦", "consumer_brand", 80),
|
||||
("新百伦", "xin bai lun", "New Balance", "consumer_brand", 80),
|
||||
("Puma", "", "puma|彪马", "consumer_brand", 78),
|
||||
("彪马", "biao ma", "Puma", "consumer_brand", 78),
|
||||
("Converse", "", "converse|匡威", "consumer_brand", 76),
|
||||
("匡威", "kuang wei", "Converse", "consumer_brand", 76),
|
||||
("Vans", "", "vans", "consumer_brand", 76),
|
||||
("Skechers", "", "skechers|斯凯奇", "consumer_brand", 76),
|
||||
("斯凯奇", "si kai qi", "Skechers", "consumer_brand", 76),
|
||||
("Levi's", "", "levis|李维斯", "consumer_brand", 76),
|
||||
("李维斯", "li wei si", "Levi's", "consumer_brand", 76),
|
||||
("Coach", "", "coach|蔻驰", "consumer_brand", 78),
|
||||
("蔻驰", "kou chi", "Coach", "consumer_brand", 78),
|
||||
("Michael Kors", "", "michael kors|MK", "consumer_brand", 76),
|
||||
("Estée Lauder", "", "estee lauder|雅诗兰黛", "consumer_brand", 78),
|
||||
("雅诗兰黛", "ya shi lan dai", "Estée Lauder", "consumer_brand", 78),
|
||||
("L'Oréal", "", "loreal|欧莱雅", "consumer_brand", 78),
|
||||
("欧莱雅", "ou lai ya", "L'Oréal", "consumer_brand", 78),
|
||||
("Shiseido", "", "shiseido|资生堂", "consumer_brand", 76),
|
||||
("资生堂", "zi sheng tang", "Shiseido", "consumer_brand", 76),
|
||||
("SK-II", "", "sk two|SK2", "consumer_brand", 78),
|
||||
("兰蔻", "lan ke", "Lancôme", "consumer_brand", 78),
|
||||
("Lancôme", "", "lancome|兰蔻", "consumer_brand", 78),
|
||||
("YSL", "", "ysl|圣罗兰", "consumer_brand", 78),
|
||||
("圣罗兰", "sheng luo lan", "YSL", "consumer_brand", 78),
|
||||
("MAC", "", "mac cosmetics", "consumer_brand", 76),
|
||||
("完美日记", "wan mei ri ji", "Perfect Diary", "consumer_brand", 80),
|
||||
("Perfect Diary", "", "perfect diary|完美日记", "consumer_brand", 80),
|
||||
("花西子", "hua xi zi", "Florasis", "consumer_brand", 78),
|
||||
("Florasis", "", "florasis|花西子", "consumer_brand", 78),
|
||||
("名创优品", "ming chuang you pin", "Miniso", "consumer_brand", 78),
|
||||
("Miniso", "", "miniso|名创优品", "consumer_brand", 78),
|
||||
("海底捞", "hai di lao", "Haidilao", "consumer_brand", 82),
|
||||
("Haidilao", "", "haidilao|海底捞", "consumer_brand", 82),
|
||||
("西贝", "xi bei", "Xibei", "consumer_brand", 76),
|
||||
("老乡鸡", "lao xiang ji", "Laoxiangji", "consumer_brand", 76),
|
||||
("盒马", "he ma", "Hema|盒马鲜生", "consumer_brand", 80),
|
||||
("Hema", "", "hema|盒马", "consumer_brand", 80),
|
||||
("山姆会员店", "shan mu hui yuan dian", "Sam's Club", "consumer_brand", 80),
|
||||
("Sam's Club", "", "sams club|山姆", "consumer_brand", 80),
|
||||
("Costco", "", "costco|开市客", "consumer_brand", 80),
|
||||
("开市客", "kai shi ke", "Costco", "consumer_brand", 80),
|
||||
("沃尔玛", "wo er ma", "Walmart", "consumer_brand", 78),
|
||||
("Walmart", "", "walmart|沃尔玛", "consumer_brand", 78),
|
||||
("家乐福", "jia le fu", "Carrefour", "consumer_brand", 72),
|
||||
("宜家", "yi jia", "IKEA", "consumer_brand", 80),
|
||||
("IKEA", "", "ikea|宜家", "consumer_brand", 80),
|
||||
("MUJI", "", "muji|无印良品", "consumer_brand", 78),
|
||||
("无印良品", "wu yin liang pin", "MUJI", "consumer_brand", 78),
|
||||
("始祖鸟", "shi zu niao", "Arc'teryx", "consumer_brand", 82),
|
||||
("Arc'teryx", "", "arcteryx|始祖鸟", "consumer_brand", 82),
|
||||
("北面", "bei mian", "The North Face", "consumer_brand", 78),
|
||||
("The North Face", "", "north face|北面", "consumer_brand", 78),
|
||||
("Columbia", "", "columbia sportswear", "consumer_brand", 72),
|
||||
("李宁", "li ning", "Li-Ning", "consumer_brand", 82),
|
||||
("Li-Ning", "", "li ning|李宁", "consumer_brand", 82),
|
||||
("安踏", "an ta", "Anta", "consumer_brand", 82),
|
||||
("Anta", "", "anta|安踏", "consumer_brand", 82),
|
||||
("特步", "te bu", "Xtep", "consumer_brand", 76),
|
||||
("Xtep", "", "xtep|特步", "consumer_brand", 76),
|
||||
("鸿星尔克", "hong xing er ke", "Erke", "consumer_brand", 76),
|
||||
("Erke", "", "erke|鸿星尔克", "consumer_brand", 76),
|
||||
("波司登", "bo si deng", "Bosideng", "consumer_brand", 78),
|
||||
("Bosideng", "", "bosideng|波司登", "consumer_brand", 78),
|
||||
("周大福", "zhou da fu", "Chow Tai Fook", "consumer_brand", 76),
|
||||
("Chow Tai Fook", "", "chow tai fook|周大福", "consumer_brand", 76),
|
||||
("老凤祥", "lao feng xiang", "Lao Feng Xiang", "consumer_brand", 74),
|
||||
("中国黄金", "zhong guo huang jin", "China Gold", "consumer_brand", 74),
|
||||
# --- Auto / EV ---
|
||||
("领克", "ling ke", "Lynk & Co|Lynk", "tech_company", 84),
|
||||
("Lynk & Co", "", "lynk co|领克", "tech_company", 84),
|
||||
("问界", "wen jie", "AITO|AITO问界", "tech_company", 86),
|
||||
("AITO", "", "aito|问界", "tech_company", 86),
|
||||
("智界", "zhi jie", "Luxeed", "tech_company", 84),
|
||||
("Luxeed", "", "luxeed|智界", "tech_company", 84),
|
||||
("享界", "xiang jie", "Stelato", "tech_company", 82),
|
||||
("Stelato", "", "stelato|享界", "tech_company", 82),
|
||||
("尊界", "zun jie", "MAEXTRO", "tech_company", 82),
|
||||
("MAEXTRO", "", "maextro|尊界", "tech_company", 82),
|
||||
("小米YU7", "xiao mi YU7", "Xiaomi YU7|YU7", "tech_company", 88),
|
||||
("YU7", "", "xiaomi yu7|小米YU7", "tech_company", 86),
|
||||
("理想L9", "li xiang L9", "Li L9", "tech_company", 82),
|
||||
("Li L9", "", "li l9|理想L9", "tech_company", 82),
|
||||
("理想MEGA", "li xiang MEGA", "Li MEGA|MEGA", "tech_company", 82),
|
||||
("小鹏MONA", "xiao peng MONA", "MONA", "tech_company", 80),
|
||||
("极狐", "ji hu", "ARCFOX", "tech_company", 80),
|
||||
("ARCFOX", "", "arcfox|极狐", "tech_company", 80),
|
||||
("阿维塔", "a wei ta", "Avatr", "tech_company", 82),
|
||||
("Avatr", "", "avatr|阿维塔", "tech_company", 82),
|
||||
("深蓝", "shen lan", "Deepal", "tech_company", 80),
|
||||
("Deepal", "", "deepal|深蓝", "tech_company", 80),
|
||||
("岚图", "lan tu", "Voyah", "tech_company", 82),
|
||||
("Voyah", "", "voyah|岚图", "tech_company", 82),
|
||||
("腾势", "teng shi", "Denza", "tech_company", 82),
|
||||
("Denza", "", "denza|腾势", "tech_company", 82),
|
||||
("方程豹", "fang cheng bao", "Fangchengbao", "tech_company", 82),
|
||||
("Fangchengbao", "", "fangchengbao|方程豹", "tech_company", 82),
|
||||
("仰望", "yang wang", "Yangwang", "tech_company", 84),
|
||||
("Yangwang", "", "yangwang|仰望", "tech_company", 84),
|
||||
("智己", "zhi ji", "IM Motors", "tech_company", 82),
|
||||
("IM Motors", "", "im motors|智己", "tech_company", 82),
|
||||
("飞凡", "fei fan", "Rising Auto", "tech_company", 76),
|
||||
("昊铂", "hao bo", "Hyper", "tech_company", 78),
|
||||
("Hyper", "", "hyper|昊铂", "tech_company", 78),
|
||||
("广汽埃安", "guang qi ai an", "Aion", "tech_company", 82),
|
||||
("Aion", "", "aion|埃安", "tech_company", 82),
|
||||
("哪吒汽车", "na zha qi che", "Neta Auto", "tech_company", 80),
|
||||
("Neta Auto", "", "neta auto|哪吒汽车", "tech_company", 80),
|
||||
("零跑", "ling pao", "Leapmotor", "tech_company", 84),
|
||||
("Leapmotor", "", "leapmotor|零跑", "tech_company", 84),
|
||||
("赛力斯", "sai li si", "Seres", "tech_company", 84),
|
||||
("Seres", "", "seres|赛力斯", "tech_company", 84),
|
||||
("长安", "chang an", "Changan", "tech_company", 78),
|
||||
("Changan", "", "changan|长安", "tech_company", 78),
|
||||
("上汽", "shang qi", "SAIC", "tech_company", 78),
|
||||
("SAIC", "", "saic|上汽", "tech_company", 78),
|
||||
("一汽", "yi qi", "FAW", "tech_company", 76),
|
||||
("FAW", "", "faw|一汽", "tech_company", 76),
|
||||
("东风", "dong feng", "Dongfeng", "tech_company", 76),
|
||||
("Dongfeng", "", "dongfeng|东风", "tech_company", 76),
|
||||
("奇瑞", "qi rui", "Chery", "tech_company", 78),
|
||||
("Chery", "", "chery|奇瑞", "tech_company", 78),
|
||||
("吉利银河", "ji li yin he", "Galaxy Geely", "tech_company", 78),
|
||||
("路特斯", "lu te si", "Lotus", "tech_company", 80),
|
||||
("Lotus", "", "lotus|路特斯", "tech_company", 80),
|
||||
("保时捷", "bao shi jie", "Porsche", "tech_company", 84),
|
||||
("Porsche", "", "porsche|保时捷", "tech_company", 84),
|
||||
("法拉利", "fa la li", "Ferrari", "tech_company", 82),
|
||||
("Ferrari", "", "ferrari|法拉利", "tech_company", 82),
|
||||
("兰博基尼", "lan bo ji ni", "Lamborghini", "tech_company", 82),
|
||||
("Lamborghini", "", "lamborghini|兰博基尼", "tech_company", 82),
|
||||
("奔驰", "ben chi", "Mercedes-Benz", "tech_company", 84),
|
||||
("Mercedes-Benz", "", "mercedes benz|奔驰", "tech_company", 84),
|
||||
("宝马", "bao ma", "BMW", "tech_company", 84),
|
||||
("BMW", "", "bmw|宝马", "tech_company", 84),
|
||||
("大众", "da zhong", "Volkswagen", "tech_company", 80),
|
||||
("Volkswagen", "", "volkswagen|大众", "tech_company", 80),
|
||||
("丰田", "feng tian", "Toyota", "tech_company", 80),
|
||||
("Toyota", "", "toyota|丰田", "tech_company", 80),
|
||||
("本田", "ben tian", "Honda", "tech_company", 78),
|
||||
("Honda", "", "honda|本田", "tech_company", 78),
|
||||
# --- Tech / semicon / cloud ---
|
||||
("Anduril", "", "anduril industries", "tech_company", 80),
|
||||
("Scale AI", "", "scale ai|Scale", "tech_company", 84),
|
||||
("Labelbox", "", "labelbox", "tech_company", 74),
|
||||
("Weights & Biases", "", "weights and biases|W&B", "dev_tool", 80),
|
||||
("W&B", "", "weights and biases", "dev_tool", 78),
|
||||
("Langfuse", "", "langfuse", "dev_tool", 78),
|
||||
("Helicone", "", "helicone", "dev_tool", 74),
|
||||
("Braintrust", "", "braintrust ai", "dev_tool", 76),
|
||||
("Arize", "", "arize ai|Arize AI", "dev_tool", 74),
|
||||
("CoreWeave", "", "coreweave", "tech_company", 82),
|
||||
("Lambda Labs", "", "lambda labs|Lambda", "tech_company", 78),
|
||||
("Crusoe", "", "crusoe energy", "tech_company", 74),
|
||||
("RunPod", "", "run pod|RunPod", "tech_company", 78),
|
||||
("Groq Cloud", "", "groq cloud", "ai_platform", 78),
|
||||
("Baseten", "", "baseten", "ai_platform", 76),
|
||||
("Anyscale", "", "anyscale|Ray", "tech_company", 78),
|
||||
("Ray Serve", "", "ray serve", "dev_tool", 76),
|
||||
("Micron", "", "micron|美光", "tech_company", 82),
|
||||
("美光", "mei guang", "Micron", "tech_company", 82),
|
||||
("SK Hynix", "", "sk hynix|海力士", "tech_company", 82),
|
||||
("海力士", "hai li shi", "SK Hynix", "tech_company", 82),
|
||||
("Kioxia", "", "kioxia|铠侠", "tech_company", 76),
|
||||
("铠侠", "kai xia", "Kioxia", "tech_company", 76),
|
||||
("Western Digital", "", "western digital|西部数据", "tech_company", 78),
|
||||
("西部数据", "xi bu shu ju", "Western Digital", "tech_company", 78),
|
||||
("MediaTek", "", "mediatek|联发科", "tech_company", 82),
|
||||
("联发科", "lian fa ke", "MediaTek", "tech_company", 82),
|
||||
("Rockchip", "", "rockchip|瑞芯微", "tech_company", 78),
|
||||
("瑞芯微", "rui xin wei", "Rockchip", "tech_company", 78),
|
||||
("Allwinner", "", "allwinner|全志", "tech_company", 74),
|
||||
("全志", "quan zhi", "Allwinner", "tech_company", 74),
|
||||
("寒武纪", "han wu ji", "Cambricon", "tech_company", 80),
|
||||
("Cambricon", "", "cambricon|寒武纪", "tech_company", 80),
|
||||
("壁仞", "bi ren", "Biren", "tech_company", 78),
|
||||
("Biren", "", "biren|壁仞", "tech_company", 78),
|
||||
("摩尔线程", "mo er xian cheng", "Moore Threads", "tech_company", 80),
|
||||
("Moore Threads", "", "moore threads|摩尔线程", "tech_company", 80),
|
||||
("燧原", "sui yuan", "Enflame", "tech_company", 76),
|
||||
("Enflame", "", "enflame|燧原", "tech_company", 76),
|
||||
("平头哥", "ping tou ge", "T-Head", "tech_company", 78),
|
||||
("T-Head", "", "t head|平头哥", "tech_company", 78),
|
||||
("紫光", "zi guang", "Unigroup", "tech_company", 76),
|
||||
("Unigroup", "", "unigroup|紫光", "tech_company", 76),
|
||||
("长鑫", "chang xin", "CXMT", "tech_company", 78),
|
||||
("CXMT", "", "cxmt|长鑫存储", "tech_company", 78),
|
||||
("长江存储", "chang jiang cun chu", "YMTC", "tech_company", 80),
|
||||
("YMTC", "", "ymtc|长江存储", "tech_company", 80),
|
||||
("商汤绝影", "shang tang jue ying", "SenseAuto", "tech_company", 78),
|
||||
("绝影", "jue ying", "SenseAuto", "tech_company", 76),
|
||||
("Momenta", "", "momenta|魔门塔", "tech_company", 82),
|
||||
("魔门塔", "mo men ta", "Momenta", "tech_company", 80),
|
||||
("文远知行", "wen yuan zhi xing", "WeRide", "tech_company", 82),
|
||||
("WeRide", "", "weride|文远知行", "tech_company", 82),
|
||||
("小马智行", "xiao ma zhi xing", "Pony.ai", "tech_company", 82),
|
||||
("Pony.ai", "", "pony ai|小马智行", "tech_company", 82),
|
||||
("元戎启行", "yuan rong qi xing", "DeepRoute", "tech_company", 78),
|
||||
("DeepRoute", "", "deeproute|元戎启行", "tech_company", 78),
|
||||
("禾赛", "he sai", "Hesai", "tech_company", 80),
|
||||
("Hesai", "", "hesai|禾赛", "tech_company", 80),
|
||||
("速腾聚创", "su teng ju chuang", "RoboSense", "tech_company", 80),
|
||||
("RoboSense", "", "robosense|速腾聚创", "tech_company", 80),
|
||||
("图达通", "tu da tong", "Innovusion", "tech_company", 76),
|
||||
("Innovusion", "", "innovusion|图达通", "tech_company", 76),
|
||||
("OpenHarmony", "", "open harmony|开源鸿蒙", "tech_company", 82),
|
||||
("开源鸿蒙", "kai yuan hong meng", "OpenHarmony", "tech_company", 82),
|
||||
("openEuler", "", "open euler|欧拉", "tech_company", 76),
|
||||
("欧拉", "ou la", "openEuler", "tech_company", 76),
|
||||
("龙芯", "long xin", "Loongson", "tech_company", 78),
|
||||
("Loongson", "", "loongson|龙芯", "tech_company", 78),
|
||||
("飞腾", "fei teng", "Phytium", "tech_company", 76),
|
||||
("Phytium", "", "phytium|飞腾", "tech_company", 76),
|
||||
("鲲鹏", "kun peng", "Kunpeng", "tech_company", 80),
|
||||
("Kunpeng", "", "kunpeng|鲲鹏", "tech_company", 80),
|
||||
("飞书", "fei shu", "Lark", "tech_company", 86),
|
||||
("Lark", "", "lark|飞书", "tech_company", 86),
|
||||
("钉钉", "ding ding", "DingTalk", "tech_company", 84),
|
||||
("DingTalk", "", "dingtalk|钉钉", "tech_company", 84),
|
||||
("企业微信", "qi ye wei xin", "WeCom", "tech_company", 84),
|
||||
("WeCom", "", "wecom|企业微信", "tech_company", 84),
|
||||
# --- AI / internet hot terms ---
|
||||
("skill", "", "skills|Agent Skills", "ai_term", 78),
|
||||
("skills", "", "agent skills|Claude Skills", "ai_term", 76),
|
||||
("Agent Skills", "", "agent skills|Claude Skills", "ai_term", 82),
|
||||
("Claude Skills", "", "claude skills", "ai_term", 80),
|
||||
("MCP协议", "MCP xie yi", "MCP protocol", "ai_term", 84),
|
||||
("A2A", "", "agent to agent|Agent2Agent", "ai_term", 82),
|
||||
("Agent2Agent", "", "a2a|agent to agent", "ai_term", 80),
|
||||
("A2A协议", "A2A xie yi", "Agent2Agent", "ai_term", 78),
|
||||
("deep agent", "", "deep agents", "ai_term", 82),
|
||||
("deep agents", "", "deep agent", "ai_term", 80),
|
||||
("agentic workflow", "", "agentic workflows", "ai_term", 84),
|
||||
("agentic coding", "", "agentic code", "ai_term", 82),
|
||||
("spec driven", "", "spec-driven|规格驱动", "ai_term", 80),
|
||||
("spec-driven", "", "spec driven", "ai_term", 80),
|
||||
("规格驱动", "gui ge qu dong", "spec-driven", "ai_term", 78),
|
||||
("vibe marketing", "", "氛围营销", "ai_term", 76),
|
||||
("氛围营销", "fen wei ying xiao", "vibe marketing", "ai_term", 74),
|
||||
("AI原生应用", "AI yuan sheng ying yong", "AI native app", "ai_term", 80),
|
||||
("AI应用", "AI ying yong", "AI app", "ai_term", 78),
|
||||
("模型蒸馏", "mo xing zheng liu", "model distillation", "ai_term", 80),
|
||||
("model distillation", "", "模型蒸馏", "ai_term", 78),
|
||||
("合成数据", "he cheng shu ju", "synthetic data", "ai_term", 80),
|
||||
("synthetic data", "", "合成数据", "ai_term", 78),
|
||||
("数据飞轮", "shu ju fei lun", "data flywheel", "ai_term", 78),
|
||||
("data flywheel", "", "数据飞轮", "ai_term", 76),
|
||||
("长上下文", "chang shang xia wen", "long context", "ai_term", 82),
|
||||
("long context", "", "长上下文", "ai_term", 80),
|
||||
("百万上下文", "bai wan shang xia wen", "million context", "ai_term", 80),
|
||||
("million context", "", "百万上下文", "ai_term", 78),
|
||||
("KV cache", "", "kv cache|KV缓存", "ai_term", 80),
|
||||
("KV缓存", "KV huan cun", "KV cache", "ai_term", 78),
|
||||
("投机解码", "tou ji jie ma", "speculative decoding", "ai_term", 76),
|
||||
("speculative decoding", "", "投机解码", "ai_term", 74),
|
||||
("MoE路由", "MoE lu you", "expert routing", "ai_term", 76),
|
||||
("expert routing", "", "MoE路由", "ai_term", 74),
|
||||
("稀疏注意力", "xi shu zhu yi li", "sparse attention", "ai_term", 74),
|
||||
("sparse attention", "", "稀疏注意力", "ai_term", 72),
|
||||
("视频大模型", "shi pin da mo xing", "video foundation model", "ai_term", 82),
|
||||
("video foundation model", "", "视频大模型", "ai_term", 80),
|
||||
("多智能体协作", "duo zhi neng ti xie zuo", "multi-agent collaboration", "ai_term", 80),
|
||||
("AI陪伴", "AI pei ban", "AI companion", "ai_term", 78),
|
||||
("AI companion", "", "AI陪伴", "ai_term", 76),
|
||||
("数字人", "shu zi ren", "digital human", "ai_term", 82),
|
||||
("digital human", "", "数字人", "ai_term", 80),
|
||||
("虚拟偶像", "xu ni ou xiang", "virtual idol", "ai_term", 76),
|
||||
("virtual idol", "", "虚拟偶像", "ai_term", 74),
|
||||
("赛博永生", "sai bo yong sheng", "cyber immortality", "tech_term", 72),
|
||||
("赛博菩萨", "sai bo pu sa", "cyber bodhisattva", "tech_term", 70),
|
||||
("赛博佛祖", "sai bo fo zu", "cyber buddha", "tech_term", 70),
|
||||
("抽象", "chou xiang", "abstract meme", "tech_term", 72),
|
||||
("钝感力", "dun gan li", "emotional bluntness", "tech_term", 74),
|
||||
("松弛感", "song chi gan", "relaxed vibe", "tech_term", 76),
|
||||
("情绪稳定", "qing xu wen ding", "emotionally stable", "tech_term", 74),
|
||||
("搭子", "da zi", "buddy partner", "tech_term", 76),
|
||||
("citywalk", "", "city walk|City Walk", "tech_term", 76),
|
||||
("City Walk", "", "citywalk", "tech_term", 74),
|
||||
("特种兵旅游", "te zhong bing lv you", "special forces travel", "tech_term", 72),
|
||||
("显眼包", "xian yan bao", "attention seeker", "tech_term", 72),
|
||||
("i人", "i ren", "introvert", "tech_term", 74),
|
||||
("e人", "e ren", "extrovert", "tech_term", 74),
|
||||
("MBTI", "", "mbti人格", "tech_term", 76),
|
||||
("脆皮大学生", "cui pi da xue sheng", "fragile college student", "tech_term", 70),
|
||||
("班味", "ban wei", "office vibe", "tech_term", 74),
|
||||
("班味很重", "ban wei hen zhong", "heavy office vibe", "tech_term", 72),
|
||||
("偷感", "tou gan", "stealth vibe", "tech_term", 72),
|
||||
("偷感很重", "tou gan hen zhong", "heavy stealth vibe", "tech_term", 70),
|
||||
("水灵灵", "shui ling ling", "fresh and lively", "tech_term", 70),
|
||||
("硬控", "ying kong", "hard control meme", "tech_term", 72),
|
||||
("绝绝子", "jue jue zi", "absolutely amazing", "tech_term", 72),
|
||||
("尊嘟假嘟", "zun du jia du", "really or not", "tech_term", 70),
|
||||
("芭比Q了", "ba bi Q le", "barbecue done|finished", "tech_term", 68),
|
||||
("栓Q", "shuan Q", "thank you meme", "tech_term", 68),
|
||||
("YYDS", "", "yyds|永远的神", "tech_term", 74),
|
||||
("永远的神", "yong yuan de shen", "YYDS", "tech_term", 72),
|
||||
("破防", "po fang", "emotionally broken", "tech_term", 74),
|
||||
("上头", "shang tou", "addictive hype", "tech_term", 70),
|
||||
("下头", "xia tou", "turn off vibe", "tech_term", 70),
|
||||
("拿捏", "na nie", "nailed it", "tech_term", 70),
|
||||
("种草", "zhong cao", "plant grass recommend", "tech_term", 78),
|
||||
("拔草", "ba cao", "unrecommend", "tech_term", 74),
|
||||
("安利", "an li", "recommend", "tech_term", 74),
|
||||
("避雷", "bi lei", "avoid pitfall", "tech_term", 74),
|
||||
("测评", "ce ping", "product review", "tech_term", 76),
|
||||
("开箱", "kai xiang", "unboxing", "tech_term", 76),
|
||||
("沉浸式", "chen jin shi", "immersive", "tech_term", 74),
|
||||
("氛围感", "fen wei gan", "atmospheric vibe", "tech_term", 76),
|
||||
("高级感", "gao ji gan", "premium feel", "tech_term", 74),
|
||||
("质感", "zhi gan", "texture quality", "tech_term", 72),
|
||||
("带货", "dai huo", "live commerce sales", "tech_term", 78),
|
||||
("直播带货", "zhi bo dai huo", "livestream commerce", "tech_term", 80),
|
||||
("短视频", "duan shi pin", "short video", "tech_term", 78),
|
||||
("中视频", "zhong shi pin", "mid-length video", "tech_term", 72),
|
||||
("知识付费", "zhi shi fu fei", "paid knowledge", "tech_term", 74),
|
||||
("私域", "si yu", "private domain traffic", "tech_term", 78),
|
||||
("公域", "gong yu", "public domain traffic", "tech_term", 74),
|
||||
("全域经营", "quan yu jing ying", "omnichannel operation", "tech_term", 74),
|
||||
("本地化", "ben di hua", "localization", "tech_term", 72),
|
||||
("GTM", "", "go to market", "tech_term", 74),
|
||||
("PLG", "", "product led growth", "tech_term", 72),
|
||||
("SLG", "", "sales led growth", "tech_term", 70),
|
||||
("ARR", "", "annual recurring revenue", "tech_term", 72),
|
||||
("MRR", "", "monthly recurring revenue", "tech_term", 70),
|
||||
("NDR", "", "net dollar retention", "tech_term", 68),
|
||||
("CAC", "", "customer acquisition cost", "tech_term", 70),
|
||||
("LTV", "", "lifetime value", "tech_term", 70),
|
||||
("融资", "rong zi", "fundraising", "tech_term", 74),
|
||||
("估值", "gu zhi", "valuation", "tech_term", 74),
|
||||
("IPO", "", "initial public offering|上市", "tech_term", 76),
|
||||
("上市", "shang shi", "IPO listing", "tech_term", 74),
|
||||
("独角兽", "du jiao shou", "unicorn startup", "tech_term", 76),
|
||||
("decacorn", "", "decacorn startup", "tech_term", 68),
|
||||
("A轮", "A lun", "Series A", "tech_term", 72),
|
||||
("B轮", "B lun", "Series B", "tech_term", 70),
|
||||
("C轮", "C lun", "Series C", "tech_term", 68),
|
||||
("Pre-A", "", "pre a round", "tech_term", 68),
|
||||
("降本增效", "jiang ben zeng xiao", "cost cut efficiency", "tech_term", 78),
|
||||
("裁员", "cai yuan", "layoffs", "tech_term", 74),
|
||||
("优化", "you hua", "layoff euphemism", "tech_term", 70),
|
||||
("毕业", "bi ye", "layoff euphemism", "tech_term", 68),
|
||||
("35岁危机", "35 sui wei ji", "age 35 crisis", "tech_term", 72),
|
||||
("考公", "kao gong", "civil service exam", "tech_term", 74),
|
||||
("考编", "kao bian", "public institution exam", "tech_term", 72),
|
||||
("灵活就业", "ling huo jiu ye", "flexible employment", "tech_term", 70),
|
||||
("银发经济", "yin fa jing ji", "silver economy", "tech_term", 78),
|
||||
("宠物经济", "chong wu jing ji", "pet economy", "tech_term", 74),
|
||||
("悦己消费", "yue ji xiao fei", "self-reward spending", "tech_term", 74),
|
||||
("情绪消费", "qing xu xiao fei", "emotional spending", "tech_term", 74),
|
||||
("体验经济", "ti yan jing ji", "experience economy", "tech_term", 72),
|
||||
("首发经济", "shou fa jing ji", "launch economy", "tech_term", 74),
|
||||
("冰雪经济", "bing xue jing ji", "ice snow economy", "tech_term", 72),
|
||||
("以旧换新", "yi jiu huan xin", "trade-in program", "tech_term", 78),
|
||||
("国补", "guo bu", "national subsidy", "tech_term", 80),
|
||||
("消费券", "xiao fei quan", "consumption voucher", "tech_term", 72),
|
||||
("数据要素", "shu ju yao su", "data as factor", "tech_term", 78),
|
||||
("数据资产", "shu ju zi chan", "data assets", "tech_term", 76),
|
||||
("数据确权", "shu ju que quan", "data rights confirmation", "tech_term", 74),
|
||||
("耐心资本", "nai xin zi ben", "patient capital", "tech_term", 76),
|
||||
("长期主义", "chang qi zhu yi", "long-termism", "tech_term", 76),
|
||||
("价值投资者", "jia zhi tou zi zhe", "value investor", "tech_term", 72),
|
||||
("价值投资", "jia zhi tou zi", "value investing", "tech_term", 72),
|
||||
# --- Games / media IP ---
|
||||
("米哈游", "mi ha you", "miHoYo", "game_ip", 86),
|
||||
("miHoYo", "", "mihoyo|米哈游", "game_ip", 86),
|
||||
("原神", "yuan shen", "Genshin Impact", "game_ip", 88),
|
||||
("Genshin Impact", "", "genshin impact|原神", "game_ip", 88),
|
||||
("崩坏星穹铁道", "beng huai xing qiong tie dao", "Honkai Star Rail", "game_ip", 86),
|
||||
("Honkai Star Rail", "", "honkai star rail|星穹铁道", "game_ip", 86),
|
||||
("星穹铁道", "xing qiong tie dao", "Honkai Star Rail", "game_ip", 84),
|
||||
("崩坏", "beng huai", "Honkai", "game_ip", 80),
|
||||
("Honkai", "", "honkai|崩坏", "game_ip", 80),
|
||||
("绝区零", "jue qu ling", "Zenless Zone Zero", "game_ip", 84),
|
||||
("Zenless Zone Zero", "", "zenless zone zero|ZZZ", "game_ip", 84),
|
||||
("鸣潮", "ming chao", "Wuthering Waves", "game_ip", 82),
|
||||
("Wuthering Waves", "", "wuthering waves|鸣潮", "game_ip", 82),
|
||||
("腾讯游戏", "teng xun you xi", "Tencent Games", "game_ip", 84),
|
||||
("王者荣耀", "wang zhe rong yao", "Honor of Kings", "game_ip", 88),
|
||||
("Honor of Kings", "", "honor of kings|王者", "game_ip", 88),
|
||||
("和平精英", "he ping jing ying", "PUBG Mobile China", "game_ip", 86),
|
||||
("PUBG Mobile", "", "pubg mobile|和平精英", "game_ip", 84),
|
||||
("英雄联盟", "ying xiong lian meng", "League of Legends", "game_ip", 86),
|
||||
("League of Legends", "", "league of legends|LOL", "game_ip", 86),
|
||||
("无畏契约", "wu wei qi yue", "Valorant", "game_ip", 84),
|
||||
("Valorant", "", "valorant|无畏契约", "game_ip", 84),
|
||||
("DOTA2", "", "dota 2|Dota 2", "game_ip", 82),
|
||||
("Dota 2", "", "dota2|DOTA2", "game_ip", 82),
|
||||
("CS2", "", "counter strike 2|CSGO", "game_ip", 82),
|
||||
("Counter-Strike", "", "counter strike|CS2", "game_ip", 80),
|
||||
("Steam", "", "steam平台", "game_ip", 86),
|
||||
("Epic Games", "", "epic games|Epic", "game_ip", 80),
|
||||
("育碧", "yu bi", "Ubisoft", "game_ip", 78),
|
||||
("Ubisoft", "", "ubisoft|育碧", "game_ip", 78),
|
||||
("暴雪", "bao xue", "Blizzard", "game_ip", 82),
|
||||
("Blizzard", "", "blizzard|暴雪", "game_ip", 82),
|
||||
("动视", "dong shi", "Activision", "game_ip", 80),
|
||||
("Activision", "", "activision|动视", "game_ip", 80),
|
||||
("PlayStation", "", "playstation|PS5", "game_ip", 82),
|
||||
("PS5", "", "playstation 5", "game_ip", 80),
|
||||
("Xbox", "", "xbox series", "game_ip", 80),
|
||||
("艾尔登法环", "ai er deng fa huan", "Elden Ring", "game_ip", 82),
|
||||
("Elden Ring", "", "elden ring|老头环", "game_ip", 82),
|
||||
("老头环", "lao tou huan", "Elden Ring", "game_ip", 80),
|
||||
("何同学", "he tong xue", "何同学UP主", "celebrity", 82),
|
||||
("老师好我叫何同学", "lao shi hao wo jiao he tong xue", "何同学", "celebrity", 80),
|
||||
("罗翔", "luo xiang", "罗翔说刑法", "celebrity", 84),
|
||||
("罗翔说刑法", "luo xiang shuo xing fa", "罗翔", "celebrity", 82),
|
||||
("papi酱", "papi jiang", "papi", "celebrity", 78),
|
||||
("李子柒", "li zi qi", "Li Ziqi", "celebrity", 84),
|
||||
("Li Ziqi", "", "li ziqi|李子柒", "celebrity", 84),
|
||||
("董宇辉", "dong yu hui", "Dong Yuhui", "celebrity", 84),
|
||||
("Dong Yuhui", "", "dong yuhui|董宇辉", "celebrity", 84),
|
||||
("东方甄选", "dong fang zhen xuan", "East Buy", "tech_company", 80),
|
||||
("疯狂小杨哥", "feng kuang xiao yang ge", "Xiao Yangge", "celebrity", 78),
|
||||
("李佳琦", "li jia qi", "Austin Li", "celebrity", 84),
|
||||
("Austin Li", "", "austin li|李佳琦", "celebrity", 84),
|
||||
("薇娅", "wei ya", "Viya", "celebrity", 76),
|
||||
("Viya", "", "viya|薇娅", "celebrity", 76),
|
||||
("交个朋友", "jiao ge peng you", "Make Friends Live", "tech_company", 78),
|
||||
("交个朋友直播间", "jiao ge peng you zhi bo jian", "交个朋友", "tech_term", 76),
|
||||
# --- Finance / investment ---
|
||||
("伯克希尔", "bo ke xi er", "Berkshire Hathaway", "fintech", 82),
|
||||
("Berkshire Hathaway", "", "berkshire hathaway|伯克希尔", "fintech", 82),
|
||||
("芒格", "mang ge", "Charlie Munger", "tech_leader", 80),
|
||||
("Charlie Munger", "", "charlie munger|芒格", "tech_leader", 80),
|
||||
("索罗斯", "suo luo si", "George Soros", "tech_leader", 78),
|
||||
("George Soros", "", "george soros|索罗斯", "tech_leader", 78),
|
||||
("达利欧", "da li ou", "Ray Dalio", "tech_leader", 80),
|
||||
("Ray Dalio", "", "ray dalio|达利欧", "tech_leader", 80),
|
||||
("桥水", "qiao shui", "Bridgewater", "fintech", 80),
|
||||
("Bridgewater", "", "bridgewater|桥水基金", "fintech", 80),
|
||||
("黑石", "hei shi", "Blackstone", "fintech", 80),
|
||||
("Blackstone", "", "blackstone|黑石集团", "fintech", 80),
|
||||
("KKR", "", "kkr", "fintech", 76),
|
||||
("红杉", "hong shan", "Sequoia", "fintech", 80),
|
||||
("Sequoia", "", "sequoia|红杉资本", "fintech", 80),
|
||||
("a16z", "", "andreessen horowitz|A16Z", "fintech", 82),
|
||||
("Andreessen Horowitz", "", "a16z|andreessen horowitz", "fintech", 82),
|
||||
("高瓴", "gao ling", "Hillhouse", "fintech", 80),
|
||||
("Hillhouse", "", "hillhouse|高瓴资本", "fintech", 80),
|
||||
("IDG", "", "idg capital", "fintech", 76),
|
||||
("经纬", "jing wei", "Matrix Partners China", "fintech", 76),
|
||||
("Matrix Partners", "", "matrix partners|经纬创投", "fintech", 76),
|
||||
("真格", "zhen ge", "ZhenFund", "fintech", 76),
|
||||
("ZhenFund", "", "zhen fund|真格基金", "fintech", 76),
|
||||
("源码", "yuan ma", "Source Code Capital", "fintech", 74),
|
||||
("中金", "zhong jin", "CICC", "fintech", 78),
|
||||
("CICC", "", "cicc|中金公司", "fintech", 78),
|
||||
("中信", "zhong xin", "CITIC", "fintech", 78),
|
||||
("CITIC", "", "citic|中信证券", "fintech", 78),
|
||||
("华泰", "hua tai", "Huatai", "fintech", 76),
|
||||
("Huatai", "", "huatai|华泰证券", "fintech", 76),
|
||||
("国泰君安", "guo tai jun an", "Guotai Junan", "fintech", 76),
|
||||
("Guotai Junan", "", "guotai junan|国泰君安", "fintech", 76),
|
||||
("摩根大通", "mo gen da tong", "JPMorgan", "fintech", 80),
|
||||
("JPMorgan", "", "jp morgan|摩根大通", "fintech", 80),
|
||||
("高盛", "gao sheng", "Goldman Sachs", "fintech", 82),
|
||||
("Goldman Sachs", "", "goldman sachs|高盛", "fintech", 82),
|
||||
("摩根士丹利", "mo gen shi dan li", "Morgan Stanley", "fintech", 80),
|
||||
("Morgan Stanley", "", "morgan stanley|摩根士丹利", "fintech", 80),
|
||||
("纳斯达克", "na si da ke", "Nasdaq", "fintech", 80),
|
||||
("Nasdaq", "", "nasdaq|纳斯达克", "fintech", 80),
|
||||
("标普500", "biao pu 500", "S&P 500", "fintech", 78),
|
||||
("S&P 500", "", "s and p 500|标普500", "fintech", 78),
|
||||
("道琼斯", "dao qiong si", "Dow Jones", "fintech", 76),
|
||||
("Dow Jones", "", "dow jones|道琼斯", "fintech", 76),
|
||||
("恒生指数", "heng sheng zhi shu", "Hang Seng Index", "fintech", 76),
|
||||
("沪深300", "hu shen 300", "CSI 300", "fintech", 76),
|
||||
("比特币", "bi te bi", "Bitcoin", "fintech", 84),
|
||||
("Bitcoin", "", "bitcoin|BTC", "fintech", 84),
|
||||
("BTC", "", "bitcoin|比特币", "fintech", 80),
|
||||
("以太坊", "yi tai fang", "Ethereum", "fintech", 82),
|
||||
("Ethereum", "", "ethereum|ETH", "fintech", 82),
|
||||
("ETH", "", "ethereum|以太坊", "fintech", 78),
|
||||
("Solana", "", "solana crypto", "fintech", 78),
|
||||
("狗狗币", "gou gou bi", "Dogecoin", "fintech", 76),
|
||||
("Dogecoin", "", "dogecoin|狗狗币", "fintech", 76),
|
||||
("美联储", "mei lian chu", "Federal Reserve", "fintech", 82),
|
||||
("Federal Reserve", "", "federal reserve|美联储", "fintech", 82),
|
||||
("鲍威尔", "bao wei er", "Jerome Powell", "tech_leader", 80),
|
||||
("Jerome Powell", "", "jerome powell|鲍威尔", "tech_leader", 80),
|
||||
("加息", "jia xi", "rate hike", "fintech", 74),
|
||||
("降息", "jiang xi", "rate cut", "fintech", 76),
|
||||
("量化宽松", "liang hua kuan song", "quantitative easing", "fintech", 74),
|
||||
]
|
||||
|
||||
|
||||
def load_existing_terms(seed_path: Path) -> set[str]:
|
||||
terms: set[str] = set()
|
||||
with seed_path.open(encoding="utf-8") as handle:
|
||||
for raw_line in handle:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if not parts:
|
||||
continue
|
||||
word = parts[0].strip()
|
||||
if word:
|
||||
terms.add(word.lower())
|
||||
if len(parts) > 2:
|
||||
for alias in parts[2].split("|"):
|
||||
alias = alias.strip()
|
||||
if alias:
|
||||
terms.add(alias.lower())
|
||||
return terms
|
||||
|
||||
|
||||
def format_row(word: str, pinyin: str, aliases: str, category: str, weight: int) -> str:
|
||||
return f"{word}\t{pinyin}\t{aliases}\t{category}\t{weight}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
existing = load_existing_terms(SEED_PATH)
|
||||
seen_in_batch: set[str] = set()
|
||||
to_append: list[str] = []
|
||||
skipped: list[str] = []
|
||||
|
||||
for word, pinyin, aliases, category, weight in EXPANSION:
|
||||
key = word.strip().lower()
|
||||
if not key:
|
||||
continue
|
||||
if key in existing or key in seen_in_batch:
|
||||
skipped.append(word)
|
||||
continue
|
||||
seen_in_batch.add(key)
|
||||
to_append.append(format_row(word, pinyin, aliases, category, weight))
|
||||
|
||||
original = SEED_PATH.read_text(encoding="utf-8")
|
||||
if not original.endswith("\n"):
|
||||
original += "\n"
|
||||
|
||||
block = [
|
||||
"",
|
||||
"# --- Batch 2 expansion (2026-07-06): AI, celebrities, brands, hot terms ---",
|
||||
*to_append,
|
||||
"",
|
||||
]
|
||||
SEED_PATH.write_text(original + "\n".join(block), encoding="utf-8")
|
||||
|
||||
print(f"Appended: {len(to_append)} rows")
|
||||
print(f"Skipped duplicates: {len(skipped)}")
|
||||
if skipped:
|
||||
preview = ", ".join(skipped[:40])
|
||||
suffix = "..." if len(skipped) > 40 else ""
|
||||
print(f"Skipped preview: {preview}{suffix}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
# Personal Dictionary iCloud KVS — Manual Verification Checklist
|
||||
|
||||
Use this on **macOS with Xcode 16+** and at least two devices signed into the **same Apple ID** with iCloud Drive / iCloud enabled.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. In Apple Developer Portal, enable **iCloud** → **Key-value storage** for `com.osgkeyboard.ios`.
|
||||
2. Regenerate provisioning profiles after entitlements change.
|
||||
3. Run `xcodegen generate` and install a fresh build on each device.
|
||||
|
||||
## Scenarios
|
||||
|
||||
| # | Steps | Expected |
|
||||
|---|--------|----------|
|
||||
| 1 | Device A: open Personal Dictionary, enable **Sync via iCloud**, add term `TestWordA` | Toggle stays on; term appears locally |
|
||||
| 2 | Device B: open app → Personal Dictionary tab | `TestWordA` appears after pull (may take up to ~1 min) |
|
||||
| 3 | Device B: add `TestWordB` | Device A eventually shows both terms |
|
||||
| 4 | Both devices: edit same term offline, then go online | Newer edit wins; aliases union when terms match |
|
||||
| 5 | Device A: delete a term | Term disappears on Device B after sync |
|
||||
| 6 | Device A: disable iCloud sync | Local dictionary remains; Device B stops receiving new edits from A |
|
||||
| 7 | Sign out of iCloud on one device | App keeps local dictionary; sync errors may surface in UI |
|
||||
| 8 | Keyboard extension on Device A | Uses App Group cache immediately after main-app save — no iCloud wait |
|
||||
|
||||
## Notes
|
||||
|
||||
- KVS propagation is **eventual**; force-quit and reopen the app to speed up pulls.
|
||||
- The keyboard extension never talks to iCloud directly; only the main app syncs.
|
||||
- Payload limit is ~1 MB per key; very large dictionaries should show the “too large” error.
|
||||
@@ -79,6 +79,12 @@ targets:
|
||||
# specify kSecAttrAccessGroup explicitly.
|
||||
keychain-access-groups:
|
||||
- $(AppIdentifierPrefix)com.osgkeyboard.shared
|
||||
# iCloud Key-Value Store — personal dictionary sync across
|
||||
# iPhone / iPad / Mac (Designed for iPad). Main app only.
|
||||
# NOTE: this entitlement is a single STRING, not an array — an
|
||||
# array value never matches the profile and breaks automatic
|
||||
# signing ("doesn't match the entitlements file's value").
|
||||
com.apple.developer.ubiquity-kvstore-identifier: $(TeamIdentifierPrefix)com.osgkeyboard.ios
|
||||
resources:
|
||||
- path: OSGKeyboard/Assets.xcassets
|
||||
- path: OSGKeyboard/en.lproj
|
||||
|
||||
Reference in New Issue
Block a user