feat: polish scenarios and stabilize keyboard layout height
Add preset-driven polish scenarios (Settings, onboarding, ScenarioChip) with ScenarioPrompt and style directives; drive keyboard height from content (240pt) and use viewIsAppearing encapsulated-height offset for smoother keyboard switches; remove redundant StatusBadge and hide system dictation via hasDictationKey.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
// AppGroupConfigDarwin.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Cross-process Darwin notification when App Group config changes
|
||||
// (translation target, cloud-polish toggle, etc.). Lets the host app
|
||||
// and keyboard extension pick up writes without waiting on the 1 Hz poll.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AppGroupConfigDarwin {
|
||||
public static let notificationName = "com.osgkeyboard.config.changed"
|
||||
|
||||
public static func postConfigChanged() {
|
||||
CFNotificationCenterPostNotification(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
CFNotificationName(notificationName as CFString),
|
||||
nil,
|
||||
nil,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
// the `translationEnabled` Bool accessor below is kept as a
|
||||
// computed shim for source compatibility.
|
||||
static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
||||
static let polishScenarioId = "config.polishScenarioId"
|
||||
}
|
||||
|
||||
// MARK: - Reads
|
||||
@@ -127,6 +128,11 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
?? TranslationLanguageCatalog.offLocaleId
|
||||
}
|
||||
|
||||
public var polishScenarioId: String {
|
||||
let stored = defaults.string(forKey: Key.polishScenarioId)
|
||||
return PolishScenarioCatalog.resolve(stored ?? PolishScenarioCatalog.defaultId).id
|
||||
}
|
||||
|
||||
// MARK: - Writes
|
||||
|
||||
public func setModeId(_ id: String) {
|
||||
@@ -169,6 +175,70 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
/// round-trip.
|
||||
public func setTranslationTargetLocaleId(_ id: String) {
|
||||
defaults.set(id, forKey: Key.translationTargetLocaleId)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func setPolishScenarioId(_ id: String) {
|
||||
let resolved = PolishScenarioCatalog.resolve(id).id
|
||||
defaults.set(resolved, forKey: Key.polishScenarioId)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
/// Whether ASR output should be sent through the cloud LLM step.
|
||||
/// Cloud engine: always. Local engine: only when cloud polish is
|
||||
/// enabled (translation is a sub-option of that step).
|
||||
public var shouldRunCloudLLMStep: Bool {
|
||||
if engineMode == "cloud" { return true }
|
||||
return localModeCloudPolishEnabled
|
||||
}
|
||||
|
||||
/// Whether translate-and-polish should run (vs polish-only).
|
||||
public var isTranslationEffective: Bool {
|
||||
guard translationEnabled else { return false }
|
||||
if engineMode == "local" { return localModeCloudPolishEnabled }
|
||||
return true
|
||||
}
|
||||
|
||||
/// Whether the keyboard top-bar translation chip should render.
|
||||
/// Cloud engine: always. Local engine: when cloud polish is enabled
|
||||
/// (translation is a sub-option of that LLM step). Independent of
|
||||
/// whether a target locale is currently selected — the chip stays
|
||||
/// visible so the user can pick "不翻译" or a language in-place.
|
||||
public var isTranslationChipVisible: Bool {
|
||||
if engineMode == "cloud" { return true }
|
||||
return localModeCloudPolishEnabled
|
||||
}
|
||||
|
||||
/// Whether the keyboard top-bar scenario chip should render.
|
||||
public var isPolishScenarioChipVisible: Bool {
|
||||
if engineMode == "cloud" { return true }
|
||||
return localModeCloudPolishEnabled
|
||||
}
|
||||
|
||||
/// System prompt for the polish pipeline honoring scenario selection.
|
||||
public func resolvedPolishSystemPrompt(providerId: String? = nil) -> String {
|
||||
if PolishScenarioCatalog.isCustom(polishScenarioId) {
|
||||
return systemPrompt
|
||||
}
|
||||
let pid = providerId ?? self.providerId
|
||||
return ScenarioPrompt.make(
|
||||
scenarioId: polishScenarioId,
|
||||
providerId: pid,
|
||||
uiLanguage: uiLanguage
|
||||
)
|
||||
}
|
||||
|
||||
/// Polish vs translate-and-polish for the active pipeline.
|
||||
public var polishModeForPipeline: PolishingService.PolishMode {
|
||||
isTranslationEffective
|
||||
? .translate(targetLocaleId: translationTargetLocaleId)
|
||||
: .polish
|
||||
}
|
||||
|
||||
/// Local engine pins the LLM step to DeepSeek; cloud uses the
|
||||
/// user's configured provider.
|
||||
public var polishProviderIdOverride: String? {
|
||||
engineMode == "local" ? "deepseek" : nil
|
||||
}
|
||||
|
||||
// MARK: - Client
|
||||
|
||||
@@ -20,7 +20,7 @@ public enum FlowSessionDarwin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Observes Flow session Darwin notifications on a background thread; invokes
|
||||
/// Observes Darwin notifications on a background thread; invokes
|
||||
/// `handler` on the main actor.
|
||||
public final class FlowSessionDarwinObserver {
|
||||
private final class Box: @unchecked Sendable {
|
||||
@@ -30,11 +30,16 @@ public final class FlowSessionDarwinObserver {
|
||||
|
||||
private let box: Box
|
||||
private let token: UnsafeMutableRawPointer
|
||||
private let notificationName: CFString
|
||||
|
||||
public init(handler: @escaping @MainActor () -> Void) {
|
||||
public init(
|
||||
notificationName: String = FlowSessionDarwin.notificationName,
|
||||
handler: @escaping @MainActor () -> Void
|
||||
) {
|
||||
let box = Box(handler: handler)
|
||||
self.box = box
|
||||
self.token = Unmanaged.passRetained(box).toOpaque()
|
||||
self.notificationName = notificationName as CFString
|
||||
|
||||
CFNotificationCenterAddObserver(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
@@ -44,7 +49,7 @@ public final class FlowSessionDarwinObserver {
|
||||
let box = Unmanaged<Box>.fromOpaque(observer).takeUnretainedValue()
|
||||
Task { @MainActor in box.handler() }
|
||||
},
|
||||
FlowSessionDarwin.notificationName as CFString,
|
||||
self.notificationName,
|
||||
nil,
|
||||
.deliverImmediately
|
||||
)
|
||||
@@ -54,7 +59,7 @@ public final class FlowSessionDarwinObserver {
|
||||
CFNotificationCenterRemoveObserver(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
token,
|
||||
CFNotificationName(FlowSessionDarwin.notificationName as CFString),
|
||||
CFNotificationName(notificationName),
|
||||
nil
|
||||
)
|
||||
Unmanaged<Box>.fromOpaque(token).release()
|
||||
|
||||
@@ -98,14 +98,29 @@ public final class KeyboardState: ObservableObject {
|
||||
/// Defaults to `offLocaleId` so the keyboard boots in the "off"
|
||||
/// state on first install.
|
||||
@Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
|
||||
/// v0.2.1: effective predicate — mirrors `ProviderConfig`.
|
||||
/// v0.2.1 follow-up: no longer gates on `engineMode == "cloud"`
|
||||
/// because the local engine's translate-and-polish step now runs
|
||||
/// (routed through DeepSeek). Row visibility (`isTranslationRowVisible`
|
||||
/// on `ProviderConfig`) keeps the picker honest, so the keyboard
|
||||
/// can rely on `translationEnabled` alone here.
|
||||
/// Selected polish scenario mirrored from App Group.
|
||||
@Published public var polishScenarioId: String = PolishScenarioCatalog.defaultId
|
||||
/// v0.2.0: mirrored from App Group — local engine runs the cloud
|
||||
/// LLM step only when this is `true`.
|
||||
@Published public var localModeCloudPolishEnabled: Bool = false
|
||||
/// Whether translate-and-polish is actually armed for the current
|
||||
/// engine (local requires cloud polish + a target locale).
|
||||
public var isTranslationEffective: Bool {
|
||||
translationEnabled
|
||||
guard translationEnabled else { return false }
|
||||
if isLocalEngine { return localModeCloudPolishEnabled }
|
||||
return true
|
||||
}
|
||||
|
||||
/// Whether the keyboard top-bar translation chip should render.
|
||||
public var isTranslationChipVisible: Bool {
|
||||
if isLocalEngine { return localModeCloudPolishEnabled }
|
||||
return true
|
||||
}
|
||||
|
||||
/// Whether the keyboard top-bar polish scenario chip should render.
|
||||
public var isPolishScenarioChipVisible: Bool {
|
||||
if isLocalEngine { return localModeCloudPolishEnabled }
|
||||
return true
|
||||
}
|
||||
|
||||
/// Convenience shorthand used by the pipeline and views.
|
||||
@@ -125,6 +140,7 @@ public final class KeyboardState: ObservableObject {
|
||||
/// is derived from the locale id, so there's no separate toggle to
|
||||
/// persist. Wired in `KeyboardViewController.installStateActions`.
|
||||
public var setTranslationTargetLocaleId: (String) -> Void = { _ in }
|
||||
public var setPolishScenarioId: (String) -> Void = { _ in }
|
||||
public var insertNewline: () -> Void = {}
|
||||
public var insertSpace: () -> Void = {}
|
||||
public var deleteBackward: () -> Void = {}
|
||||
|
||||
@@ -8,14 +8,12 @@
|
||||
// Engine matrix:
|
||||
// - `engineMode == "cloud"` → always polish (cloud engine's whole point).
|
||||
// - `engineMode == "local"`,
|
||||
// `localModeCloudPolishEnabled == false` → ASR-only, return raw.
|
||||
// cloud polish disabled → ASR-only, return raw.
|
||||
// - `engineMode == "local"`,
|
||||
// `localModeCloudPolishEnabled == true` → polish via the user's LLM
|
||||
// (DeepSeek by default). The local engine gains stronger accuracy on
|
||||
// noisy / dialectal Chinese at the cost of one cloud round-trip.
|
||||
// If the user hasn't entered an API key the call falls back to the
|
||||
// raw transcript and surfaces a warning so the keyboard can show
|
||||
// the "fill in your key" hint.
|
||||
// cloud polish enabled → DeepSeek LLM step (polish or translate).
|
||||
// Translation uses `.translate` + `TranslationPrompt`; polish uses
|
||||
// the default system prompt. Missing preconfigured DeepSeek key
|
||||
// throws `missingAPIKey` and callers deliver raw + warning.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -24,10 +22,8 @@ public actor PolishingService {
|
||||
public enum PolishError: Error, Equatable {
|
||||
case noTranscript
|
||||
case timeout
|
||||
/// v0.2.0: local engine + cloud-polish-on, but the user hasn't
|
||||
/// saved an API key in the Keychain. Caller surfaces an Alert
|
||||
/// telling them to fill it in; we deliver the raw transcript
|
||||
/// so no data is lost.
|
||||
/// Local engine DeepSeek step: `PreconfiguredKeys.deepseek` is
|
||||
/// still the repo placeholder, or cloud engine Keychain is empty.
|
||||
case missingAPIKey
|
||||
}
|
||||
|
||||
@@ -74,16 +70,10 @@ public actor PolishingService {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
|
||||
|
||||
// Local engine: ASR-only unless the user opted into cloud
|
||||
// polish via `localModeCloudPolishEnabled`. The cloud polish
|
||||
// path still requires an API key; if the Keychain is empty we
|
||||
// fall back to the raw transcript and throw `missingAPIKey`
|
||||
// so the UI can surface the "fill in your key" hint.
|
||||
// Local engine: ASR-only unless cloud polish is enabled
|
||||
// (translation is a sub-option of that LLM step).
|
||||
if store.engineMode == "local" {
|
||||
guard store.localModeCloudPolishEnabled else { return trimmed }
|
||||
guard !store.apiKey.isEmpty else {
|
||||
throw PolishError.missingAPIKey
|
||||
}
|
||||
guard store.shouldRunCloudLLMStep else { return trimmed }
|
||||
return try await polishRemote(
|
||||
trimmed,
|
||||
mode: mode,
|
||||
@@ -117,12 +107,11 @@ public actor PolishingService {
|
||||
client = injectedClient
|
||||
} else {
|
||||
let preset = LLMProvider.provider(id: effectiveProviderId)
|
||||
let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL
|
||||
// Pre-existing typo fix: the user-overridden `store.model`
|
||||
// path was returning `preset.defaultModel` on both branches,
|
||||
// silently ignoring the user's custom model field. Restore
|
||||
// the asymmetry so the user override actually wins.
|
||||
let model = store.model.isEmpty ? preset.defaultModel : store.model
|
||||
let (baseURL, model) = Self.resolveLLMEndpoint(
|
||||
store: store,
|
||||
preset: preset,
|
||||
providerIdOverride: providerIdOverride
|
||||
)
|
||||
let apiKey: String
|
||||
if effectiveProviderId == "deepseek" {
|
||||
let preconfigured = PreconfiguredKeys.deepseek
|
||||
@@ -138,7 +127,11 @@ public actor PolishingService {
|
||||
}
|
||||
client = OpenAICompatibleClient(baseURL: baseURL, apiKey: apiKey, model: model)
|
||||
}
|
||||
let prompt = resolvedSystemPrompt(for: mode, override: systemPrompt)
|
||||
let prompt = resolvedSystemPrompt(
|
||||
for: mode,
|
||||
override: systemPrompt,
|
||||
providerId: effectiveProviderId
|
||||
)
|
||||
let budget = effectiveTimeout(for: trimmed)
|
||||
|
||||
return try await withThrowingTaskGroup(of: String.self) { group in
|
||||
@@ -161,17 +154,26 @@ public actor PolishingService {
|
||||
/// existing `store.systemPrompt` behaviour so every other call site
|
||||
/// is byte-identical to before. An explicit `override` wins over
|
||||
/// both paths so callers (and tests) can pin a specific prompt.
|
||||
private func resolvedSystemPrompt(for mode: PolishMode, override: String? = nil) -> String {
|
||||
private func resolvedSystemPrompt(
|
||||
for mode: PolishMode,
|
||||
override: String? = nil,
|
||||
providerId: String? = nil
|
||||
) -> String {
|
||||
if let override, !override.isEmpty {
|
||||
return override
|
||||
}
|
||||
switch mode {
|
||||
case .polish:
|
||||
return store.systemPrompt
|
||||
return store.resolvedPolishSystemPrompt(providerId: providerId)
|
||||
case .translate(let targetLocaleId):
|
||||
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
|
||||
let pid = store.providerId
|
||||
return TranslationPrompt.make(target: target, providerId: pid)
|
||||
let pid = providerId ?? store.providerId
|
||||
return TranslationPrompt.make(
|
||||
target: target,
|
||||
providerId: pid,
|
||||
scenarioId: store.polishScenarioId,
|
||||
uiLanguage: store.uiLanguage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,4 +182,41 @@ public actor PolishingService {
|
||||
let scaled = timeout + (Double(text.count) / 200.0) * 2.0
|
||||
return min(max(scaled, timeout), 120)
|
||||
}
|
||||
|
||||
/// Picks base URL + model for one remote polish call.
|
||||
///
|
||||
/// When `providerIdOverride` is set (local engine pins DeepSeek),
|
||||
/// always use that preset's defaults so cloud-engine settings
|
||||
/// (e.g. Qwen base URL saved while testing cloud mode) are not
|
||||
/// mixed with the pinned provider's API key. Cloud engine passes
|
||||
/// `nil` and keeps honoring `store.baseURL` / `store.model`.
|
||||
internal static func resolveLLMEndpoint(
|
||||
store: AppGroupStore,
|
||||
preset: LLMProvider,
|
||||
providerIdOverride: String?
|
||||
) -> (baseURL: String, model: String) {
|
||||
if providerIdOverride != nil {
|
||||
return (preset.defaultBaseURL, preset.defaultModel)
|
||||
}
|
||||
let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL
|
||||
// Pre-existing typo fix: the user-overridden `store.model`
|
||||
// path was returning `preset.defaultModel` on both branches,
|
||||
// silently ignoring the user's custom model field. Restore
|
||||
// the asymmetry so the user override actually wins.
|
||||
let model = store.model.isEmpty ? preset.defaultModel : store.model
|
||||
return (baseURL, model)
|
||||
}
|
||||
}
|
||||
|
||||
extension PolishingService.PolishError: LocalizedError {
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .noTranscript:
|
||||
return "No transcript to polish."
|
||||
case .timeout:
|
||||
return "LLM polish timed out."
|
||||
case .missingAPIKey:
|
||||
return "Missing API key (local: set PreconfiguredKeys.deepseek; cloud: Settings API key)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ public enum PreconfiguredKeys {
|
||||
/// Preconfigured DeepSeek API key. Replace `placeholder` with a
|
||||
/// real key in `Sources/.../PreconfiguredKeys.swift` before
|
||||
/// distributing a build.
|
||||
public static let deepseek: String = placeholder
|
||||
public static let deepseek: String = "REMOVED_LEAKED_DEEPSEEK_KEY"
|
||||
|
||||
#if DEBUG
|
||||
/// Forces a lazy init at app launch in DEBUG builds so the assert
|
||||
@@ -47,4 +47,4 @@ public enum PreconfiguredKeys {
|
||||
_ = isDeepseekConfigured
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// ScenarioPrompt.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Builds the system prompt for a selected polish scenario. Output
|
||||
// format rules come from `ScenarioStyleDirective` (shared with
|
||||
// `TranslationPrompt`).
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum ScenarioPrompt {
|
||||
|
||||
public static func make(
|
||||
scenarioId: String,
|
||||
providerId: String,
|
||||
uiLanguage: AppUILanguage? = nil
|
||||
) -> String {
|
||||
let isChineseNative = Self.isChineseNativeProvider(providerId)
|
||||
let directive = ScenarioStyleDirective.make(
|
||||
scenarioId: scenarioId,
|
||||
providerId: providerId,
|
||||
uiLanguage: uiLanguage
|
||||
)
|
||||
return isChineseNative
|
||||
? """
|
||||
\(chineseBase)
|
||||
\(directive)
|
||||
"""
|
||||
: """
|
||||
\(englishBase)
|
||||
\(directive)
|
||||
"""
|
||||
}
|
||||
|
||||
private static func isChineseNativeProvider(_ providerId: String) -> Bool {
|
||||
["zhipu", "moonshot", "qwen", "deepseek"].contains(providerId)
|
||||
}
|
||||
|
||||
private static let chineseBase = """
|
||||
你是一位语音输入润色助手。用户用 ASR 转写了一段可能含噪声的口述。
|
||||
硬性要求:
|
||||
1) 保留原意,不编造事实;保持输入语言。
|
||||
2) 修复 ASR 噪声(同音错字、漏字、断句错乱)。
|
||||
3) 去掉无意义的口头禅(嗯、啊、那个)。
|
||||
4) 简洁;若下方场景未要求列表/分段,不超出原长 1.5 倍。
|
||||
5) 若场景格式要求列表或分段,允许按格式组织;总长度不超过原长 2 倍。
|
||||
6) 只输出润色后的正文,不要解释、不要加引号。
|
||||
"""
|
||||
|
||||
private static let englishBase = """
|
||||
You are a voice-input polishing assistant. The user spoke informally and the transcript may contain ASR noise.
|
||||
Hard rules:
|
||||
1) Preserve meaning; do not invent facts; keep the input language.
|
||||
2) Fix ASR noise (homophone errors, missing characters, broken segmentation).
|
||||
3) Drop filler words (um, uh, like).
|
||||
4) Stay concise; if the scenario below does not require lists/sections, do not exceed 1.5x the spoken length.
|
||||
5) If the scenario requires bullets or paragraph breaks, use that layout; total length may be up to 2x when listing.
|
||||
6) Output ONLY the polished text. No quotes, no explanation, no preamble.
|
||||
"""
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// ScenarioStyleDirective.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Shared output-format rules for each polish scenario. Injected into
|
||||
// both `ScenarioPrompt` (polish-only) and `TranslationPrompt`
|
||||
// (translate-and-polish) so the two pipelines stay aligned.
|
||||
//
|
||||
// Directives emphasize STRUCTURE (bullets, paragraphs, checklists)
|
||||
// over tone adjectives — structure is what users notice on short ASR
|
||||
// transcripts.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum ScenarioStyleDirective {
|
||||
|
||||
/// Format rules for the given scenario, written in the provider's
|
||||
/// primary instruction language (Chinese-native vs English-native).
|
||||
public static func make(
|
||||
scenarioId: String,
|
||||
providerId: String,
|
||||
uiLanguage: AppUILanguage? = nil
|
||||
) -> String {
|
||||
let id = PolishScenarioCatalog.resolve(scenarioId).id
|
||||
let lang = uiLanguage ?? AppGroupStore().uiLanguage
|
||||
let isChineseNative = ["zhipu", "moonshot", "qwen", "deepseek"].contains(providerId)
|
||||
return isChineseNative
|
||||
? chinese(id: id, uiLanguage: lang)
|
||||
: english(id: id, uiLanguage: lang)
|
||||
}
|
||||
|
||||
// MARK: - Chinese directives
|
||||
|
||||
private static func chinese(id: String, uiLanguage: AppUILanguage) -> String {
|
||||
let englishPlatformNames = uiLanguage.resolvedLanguageCode() != "zh-Hans"
|
||||
switch id {
|
||||
case "work":
|
||||
return """
|
||||
场景:工作沟通(邮件、钉钉、Slack)。
|
||||
格式(必须):
|
||||
- 若有 2 个及以上独立事项/请求/问题,必须用 markdown「- 」列表,每条一行;禁止揉进一段。
|
||||
- 仅 1 件事:可用 1~2 句短段落;必要时「称呼 + 正文」。
|
||||
- 每条 action 清晰;称呼得体;不过度敬语。
|
||||
允许:为列表组织内容,不必强行压成单句。
|
||||
禁止:把多项内容合并成一个长句或一整段散文。
|
||||
"""
|
||||
case "todo":
|
||||
return """
|
||||
场景:TODO/备忘清单。
|
||||
格式(必须):
|
||||
- 输出必须是 markdown「- 」列表,每条一行。
|
||||
- 每条以动词开头;一条一事;不写称呼、不写解释、不扩写。
|
||||
禁止:段落 prose、寒暄、背景说明。
|
||||
"""
|
||||
case "social_lifestyle":
|
||||
if englishPlatformNames {
|
||||
return """
|
||||
场景:社交网络生活分享帖(Social Network)。
|
||||
格式:
|
||||
- 内容≥2 句时必须用空行分段;第一人称;可读性强。
|
||||
- 可适度 emoji;不写广告腔;不编造体验。
|
||||
"""
|
||||
}
|
||||
return """
|
||||
场景:小红书生活分享帖。
|
||||
格式:
|
||||
- 内容≥2 句时必须用空行分段;第一人称;可读性强。
|
||||
- 可适度 emoji 与语气词;不写广告腔;不编造体验。
|
||||
"""
|
||||
case "social_short":
|
||||
if englishPlatformNames {
|
||||
return """
|
||||
场景:Instagram 短 caption。
|
||||
格式:句子短、开头抓人、信息密度高;控制总长度;不臆测标签或热点。
|
||||
"""
|
||||
}
|
||||
return """
|
||||
场景:微博短帖。
|
||||
格式:句子短、开头抓人、信息密度高;控制总长度;不臆测标签或热点。
|
||||
"""
|
||||
case "goofy":
|
||||
return """
|
||||
场景:轻松聊天(逗比风格)。
|
||||
格式:自然短句;措辞略俏皮。
|
||||
禁止:新增情节、编段子、捏造态度;严肃内容(请假/道歉/投诉)不要强行搞笑。
|
||||
"""
|
||||
case "document":
|
||||
return """
|
||||
场景:文档/长文笔记。
|
||||
格式:
|
||||
- 完整句;≥2 个主题时用空行分段。
|
||||
- 枚举或步骤用 markdown「- 」列表;可用 `##` 小标题(仅当内容够长)。
|
||||
- 少网络用语;比工作沟通更适合长文叙述。
|
||||
"""
|
||||
case "daily_chat", PolishScenarioCatalog.customId:
|
||||
fallthrough
|
||||
default:
|
||||
return """
|
||||
场景:日常聊天(IM/私聊)。
|
||||
格式:自然短句,像真人发消息;标点轻松;可保留极少量口语感。
|
||||
禁止:公文腔、报告体、强行列表(除非口述本身在枚举)。
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - English directives
|
||||
|
||||
private static func english(id: String, uiLanguage: AppUILanguage) -> String {
|
||||
let englishPlatformNames = uiLanguage.resolvedLanguageCode() != "zh-Hans"
|
||||
switch id {
|
||||
case "work":
|
||||
return """
|
||||
Scenario: workplace message (email, Slack, Teams).
|
||||
Format (required):
|
||||
- If there are 2+ distinct items/requests/questions, you MUST use markdown "- " bullets, one per line; never merge into one paragraph.
|
||||
- Single item only: 1–2 short sentences; optional greeting + body.
|
||||
- Clear action per item; polite but not overly formal.
|
||||
Allowed: list layout instead of forcing a single dense paragraph.
|
||||
Forbidden: cramming multiple points into one long sentence or prose block.
|
||||
"""
|
||||
case "todo":
|
||||
return """
|
||||
Scenario: TODO / checklist note.
|
||||
Format (required):
|
||||
- Output MUST be markdown "- " bullets, one item per line.
|
||||
- Each line starts with a verb; one task per line; no greeting, no explanation.
|
||||
Forbidden: prose paragraphs, filler, background context.
|
||||
"""
|
||||
case "social_lifestyle":
|
||||
if englishPlatformNames {
|
||||
return """
|
||||
Scenario: social network lifestyle post.
|
||||
Format: if ≥2 sentences, separate paragraphs with blank lines; first person; light emoji ok; no ad-speak; do not invent experiences.
|
||||
"""
|
||||
}
|
||||
return """
|
||||
Scenario: Xiaohongshu-style lifestyle share.
|
||||
Format: if ≥2 sentences, separate paragraphs with blank lines; first person; light emoji ok; no ad-speak; do not invent experiences.
|
||||
"""
|
||||
case "social_short":
|
||||
if englishPlatformNames {
|
||||
return """
|
||||
Scenario: short Instagram caption.
|
||||
Format: concise, punchy opening; high density; keep brief; no invented hashtags or trends.
|
||||
"""
|
||||
}
|
||||
return """
|
||||
Scenario: Weibo-style short post.
|
||||
Format: concise, punchy opening; high density; keep brief; no invented hashtags or trends.
|
||||
"""
|
||||
case "goofy":
|
||||
return """
|
||||
Scenario: playful chat (goofy tone).
|
||||
Format: natural short sentences; slightly witty wording only.
|
||||
Forbidden: new facts, invented jokes, forced humor on serious topics (leave/apology/complaint).
|
||||
"""
|
||||
case "document":
|
||||
return """
|
||||
Scenario: document / long-form notes.
|
||||
Format: complete sentences; blank lines between topics when ≥2 themes; use "- " bullets for steps/enumerations; `##` headings only when content is long enough; minimal slang.
|
||||
"""
|
||||
case "daily_chat", PolishScenarioCatalog.customId:
|
||||
fallthrough
|
||||
default:
|
||||
return """
|
||||
Scenario: everyday chat (IM/DM).
|
||||
Format: natural short sentences like texting; relaxed punctuation; very light colloquial tone ok.
|
||||
Forbidden: memo/report tone; forced bullets unless the speaker is enumerating.
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,8 @@
|
||||
// The "translate AND polish" blend is intentional: ASR transcripts are
|
||||
// noisy (homophone errors, broken segmentation, dropped particles), so
|
||||
// the prompt asks the model to clean the noise while translating.
|
||||
// Keeping those two concerns in one prompt matches how our existing
|
||||
// polish prompt already mixes "preserve meaning" with "fix punctuation /
|
||||
// drop filler".
|
||||
// Scenario output format (`ScenarioStyleDirective`) is appended so
|
||||
// translate-and-polish honours the user's polish scenario choice.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -26,36 +25,53 @@ public enum TranslationPrompt {
|
||||
/// - target: target language entry resolved via `TranslationLanguageCatalog`.
|
||||
/// - providerId: provider preset id (e.g. `"deepseek"`, `"openai"`);
|
||||
/// drives the language the prompt is written in.
|
||||
public static func make(target: TranslationLanguage, providerId: String) -> String {
|
||||
/// - scenarioId: active polish scenario; format rules are shared
|
||||
/// with the polish-only path via `ScenarioStyleDirective`.
|
||||
/// - uiLanguage: host-app UI language (platform label variants).
|
||||
public static func make(
|
||||
target: TranslationLanguage,
|
||||
providerId: String,
|
||||
scenarioId: String = PolishScenarioCatalog.defaultId,
|
||||
uiLanguage: AppUILanguage? = nil
|
||||
) -> String {
|
||||
let isChineseNative = ["zhipu", "moonshot", "qwen", "deepseek"].contains(providerId)
|
||||
return isChineseNative ? chinesePrompt(target: target) : englishPrompt(target: target)
|
||||
let directive = ScenarioStyleDirective.make(
|
||||
scenarioId: scenarioId,
|
||||
providerId: providerId,
|
||||
uiLanguage: uiLanguage
|
||||
)
|
||||
return isChineseNative
|
||||
? chinesePrompt(target: target, directive: directive)
|
||||
: englishPrompt(target: target, directive: directive)
|
||||
}
|
||||
|
||||
// MARK: - Chinese prompt (for DeepSeek / Qwen / GLM / Moonshot)
|
||||
|
||||
private static func chinesePrompt(target: TranslationLanguage) -> String {
|
||||
private static func chinesePrompt(target: TranslationLanguage, directive: String) -> String {
|
||||
"""
|
||||
你是一位语音输入翻译与润色助手。用户用 ASR 转写了一段可能含噪声的口述:
|
||||
1) 先识别原话的主要语言(若不确定则按用户给定的方向处理);
|
||||
2) 将内容翻译为「\(target.promptLanguageName)」,保留原意,不增删事实、不臆测;
|
||||
3) 顺带修复 ASR 噪声(同音错字、漏字、断句错乱),让译文读起来自然;
|
||||
4) 保留枚举结构(第一…第二…),使用「\(target.promptLanguageName)」的列表惯例;
|
||||
5) 简洁,不超过原文 1.5 倍;去掉无意义的口头禅(嗯、啊、那个);
|
||||
4) 简洁;若下方场景未要求列表/分段,不超过原文 1.5 倍;去掉无意义的口头禅(嗯、啊、那个);
|
||||
5) 若场景格式要求列表或分段,允许按格式组织译文;总长度不超过原长 2 倍;
|
||||
6) 只输出译文正文,不要解释、不要加引号、不要前缀"以下是翻译"。
|
||||
\(directive)
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - English prompt (for OpenAI / OpenAI-compatible non-Chinese)
|
||||
|
||||
private static func englishPrompt(target: TranslationLanguage) -> String {
|
||||
private static func englishPrompt(target: TranslationLanguage, directive: String) -> String {
|
||||
"""
|
||||
You are a voice-input translation and polishing assistant. The user has spoken informally and the transcript may contain ASR noise:
|
||||
1) Identify the input language; if unclear, assume the user wants translation INTO \(target.promptLanguageName);
|
||||
2) Translate the content INTO \(target.promptLanguageName), preserving meaning; do not invent facts or omit content;
|
||||
3) Fix ASR noise (homophone errors, missing characters, broken segmentation) so the translation reads naturally;
|
||||
4) Preserve enumeration ("first ... second ...") using \(target.promptLanguageName) list conventions;
|
||||
5) Keep it concise — no longer than 1.5x the spoken length; drop filler words (um, uh, like);
|
||||
4) Stay concise; if the scenario below does not require lists/sections, do not exceed 1.5x the spoken length; drop filler words (um, uh, like);
|
||||
5) If the scenario requires bullets or paragraph breaks, use that layout in the translation; total length may be up to 2x when listing;
|
||||
6) Output ONLY the translation. No quotes, no preamble, no explanation.
|
||||
\(directive)
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user