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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
|
<string>$(TeamIdentifierPrefix)com.osgkeyboard.ios</string>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.com.osgkeyboard.shared</string>
|
<string>group.com.osgkeyboard.shared</string>
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
{
|
{
|
||||||
"version": "v1",
|
"version": "v1",
|
||||||
"name": "ai-tech-brands",
|
"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",
|
"locale": "zh-Hans",
|
||||||
"entry_count": 1259,
|
"entry_count": 3040,
|
||||||
"seed_file": "Scripts/lexicon/seeds/ai_tech_brands_seed.tsv",
|
"seed_file": "Scripts/lexicon/seeds/ai_tech_brands_seed.tsv",
|
||||||
"license": "MIT (curated seed; OSGKeyboard contributors)",
|
"license": "MIT (curated seed; OSGKeyboard contributors)",
|
||||||
"categories": {
|
"categories": {
|
||||||
"ai_brand": 169,
|
"ai_brand": 284,
|
||||||
"ai_model": 90,
|
"ai_model": 152,
|
||||||
"ai_platform": 53,
|
"ai_platform": 116,
|
||||||
"ai_term": 197,
|
"ai_term": 270,
|
||||||
"dev_tool": 283,
|
"athlete": 73,
|
||||||
"fintech": 7,
|
"celebrity": 251,
|
||||||
"tech_company": 314,
|
"consumer_brand": 215,
|
||||||
"tech_leader": 37,
|
"dev_tool": 330,
|
||||||
"tech_term": 109
|
"fintech": 109,
|
||||||
|
"game_ip": 70,
|
||||||
|
"tech_company": 601,
|
||||||
|
"tech_leader": 202,
|
||||||
|
"tech_term": 367
|
||||||
},
|
},
|
||||||
"notes": [
|
"notes": [
|
||||||
"Curated bilingual AI brands, tech companies, terminology, and hot words.",
|
"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 sessionASRWarmedLocaleID: String?
|
||||||
private var asr: ASRService {
|
private var asr: ASRService {
|
||||||
if let sessionASR { return sessionASR }
|
if let sessionASR { return sessionASR }
|
||||||
let service = ASRServiceFactory.make()
|
let service = ASRServiceFactory.make(store: store)
|
||||||
sessionASR = service
|
sessionASR = service
|
||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
@@ -342,7 +342,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
sessionASR?.cancel()
|
sessionASR?.cancel()
|
||||||
sessionASR = ASRServiceFactory.make()
|
sessionASR = ASRServiceFactory.make(store: store)
|
||||||
sessionASREngineMode = engineMode
|
sessionASREngineMode = engineMode
|
||||||
sessionASRWarmedLocaleID = nil
|
sessionASRWarmedLocaleID = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var headerGradientColors: [Color] {
|
private var headerGradientColors: [Color] {
|
||||||
if sessionIsLive {
|
// 云端引擎未配置(缺 API Key)时不算就绪,保持中性灰渐变。
|
||||||
|
if sessionIsLive, !needsCloudSetup {
|
||||||
return [
|
return [
|
||||||
palette.accent.opacity(0.28),
|
palette.accent.opacity(0.28),
|
||||||
palette.accent.opacity(0.10),
|
palette.accent.opacity(0.10),
|
||||||
@@ -175,7 +176,14 @@ struct HomeView: View {
|
|||||||
.fill(flowStatusColor)
|
.fill(flowStatusColor)
|
||||||
.frame(width: 6, height: 6)
|
.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 {
|
let expires = flowManager.sessionExpiresAt {
|
||||||
Text("home.flow.label")
|
Text("home.flow.label")
|
||||||
.font(TypeStyle.caption2)
|
.font(TypeStyle.caption2)
|
||||||
@@ -195,7 +203,10 @@ struct HomeView: View {
|
|||||||
.minimumScaleFactor(0.85)
|
.minimumScaleFactor(0.85)
|
||||||
}
|
}
|
||||||
|
|
||||||
if flowManager.isActive {
|
if needsCloudSetup {
|
||||||
|
// 无按钮:引导卡片已提示去设置填 API Key。
|
||||||
|
EmptyView()
|
||||||
|
} else if flowManager.isActive {
|
||||||
Button {
|
Button {
|
||||||
flowManager.endSession()
|
flowManager.endSession()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -306,6 +317,7 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var flowStatusColor: Color {
|
private var flowStatusColor: Color {
|
||||||
|
if needsCloudSetup { return palette.warning }
|
||||||
if flowManager.isActive { return palette.accent }
|
if flowManager.isActive { return palette.accent }
|
||||||
if flowManager.isStarting { return palette.accent }
|
if flowManager.isStarting { return palette.accent }
|
||||||
if needsPermissionSetup { return palette.warning }
|
if needsPermissionSetup { return palette.warning }
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ struct MainAppRoot: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
flowManager.setAppForeground(scenePhase == .active)
|
flowManager.setAppForeground(scenePhase == .active)
|
||||||
flowManager.activateOnForeground()
|
flowManager.activateOnForeground()
|
||||||
|
PersonalDictionaryCloudSync.shared.startObservingExternalChanges()
|
||||||
|
Task {
|
||||||
|
await PersonalDictionaryCloudSync.shared.pullAndMergeIfEnabled()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .osgKeyboardOpenURL)) { notification in
|
.onReceive(NotificationCenter.default.publisher(for: .osgKeyboardOpenURL)) { notification in
|
||||||
guard let url = notification.userInfo?["url"] as? URL else { return }
|
guard let url = notification.userInfo?["url"] as? URL else { return }
|
||||||
@@ -50,8 +54,13 @@ struct MainAppRoot: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, phase in
|
.onChange(of: scenePhase) { _, phase in
|
||||||
flowManager.handleScenePhase(phase)
|
flowManager.handleScenePhase(phase)
|
||||||
guard phase == .active, config.hasCompletedOnboarding else { return }
|
guard phase == .active else { return }
|
||||||
flowManager.activateOnForeground()
|
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)
|
saveManualEntry(term: term, editingID: editingEntry?.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
reloadFromStore()
|
||||||
|
await PersonalDictionaryCloudSync.shared.pullAndMergeIfEnabled()
|
||||||
|
reloadFromStore()
|
||||||
|
}
|
||||||
|
.onReceive(
|
||||||
|
NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)
|
||||||
|
) { _ in
|
||||||
|
reloadFromStore()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - List
|
// MARK: - List
|
||||||
|
|
||||||
private var list: some View {
|
private var list: some View {
|
||||||
ScrollView {
|
List {
|
||||||
LazyVStack(alignment: .leading, spacing: Spacing.lg) {
|
ForEach(filteredSections, id: \.0) { _, items in
|
||||||
introBanner
|
Section {
|
||||||
ForEach(filteredSections, id: \.0) { category, items in
|
ForEach(items) { entry in
|
||||||
section(for: category, items: items)
|
entryRow(entry)
|
||||||
}
|
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||||
}
|
.listRowBackground(palette.surface)
|
||||||
.padding(.horizontal, Spacing.lg)
|
.listRowSeparatorTint(palette.divider)
|
||||||
.padding(.vertical, Spacing.md)
|
}
|
||||||
.tabBarScrollBottomPadding()
|
.onDelete { offsets in
|
||||||
}
|
delete(items: items, at: offsets)
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.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 {
|
private func entryRow(_ entry: PersonalDictionary.Entry) -> some View {
|
||||||
@@ -187,18 +175,10 @@ struct PersonalDictionaryView: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
delete(entry)
|
|
||||||
} label: {
|
|
||||||
Label("common.delete", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
VStack(spacing: Spacing.sm) {
|
VStack(spacing: Spacing.sm) {
|
||||||
Spacer()
|
|
||||||
Image(systemName: "square.stack.3d.down.right.fill")
|
Image(systemName: "square.stack.3d.down.right.fill")
|
||||||
.font(.system(size: 36, weight: .regular))
|
.font(.system(size: 36, weight: .regular))
|
||||||
.foregroundStyle(palette.textTertiary.opacity(0.5))
|
.foregroundStyle(palette.textTertiary.opacity(0.5))
|
||||||
@@ -219,8 +199,11 @@ struct PersonalDictionaryView: View {
|
|||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(palette.accent)
|
.tint(palette.accent)
|
||||||
.padding(.top, Spacing.sm)
|
.padding(.top, Spacing.sm)
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
|
// 内容按自身高度居中,再上移抵消 large title 占用的顶部空间,
|
||||||
|
// 让空状态在整屏视觉上真正垂直居中。
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||||
|
.padding(.bottom, 72)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Derived data
|
// MARK: - Derived data
|
||||||
@@ -285,6 +268,12 @@ struct PersonalDictionaryView: View {
|
|||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func delete(items: [PersonalDictionary.Entry], at offsets: IndexSet) {
|
||||||
|
for index in offsets {
|
||||||
|
delete(items[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func clearAll() {
|
private func clearAll() {
|
||||||
dictionary = .empty
|
dictionary = .empty
|
||||||
generatingAliasEntryIDs = []
|
generatingAliasEntryIDs = []
|
||||||
@@ -294,6 +283,13 @@ struct PersonalDictionaryView: View {
|
|||||||
private func persist() {
|
private func persist() {
|
||||||
dictionary.version += 1
|
dictionary.version += 1
|
||||||
store.setPersonalDictionary(dictionary)
|
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)
|
.font(TypeStyle.body)
|
||||||
.foregroundStyle(palette.textPrimary)
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
|
||||||
|
if provider.supportsPersonalDictionaryCloudASR {
|
||||||
|
personalDictionaryBadge
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(minLength: Spacing.xs)
|
Spacer(minLength: Spacing.xs)
|
||||||
|
|
||||||
if selected {
|
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 {
|
enum ProviderLogo {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ struct SettingsView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: Spacing.md) {
|
VStack(spacing: Spacing.md) {
|
||||||
languageAndPolishSection
|
languageAndPolishSection
|
||||||
|
dictionaryAndPolishSection
|
||||||
flowSessionSection
|
flowSessionSection
|
||||||
engineSection
|
engineSection
|
||||||
// v0.2.1: hide provider/api card when the
|
// v0.2.1: hide provider/api card when the
|
||||||
@@ -180,15 +181,6 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
Divider().background(palette.divider)
|
Divider().background(palette.divider)
|
||||||
|
|
||||||
polishIntensityPreferenceRows
|
|
||||||
|
|
||||||
if config.isTranslationRowVisible {
|
|
||||||
Divider().background(palette.divider)
|
|
||||||
TranslationPickerRow(config: config, isVisible: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider().background(palette.divider)
|
|
||||||
|
|
||||||
cursorDragNavigationToggleRow
|
cursorDragNavigationToggleRow
|
||||||
}
|
}
|
||||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
.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
|
/// v0.2.1 follow-up: dedicated section for the local engine's
|
||||||
/// settings (cloud-polish toggle + translation row). Renders only
|
/// settings (cloud-polish toggle + translation row). Renders only
|
||||||
/// when `engineMode == "local"` so the cloud-engine user doesn't
|
/// 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.ios26" = "Always on-device, no network.";
|
||||||
"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish.";
|
"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish.";
|
||||||
"settings.engine.cloud.title" = "Cloud recognition & 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.title" = "Provider";
|
||||||
|
"settings.provider.personalDictionaryBadge" = "Personal dictionary";
|
||||||
"settings.provider.subtitle" = "Pick the LLM that polishes your dictation.";
|
"settings.provider.subtitle" = "Pick the LLM that polishes your dictation.";
|
||||||
"provider.openai" = "OpenAI";
|
"provider.openai" = "OpenAI";
|
||||||
"provider.deepseek" = "DeepSeek";
|
"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.systemPrompt.hint" = "Used by the cloud LLM when polishing your dictation. Local engine skips this step.";
|
||||||
"settings.about.title" = "About";
|
"settings.about.title" = "About";
|
||||||
"settings.preferences.title" = "Preferences";
|
"settings.preferences.title" = "Preferences";
|
||||||
|
"settings.dictionaryAndPolish.title" = "Dictionary & polish";
|
||||||
"settings.handedness.title" = "Handedness";
|
"settings.handedness.title" = "Handedness";
|
||||||
"settings.handedness.left" = "Left hand";
|
"settings.handedness.left" = "Left hand";
|
||||||
"settings.handedness.right" = "Right hand";
|
"settings.handedness.right" = "Right hand";
|
||||||
@@ -283,6 +286,7 @@
|
|||||||
"home.flow.active" = "Voice session active";
|
"home.flow.active" = "Voice session active";
|
||||||
"home.flow.label" = "Ready";
|
"home.flow.label" = "Ready";
|
||||||
"home.flow.inactive" = "Voice session inactive";
|
"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.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.mic" = "Microphone access is off — voice input won't work.";
|
||||||
"home.setup.permission.speech" = "Speech recognition 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.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.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.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.search.prompt" = "Search words";
|
||||||
"settings.personalDictionary.usageCount" = "%lld uses";
|
"settings.personalDictionary.usageCount" = "%lld uses";
|
||||||
"settings.personalDictionary.add.title" = "Add word";
|
"settings.personalDictionary.add.title" = "Add word";
|
||||||
@@ -358,6 +362,11 @@
|
|||||||
"settings.personalDictionary.clearAll.confirmTitle" = "Clear all dictionary words?";
|
"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.message" = "This removes every word the LLM was protecting. This cannot be undone.";
|
||||||
"settings.personalDictionary.clearAll.confirm" = "Clear all";
|
"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 */
|
/* v0.3.0: Polish intensity */
|
||||||
"settings.polishIntensity.title" = "Polish intensity";
|
"settings.polishIntensity.title" = "Polish intensity";
|
||||||
|
|||||||
@@ -98,8 +98,10 @@
|
|||||||
"settings.engine.local.ios26" = "全程在手机本地,不用联网";
|
"settings.engine.local.ios26" = "全程在手机本地,不用联网";
|
||||||
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
|
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
|
||||||
"settings.engine.cloud.title" = "云端识别与润色";
|
"settings.engine.cloud.title" = "云端识别与润色";
|
||||||
"settings.engine.cloud.subtitle" = "本地转写 + 你配置的 API 润色,文字发往该第三方服务";
|
"settings.engine.cloud.subtitle" = "云端 ASR(含个性词库)+ API 润色,音频将发往第三方服务";
|
||||||
|
"settings.engine.cloud.badge" = "云端引擎";
|
||||||
"settings.provider.title" = "云端引擎";
|
"settings.provider.title" = "云端引擎";
|
||||||
|
"settings.provider.personalDictionaryBadge" = "个性词库";
|
||||||
"settings.provider.subtitle" = "选择 LLM 提供商。";
|
"settings.provider.subtitle" = "选择 LLM 提供商。";
|
||||||
"provider.openai" = "OpenAI";
|
"provider.openai" = "OpenAI";
|
||||||
"provider.deepseek" = "DeepSeek";
|
"provider.deepseek" = "DeepSeek";
|
||||||
@@ -141,6 +143,7 @@
|
|||||||
"settings.systemPrompt.hint" = "云端润色时使用。本地识别模式不会用到此提示词。";
|
"settings.systemPrompt.hint" = "云端润色时使用。本地识别模式不会用到此提示词。";
|
||||||
"settings.about.title" = "关于";
|
"settings.about.title" = "关于";
|
||||||
"settings.preferences.title" = "偏好设置";
|
"settings.preferences.title" = "偏好设置";
|
||||||
|
"settings.dictionaryAndPolish.title" = "词库与润色";
|
||||||
"settings.handedness.title" = "握持偏好";
|
"settings.handedness.title" = "握持偏好";
|
||||||
"settings.handedness.left" = "左手";
|
"settings.handedness.left" = "左手";
|
||||||
"settings.handedness.right" = "右手";
|
"settings.handedness.right" = "右手";
|
||||||
@@ -282,6 +285,7 @@
|
|||||||
"home.flow.active" = "语音会话进行中";
|
"home.flow.active" = "语音会话进行中";
|
||||||
"home.flow.label" = "就绪";
|
"home.flow.label" = "就绪";
|
||||||
"home.flow.inactive" = "语音会话未启动";
|
"home.flow.inactive" = "语音会话未启动";
|
||||||
|
"home.flow.notReady" = "未就绪";
|
||||||
"home.flow.hint" = "切到别的 App,点键盘麦克风就能说。";
|
"home.flow.hint" = "切到别的 App,点键盘麦克风就能说。";
|
||||||
"home.setup.permission.mic" = "麦克风还没授权,语音输入用不了。";
|
"home.setup.permission.mic" = "麦克风还没授权,语音输入用不了。";
|
||||||
"home.setup.permission.speech" = "语音识别还没授权,语音输入用不了。";
|
"home.setup.permission.speech" = "语音识别还没授权,语音输入用不了。";
|
||||||
@@ -345,7 +349,7 @@
|
|||||||
"settings.personalDictionary.intro.title" = "关于词库";
|
"settings.personalDictionary.intro.title" = "关于词库";
|
||||||
"settings.personalDictionary.intro.body" = "手动添加词条;保存后会自动生成常见误识别写法。点击词条可编辑。";
|
"settings.personalDictionary.intro.body" = "手动添加词条;保存后会自动生成常见误识别写法。点击词条可编辑。";
|
||||||
"settings.personalDictionary.empty.title" = "还没有词条";
|
"settings.personalDictionary.empty.title" = "还没有词条";
|
||||||
"settings.personalDictionary.empty.body" = "添加希望在语音纠错时保护的词汇。";
|
"settings.personalDictionary.empty.body" = "添加个性化词库,提升识别准确性\n词库将通过 iCloud 实现跨设备同步";
|
||||||
"settings.personalDictionary.search.prompt" = "搜索词条";
|
"settings.personalDictionary.search.prompt" = "搜索词条";
|
||||||
"settings.personalDictionary.usageCount" = "使用 %lld 次";
|
"settings.personalDictionary.usageCount" = "使用 %lld 次";
|
||||||
"settings.personalDictionary.add.title" = "添加词条";
|
"settings.personalDictionary.add.title" = "添加词条";
|
||||||
@@ -357,9 +361,14 @@
|
|||||||
"settings.personalDictionary.clearAll.confirmTitle" = "清空全部词条?";
|
"settings.personalDictionary.clearAll.confirmTitle" = "清空全部词条?";
|
||||||
"settings.personalDictionary.clearAll.message" = "这会移除所有受保护的词汇,且无法撤销。";
|
"settings.personalDictionary.clearAll.message" = "这会移除所有受保护的词汇,且无法撤销。";
|
||||||
"settings.personalDictionary.clearAll.confirm" = "全部清空";
|
"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: 润色档位 */
|
/* v0.3.0: 润色强度 */
|
||||||
"settings.polishIntensity.title" = "润色档位";
|
"settings.polishIntensity.title" = "润色强度";
|
||||||
|
|
||||||
/* Flow 会话策略 */
|
/* Flow 会话策略 */
|
||||||
"settings.flow.title" = "语音会话";
|
"settings.flow.title" = "语音会话";
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
|||||||
public static let detectedAppContext = "config.detectedAppContext"
|
public static let detectedAppContext = "config.detectedAppContext"
|
||||||
public static let detectedAppContextAt = "config.detectedAppContextAt"
|
public static let detectedAppContextAt = "config.detectedAppContextAt"
|
||||||
public static let personalDictionary = "config.personalDictionary.v1"
|
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.
|
/// When true, the host app auto-returns to the source app after a cold-start handoff.
|
||||||
public static let flowSkipAppSwitch = "config.flowSkipAppSwitch"
|
public static let flowSkipAppSwitch = "config.flowSkipAppSwitch"
|
||||||
/// Raw `FlowInactivityDuration` value; session expires after this idle window.
|
/// Raw `FlowInactivityDuration` value; session expires after this idle window.
|
||||||
@@ -53,6 +55,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
|||||||
public var cursorDragNavigationEnabled: Bool
|
public var cursorDragNavigationEnabled: Bool
|
||||||
public var polishIntensity: PolishIntensity
|
public var polishIntensity: PolishIntensity
|
||||||
public var personalDictionary: PersonalDictionary
|
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).
|
/// Auto-return to the host app after `startflow` cold start (default on).
|
||||||
public var flowSkipAppSwitch: Bool
|
public var flowSkipAppSwitch: Bool
|
||||||
/// Idle timeout before the Flow session ends; resets on each utterance.
|
/// Idle timeout before the Flow session ends; resets on each utterance.
|
||||||
@@ -154,6 +158,12 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
|||||||
}(),
|
}(),
|
||||||
polishIntensity: resolvePolishIntensity(from: defaults),
|
polishIntensity: resolvePolishIntensity(from: defaults),
|
||||||
personalDictionary: decodePersonalDictionary(from: defaults),
|
personalDictionary: decodePersonalDictionary(from: defaults),
|
||||||
|
personalDictionaryICloudSyncEnabled: {
|
||||||
|
if defaults.object(forKey: Keys.personalDictionaryICloudSyncEnabled) == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return defaults.bool(forKey: Keys.personalDictionaryICloudSyncEnabled)
|
||||||
|
}(),
|
||||||
flowSkipAppSwitch: {
|
flowSkipAppSwitch: {
|
||||||
if defaults.object(forKey: Keys.flowSkipAppSwitch) == nil {
|
if defaults.object(forKey: Keys.flowSkipAppSwitch) == nil {
|
||||||
return true
|
return true
|
||||||
@@ -212,6 +222,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
|||||||
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
|
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
|
||||||
defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
|
defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
|
||||||
defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration)
|
defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration)
|
||||||
|
defaults.set(personalDictionaryICloudSyncEnabled, forKey: Keys.personalDictionaryICloudSyncEnabled)
|
||||||
Self.encodePersonalDictionary(personalDictionary, to: defaults)
|
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 struct PersonalDictionary: Codable, Sendable, Equatable {
|
||||||
public var entries: [Entry]
|
public var entries: [Entry]
|
||||||
public var version: Int
|
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.entries = entries
|
||||||
self.version = version
|
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 {
|
public struct Entry: Codable, Sendable, Equatable, Identifiable {
|
||||||
@@ -34,6 +57,8 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
|
|||||||
public var category: Category
|
public var category: Category
|
||||||
public var source: Source
|
public var source: Source
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
|
/// Last mutation time — used for iCloud merge conflict resolution.
|
||||||
|
public var updatedAt: Date
|
||||||
public var usageCount: Int
|
public var usageCount: Int
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -43,6 +68,7 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
|
|||||||
category: Category,
|
category: Category,
|
||||||
source: Source,
|
source: Source,
|
||||||
createdAt: Date = Date(),
|
createdAt: Date = Date(),
|
||||||
|
updatedAt: Date? = nil,
|
||||||
usageCount: Int = 0
|
usageCount: Int = 0
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -51,9 +77,45 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
|
|||||||
self.category = category
|
self.category = category
|
||||||
self.source = source
|
self.source = source
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
|
self.updatedAt = updatedAt ?? createdAt
|
||||||
self.usageCount = usageCount
|
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 {
|
public enum Category: String, Codable, Sendable, CaseIterable {
|
||||||
/// Person / place / brand / organization.
|
/// Person / place / brand / organization.
|
||||||
case properNoun
|
case properNoun
|
||||||
@@ -140,6 +202,7 @@ extension PersonalDictionary {
|
|||||||
category: .productName,
|
category: .productName,
|
||||||
source: .manual,
|
source: .manual,
|
||||||
createdAt: Date(timeIntervalSince1970: 0),
|
createdAt: Date(timeIntervalSince1970: 0),
|
||||||
|
updatedAt: Date(timeIntervalSince1970: 0),
|
||||||
usageCount: 0
|
usageCount: 0
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -182,6 +245,7 @@ extension PersonalDictionary {
|
|||||||
if termChanged || regenerateAliases {
|
if termChanged || regenerateAliases {
|
||||||
entry.aliases = []
|
entry.aliases = []
|
||||||
}
|
}
|
||||||
|
entry.updatedAt = Date()
|
||||||
entries[idx] = entry
|
entries[idx] = entry
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
@@ -193,15 +257,19 @@ extension PersonalDictionary {
|
|||||||
entry.term = trimmed
|
entry.term = trimmed
|
||||||
entry.category = category
|
entry.category = category
|
||||||
entry.source = .manual
|
entry.source = .manual
|
||||||
|
entry.updatedAt = Date()
|
||||||
entries[idx] = entry
|
entries[idx] = entry
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
let entry = Entry(
|
let entry = Entry(
|
||||||
term: trimmed,
|
term: trimmed,
|
||||||
aliases: [],
|
aliases: [],
|
||||||
category: category,
|
category: category,
|
||||||
source: .manual
|
source: .manual,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
)
|
)
|
||||||
entries.append(entry)
|
entries.append(entry)
|
||||||
return entry
|
return entry
|
||||||
@@ -216,6 +284,7 @@ extension PersonalDictionary {
|
|||||||
entries[idx].aliases = Array(
|
entries[idx].aliases = Array(
|
||||||
Set(cleaned.filter { $0.lowercased() != termLower })
|
Set(cleaned.filter { $0.lowercased() != termLower })
|
||||||
).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||||
|
entries[idx].updatedAt = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders the entire dictionary as a prompt fragment. Entries
|
/// 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.
|
/// "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 {
|
@Published public var engineMode: String {
|
||||||
didSet {
|
didSet {
|
||||||
guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return }
|
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",
|
"bin_file" : "OSGKeyboardCLM.bin",
|
||||||
"export_seconds" : 0.029847979545593262,
|
"export_seconds" : 0.035165071487426758,
|
||||||
"generated_at" : "2026-07-05T11:34:09Z",
|
"generated_at" : "2026-07-06T13:16:27Z",
|
||||||
"identifier" : "com.osgkeyboard.custom-lm.v1",
|
"identifier" : "com.osgkeyboard.custom-lm.v1",
|
||||||
"locale" : "zh_CN",
|
"locale" : "zh_CN",
|
||||||
"phrase_count" : 11550,
|
"phrase_count" : 13329,
|
||||||
"sources" : {
|
"sources" : {
|
||||||
"ai_tech_seed" : 1259,
|
"ai_tech_seed" : 3040,
|
||||||
"computer_terms" : 10300
|
"computer_terms" : 10300
|
||||||
},
|
},
|
||||||
"version" : "1.0.0"
|
"version" : "1.0.0"
|
||||||
|
|||||||
@@ -121,9 +121,13 @@ public enum ASREvent: Sendable, Equatable {
|
|||||||
// MARK: - Factory
|
// MARK: - Factory
|
||||||
|
|
||||||
public enum ASRServiceFactory {
|
public enum ASRServiceFactory {
|
||||||
/// Returns the on-device `SpeechAnalyzer` + `DictationTranscriber` backend.
|
/// Returns on-device SpeechAnalyzer for `local`, or the user's cloud
|
||||||
public static func make() -> ASRService {
|
/// ASR provider when `engineMode == "cloud"`.
|
||||||
SpeechAnalyzerASR()
|
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) {
|
public func setPersonalDictionary(_ dictionary: PersonalDictionary) {
|
||||||
mutateConfiguration { $0.personalDictionary = dictionary }
|
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
|
// 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
|
private var didInstallTap = false
|
||||||
|
|
||||||
public init(asr: ASRService? = nil) {
|
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`, …).
|
/// 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.
|
// English dictation while halving the network round-trip.
|
||||||
//
|
//
|
||||||
// Engine matrix:
|
// Engine matrix:
|
||||||
// - `engineMode == "cloud"` → on-device ASR, then user's cloud LLM
|
// - `engineMode == "cloud"` → provider cloud ASR + user's cloud LLM
|
||||||
// - `engineMode == "local"` → on-device ASR, then built-in DeepSeek
|
// - `engineMode == "local"` → on-device ASR + built-in DeepSeek
|
||||||
// - Ultra-short, structure-free utterances skip the LLM entirely
|
// - Ultra-short, structure-free utterances skip the LLM entirely
|
||||||
// - Cloud without API key → raw + `.missingAPIKey` warning
|
// - Cloud without API key → raw + `.missingAPIKey` warning
|
||||||
// - Local without build 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).
|
// or a safety timeout elapses (symmetric to pre-roll at utterance start).
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
/// Tunable tail-drain policy shared by Flow capture and preview dictation.
|
/// Tunable tail-drain policy shared by Flow capture and preview dictation.
|
||||||
public struct FlowCaptureTailDrainPolicy: Sendable, Equatable {
|
public struct FlowCaptureTailDrainPolicy: Sendable, Equatable {
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import os
|
|||||||
public enum FlowPipelineDiagnostics {
|
public enum FlowPipelineDiagnostics {
|
||||||
public static func logDrain(_ report: FlowCaptureDrainReport) {
|
public static func logDrain(_ report: FlowCaptureDrainReport) {
|
||||||
OSGLog.flow.info(
|
OSGLog.flow.info(
|
||||||
"tailDrain duration=\(report.drainDurationSeconds, format: .fixed(precision: 2))s " +
|
"tailDrain duration=\(report.drainDurationSeconds, format: .fixed(precision: 2))s silenceEnd=\(report.endedBySilence) tailSamples=\(report.tailSampleCount)"
|
||||||
"silenceEnd=\(report.endedBySilence) tailSamples=\(report.tailSampleCount)"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,8 +20,7 @@ public enum FlowPipelineDiagnostics {
|
|||||||
chunkWarnings: Int
|
chunkWarnings: Int
|
||||||
) {
|
) {
|
||||||
OSGLog.flow.info(
|
OSGLog.flow.info(
|
||||||
"chunkPipeline chunks=\(chunkCount) lastChunkSamples=\(lastChunkSamples) " +
|
"chunkPipeline chunks=\(chunkCount) lastChunkSamples=\(lastChunkSamples) stitchedLen=\(stitchedLength) warnings=\(chunkWarnings)"
|
||||||
"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.noSpeech" = "No speech detected. Please try again.";
|
||||||
"error.asr.chunkFailed" = "Segment %lld failed: %@";
|
"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 */
|
/* Polish scenarios */
|
||||||
"polishScenario.daily_chat" = "Daily Chat";
|
"polishScenario.daily_chat" = "Daily Chat";
|
||||||
"polishScenario.social_lifestyle" = "Social Network";
|
"polishScenario.social_lifestyle" = "Social Network";
|
||||||
|
|||||||
@@ -33,6 +33,17 @@
|
|||||||
"error.asr.noSpeech" = "未识别到语音内容,请重试。";
|
"error.asr.noSpeech" = "未识别到语音内容,请重试。";
|
||||||
"error.asr.chunkFailed" = "第 %lld 段识别失败:%@";
|
"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.daily_chat" = "日常聊天";
|
||||||
"polishScenario.social_lifestyle" = "小红书";
|
"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.
|
# specify kSecAttrAccessGroup explicitly.
|
||||||
keychain-access-groups:
|
keychain-access-groups:
|
||||||
- $(AppIdentifierPrefix)com.osgkeyboard.shared
|
- $(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:
|
resources:
|
||||||
- path: OSGKeyboard/Assets.xcassets
|
- path: OSGKeyboard/Assets.xcassets
|
||||||
- path: OSGKeyboard/en.lproj
|
- path: OSGKeyboard/en.lproj
|
||||||
|
|||||||
Reference in New Issue
Block a user