feat(polish): allow mood emoji on custom styles and ship Flow/ASR fixes
Custom polish styles can opt in to emotion-matched emoji (default off), with prompt-level opt-in detection so paste-only styles keep model-added emoji. Also include Volcengine API-Key ASR auth, voice-processing capture, PiP flash fix, and related keyboard Shift/haptics reliability work.
This commit is contained in:
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Volcengine ASR API Key auth**: settings can switch to the new-console single `X-Api-Key` mode while keeping legacy APP ID + Access Token as the default; SAUC resource is fixed to Doubao streaming 2.0 (`volc.seedasr.sauc.duration`). / **火山 ASR API Key 鉴权**:设置可切换到新控制台单字段 `X-Api-Key`,默认仍为旧版 APP ID + Access Token;SAUC 资源固定为豆包流式 2.0(`volc.seedasr.sauc.duration`)。
|
||||||
|
- **Custom style mood emoji**: custom polish styles can opt in (default off) to allow emotion-matched emoji; the prompt overrides R5 and post-processing keeps them on screen. Paste-only prompts that declare emoji opt-in are detected automatically. / **自定义风格情绪 emoji**:自定义润色风格可单独开启(默认关)按情绪点缀 emoji;提示词覆盖 R5,后处理保留上屏。仅粘贴声明允许 emoji 的 prompt 也会自动识别。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **PiP post-utterance yellow flash**: after a voice turn, the mic no longer briefly shows yellow「正在启动画中画」— hold ready once the session proved live, refresh host ready on ack, and avoid labeling an already-active PiP as `.starting`. / **PiP 句末黄色闪烁**:语音说完后麦克风不再短暂变黄并提示「正在启动画中画」——会话曾就绪后保持绿灯、ack 后立即刷新 host ready,且已激活的 PiP 不再标成 `.starting`。
|
||||||
|
- **English Shift after Return**: sentence autocapitalization treats newline as a new-line boundary (system keyboard behavior), so Notes-style multi-line Return arms Shift again. / **回车后英文 Shift**:句首自动大写将换行视为新行边界(对齐系统键盘),备忘录等多行回车后会重新点亮 Shift。
|
||||||
|
- **English Shift with stale proxy**: after our own insert/delete, autocap merges a local caret-prefix shadow when `documentContextBeforeInput` lags (e.g. period / Return in Notes). / **滞后前文下的英文 Shift**:自身插删后若 `documentContextBeforeInput` 滞后,自动大写会合并本地光标前文影子(如备忘录中的句号/回车)。
|
||||||
|
|
||||||
|
## [1.6.2] - 2026-08-06
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **ASR capture voice processing**: iOS App Flow / preview capture switches from `.measurement` to `.voiceChat`, enables `AVAudioEngine` Voice Processing, and prefers a near-talk built-in mic pattern so competing talkers in the same room are suppressed more strongly (Control Center Voice Isolation remains available once VP is on). / **ASR 采音人声处理**:iOS App 的 Flow / 预览采音从 `.measurement` 改为 `.voiceChat`,打开 `AVAudioEngine` Voice Processing,并优先近讲内置麦指向,以更好压制同房间旁人说话(开启 VP 后控制中心仍可选人声隔离)。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Voice toolbar haptics**: delete / space / return on the voice keyboard follow Settings → General → Haptics (mic unchanged). / **语音底栏震动**:语音键盘的删除 / 空格 / 回车跟随设置 → 通用 → 震动(麦克风大圆钮不变)。
|
||||||
|
- **Haptics after app switch**: re-prepare Taptic when the keyboard appears so feedback does not go cold after switching host apps. / **切 App 后震动消失**:键盘每次出现时重新预热触觉引擎,避免从 A 切到 B 后触感变冷。
|
||||||
|
- **Typing switch after cold start**: a sticky App Group `hostHeavy` flag no longer silently blocks 中文/EN when the host died mid-warmup; the flag now expires and is cleared on Flow state reset / host relaunch. / **冷启动后无法切拼音/英文**:宿主中途被杀留下的 `hostHeavy` 不再静默挡住中文/EN;该标志会过期,并在 Flow 状态清理与宿主重启时清除。
|
||||||
|
- **Chinese Shift uppercase**: with Shift / Caps Lock / Shift-hold on in Pinyin mode, letter keys insert Latin uppercase directly (system-keyboard style) instead of sending uppercase keycodes to Rime, which rejects them. / **拼音 Shift 大写**:拼音模式下开启 Shift / Caps Lock / 按住 Shift 时,字母键直接插入大写拉丁字母(对齐系统键盘),不再把大写键码送给会拒绝的 Rime。
|
||||||
|
|
||||||
## [1.6.1] - 2026-08-05
|
## [1.6.1] - 2026-08-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect x="103" y="555" width="81.4876" height="81.4876" rx="40.7438" stroke="white" stroke-opacity="0.8" style="stroke:white;stroke-opacity:0.8;" stroke-width="10"/>
|
|
||||||
<rect x="103" y="677.633" width="81.4876" height="81.4876" rx="40.7438" stroke="white" stroke-opacity="0.8" style="stroke:white;stroke-opacity:0.8;" stroke-width="10"/>
|
|
||||||
<rect x="225.632" y="555" width="81.4876" height="81.4876" rx="40.7438" stroke="white" stroke-opacity="0.8" style="stroke:white;stroke-opacity:0.8;" stroke-width="10"/>
|
|
||||||
<rect x="225.632" y="677.633" width="81.4876" height="81.4876" rx="40.7438" stroke="white" stroke-opacity="0.8" style="stroke:white;stroke-opacity:0.8;" stroke-width="10"/>
|
|
||||||
<rect x="348.265" y="555" width="81.4876" height="81.4876" rx="40.7438" stroke="white" stroke-opacity="0.8" style="stroke:white;stroke-opacity:0.8;" stroke-width="10"/>
|
|
||||||
<rect x="348.265" y="677.633" width="81.4876" height="81.4876" rx="40.7438" stroke="white" stroke-opacity="0.8" style="stroke:white;stroke-opacity:0.8;" stroke-width="10"/>
|
|
||||||
<rect x="225.632" y="827.837" width="572.017" height="81.4876" rx="40.7438" stroke="white" stroke-opacity="0.8" style="stroke:white;stroke-opacity:0.8;" stroke-width="10"/>
|
|
||||||
<rect x="470.897" y="555" width="81.4876" height="81.4876" rx="40.7438" stroke="white" stroke-opacity="0.8" style="stroke:white;stroke-opacity:0.8;" stroke-width="10"/>
|
|
||||||
<rect x="470.897" y="677.633" width="81.4876" height="81.4876" rx="40.7438" stroke="white" stroke-opacity="0.8" style="stroke:white;stroke-opacity:0.8;" stroke-width="10"/>
|
|
||||||
<rect x="716.162" y="555" width="81.4876" height="81.4876" rx="40.7438" stroke="white" stroke-opacity="0.8" style="stroke:white;stroke-opacity:0.8;" stroke-width="10"/>
|
|
||||||
<rect x="716.162" y="677.633" width="81.4876" height="81.4876" rx="40.7438" stroke="white" stroke-opacity="0.8" style="stroke:white;stroke-opacity:0.8;" stroke-width="10"/>
|
|
||||||
<rect x="593.529" y="555" width="81.4876" height="81.4876" rx="40.7438" stroke="white" stroke-opacity="0.8" style="stroke:white;stroke-opacity:0.8;" stroke-width="10"/>
|
|
||||||
<rect x="593.529" y="677.633" width="81.4876" height="81.4876" rx="40.7438" stroke="white" stroke-opacity="0.8" style="stroke:white;stroke-opacity:0.8;" stroke-width="10"/>
|
|
||||||
<rect x="838.794" y="555" width="81.4876" height="81.4876" rx="40.7438" stroke="white" stroke-opacity="0.8" style="stroke:white;stroke-opacity:0.8;" stroke-width="10"/>
|
|
||||||
<rect x="838.794" y="677.633" width="81.4876" height="81.4876" rx="40.7438" stroke="white" stroke-opacity="0.8" style="stroke:white;stroke-opacity:0.8;" stroke-width="10"/>
|
|
||||||
<path d="M578.033 284.581C578.033 248.262 548.706 218.82 512.53 218.82C476.353 218.82 447.026 248.262 447.026 284.581C447.026 320.9 476.353 350.343 512.53 350.343V356.163C473.151 356.163 441.229 324.115 441.229 284.581C441.229 245.048 473.151 213 512.53 213C551.908 213 583.83 245.048 583.83 284.581C583.83 324.115 551.908 356.163 512.53 356.163V350.343C548.706 350.343 578.033 320.9 578.033 284.581Z" fill="#3AA05A" style="fill:#3AA05A;fill:color(display-p3 0.2267 0.6268 0.3534);fill-opacity:1;"/>
|
|
||||||
<path d="M252 284.581C252 324.115 283.922 356.163 323.301 356.163C362.679 356.163 394.601 324.115 394.601 284.581C394.601 245.048 362.679 213 323.301 213C283.922 213 252 245.048 252 284.581Z" fill="#3AA05A" style="fill:#3AA05A;fill:color(display-p3 0.2267 0.6268 0.3534);fill-opacity:1;"/>
|
|
||||||
<path d="M701.197 213.561C739.824 213.561 771.275 244.276 772.461 282.615L701.197 282.616V288.229H772.418C770.659 326.044 739.446 356.162 701.197 356.162C661.819 356.162 629.897 324.24 629.896 284.861C629.896 245.483 661.819 213.561 701.197 213.561ZM772.498 284.861C772.498 284.912 772.497 284.964 772.497 285.015V284.707C772.497 284.758 772.498 284.81 772.498 284.861Z" fill="#3AA05A" style="fill:#3AA05A;fill:color(display-p3 0.2267 0.6268 0.3534);fill-opacity:1;"/>
|
|
||||||
<path d="M447.966 312.933L447.966 307.319L512.53 307.319L512.53 312.933L447.966 312.933Z" fill="#3AA05A" style="fill:#3AA05A;fill:color(display-p3 0.2267 0.6268 0.3534);fill-opacity:1;"/>
|
|
||||||
<path d="M512.53 268.018V262.404H577.093V268.018H512.53Z" fill="#3AA05A" style="fill:#3AA05A;fill:color(display-p3 0.2267 0.6268 0.3534);fill-opacity:1;"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,58 @@
|
|||||||
|
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="1024" height="1024" fill="white"/>
|
||||||
|
<rect width="1024" height="1024" fill="url(#paint0_linear_2178_78)" fill-opacity="0.2"/>
|
||||||
|
<g filter="url(#filter0_dd_2178_78)">
|
||||||
|
<path d="M603.944 512.419C603.944 461.795 563.066 420.756 512.64 420.756C462.215 420.756 421.337 461.795 421.337 512.419C421.337 563.043 462.215 604.081 512.64 604.081V612.193C457.752 612.193 413.257 567.523 413.257 512.419C413.257 457.315 457.752 412.644 512.64 412.644C567.528 412.644 612.023 457.315 612.023 512.419C612.023 567.523 567.528 612.193 512.64 612.193V604.081C563.066 604.081 603.944 563.043 603.944 512.419Z" fill="#00AB11"/>
|
||||||
|
<path d="M603.944 512.419C603.944 461.795 563.066 420.756 512.64 420.756C462.215 420.756 421.337 461.795 421.337 512.419C421.337 563.043 462.215 604.081 512.64 604.081V612.193C457.752 612.193 413.257 567.523 413.257 512.419C413.257 457.315 457.752 412.644 512.64 412.644C567.528 412.644 612.023 457.315 612.023 512.419C612.023 567.523 567.528 612.193 512.64 612.193V604.081C563.066 604.081 603.944 563.043 603.944 512.419Z" fill="url(#paint1_linear_2178_78)" fill-opacity="0.2"/>
|
||||||
|
<path d="M149.498 512.419C149.498 567.523 193.993 612.193 248.881 612.193C303.769 612.193 348.264 567.523 348.264 512.419C348.264 457.315 303.769 412.644 248.881 412.644C193.993 412.644 149.498 457.315 149.498 512.419Z" fill="#00AB11"/>
|
||||||
|
<path d="M149.498 512.419C149.498 567.523 193.993 612.193 248.881 612.193C303.769 612.193 348.264 567.523 348.264 512.419C348.264 457.315 303.769 412.644 248.881 412.644C193.993 412.644 149.498 457.315 149.498 512.419Z" fill="url(#paint2_linear_2178_78)" fill-opacity="0.2"/>
|
||||||
|
<path d="M422.647 551.937L422.647 544.112L512.64 544.112L512.64 551.937L422.647 551.937Z" fill="#00AB11"/>
|
||||||
|
<path d="M422.647 551.937L422.647 544.112L512.64 544.112L512.64 551.937L422.647 551.937Z" fill="url(#paint3_linear_2178_78)" fill-opacity="0.2"/>
|
||||||
|
<path d="M512.641 489.332V481.506H602.633V489.332H512.641Z" fill="#00AB11"/>
|
||||||
|
<path d="M512.641 489.332V481.506H602.633V489.332H512.641Z" fill="url(#paint4_linear_2178_78)" fill-opacity="0.2"/>
|
||||||
|
<path d="M775.617 413.425C829.458 413.425 873.296 456.239 874.949 509.679H775.617V517.504H874.89C872.438 570.212 828.931 612.191 775.617 612.191C720.729 612.191 676.233 567.696 676.233 512.809C676.233 457.921 720.729 413.425 775.617 413.425ZM875 517.504H874.89C874.962 515.948 875 514.383 875 512.809C875 511.761 874.981 510.718 874.949 509.679H875V517.504Z" fill="#00AB11"/>
|
||||||
|
<path d="M775.617 413.425C829.458 413.425 873.296 456.239 874.949 509.679H775.617V517.504H874.89C872.438 570.212 828.931 612.191 775.617 612.191C720.729 612.191 676.233 567.696 676.233 512.809C676.233 457.921 720.729 413.425 775.617 413.425ZM875 517.504H874.89C874.962 515.948 875 514.383 875 512.809C875 511.761 874.981 510.718 874.949 509.679H875V517.504Z" fill="url(#paint5_linear_2178_78)" fill-opacity="0.2"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_dd_2178_78" x="124.498" y="402.644" width="775.502" height="249.549" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset dy="4"/>
|
||||||
|
<feGaussianBlur stdDeviation="2"/>
|
||||||
|
<feComposite in2="hardAlpha" operator="out"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.382061 0 0 0 0 0.0382061 0 0 0 0.3 0"/>
|
||||||
|
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2178_78"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset dy="15"/>
|
||||||
|
<feGaussianBlur stdDeviation="12.5"/>
|
||||||
|
<feComposite in2="hardAlpha" operator="out"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.6 0 0 0 0 0.06 0 0 0 0.3 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect1_dropShadow_2178_78" result="effect2_dropShadow_2178_78"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_2178_78" result="shape"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_2178_78" x1="512" y1="1207" x2="131" y2="56.9999" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop/>
|
||||||
|
<stop offset="1" stop-color="#666666" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_2178_78" x1="443.12" y1="400.5" x2="615.798" y2="422.58" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop/>
|
||||||
|
<stop offset="1" stop-color="#666666"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_2178_78" x1="179.361" y1="400.5" x2="352.039" y2="422.58" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop/>
|
||||||
|
<stop offset="1" stop-color="#666666"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint3_linear_2178_78" x1="499.119" y1="552.413" x2="474.127" y2="515.519" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop/>
|
||||||
|
<stop offset="1" stop-color="#666666"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint4_linear_2178_78" x1="589.112" y1="489.808" x2="564.121" y2="452.913" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop/>
|
||||||
|
<stop offset="1" stop-color="#666666"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint5_linear_2178_78" x1="706.097" y1="401.328" x2="878.752" y2="423.493" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop/>
|
||||||
|
<stop offset="1" stop-color="#666666"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.1 KiB |
@@ -8,11 +8,8 @@
|
|||||||
"hidden" : false,
|
"hidden" : false,
|
||||||
"layers" : [
|
"layers" : [
|
||||||
{
|
{
|
||||||
"fill" : "none",
|
"image-name" : "Group 29.svg",
|
||||||
"hidden" : false,
|
"name" : "Group 29"
|
||||||
"image-name" : "App Icon Template.svg",
|
|
||||||
"name" : "App Icon Template",
|
|
||||||
"opacity" : 1
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"lighting" : "individual",
|
"lighting" : "individual",
|
||||||
|
|||||||
@@ -787,6 +787,11 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
reason = .audioEngineNotLive
|
reason = .audioEngineNotLive
|
||||||
} else if !usesPiPKeepAlive, !hasRecentAudio {
|
} else if !usesPiPKeepAlive, !hasRecentAudio {
|
||||||
reason = .waitingForAudioProof
|
reason = .waitingForAudioProof
|
||||||
|
} else if usesPiPKeepAlive, pipController.isPictureInPictureActive {
|
||||||
|
// PiP is already up — transient gates (!polling / interruption)
|
||||||
|
// are not a cold start. Prefer awaitingDelivery-adjacent idle over
|
||||||
|
// `.starting` so the keyboard never flashes「正在启动画中画」.
|
||||||
|
reason = .awaitingDelivery
|
||||||
} else {
|
} else {
|
||||||
reason = .starting
|
reason = .starting
|
||||||
}
|
}
|
||||||
@@ -1331,6 +1336,10 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
FlowSessionBridge.clearResult()
|
FlowSessionBridge.clearResult()
|
||||||
|
// Ack clears the terminal result; republish ready immediately so the
|
||||||
|
// keyboard does not linger on awaitingDelivery / starting between the
|
||||||
|
// 500 ms poll and the next heartbeat.
|
||||||
|
refreshHostReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func hasUnacknowledgedTerminalResult() -> Bool {
|
private func hasUnacknowledgedTerminalResult() -> Bool {
|
||||||
|
|||||||
@@ -58,41 +58,62 @@ struct ASRSettingsCard: View {
|
|||||||
|
|
||||||
private var volcengineRows: some View {
|
private var volcengineRows: some View {
|
||||||
Group {
|
Group {
|
||||||
SettingsCredentialRow(
|
Toggle(isOn: volcengineAPIKeyModeBinding) {
|
||||||
title: AppL10n.string("settings.asr.volcengine.appId"),
|
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||||
placeholder: "APP ID",
|
Text("settings.asr.volcengine.apiKeyMode.title")
|
||||||
text: Binding(
|
.font(TypeStyle.body)
|
||||||
get: { volcengineFields.appID },
|
.foregroundStyle(palette.textPrimary)
|
||||||
set: { updateVolcengine(appID: $0) }
|
Text("settings.asr.volcengine.apiKeyMode.subtitle")
|
||||||
),
|
.font(TypeStyle.caption2)
|
||||||
isSecret: true,
|
.foregroundStyle(palette.textTertiary)
|
||||||
isMonospaced: true
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
.tint(palette.accent)
|
||||||
|
.settingsListRow()
|
||||||
|
|
||||||
rowDivider
|
rowDivider
|
||||||
SettingsCredentialRow(
|
|
||||||
title: AppL10n.string("settings.asr.volcengine.accessToken"),
|
if volcengineFields.usesAPIKeyAuth {
|
||||||
placeholder: "Access Token",
|
SettingsCredentialRow(
|
||||||
text: Binding(
|
title: AppL10n.string("settings.asr.volcengine.apiKey"),
|
||||||
get: { volcengineFields.accessToken },
|
placeholder: "API Key",
|
||||||
set: { updateVolcengine(accessToken: $0) }
|
text: Binding(
|
||||||
),
|
get: { volcengineFields.apiKeyCredential },
|
||||||
isSecret: true,
|
set: { updateVolcengine(apiKeyCredential: $0) }
|
||||||
isMonospaced: true
|
),
|
||||||
)
|
isSecret: true,
|
||||||
rowDivider
|
isMonospaced: true
|
||||||
SettingsCredentialRow(
|
)
|
||||||
title: AppL10n.string("settings.asr.volcengine.resourceId"),
|
} else {
|
||||||
placeholder: CloudASRModelCatalog.defaultModel(for: "volcengine"),
|
SettingsCredentialRow(
|
||||||
text: Binding(
|
title: AppL10n.string("settings.asr.volcengine.appId"),
|
||||||
get: { volcengineFields.resourceID },
|
placeholder: "APP ID",
|
||||||
set: { updateVolcengine(resourceID: $0) }
|
text: Binding(
|
||||||
),
|
get: { volcengineFields.appID },
|
||||||
isMonospaced: true,
|
set: { updateVolcengine(appID: $0) }
|
||||||
defaultValue: CloudASRModelCatalog.defaultModel(for: "volcengine")
|
),
|
||||||
)
|
isSecret: true,
|
||||||
|
isMonospaced: true
|
||||||
|
)
|
||||||
|
rowDivider
|
||||||
|
SettingsCredentialRow(
|
||||||
|
title: AppL10n.string("settings.asr.volcengine.accessToken"),
|
||||||
|
placeholder: "Access Token",
|
||||||
|
text: Binding(
|
||||||
|
get: { volcengineFields.accessToken },
|
||||||
|
set: { updateVolcengine(accessToken: $0) }
|
||||||
|
),
|
||||||
|
isSecret: true,
|
||||||
|
isMonospaced: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
rowDivider
|
rowDivider
|
||||||
SettingsProviderRow(title: AppL10n.string("settings.provider.note")) {
|
SettingsProviderRow(title: AppL10n.string("settings.provider.note")) {
|
||||||
Text("settings.asr.volcengine.note")
|
Text(volcengineFields.usesAPIKeyAuth
|
||||||
|
? "settings.asr.volcengine.note.apiKey"
|
||||||
|
: "settings.asr.volcengine.note.appToken")
|
||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
.foregroundStyle(palette.textTertiary)
|
.foregroundStyle(palette.textTertiary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -105,22 +126,29 @@ struct ASRSettingsCard: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var volcengineFields: VolcengineASRFields {
|
private var volcengineFields: VolcengineASRFields {
|
||||||
VolcengineASRFields.parse(
|
VolcengineASRFields.parse(apiKey: config.asrApiKey)
|
||||||
apiKey: config.asrApiKey,
|
}
|
||||||
resourceFallback: config.asrModel.isEmpty
|
|
||||||
? CloudASRModelCatalog.defaultModel(for: "volcengine")
|
private var volcengineAPIKeyModeBinding: Binding<Bool> {
|
||||||
: config.asrModel
|
Binding(
|
||||||
|
get: { volcengineFields.usesAPIKeyAuth },
|
||||||
|
set: { updateVolcengine(authMode: $0 ? .apiKey : .appToken) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateVolcengine(appID: String? = nil, accessToken: String? = nil, resourceID: String? = nil) {
|
private func updateVolcengine(
|
||||||
|
authMode: VolcengineASRAuthMode? = nil,
|
||||||
|
appID: String? = nil,
|
||||||
|
accessToken: String? = nil,
|
||||||
|
apiKeyCredential: String? = nil
|
||||||
|
) {
|
||||||
var fields = volcengineFields
|
var fields = volcengineFields
|
||||||
|
if let authMode { fields.authMode = authMode }
|
||||||
if let appID { fields.appID = appID }
|
if let appID { fields.appID = appID }
|
||||||
if let accessToken { fields.accessToken = accessToken }
|
if let accessToken { fields.accessToken = accessToken }
|
||||||
if let resourceID {
|
if let apiKeyCredential { fields.apiKeyCredential = apiKeyCredential }
|
||||||
fields.resourceID = resourceID
|
// Keep store model aligned with the locked SAUC 2.0 resource.
|
||||||
config.asrModel = resourceID
|
config.asrModel = VolcengineASRFields.fixedResourceID
|
||||||
}
|
|
||||||
config.asrApiKey = fields.encodedAPIKey
|
config.asrApiKey = fields.encodedAPIKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -239,7 +239,8 @@ struct PolishStylesView: View {
|
|||||||
format: AppL10n.string("polishStyles.copyName"),
|
format: AppL10n.string("polishStyles.copyName"),
|
||||||
pack.displayName(language: config.uiLanguage)
|
pack.displayName(language: config.uiLanguage)
|
||||||
),
|
),
|
||||||
prompt: pack.prompt
|
prompt: pack.prompt,
|
||||||
|
allowsAddedEmoji: pack.allowsAddedEmoji
|
||||||
)
|
)
|
||||||
showEditor = true
|
showEditor = true
|
||||||
}
|
}
|
||||||
@@ -310,12 +311,14 @@ private struct PolishStyleEditorSheet: View {
|
|||||||
@Environment(\.themePalette) private var palette
|
@Environment(\.themePalette) private var palette
|
||||||
@State private var name: String
|
@State private var name: String
|
||||||
@State private var prompt: String
|
@State private var prompt: String
|
||||||
|
@State private var allowsAddedEmoji: Bool
|
||||||
|
|
||||||
init(pack: PolishStylePack?, onSave: @escaping (PolishStylePack) -> Void) {
|
init(pack: PolishStylePack?, onSave: @escaping (PolishStylePack) -> Void) {
|
||||||
self.pack = pack
|
self.pack = pack
|
||||||
self.onSave = onSave
|
self.onSave = onSave
|
||||||
_name = State(initialValue: pack?.name ?? "")
|
_name = State(initialValue: pack?.name ?? "")
|
||||||
_prompt = State(initialValue: pack?.prompt ?? PolishStylePackCatalog.newUserPromptTemplate)
|
_prompt = State(initialValue: pack?.prompt ?? PolishStylePackCatalog.newUserPromptTemplate)
|
||||||
|
_allowsAddedEmoji = State(initialValue: pack?.allowsAddedEmoji ?? false)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -324,10 +327,23 @@ private struct PolishStyleEditorSheet: View {
|
|||||||
Section("polishStyles.editor.name") {
|
Section("polishStyles.editor.name") {
|
||||||
TextField("polishStyles.editor.namePlaceholder", text: $name)
|
TextField("polishStyles.editor.namePlaceholder", text: $name)
|
||||||
}
|
}
|
||||||
|
Section {
|
||||||
|
Toggle("polishStyles.editor.allowsAddedEmoji", isOn: $allowsAddedEmoji)
|
||||||
|
} footer: {
|
||||||
|
Text("polishStyles.editor.allowsAddedEmoji.hint")
|
||||||
|
}
|
||||||
Section {
|
Section {
|
||||||
TextEditor(text: $prompt)
|
TextEditor(text: $prompt)
|
||||||
.font(.body.monospaced())
|
.font(.body.monospaced())
|
||||||
.frame(minHeight: 320)
|
.frame(minHeight: 320)
|
||||||
|
.onChange(of: prompt) { _, newValue in
|
||||||
|
// Paste-only custom prompts that declare emoji opt-in
|
||||||
|
// should flip the toggle so post-processing keeps them.
|
||||||
|
if !allowsAddedEmoji,
|
||||||
|
PolishStylePack.promptDeclaresAddedEmojiOptIn(newValue) {
|
||||||
|
allowsAddedEmoji = true
|
||||||
|
}
|
||||||
|
}
|
||||||
} header: {
|
} header: {
|
||||||
HStack {
|
HStack {
|
||||||
Text("polishStyles.editor.prompt")
|
Text("polishStyles.editor.prompt")
|
||||||
@@ -355,6 +371,8 @@ private struct PolishStyleEditorSheet: View {
|
|||||||
id: pack?.id ?? "user.\(UUID().uuidString.lowercased())",
|
id: pack?.id ?? "user.\(UUID().uuidString.lowercased())",
|
||||||
name: name,
|
name: name,
|
||||||
prompt: prompt,
|
prompt: prompt,
|
||||||
|
allowsAddedEmoji: allowsAddedEmoji
|
||||||
|
|| PolishStylePack.promptDeclaresAddedEmojiOptIn(prompt),
|
||||||
kind: .user,
|
kind: .user,
|
||||||
createdAt: pack?.createdAt ?? Date()
|
createdAt: pack?.createdAt ?? Date()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -137,8 +137,11 @@
|
|||||||
"settings.provider.note" = "Note";
|
"settings.provider.note" = "Note";
|
||||||
"settings.asr.volcengine.appId" = "APP ID";
|
"settings.asr.volcengine.appId" = "APP ID";
|
||||||
"settings.asr.volcengine.accessToken" = "Access Token";
|
"settings.asr.volcengine.accessToken" = "Access Token";
|
||||||
"settings.asr.volcengine.resourceId" = "Resource ID";
|
"settings.asr.volcengine.apiKey" = "API Key";
|
||||||
"settings.asr.volcengine.note" = "Secret Key is not required. Resource ID defaults to volc.seedasr.sauc.duration.";
|
"settings.asr.volcengine.apiKeyMode.title" = "Use new API Key auth";
|
||||||
|
"settings.asr.volcengine.apiKeyMode.subtitle" = "Turn on for the new Volcengine console. Keep off if you already use APP ID + Access Token.";
|
||||||
|
"settings.asr.volcengine.note.appToken" = "Legacy console: enter APP ID and Access Token. Secret Key is not required. Resource is fixed to Doubao streaming 2.0 (volc.seedasr.sauc.duration).";
|
||||||
|
"settings.asr.volcengine.note.apiKey" = "New console: enter only the API Key. Resource is fixed to Doubao streaming 2.0 (volc.seedasr.sauc.duration).";
|
||||||
"provider.openai" = "OpenAI";
|
"provider.openai" = "OpenAI";
|
||||||
"provider.deepseek" = "DeepSeek";
|
"provider.deepseek" = "DeepSeek";
|
||||||
"provider.qwen" = "Qwen (DashScope)";
|
"provider.qwen" = "Qwen (DashScope)";
|
||||||
@@ -404,6 +407,8 @@
|
|||||||
"polishStyles.copyName" = "%@ Copy";
|
"polishStyles.copyName" = "%@ Copy";
|
||||||
"polishStyles.editor.name" = "Name";
|
"polishStyles.editor.name" = "Name";
|
||||||
"polishStyles.editor.namePlaceholder" = "Style name";
|
"polishStyles.editor.namePlaceholder" = "Style name";
|
||||||
|
"polishStyles.editor.allowsAddedEmoji" = "Allow mood emojis";
|
||||||
|
"polishStyles.editor.allowsAddedEmoji.hint" = "When on, polish may add a few emojis that match the draft’s emotion, and keeps them on screen. Off by default.";
|
||||||
"polishStyles.editor.prompt" = "Complete prompt";
|
"polishStyles.editor.prompt" = "Complete prompt";
|
||||||
"polishStyles.editor.hint" = "Describe only the role, tone, style boundaries, and examples. The system appends the personal dictionary, safety contract, and output rules.";
|
"polishStyles.editor.hint" = "Describe only the role, tone, style boundaries, and examples. The system appends the personal dictionary, safety contract, and output rules.";
|
||||||
"polishStyles.error.title" = "Couldn’t save style";
|
"polishStyles.error.title" = "Couldn’t save style";
|
||||||
|
|||||||
@@ -137,8 +137,11 @@
|
|||||||
"settings.provider.note" = "提示";
|
"settings.provider.note" = "提示";
|
||||||
"settings.asr.volcengine.appId" = "APP ID";
|
"settings.asr.volcengine.appId" = "APP ID";
|
||||||
"settings.asr.volcengine.accessToken" = "Access Token";
|
"settings.asr.volcengine.accessToken" = "Access Token";
|
||||||
"settings.asr.volcengine.resourceId" = "Resource ID";
|
"settings.asr.volcengine.apiKey" = "API Key";
|
||||||
"settings.asr.volcengine.note" = "Secret Key 当前无需填写。Resource ID 默认使用 volc.seedasr.sauc.duration。";
|
"settings.asr.volcengine.apiKeyMode.title" = "使用新版 API Key 鉴权";
|
||||||
|
"settings.asr.volcengine.apiKeyMode.subtitle" = "新控制台请打开此开关;已有 AppID + Token 可保持关闭。";
|
||||||
|
"settings.asr.volcengine.note.appToken" = "旧版控制台:填写 APP ID 与 Access Token。Secret Key 无需填写。识别资源固定为豆包流式 2.0(volc.seedasr.sauc.duration)。";
|
||||||
|
"settings.asr.volcengine.note.apiKey" = "新版控制台:只需填写 API Key。识别资源固定为豆包流式 2.0(volc.seedasr.sauc.duration)。";
|
||||||
"provider.openai" = "OpenAI";
|
"provider.openai" = "OpenAI";
|
||||||
"provider.deepseek" = "DeepSeek";
|
"provider.deepseek" = "DeepSeek";
|
||||||
"provider.qwen" = "通义千问";
|
"provider.qwen" = "通义千问";
|
||||||
@@ -403,6 +406,8 @@
|
|||||||
"polishStyles.copyName" = "%@副本";
|
"polishStyles.copyName" = "%@副本";
|
||||||
"polishStyles.editor.name" = "名称";
|
"polishStyles.editor.name" = "名称";
|
||||||
"polishStyles.editor.namePlaceholder" = "风格名称";
|
"polishStyles.editor.namePlaceholder" = "风格名称";
|
||||||
|
"polishStyles.editor.allowsAddedEmoji" = "允许按情绪添加 emoji";
|
||||||
|
"polishStyles.editor.allowsAddedEmoji.hint" = "开启后,润色可按原文情绪点缀少量 emoji,并保留上屏。默认关闭。";
|
||||||
"polishStyles.editor.prompt" = "完整提示词";
|
"polishStyles.editor.prompt" = "完整提示词";
|
||||||
"polishStyles.editor.hint" = "只需描述角色、语气、风格边界和示例。系统会自动追加个人词典、安全边界和输出契约。";
|
"polishStyles.editor.hint" = "只需描述角色、语气、风格边界和示例。系统会自动追加个人词典、安全边界和输出契约。";
|
||||||
"polishStyles.error.title" = "无法保存风格";
|
"polishStyles.error.title" = "无法保存风格";
|
||||||
|
|||||||
@@ -165,6 +165,9 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
configSync.refreshConfigFromAppGroup()
|
configSync.refreshConfigFromAppGroup()
|
||||||
// Settings may have changed while the extension stayed alive.
|
// Settings may have changed while the extension stayed alive.
|
||||||
applyPreferredSurfaceOnOpen()
|
applyPreferredSurfaceOnOpen()
|
||||||
|
// Re-warm Taptic after host app switches: SwiftUI `onAppear` often
|
||||||
|
// skips when the extension process is reused, leaving generators cold.
|
||||||
|
KeyboardHapticFeedback.prepare()
|
||||||
if state.surface == .typing {
|
if state.surface == .typing {
|
||||||
OSGDiag.log("KVC.viewWillAppear enterTypingMode", category: "boot")
|
OSGDiag.log("KVC.viewWillAppear enterTypingMode", category: "boot")
|
||||||
typingSession.enterTypingMode()
|
typingSession.enterTypingMode()
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ final class KeyboardFlowCoordinator {
|
|||||||
/// stale sample never flashes the mic orange while the session is healthy.
|
/// stale sample never flashes the mic orange while the session is healthy.
|
||||||
private var lastHostReadyAt: TimeInterval = 0
|
private var lastHostReadyAt: TimeInterval = 0
|
||||||
private static let hostReadyGrace: TimeInterval = 4
|
private static let hostReadyGrace: TimeInterval = 4
|
||||||
|
/// Once the host has published ready for this session, hold green through
|
||||||
|
/// brief inter-utterance ready flaps instead of flashing preparingSession.
|
||||||
|
private var sessionProvenReady = false
|
||||||
private var flowSessionMonitorTask: Task<Void, Never>?
|
private var flowSessionMonitorTask: Task<Void, Never>?
|
||||||
private var isAwaitingFlowResult = false
|
private var isAwaitingFlowResult = false
|
||||||
private var activeSessionId: UUID?
|
private var activeSessionId: UUID?
|
||||||
@@ -168,9 +171,17 @@ final class KeyboardFlowCoordinator {
|
|||||||
// show red/white instead of a fake orange "starting" state.
|
// show red/white instead of a fake orange "starting" state.
|
||||||
adoptHostBusyStateIfNeeded(snapshot: readySnapshot)
|
adoptHostBusyStateIfNeeded(snapshot: readySnapshot)
|
||||||
|
|
||||||
let hostReady = readySnapshot?.ready == true && FlowSessionBridge.isHostReady()
|
let hostReadyRaw = readySnapshot?.ready == true && FlowSessionBridge.isHostReady()
|
||||||
|
let sessionActive = FlowSessionBridge.isSessionActive()
|
||||||
let now = Date().timeIntervalSince1970
|
let now = Date().timeIntervalSince1970
|
||||||
if hostReady { lastHostReadyAt = now }
|
if hostReadyRaw {
|
||||||
|
lastHostReadyAt = now
|
||||||
|
sessionProvenReady = true
|
||||||
|
}
|
||||||
|
if !sessionActive {
|
||||||
|
sessionProvenReady = false
|
||||||
|
lastHostReadyAt = 0
|
||||||
|
}
|
||||||
// Grace window: the host was ready very recently, so treat a momentary
|
// Grace window: the host was ready very recently, so treat a momentary
|
||||||
// stale heartbeat read as "still warming" rather than an outright
|
// stale heartbeat read as "still warming" rather than an outright
|
||||||
// failure. `isSessionActive` is heartbeat-independent, so it stays true
|
// failure. `isSessionActive` is heartbeat-independent, so it stays true
|
||||||
@@ -181,19 +192,30 @@ final class KeyboardFlowCoordinator {
|
|||||||
// it as preparingSession was the orange-stuck bug after cold start:
|
// it as preparingSession was the orange-stuck bug after cold start:
|
||||||
// host utt.rec=1 → ready=false → keyboard forever "正在启动…".
|
// host utt.rec=1 → ready=false → keyboard forever "正在启动…".
|
||||||
let hostBusy = FlowKeyboardHostWarming.isHostBusy(reason: readySnapshot?.reason)
|
let hostBusy = FlowKeyboardHostWarming.isHostBusy(reason: readySnapshot?.reason)
|
||||||
|
// Hold green after the session already proved ready — PiP mic release /
|
||||||
|
// ack lag must not flash yellow「正在启动画中画».
|
||||||
|
let holdReady = FlowKeyboardHostWarming.shouldHoldReady(
|
||||||
|
hostReady: hostReadyRaw,
|
||||||
|
hostBusy: hostBusy,
|
||||||
|
sessionActive: sessionActive,
|
||||||
|
sessionProvenReady: sessionProvenReady,
|
||||||
|
isPendingFlowStart: isPendingFlowStart,
|
||||||
|
snapshotReason: readySnapshot?.reason
|
||||||
|
)
|
||||||
|
let hostReady = hostReadyRaw || holdReady
|
||||||
// PiP sessions publish `reason=.starting` while the small window is
|
// PiP sessions publish `reason=.starting` while the small window is
|
||||||
// coming up — treat that as warming so the mic stays orange (wait)
|
// coming up — treat that as warming so the mic stays orange (wait)
|
||||||
// instead of jumping into another cold start.
|
// instead of jumping into another cold start.
|
||||||
let hostWarming = FlowKeyboardHostWarming.isHostWarming(
|
let hostWarming = FlowKeyboardHostWarming.isHostWarming(
|
||||||
hostReady: hostReady,
|
hostReady: hostReady,
|
||||||
hostBusy: hostBusy,
|
hostBusy: hostBusy,
|
||||||
sessionActive: FlowSessionBridge.isSessionActive(),
|
sessionActive: sessionActive,
|
||||||
hostReachable: FlowSessionBridge.isHostReachable(),
|
hostReachable: FlowSessionBridge.isHostReachable(),
|
||||||
isPendingFlowStart: isPendingFlowStart,
|
isPendingFlowStart: isPendingFlowStart,
|
||||||
withinReadyGrace: withinReadyGrace,
|
withinReadyGrace: withinReadyGrace,
|
||||||
snapshotReason: readySnapshot?.reason
|
snapshotReason: readySnapshot?.reason
|
||||||
)
|
)
|
||||||
state.flowSessionActive = FlowSessionBridge.isSessionActive()
|
state.flowSessionActive = sessionActive
|
||||||
state.debugPendingFlowStart = isPendingFlowStart
|
state.debugPendingFlowStart = isPendingFlowStart
|
||||||
state.debugFlowRecording = isFlowRecording
|
state.debugFlowRecording = isFlowRecording
|
||||||
state.debugAwaitingFlowResult = isAwaitingFlowResult
|
state.debugAwaitingFlowResult = isAwaitingFlowResult
|
||||||
@@ -626,6 +648,7 @@ final class KeyboardFlowCoordinator {
|
|||||||
"utterance=\(result.utteranceId.uuidString.prefix(8)) "
|
"utterance=\(result.utteranceId.uuidString.prefix(8)) "
|
||||||
+ "commandSeq=\(result.commandSeq) warning=\(result.warning == nil ? 0 : 1)"
|
+ "commandSeq=\(result.commandSeq) warning=\(result.warning == nil ? 0 : 1)"
|
||||||
)
|
)
|
||||||
|
recomputeMicVoiceAvailability()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let result = matchingResult(), isTerminalFailure(result) {
|
if let result = matchingResult(), isTerminalFailure(result) {
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ struct TypingRootView: View {
|
|||||||
if !hasContent { candidatePanelMounted = false }
|
if !hasContent { candidatePanelMounted = false }
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
// Primary warm-up lives in KVC.viewWillAppear (covers app switch).
|
||||||
|
// Keep this for first mount / surface flip into typing.
|
||||||
KeyboardHapticFeedback.prepare()
|
KeyboardHapticFeedback.prepare()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -489,8 +491,12 @@ struct TypingRootView: View {
|
|||||||
if !output.text.isEmpty {
|
if !output.text.isEmpty {
|
||||||
onInsert(output.text)
|
onInsert(output.text)
|
||||||
}
|
}
|
||||||
// Proxy context is up to date after inserts/deletes — refresh Shift.
|
// Notes / some hosts lag `documentContextBeforeInput` — pass the edit we
|
||||||
typing.syncAutocapitalization()
|
// just applied so sentence Shift can arm after Return / "." immediately.
|
||||||
|
typing.syncAutocapitalization(
|
||||||
|
accountingForInsert: output.text,
|
||||||
|
deleteCount: output.deleteCount
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func keyWeight(label: String, index: Int, rowIndex: Int) -> CGFloat {
|
private func keyWeight(label: String, index: Int, rowIndex: Int) -> CGFloat {
|
||||||
|
|||||||
@@ -234,14 +234,22 @@ public struct KeyboardRootView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func bottomDeleteButton(disabled: Bool) -> some View {
|
private func bottomDeleteButton(disabled: Bool) -> some View {
|
||||||
RepeatingDeleteButton(disabled: disabled) {
|
RepeatingDeleteButton(
|
||||||
|
disabled: disabled,
|
||||||
|
hapticIntensity: state.keyboardHapticIntensity
|
||||||
|
) {
|
||||||
state.deleteBackward()
|
state.deleteBackward()
|
||||||
}
|
}
|
||||||
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
|
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func bottomSpaceButton(disabled: Bool) -> some View {
|
private func bottomSpaceButton(disabled: Bool) -> some View {
|
||||||
RectangularToolbarButton(spaceStyle: true, label: "space", disabled: disabled) {
|
RectangularToolbarButton(
|
||||||
|
spaceStyle: true,
|
||||||
|
label: "space",
|
||||||
|
disabled: disabled,
|
||||||
|
hapticIntensity: state.keyboardHapticIntensity
|
||||||
|
) {
|
||||||
state.insertSpace()
|
state.insertSpace()
|
||||||
}
|
}
|
||||||
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
|
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
|
||||||
@@ -253,7 +261,8 @@ public struct KeyboardRootView: View {
|
|||||||
title: title,
|
title: title,
|
||||||
label: title,
|
label: title,
|
||||||
disabled: disabled,
|
disabled: disabled,
|
||||||
isSend: state.returnKeyRole == .send
|
isSend: state.returnKeyRole == .send,
|
||||||
|
hapticIntensity: state.keyboardHapticIntensity
|
||||||
) {
|
) {
|
||||||
state.insertNewline()
|
state.insertNewline()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,10 @@ struct KeyboardTopControls: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
onInsert(output.text)
|
onInsert(output.text)
|
||||||
typing.syncAutocapitalization()
|
typing.syncAutocapitalization(
|
||||||
|
accountingForInsert: output.text,
|
||||||
|
deleteCount: output.deleteCount
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func accessibilityLabel(for tab: KeyboardInputTab) -> String {
|
private func accessibilityLabel(for tab: KeyboardInputTab) -> String {
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ struct RepeatingPressButton<Label: View>: View {
|
|||||||
var disabled: Bool = false
|
var disabled: Bool = false
|
||||||
/// Plays the system delete click on each fire (matches stock keyboard).
|
/// Plays the system delete click on each fire (matches stock keyboard).
|
||||||
var playsDeleteSound: Bool = true
|
var playsDeleteSound: Bool = true
|
||||||
/// Typing-grid haptic strength; `.off` skips haptics (voice toolbar default).
|
/// Settings → General → Haptics; `.off` skips impact feedback.
|
||||||
var hapticIntensity: KeyboardHapticIntensity = .off
|
var hapticIntensity: KeyboardHapticIntensity = .off
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
@ViewBuilder let label: (_ isPressed: Bool) -> Label
|
@ViewBuilder let label: (_ isPressed: Bool) -> Label
|
||||||
@@ -166,10 +166,16 @@ struct RepeatingDeleteButton: View {
|
|||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
let disabled: Bool
|
let disabled: Bool
|
||||||
|
/// Mirrors Settings → General → Haptics (same as typing delete).
|
||||||
|
var hapticIntensity: KeyboardHapticIntensity = .off
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
RepeatingPressButton(disabled: disabled, action: action) { isPressed in
|
RepeatingPressButton(
|
||||||
|
disabled: disabled,
|
||||||
|
hapticIntensity: hapticIntensity,
|
||||||
|
action: action
|
||||||
|
) { isPressed in
|
||||||
ToolbarKeySurface(
|
ToolbarKeySurface(
|
||||||
isPressed: isPressed,
|
isPressed: isPressed,
|
||||||
cornerRadius: ToolbarButtonMetrics.cornerRadius,
|
cornerRadius: ToolbarButtonMetrics.cornerRadius,
|
||||||
@@ -227,15 +233,24 @@ struct RectangularToolbarButton: View {
|
|||||||
let label: String
|
let label: String
|
||||||
let disabled: Bool
|
let disabled: Bool
|
||||||
let isSend: Bool
|
let isSend: Bool
|
||||||
|
/// Settings → General → Haptics; space / return use `.action` role.
|
||||||
|
var hapticIntensity: KeyboardHapticIntensity = .off
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
|
|
||||||
init(systemName: String, label: String, disabled: Bool = false, action: @escaping () -> Void) {
|
init(
|
||||||
|
systemName: String,
|
||||||
|
label: String,
|
||||||
|
disabled: Bool = false,
|
||||||
|
hapticIntensity: KeyboardHapticIntensity = .off,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) {
|
||||||
self.systemName = systemName
|
self.systemName = systemName
|
||||||
self.spaceStyle = false
|
self.spaceStyle = false
|
||||||
self.title = nil
|
self.title = nil
|
||||||
self.label = label
|
self.label = label
|
||||||
self.disabled = disabled
|
self.disabled = disabled
|
||||||
self.isSend = false
|
self.isSend = false
|
||||||
|
self.hapticIntensity = hapticIntensity
|
||||||
self.action = action
|
self.action = action
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +259,7 @@ struct RectangularToolbarButton: View {
|
|||||||
label: String,
|
label: String,
|
||||||
disabled: Bool = false,
|
disabled: Bool = false,
|
||||||
isSend: Bool = false,
|
isSend: Bool = false,
|
||||||
|
hapticIntensity: KeyboardHapticIntensity = .off,
|
||||||
action: @escaping () -> Void
|
action: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
self.systemName = nil
|
self.systemName = nil
|
||||||
@@ -251,17 +267,25 @@ struct RectangularToolbarButton: View {
|
|||||||
self.label = label
|
self.label = label
|
||||||
self.disabled = disabled
|
self.disabled = disabled
|
||||||
self.isSend = isSend
|
self.isSend = isSend
|
||||||
|
self.hapticIntensity = hapticIntensity
|
||||||
self.action = action
|
self.action = action
|
||||||
self.title = title
|
self.title = title
|
||||||
}
|
}
|
||||||
|
|
||||||
init(spaceStyle: Bool, label: String, disabled: Bool = false, action: @escaping () -> Void) {
|
init(
|
||||||
|
spaceStyle: Bool,
|
||||||
|
label: String,
|
||||||
|
disabled: Bool = false,
|
||||||
|
hapticIntensity: KeyboardHapticIntensity = .off,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) {
|
||||||
self.systemName = nil
|
self.systemName = nil
|
||||||
self.spaceStyle = spaceStyle
|
self.spaceStyle = spaceStyle
|
||||||
self.title = nil
|
self.title = nil
|
||||||
self.label = label
|
self.label = label
|
||||||
self.disabled = disabled
|
self.disabled = disabled
|
||||||
self.isSend = false
|
self.isSend = false
|
||||||
|
self.hapticIntensity = hapticIntensity
|
||||||
self.action = action
|
self.action = action
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,13 +323,14 @@ struct RectangularToolbarButton: View {
|
|||||||
isSend ? .white : NativeKeyboardKeyColors.text(for: colorScheme)
|
isSend ? .white : NativeKeyboardKeyColors.text(for: colorScheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按下即响、按下即执行,与系统键盘保持一致(Button 默认松手才触发)。
|
// 按下即响、即震、即执行,与系统键盘 / 打字面保持一致(Button 默认松手才触发)。
|
||||||
private var pressGesture: some Gesture {
|
private var pressGesture: some Gesture {
|
||||||
DragGesture(minimumDistance: 0)
|
DragGesture(minimumDistance: 0)
|
||||||
.onChanged { _ in
|
.onChanged { _ in
|
||||||
guard !disabled, !isPressing else { return }
|
guard !disabled, !isPressing else { return }
|
||||||
isPressing = true
|
isPressing = true
|
||||||
KeyboardSoundFeedback.keyClick()
|
KeyboardSoundFeedback.keyClick()
|
||||||
|
KeyboardHapticFeedback.play(role: .action, intensity: hapticIntensity)
|
||||||
action()
|
action()
|
||||||
}
|
}
|
||||||
.onEnded { _ in
|
.onEnded { _ in
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
TypingAutocapitalization.shouldCapitalize(precedingText: "Hello!\n", mode: .sentences)
|
TypingAutocapitalization.shouldCapitalize(precedingText: "Hello!\n", mode: .sentences)
|
||||||
)
|
)
|
||||||
|
// System keyboard (.sentences): Return starts a new line → capitalize.
|
||||||
|
// Notes-style document context after Return is typically "…\n".
|
||||||
|
XCTAssertTrue(
|
||||||
|
TypingAutocapitalization.shouldCapitalize(precedingText: "Hello\n", mode: .sentences),
|
||||||
|
"Return/newline must arm Shift (Notes multi-line)"
|
||||||
|
)
|
||||||
|
XCTAssertTrue(
|
||||||
|
TypingAutocapitalization.shouldCapitalize(precedingText: "Hello\n\n", mode: .sentences)
|
||||||
|
)
|
||||||
XCTAssertFalse(
|
XCTAssertFalse(
|
||||||
TypingAutocapitalization.shouldCapitalize(precedingText: "Hello ", mode: .sentences)
|
TypingAutocapitalization.shouldCapitalize(precedingText: "Hello ", mode: .sentences)
|
||||||
)
|
)
|
||||||
@@ -72,6 +81,55 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testEnglishShiftArmsAfterReturnLikeNotes() {
|
||||||
|
// Simulates Notes: proxy preceding text gains a trailing newline after Return.
|
||||||
|
var preceding = "Hello"
|
||||||
|
let typing = TypingSessionController()
|
||||||
|
typing.precedingTextProvider = { preceding }
|
||||||
|
typing.autocapitalizationModeProvider = { .sentences }
|
||||||
|
_ = typing.setLanguage(.english)
|
||||||
|
XCTAssertFalse(typing.shiftActive, "mid-word should not arm Shift")
|
||||||
|
|
||||||
|
_ = typing.handleReturn()
|
||||||
|
preceding = "Hello\n" // host document after inserting newline
|
||||||
|
typing.syncAutocapitalization(accountingForInsert: "\n")
|
||||||
|
XCTAssertTrue(
|
||||||
|
typing.shiftActive,
|
||||||
|
"after Return, Shift must light for next line (system sentences behavior)"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(typing.keyRows.first?.first, "Q")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testEnglishShiftArmsWhenProxyLagsAfterReturn() {
|
||||||
|
// Notes often still reports pre-Return context right after insertText("\n").
|
||||||
|
var preceding = "Hello"
|
||||||
|
let typing = TypingSessionController()
|
||||||
|
typing.precedingTextProvider = { preceding }
|
||||||
|
typing.autocapitalizationModeProvider = { .sentences }
|
||||||
|
_ = typing.setLanguage(.english)
|
||||||
|
|
||||||
|
_ = typing.handleReturn()
|
||||||
|
// Proxy intentionally stale — still "Hello" without "\n".
|
||||||
|
typing.syncAutocapitalization(accountingForInsert: "\n")
|
||||||
|
XCTAssertTrue(typing.shiftActive)
|
||||||
|
XCTAssertEqual(typing.keyRows.first?.first, "Q")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testEnglishShiftArmsWhenProxyLagsAfterPeriod() {
|
||||||
|
var preceding = "Hello"
|
||||||
|
let typing = TypingSessionController()
|
||||||
|
typing.precedingTextProvider = { preceding }
|
||||||
|
typing.autocapitalizationModeProvider = { .sentences }
|
||||||
|
_ = typing.setLanguage(.english)
|
||||||
|
|
||||||
|
_ = typing.handleKey(".")
|
||||||
|
typing.syncAutocapitalization(accountingForInsert: ".")
|
||||||
|
XCTAssertTrue(typing.shiftActive)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testEnglishIdleShowsNoCandidatesUntilLetterTyped() {
|
func testEnglishIdleShowsNoCandidatesUntilLetterTyped() {
|
||||||
let typing = TypingSessionController()
|
let typing = TypingSessionController()
|
||||||
|
|||||||
@@ -154,4 +154,120 @@ final class KeyboardSurfaceStateTests: XCTestCase {
|
|||||||
XCTAssertFalse(typing.shiftHeld)
|
XCTAssertFalse(typing.shiftHeld)
|
||||||
XCTAssertFalse(typing.capsLock)
|
XCTAssertFalse(typing.capsLock)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testChineseShiftInsertsUppercaseLatinBypassingRime() {
|
||||||
|
let engine = TrackingStubRimeEngine()
|
||||||
|
let typing = TypingSessionController(engine: { engine })
|
||||||
|
|
||||||
|
_ = typing.handleKey("⇧")
|
||||||
|
XCTAssertTrue(typing.shiftActive)
|
||||||
|
|
||||||
|
let output = typing.handleKey("N")
|
||||||
|
XCTAssertEqual(output, .insert("N"))
|
||||||
|
XCTAssertEqual(engine.processCharacterCallCount, 0)
|
||||||
|
XCTAssertFalse(typing.shiftActive, "one-shot Shift clears after Latin insert")
|
||||||
|
XCTAssertTrue(typing.composition.preedit.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testChineseShiftPreservesExistingComposition() {
|
||||||
|
let engine = TrackingStubRimeEngine()
|
||||||
|
let typing = TypingSessionController(engine: { engine })
|
||||||
|
// Seed session composition via a lowercase letter (goes to Rime).
|
||||||
|
_ = typing.handleKey("n")
|
||||||
|
XCTAssertEqual(typing.composition.preedit, "n")
|
||||||
|
|
||||||
|
_ = typing.handleKey("⇧")
|
||||||
|
let output = typing.handleKey("A")
|
||||||
|
XCTAssertEqual(output, .insert("A"))
|
||||||
|
XCTAssertEqual(engine.processCharacterCallCount, 1, "only the unshifted letter hits Rime")
|
||||||
|
XCTAssertEqual(typing.composition.preedit, "n", "Shift Latin must not clear preedit")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testChineseCapsLockKeepsInsertingUppercaseLatin() {
|
||||||
|
let engine = TrackingStubRimeEngine()
|
||||||
|
let typing = TypingSessionController(engine: { engine })
|
||||||
|
|
||||||
|
_ = typing.handleKey("⇧")
|
||||||
|
_ = typing.handleKey("⇧") // second tap → Caps Lock
|
||||||
|
XCTAssertTrue(typing.capsLock)
|
||||||
|
|
||||||
|
XCTAssertEqual(typing.handleKey("O"), .insert("O"))
|
||||||
|
XCTAssertEqual(typing.handleKey("S"), .insert("S"))
|
||||||
|
XCTAssertEqual(engine.processCharacterCallCount, 0)
|
||||||
|
XCTAssertTrue(typing.capsLock)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testChineseUnshiftedLetterStillComposes() {
|
||||||
|
let engine = TrackingStubRimeEngine()
|
||||||
|
let typing = TypingSessionController(engine: { engine })
|
||||||
|
|
||||||
|
let output = typing.handleKey("n")
|
||||||
|
XCTAssertEqual(output, .none)
|
||||||
|
XCTAssertEqual(engine.processCharacterCallCount, 1)
|
||||||
|
XCTAssertEqual(engine.lastProcessedCharacter, "n")
|
||||||
|
XCTAssertEqual(typing.composition.preedit, "n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stub that records `processCharacter` calls for Chinese Shift bypass tests.
|
||||||
|
@MainActor
|
||||||
|
private final class TrackingStubRimeEngine: RimeEngineBridging {
|
||||||
|
var composition: TypingComposition = .empty
|
||||||
|
var isReady: Bool = true
|
||||||
|
var schema: TypingInputSchema = .fullPinyin
|
||||||
|
private(set) var processCharacterCallCount = 0
|
||||||
|
private(set) var lastProcessedCharacter: Character?
|
||||||
|
|
||||||
|
func prepare() async throws {}
|
||||||
|
func teardown() { composition = .empty }
|
||||||
|
|
||||||
|
func setLanguage(_ language: TypingInputLanguage) {
|
||||||
|
if language == .english { composition = .empty }
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setSchema(_ schema: TypingInputSchema) -> Bool {
|
||||||
|
self.schema = schema
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func processCharacter(_ character: Character) -> String? {
|
||||||
|
processCharacterCallCount += 1
|
||||||
|
lastProcessedCharacter = character
|
||||||
|
composition = TypingComposition(
|
||||||
|
preedit: String(character).lowercased(),
|
||||||
|
candidates: [TypingCandidate(text: "你")]
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processBackspace() -> String? {
|
||||||
|
composition = .empty
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processSpace() -> String? {
|
||||||
|
composition = .empty
|
||||||
|
return " "
|
||||||
|
}
|
||||||
|
|
||||||
|
func processReturn() -> String? {
|
||||||
|
composition = .empty
|
||||||
|
return "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectCandidate(at index: Int) -> String {
|
||||||
|
composition = .empty
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func flushPreedit() -> String {
|
||||||
|
let raw = composition.preedit
|
||||||
|
composition = .empty
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearComposition() {
|
||||||
|
composition = .empty
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,19 +29,15 @@ struct VolcengineCloudASRClient: CloudASRTranscribing, CloudASRStreamingCapable
|
|||||||
onPartial: @escaping @Sendable (String) -> Void
|
onPartial: @escaping @Sendable (String) -> Void
|
||||||
) async throws -> any CloudASRStreamingSession {
|
) async throws -> any CloudASRStreamingSession {
|
||||||
_ = locale
|
_ = locale
|
||||||
let credentials = try VolcengineCredentials.parse(
|
_ = resourceID // Product is locked to Doubao streaming 2.0 duration.
|
||||||
apiKey: apiKey,
|
let credentials = VolcengineASRFields.parse(apiKey: apiKey)
|
||||||
fallbackResourceID: resolvedResourceID
|
guard credentials.hasUsableCredentials else { throw CloudASRError.noAPIKey }
|
||||||
)
|
|
||||||
let url = try resolvedEndpointURL()
|
let url = try resolvedEndpointURL()
|
||||||
let connectID = UUID().uuidString
|
let connectID = UUID().uuidString
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.timeoutInterval = 8
|
request.timeoutInterval = 8
|
||||||
request.setValue(credentials.appID, forHTTPHeaderField: "X-Api-App-Key")
|
credentials.applyWebSocketAuthHeaders(to: &request, connectID: connectID)
|
||||||
request.setValue(credentials.accessToken, forHTTPHeaderField: "X-Api-Access-Key")
|
|
||||||
request.setValue(credentials.resourceID, forHTTPHeaderField: "X-Api-Resource-Id")
|
|
||||||
request.setValue(connectID, forHTTPHeaderField: "X-Api-Connect-Id")
|
|
||||||
|
|
||||||
let task = session.webSocketTask(with: request)
|
let task = session.webSocketTask(with: request)
|
||||||
task.resume()
|
task.resume()
|
||||||
@@ -90,12 +86,6 @@ struct VolcengineCloudASRClient: CloudASRTranscribing, CloudASRStreamingCapable
|
|||||||
live.cancel()
|
live.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var resolvedResourceID: String {
|
|
||||||
resourceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
? CloudASRModelCatalog.volcengineDefaultResourceID
|
|
||||||
: resourceID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolvedEndpointURL() throws -> URL {
|
private func resolvedEndpointURL() throws -> URL {
|
||||||
let raw = endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
let raw = endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
? CloudASRModelCatalog.volcengineEndpoint
|
? CloudASRModelCatalog.volcengineEndpoint
|
||||||
@@ -424,46 +414,6 @@ private final class VolcengineStreamingSession: CloudASRStreamingSession, @unche
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct VolcengineCredentials {
|
|
||||||
let appID: String
|
|
||||||
let accessToken: String
|
|
||||||
let resourceID: String
|
|
||||||
|
|
||||||
static func parse(apiKey: String, fallbackResourceID: String) throws -> VolcengineCredentials {
|
|
||||||
let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { throw CloudASRError.noAPIKey }
|
|
||||||
|
|
||||||
if let data = trimmed.data(using: .utf8),
|
|
||||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
||||||
let appID = string(json, keys: ["app_id", "appId", "appid"])
|
|
||||||
let token = string(json, keys: ["access_token", "accessToken", "token"])
|
|
||||||
let resourceID = string(json, keys: ["resource_id", "resourceId", "resource"])
|
|
||||||
?? fallbackResourceID
|
|
||||||
guard let appID, let token, !resourceID.isEmpty else { throw CloudASRError.noAPIKey }
|
|
||||||
return VolcengineCredentials(appID: appID, accessToken: token, resourceID: resourceID)
|
|
||||||
}
|
|
||||||
|
|
||||||
let separators = CharacterSet(charactersIn: ":\n,")
|
|
||||||
let parts = trimmed
|
|
||||||
.components(separatedBy: separators)
|
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
guard parts.count >= 2 else { throw CloudASRError.noAPIKey }
|
|
||||||
let resourceID = parts.count >= 3 ? parts[2] : fallbackResourceID
|
|
||||||
return VolcengineCredentials(appID: parts[0], accessToken: parts[1], resourceID: resourceID)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func string(_ json: [String: Any], keys: [String]) -> String? {
|
|
||||||
for key in keys {
|
|
||||||
if let value = json[key] as? String {
|
|
||||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if !trimmed.isEmpty { return trimmed }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum VolcengineMessageType: UInt8 {
|
enum VolcengineMessageType: UInt8 {
|
||||||
case fullClientRequest = 0b0001
|
case fullClientRequest = 0b0001
|
||||||
case audioOnlyRequest = 0b0010
|
case audioOnlyRequest = 0b0010
|
||||||
|
|||||||
@@ -68,10 +68,13 @@ public final class FlowAudioSessionCoordinator: @unchecked Sendable {
|
|||||||
public func activateCapture() async throws -> FlowAudioSessionSnapshot {
|
public func activateCapture() async throws -> FlowAudioSessionSnapshot {
|
||||||
let activation: CaptureActivation = try await perform {
|
let activation: CaptureActivation = try await perform {
|
||||||
if self.mode != .capture {
|
if self.mode != .capture {
|
||||||
|
// `.voiceChat` turns on system speech DSP (noise suppression /
|
||||||
|
// AGC). `.measurement` feeds near-raw PCM and is weak against
|
||||||
|
// a competing talker in the same room.
|
||||||
try self.session.setCategory(
|
try self.session.setCategory(
|
||||||
.playAndRecord,
|
.playAndRecord,
|
||||||
mode: .measurement,
|
mode: FlowCaptureVoiceProcessing.captureMode,
|
||||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers]
|
options: FlowCaptureVoiceProcessing.captureOptions
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if !self.active {
|
if !self.active {
|
||||||
@@ -95,6 +98,9 @@ public final class FlowAudioSessionCoordinator: @unchecked Sendable {
|
|||||||
category: "flow"
|
category: "flow"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Built-in near-talk preference (cardioid when available).
|
||||||
|
FlowCaptureVoiceProcessing.preferNearTalkBuiltInMic(on: self.session)
|
||||||
}
|
}
|
||||||
self.mode = .capture
|
self.mode = .capture
|
||||||
return CaptureActivation(
|
return CaptureActivation(
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// FlowCaptureVoiceProcessing.swift
|
||||||
|
// OSGKeyboard · Host Support
|
||||||
|
//
|
||||||
|
// Capture-side speech front-end for local ASR: Apple Voice Processing
|
||||||
|
// (noise suppression / AGC / AEC) plus near-talk built-in mic preference.
|
||||||
|
// `.measurement` delivers near-raw PCM and is a poor fit for competing
|
||||||
|
// talkers in the same room; `.voiceChat` + VP is the system path that
|
||||||
|
// also unlocks Control Center Mic Modes (incl. Voice Isolation).
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import OSGKeyboardShared
|
||||||
|
|
||||||
|
public enum FlowCaptureVoiceProcessing {
|
||||||
|
|
||||||
|
/// Speech-oriented session mode. Prefer over `.measurement` for ASR.
|
||||||
|
public static let captureMode: AVAudioSession.Mode = .voiceChat
|
||||||
|
|
||||||
|
public static let captureOptions: AVAudioSession.CategoryOptions = [
|
||||||
|
.defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Enable Apple Voice Processing on a stopped engine. Format may change
|
||||||
|
/// afterward — callers must re-read `inputNode` formats before `installTap`.
|
||||||
|
@discardableResult
|
||||||
|
public static func enableVoiceProcessing(on engine: AVAudioEngine) -> Bool {
|
||||||
|
let input = engine.inputNode
|
||||||
|
if input.isVoiceProcessingEnabled { return true }
|
||||||
|
do {
|
||||||
|
try input.setVoiceProcessingEnabled(true)
|
||||||
|
OSGDiag.log("voiceProcessing enabled", category: "flow")
|
||||||
|
logActiveMicrophoneMode()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
OSGDiag.log(
|
||||||
|
"voiceProcessing enable failed: \(error.localizedDescription)",
|
||||||
|
category: "flow"
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefer a near-field built-in data source (front/lower + cardioid when
|
||||||
|
/// available). No-op while Bluetooth HFP is preferred or active — keep
|
||||||
|
/// the existing headset route.
|
||||||
|
public static func preferNearTalkBuiltInMic(on session: AVAudioSession) {
|
||||||
|
if session.currentRoute.inputs.first?.portType == .bluetoothHFP { return }
|
||||||
|
if session.preferredInput?.portType == .bluetoothHFP { return }
|
||||||
|
|
||||||
|
guard let builtIn = session.availableInputs?.first(where: {
|
||||||
|
$0.portType == .builtInMic
|
||||||
|
}) else { return }
|
||||||
|
|
||||||
|
let sources = builtIn.dataSources ?? []
|
||||||
|
let preferred =
|
||||||
|
sources.first(where: { $0.orientation == .front })
|
||||||
|
?? sources.first(where: { $0.location == .lower })
|
||||||
|
?? sources.first
|
||||||
|
|
||||||
|
if let preferred {
|
||||||
|
if preferred.supportedPolarPatterns?.contains(.cardioid) == true {
|
||||||
|
do {
|
||||||
|
try preferred.setPreferredPolarPattern(.cardioid)
|
||||||
|
} catch {
|
||||||
|
OSGDiag.log(
|
||||||
|
"cardioid polar pattern failed: \(error.localizedDescription)",
|
||||||
|
category: "flow"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try builtIn.setPreferredDataSource(preferred)
|
||||||
|
} catch {
|
||||||
|
OSGDiag.log(
|
||||||
|
"preferred data source failed: \(error.localizedDescription)",
|
||||||
|
category: "flow"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try session.setPreferredInput(builtIn)
|
||||||
|
} catch {
|
||||||
|
OSGDiag.log(
|
||||||
|
"preferred built-in mic failed: \(error.localizedDescription)",
|
||||||
|
category: "flow"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func logActiveMicrophoneMode() {
|
||||||
|
let preferred = micModeLabel(AVCaptureDevice.preferredMicrophoneMode)
|
||||||
|
let active = micModeLabel(AVCaptureDevice.activeMicrophoneMode)
|
||||||
|
OSGDiag.log(
|
||||||
|
"micMode preferred=\(preferred) active=\(active)",
|
||||||
|
category: "flow"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func micModeLabel(_ mode: AVCaptureDevice.MicrophoneMode) -> String {
|
||||||
|
switch mode {
|
||||||
|
case .standard: return "standard"
|
||||||
|
case .wideSpectrum: return "wideSpectrum"
|
||||||
|
case .voiceIsolation: return "voiceIsolation"
|
||||||
|
@unknown default: return "unknown(\(mode.rawValue))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -604,13 +604,25 @@ public final class FlowContinuousCapture {
|
|||||||
FlowAudioEngineHandle(audioEngine)
|
FlowAudioEngineHandle(audioEngine)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Voice Processing must be enabled while the engine is stopped, and
|
||||||
|
// it can change the RemoteIO format — enable first, then sync rates.
|
||||||
var candidateEngine = AVAudioEngine()
|
var candidateEngine = AVAudioEngine()
|
||||||
|
var voiceProcessingOn = FlowCaptureVoiceProcessing.enableVoiceProcessing(
|
||||||
|
on: candidateEngine
|
||||||
|
)
|
||||||
var inputNode = candidateEngine.inputNode
|
var inputNode = candidateEngine.inputNode
|
||||||
var hardwareFormat = inputNode.inputFormat(forBus: 0)
|
var hardwareFormat = inputNode.inputFormat(forBus: 0)
|
||||||
var outputFormat = inputNode.outputFormat(forBus: 0)
|
var outputFormat = inputNode.outputFormat(forBus: 0)
|
||||||
for _ in 0..<3 where abs(hardwareFormat.sampleRate - sessionSnapshot.sampleRate) >= 1 {
|
var resolvedSession = sessionSnapshot
|
||||||
|
for _ in 0..<3 where abs(hardwareFormat.sampleRate - resolvedSession.sampleRate) >= 1 {
|
||||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
// Re-enter capture so the session rate tracks VP / route shifts.
|
||||||
|
resolvedSession = (try? await FlowAudioSessionCoordinator.shared.activateCapture())
|
||||||
|
?? resolvedSession
|
||||||
candidateEngine = AVAudioEngine()
|
candidateEngine = AVAudioEngine()
|
||||||
|
voiceProcessingOn = FlowCaptureVoiceProcessing.enableVoiceProcessing(
|
||||||
|
on: candidateEngine
|
||||||
|
)
|
||||||
inputNode = candidateEngine.inputNode
|
inputNode = candidateEngine.inputNode
|
||||||
hardwareFormat = inputNode.inputFormat(forBus: 0)
|
hardwareFormat = inputNode.inputFormat(forBus: 0)
|
||||||
outputFormat = inputNode.outputFormat(forBus: 0)
|
outputFormat = inputNode.outputFormat(forBus: 0)
|
||||||
@@ -619,8 +631,9 @@ public final class FlowContinuousCapture {
|
|||||||
"audioSession.active",
|
"audioSession.active",
|
||||||
"hwRate=\(Int(hardwareFormat.sampleRate)) hwChannels=\(hardwareFormat.channelCount) "
|
"hwRate=\(Int(hardwareFormat.sampleRate)) hwChannels=\(hardwareFormat.channelCount) "
|
||||||
+ "outputRate=\(Int(outputFormat.sampleRate)) "
|
+ "outputRate=\(Int(outputFormat.sampleRate)) "
|
||||||
+ "sessionRate=\(Int(sessionSnapshot.sampleRate)) "
|
+ "sessionRate=\(Int(resolvedSession.sampleRate)) "
|
||||||
+ "route=\(sessionSnapshot.inputPortType)"
|
+ "route=\(resolvedSession.inputPortType) "
|
||||||
|
+ "voiceProcessing=\(voiceProcessingOn ? 1 : 0)"
|
||||||
)
|
)
|
||||||
guard hardwareFormat.sampleRate > 0, hardwareFormat.channelCount > 0 else {
|
guard hardwareFormat.sampleRate > 0, hardwareFormat.channelCount > 0 else {
|
||||||
FlowTrace.warn(
|
FlowTrace.warn(
|
||||||
@@ -632,8 +645,8 @@ public final class FlowContinuousCapture {
|
|||||||
channels: Int(hardwareFormat.channelCount)
|
channels: Int(hardwareFormat.channelCount)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
guard sessionSnapshot.sampleRate <= 0
|
guard resolvedSession.sampleRate <= 0
|
||||||
|| abs(hardwareFormat.sampleRate - sessionSnapshot.sampleRate) < 1 else {
|
|| abs(hardwareFormat.sampleRate - resolvedSession.sampleRate) < 1 else {
|
||||||
throw StartError.invalidHardwareFormat(
|
throw StartError.invalidHardwareFormat(
|
||||||
sampleRate: hardwareFormat.sampleRate,
|
sampleRate: hardwareFormat.sampleRate,
|
||||||
channels: Int(hardwareFormat.channelCount)
|
channels: Int(hardwareFormat.channelCount)
|
||||||
@@ -703,7 +716,7 @@ public final class FlowContinuousCapture {
|
|||||||
throw StartError.engineStartFailed(error.localizedDescription)
|
throw StartError.engineStartFailed(error.localizedDescription)
|
||||||
}
|
}
|
||||||
audioEngine = candidateEngine
|
audioEngine = candidateEngine
|
||||||
activeRouteSnapshot = sessionSnapshot
|
activeRouteSnapshot = resolvedSession
|
||||||
engineActivationCount += 1
|
engineActivationCount += 1
|
||||||
lastActivationAt = Date()
|
lastActivationAt = Date()
|
||||||
FlowTrace.capture("engine.started", "running=\(audioEngine.isRunning ? 1 : 0)")
|
FlowTrace.capture("engine.started", "running=\(audioEngine.isRunning ? 1 : 0)")
|
||||||
|
|||||||
@@ -177,30 +177,27 @@ public final class LiveDictationController: ObservableObject {
|
|||||||
|
|
||||||
// 3. Audio session — only configure once per process.
|
// 3. Audio session — only configure once per process.
|
||||||
//
|
//
|
||||||
// Category is `.record` (not `.playAndRecord`) because the
|
// On device we use `.playAndRecord` + `.voiceChat` so Apple Voice
|
||||||
// preview never plays back audio — it just records from the
|
// Processing can suppress competing talkers. The iOS Simulator's
|
||||||
// mic and hands the buffers to `SpeechAnalyzer`. On the
|
// speaker reports a 0 Hz output format, so `.playAndRecord` fails
|
||||||
// iOS Simulator, `.playAndRecord` requires the
|
// with `kAudioUnitErr_FormatNotSupported` (-10851); keep `.record`
|
||||||
// `AURemoteIO` Audio Unit's *output* side to also be
|
// there so preview still works under CoreSimulator.
|
||||||
// enabled, but the simulator's "speaker" reports a 0 Hz
|
|
||||||
// hardware format, so `AURemoteIO::enable` fails with
|
|
||||||
// `kAudioUnitErr_FormatNotSupported` (-10851) and any
|
|
||||||
// subsequent `installTap` traps with "Failed to create tap
|
|
||||||
// due to format mismatch". `.record` skips the output
|
|
||||||
// side entirely, so the simulator can record.
|
|
||||||
//
|
|
||||||
// The real keyboard extension (`OSGKeyboardExt`) keeps
|
|
||||||
// `.playAndRecord` because it runs on a real device where
|
|
||||||
// the output side has a real hardware format, and may want
|
|
||||||
// to play click sounds / haptic feedback. Only the preview
|
|
||||||
// needs the simulator-friendly category.
|
|
||||||
if !didConfigureAudioSession {
|
if !didConfigureAudioSession {
|
||||||
do {
|
do {
|
||||||
let session = AVAudioSession.sharedInstance()
|
let session = AVAudioSession.sharedInstance()
|
||||||
try session.setCategory(.record,
|
#if targetEnvironment(simulator)
|
||||||
mode: .measurement,
|
try session.setCategory(.record, mode: .default, options: [])
|
||||||
options: [])
|
#else
|
||||||
|
try session.setCategory(
|
||||||
|
.playAndRecord,
|
||||||
|
mode: FlowCaptureVoiceProcessing.captureMode,
|
||||||
|
options: FlowCaptureVoiceProcessing.captureOptions
|
||||||
|
)
|
||||||
|
#endif
|
||||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||||
|
#if !targetEnvironment(simulator)
|
||||||
|
FlowCaptureVoiceProcessing.preferNearTalkBuiltInMic(on: session)
|
||||||
|
#endif
|
||||||
didConfigureAudioSession = true
|
didConfigureAudioSession = true
|
||||||
} catch {
|
} catch {
|
||||||
debug("audio session failed: \(error.localizedDescription)")
|
debug("audio session failed: \(error.localizedDescription)")
|
||||||
@@ -214,6 +211,8 @@ public final class LiveDictationController: ObservableObject {
|
|||||||
// The route may have changed while the preview was closed. A fresh
|
// The route may have changed while the preview was closed. A fresh
|
||||||
// engine created after session activation avoids a stale input node.
|
// engine created after session activation avoids a stale input node.
|
||||||
audioEngine = AVAudioEngine()
|
audioEngine = AVAudioEngine()
|
||||||
|
// Enable VP before reading hardware format — RemoteIO rate can shift.
|
||||||
|
_ = FlowCaptureVoiceProcessing.enableVoiceProcessing(on: audioEngine)
|
||||||
|
|
||||||
// 4. Spin up the engine + ASR.
|
// 4. Spin up the engine + ASR.
|
||||||
phase = .recording
|
phase = .recording
|
||||||
|
|||||||
@@ -146,7 +146,8 @@ struct MacPolishStylesView: View {
|
|||||||
duplicate: {
|
duplicate: {
|
||||||
editingPack = PolishStylePack(
|
editingPack = PolishStylePack(
|
||||||
name: "\(pack.displayName(language: lang)) \(MacL10n.string("mac.styles.copy", language: lang))",
|
name: "\(pack.displayName(language: lang)) \(MacL10n.string("mac.styles.copy", language: lang))",
|
||||||
prompt: pack.prompt
|
prompt: pack.prompt,
|
||||||
|
allowsAddedEmoji: pack.allowsAddedEmoji
|
||||||
)
|
)
|
||||||
showEditor = true
|
showEditor = true
|
||||||
},
|
},
|
||||||
@@ -347,6 +348,7 @@ private struct MacPolishStyleEditor: View {
|
|||||||
@Environment(\.themePalette) private var palette
|
@Environment(\.themePalette) private var palette
|
||||||
@State private var name: String
|
@State private var name: String
|
||||||
@State private var prompt: String
|
@State private var prompt: String
|
||||||
|
@State private var allowsAddedEmoji: Bool
|
||||||
|
|
||||||
init(
|
init(
|
||||||
pack: PolishStylePack?,
|
pack: PolishStylePack?,
|
||||||
@@ -358,6 +360,7 @@ private struct MacPolishStyleEditor: View {
|
|||||||
self.onSave = onSave
|
self.onSave = onSave
|
||||||
_name = State(initialValue: pack?.name ?? "")
|
_name = State(initialValue: pack?.name ?? "")
|
||||||
_prompt = State(initialValue: pack?.prompt ?? PolishStylePackCatalog.newUserPromptTemplate)
|
_prompt = State(initialValue: pack?.prompt ?? PolishStylePackCatalog.newUserPromptTemplate)
|
||||||
|
_allowsAddedEmoji = State(initialValue: pack?.allowsAddedEmoji ?? false)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -366,6 +369,13 @@ private struct MacPolishStyleEditor: View {
|
|||||||
.font(TypeStyle.title2)
|
.font(TypeStyle.title2)
|
||||||
TextField(MacL10n.string("mac.styles.name", language: language), text: $name)
|
TextField(MacL10n.string("mac.styles.name", language: language), text: $name)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
|
Toggle(
|
||||||
|
MacL10n.string("mac.styles.allowsAddedEmoji", language: language),
|
||||||
|
isOn: $allowsAddedEmoji
|
||||||
|
)
|
||||||
|
Text(MacL10n.string("mac.styles.allowsAddedEmoji.hint", language: language))
|
||||||
|
.font(TypeStyle.caption2)
|
||||||
|
.foregroundStyle(palette.textTertiary)
|
||||||
HStack {
|
HStack {
|
||||||
Text(MacL10n.string("mac.styles.prompt", language: language))
|
Text(MacL10n.string("mac.styles.prompt", language: language))
|
||||||
.font(MacSettingsType.sectionTitle)
|
.font(MacSettingsType.sectionTitle)
|
||||||
@@ -380,13 +390,19 @@ private struct MacPolishStyleEditor: View {
|
|||||||
}
|
}
|
||||||
TextEditor(text: $prompt)
|
TextEditor(text: $prompt)
|
||||||
.font(.body.monospaced())
|
.font(.body.monospaced())
|
||||||
.frame(minHeight: 360)
|
.frame(minHeight: 320)
|
||||||
.padding(4)
|
.padding(4)
|
||||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium))
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: Radius.medium)
|
RoundedRectangle(cornerRadius: Radius.medium)
|
||||||
.stroke(palette.divider, lineWidth: 1)
|
.stroke(palette.divider, lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
.onChange(of: prompt) { _, newValue in
|
||||||
|
if !allowsAddedEmoji,
|
||||||
|
PolishStylePack.promptDeclaresAddedEmojiOptIn(newValue) {
|
||||||
|
allowsAddedEmoji = true
|
||||||
|
}
|
||||||
|
}
|
||||||
Text(MacL10n.string("mac.styles.hint", language: language))
|
Text(MacL10n.string("mac.styles.hint", language: language))
|
||||||
.font(TypeStyle.caption2)
|
.font(TypeStyle.caption2)
|
||||||
.foregroundStyle(palette.textTertiary)
|
.foregroundStyle(palette.textTertiary)
|
||||||
@@ -399,6 +415,8 @@ private struct MacPolishStyleEditor: View {
|
|||||||
id: pack?.id ?? "user.\(UUID().uuidString.lowercased())",
|
id: pack?.id ?? "user.\(UUID().uuidString.lowercased())",
|
||||||
name: name,
|
name: name,
|
||||||
prompt: prompt,
|
prompt: prompt,
|
||||||
|
allowsAddedEmoji: allowsAddedEmoji
|
||||||
|
|| PolishStylePack.promptDeclaresAddedEmojiOptIn(prompt),
|
||||||
kind: .user,
|
kind: .user,
|
||||||
createdAt: pack?.createdAt ?? Date()
|
createdAt: pack?.createdAt ?? Date()
|
||||||
)
|
)
|
||||||
@@ -414,7 +432,7 @@ private struct MacPolishStyleEditor: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(Spacing.xl)
|
.padding(Spacing.xl)
|
||||||
.frame(width: 680, height: 590)
|
.frame(width: 680, height: 640)
|
||||||
.background(palette.background)
|
.background(palette.background)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,34 +188,52 @@ struct MacSettingsView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var volcengineAsrRows: some View {
|
private var volcengineAsrRows: some View {
|
||||||
MacCredentialField(
|
MacProviderThinkingRow(
|
||||||
title: MacL10n.string("mac.settings.volcengineAppId", language: lang),
|
title: MacL10n.string("mac.settings.volcengineApiKeyMode", language: lang),
|
||||||
placeholder: "APP ID",
|
subtitle: MacL10n.string("mac.settings.volcengineApiKeyModeSubtitle", language: lang),
|
||||||
text: Binding(
|
isOn: Binding(
|
||||||
get: { macVolcengineFields.appID },
|
get: { macVolcengineFields.usesAPIKeyAuth },
|
||||||
set: { updateMacVolcengine(appID: $0) }
|
set: { updateMacVolcengine(authMode: $0 ? .apiKey : .appToken) }
|
||||||
),
|
)
|
||||||
isSecret: true
|
|
||||||
)
|
)
|
||||||
MacCredentialField(
|
if macVolcengineFields.usesAPIKeyAuth {
|
||||||
title: MacL10n.string("mac.settings.volcengineAccessToken", language: lang),
|
MacCredentialField(
|
||||||
placeholder: "Access Token",
|
title: MacL10n.string("mac.settings.volcengineApiKey", language: lang),
|
||||||
text: Binding(
|
placeholder: "API Key",
|
||||||
get: { macVolcengineFields.accessToken },
|
text: Binding(
|
||||||
set: { updateMacVolcengine(accessToken: $0) }
|
get: { macVolcengineFields.apiKeyCredential },
|
||||||
),
|
set: { updateMacVolcengine(apiKeyCredential: $0) }
|
||||||
isSecret: true
|
),
|
||||||
|
isSecret: true
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
MacCredentialField(
|
||||||
|
title: MacL10n.string("mac.settings.volcengineAppId", language: lang),
|
||||||
|
placeholder: "APP ID",
|
||||||
|
text: Binding(
|
||||||
|
get: { macVolcengineFields.appID },
|
||||||
|
set: { updateMacVolcengine(appID: $0) }
|
||||||
|
),
|
||||||
|
isSecret: true
|
||||||
|
)
|
||||||
|
MacCredentialField(
|
||||||
|
title: MacL10n.string("mac.settings.volcengineAccessToken", language: lang),
|
||||||
|
placeholder: "Access Token",
|
||||||
|
text: Binding(
|
||||||
|
get: { macVolcengineFields.accessToken },
|
||||||
|
set: { updateMacVolcengine(accessToken: $0) }
|
||||||
|
),
|
||||||
|
isSecret: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MacProviderNoteRow(
|
||||||
|
text: MacL10n.string(
|
||||||
|
macVolcengineFields.usesAPIKeyAuth
|
||||||
|
? "mac.settings.volcengineNoteApiKey"
|
||||||
|
: "mac.settings.volcengineNoteAppToken",
|
||||||
|
language: lang
|
||||||
|
)
|
||||||
)
|
)
|
||||||
MacCredentialField(
|
|
||||||
title: MacL10n.string("mac.settings.volcengineResourceId", language: lang),
|
|
||||||
placeholder: CloudASRModelCatalog.defaultModel(for: "volcengine"),
|
|
||||||
text: Binding(
|
|
||||||
get: { macVolcengineFields.resourceID },
|
|
||||||
set: { updateMacVolcengine(resourceID: $0) }
|
|
||||||
),
|
|
||||||
defaultValue: CloudASRModelCatalog.defaultModel(for: "volcengine")
|
|
||||||
)
|
|
||||||
MacProviderNoteRow(text: MacL10n.string("mac.settings.volcengineNote", language: lang))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -407,22 +425,21 @@ struct MacSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var macVolcengineFields: VolcengineASRFields {
|
private var macVolcengineFields: VolcengineASRFields {
|
||||||
VolcengineASRFields.parse(
|
VolcengineASRFields.parse(apiKey: viewModel.config.asrApiKey)
|
||||||
apiKey: viewModel.config.asrApiKey,
|
|
||||||
resourceFallback: viewModel.config.asrModel.isEmpty
|
|
||||||
? CloudASRModelCatalog.defaultModel(for: "volcengine")
|
|
||||||
: viewModel.config.asrModel
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateMacVolcengine(appID: String? = nil, accessToken: String? = nil, resourceID: String? = nil) {
|
private func updateMacVolcengine(
|
||||||
|
authMode: VolcengineASRAuthMode? = nil,
|
||||||
|
appID: String? = nil,
|
||||||
|
accessToken: String? = nil,
|
||||||
|
apiKeyCredential: String? = nil
|
||||||
|
) {
|
||||||
var fields = macVolcengineFields
|
var fields = macVolcengineFields
|
||||||
|
if let authMode { fields.authMode = authMode }
|
||||||
if let appID { fields.appID = appID }
|
if let appID { fields.appID = appID }
|
||||||
if let accessToken { fields.accessToken = accessToken }
|
if let accessToken { fields.accessToken = accessToken }
|
||||||
if let resourceID {
|
if let apiKeyCredential { fields.apiKeyCredential = apiKeyCredential }
|
||||||
fields.resourceID = resourceID
|
viewModel.config.asrModel = VolcengineASRFields.fixedResourceID
|
||||||
viewModel.config.asrModel = resourceID
|
|
||||||
}
|
|
||||||
viewModel.config.asrApiKey = fields.encodedAPIKey
|
viewModel.config.asrApiKey = fields.encodedAPIKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,13 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
|||||||
|
|
||||||
public var isCloudASRKeyMissing: Bool {
|
public var isCloudASRKeyMissing: Bool {
|
||||||
guard engineMode == "cloud" else { return false }
|
guard engineMode == "cloud" else { return false }
|
||||||
return asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
let key = asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !key.isEmpty else { return true }
|
||||||
|
// Volcengine may store auth_mode JSON before credentials are filled.
|
||||||
|
if asrProviderId == "volcengine" {
|
||||||
|
return !VolcengineASRFields.parse(apiKey: key).hasUsableCredentials
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
public var isPolishKeyMissing: Bool {
|
public var isPolishKeyMissing: Bool {
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
|||||||
defaultBaseURL: "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async",
|
defaultBaseURL: "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async",
|
||||||
defaultModel: "volc.seedasr.sauc.duration",
|
defaultModel: "volc.seedasr.sauc.duration",
|
||||||
apiKeyURL: URL(string: "https://console.volcengine.com/speech"),
|
apiKeyURL: URL(string: "https://console.volcengine.com/speech"),
|
||||||
blurb: "流式大模型 ASR · API Key 填 appId:accessToken[:resourceId]",
|
blurb: "流式大模型 ASR · 旧版 AppID+Token / 新版 API Key",
|
||||||
isUserSelectable: false
|
isUserSelectable: false
|
||||||
),
|
),
|
||||||
.init(
|
.init(
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
|
|||||||
public let id: String
|
public let id: String
|
||||||
public var name: String
|
public var name: String
|
||||||
public var prompt: String
|
public var prompt: String
|
||||||
|
/// When true, polish may keep model-added emoji and the prompt overrides R5.
|
||||||
|
/// Defaults off so existing / builtin styles stay emoji-strict.
|
||||||
|
public var allowsAddedEmoji: Bool
|
||||||
public let kind: Kind
|
public let kind: Kind
|
||||||
public let createdAt: Date
|
public let createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
@@ -23,6 +26,7 @@ public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
|
|||||||
id: String = "user.\(UUID().uuidString.lowercased())",
|
id: String = "user.\(UUID().uuidString.lowercased())",
|
||||||
name: String,
|
name: String,
|
||||||
prompt: String,
|
prompt: String,
|
||||||
|
allowsAddedEmoji: Bool = false,
|
||||||
kind: Kind = .user,
|
kind: Kind = .user,
|
||||||
createdAt: Date = Date(),
|
createdAt: Date = Date(),
|
||||||
updatedAt: Date? = nil
|
updatedAt: Date? = nil
|
||||||
@@ -30,6 +34,7 @@ public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
|
|||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.prompt = prompt
|
self.prompt = prompt
|
||||||
|
self.allowsAddedEmoji = allowsAddedEmoji
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt ?? createdAt
|
self.updatedAt = updatedAt ?? createdAt
|
||||||
@@ -39,6 +44,46 @@ public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
|
|||||||
guard kind == .builtin else { return name }
|
guard kind == .builtin else { return name }
|
||||||
return SharedL10n.string("polishStyle.\(id.dropFirst("builtin.".count))", language: language)
|
return SharedL10n.string("polishStyle.\(id.dropFirst("builtin.".count))", language: language)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Effective emoji policy for polish: explicit toggle, or a custom prompt that
|
||||||
|
/// clearly opts in (so paste-only custom styles still keep model-added emoji).
|
||||||
|
public var effectiveAllowsAddedEmoji: Bool {
|
||||||
|
if allowsAddedEmoji { return true }
|
||||||
|
guard kind == .user else { return false }
|
||||||
|
return Self.promptDeclaresAddedEmojiOptIn(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heuristic for custom prompts that declare “add mood emoji” themselves.
|
||||||
|
public static func promptDeclaresAddedEmojiOptIn(_ prompt: String) -> Bool {
|
||||||
|
let markers = [
|
||||||
|
"允许新增 emoji",
|
||||||
|
"允许新增emoji",
|
||||||
|
"按情绪点缀",
|
||||||
|
"按原文情绪",
|
||||||
|
"Emoji 覆盖",
|
||||||
|
"outranks global R5",
|
||||||
|
"may add emojis",
|
||||||
|
"allow mood emoji",
|
||||||
|
"allowsAddedEmoji",
|
||||||
|
]
|
||||||
|
return markers.contains { prompt.localizedCaseInsensitiveContains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, prompt, allowsAddedEmoji, kind, createdAt, updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decode(String.self, forKey: .id)
|
||||||
|
name = try container.decode(String.self, forKey: .name)
|
||||||
|
prompt = try container.decode(String.self, forKey: .prompt)
|
||||||
|
// Older synced packs omit the key — stay emoji-strict.
|
||||||
|
allowsAddedEmoji = try container.decodeIfPresent(Bool.self, forKey: .allowsAddedEmoji) ?? false
|
||||||
|
kind = try container.decode(Kind.self, forKey: .kind)
|
||||||
|
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||||
|
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum PolishStyleLimits {
|
public enum PolishStyleLimits {
|
||||||
@@ -92,6 +137,7 @@ public struct PolishStyleCatalog: Codable, Equatable, Sendable {
|
|||||||
var updated = pack
|
var updated = pack
|
||||||
updated.name = name
|
updated.name = name
|
||||||
updated.prompt = prompt
|
updated.prompt = prompt
|
||||||
|
updated.allowsAddedEmoji = pack.allowsAddedEmoji
|
||||||
updated.updatedAt = date
|
updated.updatedAt = date
|
||||||
entries[index] = updated
|
entries[index] = updated
|
||||||
} else {
|
} else {
|
||||||
@@ -101,6 +147,7 @@ public struct PolishStyleCatalog: Codable, Equatable, Sendable {
|
|||||||
var created = pack
|
var created = pack
|
||||||
created.name = name
|
created.name = name
|
||||||
created.prompt = prompt
|
created.prompt = prompt
|
||||||
|
created.allowsAddedEmoji = pack.allowsAddedEmoji
|
||||||
created.updatedAt = date
|
created.updatedAt = date
|
||||||
entries.append(created)
|
entries.append(created)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,7 +313,14 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
|
|
||||||
public var isASRConfigured: Bool {
|
public var isASRConfigured: Bool {
|
||||||
guard !isLocalEngine else { return true }
|
guard !isLocalEngine else { return true }
|
||||||
return !asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
let key = asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let hasKey: Bool = {
|
||||||
|
if asrProviderId == "volcengine" {
|
||||||
|
return VolcengineASRFields.parse(apiKey: key).hasUsableCredentials
|
||||||
|
}
|
||||||
|
return !key.isEmpty
|
||||||
|
}()
|
||||||
|
return hasKey
|
||||||
&& (!asrBaseURL.isEmpty || CloudASRModelCatalog.strategy(for: asrProviderId) != .prompt)
|
&& (!asrBaseURL.isEmpty || CloudASRModelCatalog.strategy(for: asrProviderId) != .prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,65 +2,155 @@
|
|||||||
// OSGKeyboard · Shared
|
// OSGKeyboard · Shared
|
||||||
//
|
//
|
||||||
// Parse / encode Volcengine SAUC credentials stored in the ASR API key field.
|
// Parse / encode Volcengine SAUC credentials stored in the ASR API key field.
|
||||||
|
// Supports legacy AppID+AccessToken (old console) and single API Key (new console).
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Volcengine speech console auth style for SAUC streaming ASR.
|
||||||
|
public enum VolcengineASRAuthMode: String, Sendable, Equatable {
|
||||||
|
/// Old console: `X-Api-App-Key` + `X-Api-Access-Key`.
|
||||||
|
case appToken = "app_token"
|
||||||
|
/// New console: single `X-Api-Key`.
|
||||||
|
case apiKey = "api_key"
|
||||||
|
}
|
||||||
|
|
||||||
public struct VolcengineASRFields: Sendable, Equatable {
|
public struct VolcengineASRFields: Sendable, Equatable {
|
||||||
|
public var authMode: VolcengineASRAuthMode
|
||||||
public var appID: String
|
public var appID: String
|
||||||
public var accessToken: String
|
public var accessToken: String
|
||||||
public var resourceID: String
|
/// New-console API Key (`X-Api-Key`). Kept alongside app-token fields so the
|
||||||
|
/// settings toggle can switch modes without wiping the other credential set.
|
||||||
|
public var apiKeyCredential: String
|
||||||
|
|
||||||
|
/// Locked product: Doubao streaming ASR 2.0 · duration billing.
|
||||||
|
public static let fixedResourceID = CloudASRModelCatalog.volcengineDefaultResourceID
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
|
authMode: VolcengineASRAuthMode = .appToken,
|
||||||
appID: String = "",
|
appID: String = "",
|
||||||
accessToken: String = "",
|
accessToken: String = "",
|
||||||
resourceID: String = CloudASRModelCatalog.defaultModel(for: "volcengine")
|
apiKeyCredential: String = ""
|
||||||
) {
|
) {
|
||||||
|
self.authMode = authMode
|
||||||
self.appID = appID
|
self.appID = appID
|
||||||
self.accessToken = accessToken
|
self.accessToken = accessToken
|
||||||
self.resourceID = resourceID
|
self.apiKeyCredential = apiKeyCredential
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Always `volc.seedasr.sauc.duration` — not user-editable.
|
||||||
|
public var resourceID: String { Self.fixedResourceID }
|
||||||
|
|
||||||
|
public var usesAPIKeyAuth: Bool { authMode == .apiKey }
|
||||||
|
|
||||||
|
public var hasUsableCredentials: Bool {
|
||||||
|
switch authMode {
|
||||||
|
case .appToken:
|
||||||
|
return !appID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
&& !accessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
case .apiKey:
|
||||||
|
return !apiKeyCredential.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var encodedAPIKey: String {
|
public var encodedAPIKey: String {
|
||||||
let object = [
|
var object: [String: String] = [
|
||||||
"app_id": appID,
|
"auth_mode": authMode.rawValue,
|
||||||
"access_token": accessToken,
|
"resource_id": Self.fixedResourceID,
|
||||||
"resource_id": resourceID,
|
|
||||||
]
|
]
|
||||||
|
// Persist both credential sets so toggling auth mode is non-destructive.
|
||||||
|
if !appID.isEmpty { object["app_id"] = appID }
|
||||||
|
if !accessToken.isEmpty { object["access_token"] = accessToken }
|
||||||
|
if !apiKeyCredential.isEmpty { object["api_key"] = apiKeyCredential }
|
||||||
|
|
||||||
guard let data = try? JSONSerialization.data(withJSONObject: object),
|
guard let data = try? JSONSerialization.data(withJSONObject: object),
|
||||||
let string = String(data: data, encoding: .utf8) else {
|
let string = String(data: data, encoding: .utf8) else {
|
||||||
return [appID, accessToken, resourceID].joined(separator: ":")
|
switch authMode {
|
||||||
|
case .appToken:
|
||||||
|
return [appID, accessToken, Self.fixedResourceID].joined(separator: ":")
|
||||||
|
case .apiKey:
|
||||||
|
return apiKeyCredential
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return string
|
return string
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func parse(apiKey: String, resourceFallback: String) -> VolcengineASRFields {
|
/// Apply SAUC WebSocket handshake headers for the active auth mode.
|
||||||
|
public func applyWebSocketAuthHeaders(to request: inout URLRequest, connectID: String) {
|
||||||
|
request.setValue(Self.fixedResourceID, forHTTPHeaderField: "X-Api-Resource-Id")
|
||||||
|
request.setValue(connectID, forHTTPHeaderField: "X-Api-Connect-Id")
|
||||||
|
switch authMode {
|
||||||
|
case .apiKey:
|
||||||
|
request.setValue(
|
||||||
|
apiKeyCredential.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
forHTTPHeaderField: "X-Api-Key"
|
||||||
|
)
|
||||||
|
case .appToken:
|
||||||
|
request.setValue(
|
||||||
|
appID.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
forHTTPHeaderField: "X-Api-App-Key"
|
||||||
|
)
|
||||||
|
request.setValue(
|
||||||
|
accessToken.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
forHTTPHeaderField: "X-Api-Access-Key"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// - Parameter resourceFallback: Ignored; resource is always `fixedResourceID`.
|
||||||
|
/// Kept so call sites stay source-compatible.
|
||||||
|
public static func parse(apiKey: String, resourceFallback: String = "") -> VolcengineASRFields {
|
||||||
|
_ = resourceFallback
|
||||||
let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
var fields = VolcengineASRFields(
|
var fields = VolcengineASRFields()
|
||||||
appID: "",
|
|
||||||
accessToken: "",
|
guard !trimmed.isEmpty else { return fields }
|
||||||
resourceID: resourceFallback.isEmpty
|
|
||||||
? CloudASRModelCatalog.defaultModel(for: "volcengine")
|
|
||||||
: resourceFallback
|
|
||||||
)
|
|
||||||
|
|
||||||
if let data = trimmed.data(using: .utf8),
|
if let data = trimmed.data(using: .utf8),
|
||||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||||
fields.appID = string(json, keys: ["app_id", "appId", "appid"]) ?? ""
|
fields.appID = string(json, keys: ["app_id", "appId", "appid"]) ?? ""
|
||||||
fields.accessToken = string(json, keys: ["access_token", "accessToken", "token"]) ?? ""
|
fields.accessToken = string(json, keys: ["access_token", "accessToken", "token"]) ?? ""
|
||||||
fields.resourceID = string(json, keys: ["resource_id", "resourceId", "resource"]) ?? fields.resourceID
|
fields.apiKeyCredential = string(json, keys: ["api_key", "apiKey"]) ?? ""
|
||||||
|
fields.authMode = resolveAuthMode(
|
||||||
|
raw: string(json, keys: ["auth_mode", "authMode"]),
|
||||||
|
hasAPIKey: !fields.apiKeyCredential.isEmpty,
|
||||||
|
hasAppToken: !fields.appID.isEmpty && !fields.accessToken.isEmpty
|
||||||
|
)
|
||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy colon form is always old-console app-token auth.
|
||||||
let parts = trimmed
|
let parts = trimmed
|
||||||
.components(separatedBy: CharacterSet(charactersIn: ":\n,"))
|
.components(separatedBy: CharacterSet(charactersIn: ":\n,"))
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
if parts.indices.contains(0) { fields.appID = parts[0] }
|
if parts.indices.contains(0) { fields.appID = parts[0] }
|
||||||
if parts.indices.contains(1) { fields.accessToken = parts[1] }
|
if parts.indices.contains(1) { fields.accessToken = parts[1] }
|
||||||
if parts.indices.contains(2) { fields.resourceID = parts[2] }
|
fields.authMode = .appToken
|
||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func resolveAuthMode(
|
||||||
|
raw: String?,
|
||||||
|
hasAPIKey: Bool,
|
||||||
|
hasAppToken: Bool
|
||||||
|
) -> VolcengineASRAuthMode {
|
||||||
|
if let raw {
|
||||||
|
let normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if normalized == VolcengineASRAuthMode.apiKey.rawValue || normalized == "apikey" {
|
||||||
|
return .apiKey
|
||||||
|
}
|
||||||
|
if normalized == VolcengineASRAuthMode.appToken.rawValue
|
||||||
|
|| normalized == "apptoken"
|
||||||
|
|| normalized == "app_id_token" {
|
||||||
|
return .appToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Legacy JSON without auth_mode: prefer app-token when present.
|
||||||
|
if hasAppToken { return .appToken }
|
||||||
|
if hasAPIKey { return .apiKey }
|
||||||
|
return .appToken
|
||||||
|
}
|
||||||
|
|
||||||
private static func string(_ json: [String: Any], keys: [String]) -> String? {
|
private static func string(_ json: [String: Any], keys: [String]) -> String? {
|
||||||
for key in keys {
|
for key in keys {
|
||||||
if let value = json[key] as? String {
|
if let value = json[key] as? String {
|
||||||
|
|||||||
@@ -14,8 +14,39 @@ public enum FlowKeyboardHostWarming {
|
|||||||
reason == .recording || reason == .processing || reason == .awaitingDelivery
|
reason == .recording || reason == .processing || reason == .awaitingDelivery
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep the mic green after the session has already proven ready.
|
||||||
|
///
|
||||||
|
/// Inter-utterance PiP flaps (mic release, ack lag, brief `reason=.starting`)
|
||||||
|
/// used to flash yellow「正在启动画中画」even though Picture in Picture was
|
||||||
|
/// already running. Hold ready through those windows; real cold starts still
|
||||||
|
/// go through `isHostWarming` while `sessionProvenReady` is false.
|
||||||
|
public static func shouldHoldReady(
|
||||||
|
hostReady: Bool,
|
||||||
|
hostBusy: Bool,
|
||||||
|
sessionActive: Bool,
|
||||||
|
sessionProvenReady: Bool,
|
||||||
|
isPendingFlowStart: Bool,
|
||||||
|
snapshotReason: FlowReadySnapshot.Reason?
|
||||||
|
) -> Bool {
|
||||||
|
guard !hostReady,
|
||||||
|
sessionProvenReady,
|
||||||
|
sessionActive,
|
||||||
|
!isPendingFlowStart else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// After insert the host may still publish awaitingDelivery until it
|
||||||
|
// consumes the ack — that is not a PiP restart.
|
||||||
|
if hostBusy {
|
||||||
|
return snapshotReason == .awaitingDelivery
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/// Session lives but ready contract is not fresh — keep mic orange (wait)
|
/// Session lives but ready contract is not fresh — keep mic orange (wait)
|
||||||
/// instead of launching another cold start.
|
/// instead of launching another cold start.
|
||||||
|
///
|
||||||
|
/// `withinReadyGrace` is intentionally unused for warming: a recent ready
|
||||||
|
/// must hold green via `shouldHoldReady`, not flash preparingSession.
|
||||||
public static func isHostWarming(
|
public static func isHostWarming(
|
||||||
hostReady: Bool,
|
hostReady: Bool,
|
||||||
hostBusy: Bool,
|
hostBusy: Bool,
|
||||||
@@ -25,13 +56,13 @@ public enum FlowKeyboardHostWarming {
|
|||||||
withinReadyGrace: Bool,
|
withinReadyGrace: Bool,
|
||||||
snapshotReason: FlowReadySnapshot.Reason?
|
snapshotReason: FlowReadySnapshot.Reason?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
!hostReady
|
_ = withinReadyGrace
|
||||||
|
return !hostReady
|
||||||
&& !hostBusy
|
&& !hostBusy
|
||||||
&& sessionActive
|
&& sessionActive
|
||||||
&& (
|
&& (
|
||||||
hostReachable
|
hostReachable
|
||||||
|| isPendingFlowStart
|
|| isPendingFlowStart
|
||||||
|| withinReadyGrace
|
|
||||||
|| snapshotReason == .starting
|
|| snapshotReason == .starting
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -671,6 +671,8 @@ public enum FlowSessionBridge {
|
|||||||
store.removeObject(forKey: FlowSessionKeys.audioLevels)
|
store.removeObject(forKey: FlowSessionKeys.audioLevels)
|
||||||
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
|
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
|
||||||
clearHostReady(defaults: store, notify: false)
|
clearHostReady(defaults: store, notify: false)
|
||||||
|
// Previous generation may have died mid Rime/CLM/ASR with hostHeavy=1.
|
||||||
|
clearHostHeavy(defaults: store)
|
||||||
flush(store)
|
flush(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,13 +703,47 @@ public enum FlowSessionBridge {
|
|||||||
/// avoid stacking typing-engine RSS on top.
|
/// avoid stacking typing-engine RSS on top.
|
||||||
public static func setHostHeavy(_ heavy: Bool, defaults: UserDefaults? = nil) {
|
public static func setHostHeavy(_ heavy: Bool, defaults: UserDefaults? = nil) {
|
||||||
let store = resolvedDefaults(defaults)
|
let store = resolvedDefaults(defaults)
|
||||||
store.set(heavy, forKey: FlowSessionKeys.hostHeavy)
|
if heavy {
|
||||||
|
store.set(true, forKey: FlowSessionKeys.hostHeavy)
|
||||||
|
store.set(Date().timeIntervalSince1970, forKey: FlowSessionKeys.hostHeavyAt)
|
||||||
|
} else {
|
||||||
|
clearHostHeavy(defaults: store)
|
||||||
|
}
|
||||||
flush(store)
|
flush(store)
|
||||||
OSGDiag.log("hostHeavy=\(heavy ? 1 : 0) \(OSGDiag.memoryTag())", category: "flow")
|
OSGDiag.log("hostHeavy=\(heavy ? 1 : 0) \(OSGDiag.memoryTag())", category: "flow")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True only while the host recently marked itself busy. A sticky `true`
|
||||||
|
/// left by a dead host (no `setHostHeavy(false)`) expires after
|
||||||
|
/// `hostHeavyMaxAge` so typing 中文/EN is not silently blocked forever.
|
||||||
public static func isHostHeavy(defaults: UserDefaults? = nil) -> Bool {
|
public static func isHostHeavy(defaults: UserDefaults? = nil) -> Bool {
|
||||||
resolvedDefaults(defaults).bool(forKey: FlowSessionKeys.hostHeavy)
|
let store = resolvedDefaults(defaults)
|
||||||
|
guard store.bool(forKey: FlowSessionKeys.hostHeavy) else { return false }
|
||||||
|
let markedAt = store.double(forKey: FlowSessionKeys.hostHeavyAt)
|
||||||
|
// Legacy writes had the bool but no timestamp — treat as stale so a
|
||||||
|
// pre-fix sticky flag cannot brick typing after upgrade.
|
||||||
|
guard markedAt > 0 else {
|
||||||
|
clearHostHeavy(defaults: store)
|
||||||
|
flush(store)
|
||||||
|
OSGDiag.log("hostHeavy stale missingAt — cleared \(OSGDiag.memoryTag())", category: "flow")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let age = Date().timeIntervalSince1970 - markedAt
|
||||||
|
guard age >= 0, age <= FlowSessionKeys.hostHeavyMaxAge else {
|
||||||
|
clearHostHeavy(defaults: store)
|
||||||
|
flush(store)
|
||||||
|
OSGDiag.log(
|
||||||
|
"hostHeavy stale age=\(Int(age))s — cleared \(OSGDiag.memoryTag())",
|
||||||
|
category: "flow"
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func clearHostHeavy(defaults: UserDefaults) {
|
||||||
|
defaults.set(false, forKey: FlowSessionKeys.hostHeavy)
|
||||||
|
defaults.removeObject(forKey: FlowSessionKeys.hostHeavyAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when the host has published a fresh ready contract (stricter than heartbeat alone).
|
/// True when the host has published a fresh ready contract (stricter than heartbeat alone).
|
||||||
@@ -948,6 +984,7 @@ public enum FlowSessionBridge {
|
|||||||
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
|
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
|
||||||
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
|
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
|
||||||
clearHostReady(defaults: store, notify: false)
|
clearHostReady(defaults: store, notify: false)
|
||||||
|
clearHostHeavy(defaults: store)
|
||||||
flush(store)
|
flush(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ public enum FlowSessionKeys {
|
|||||||
/// Host is mid heavy work (Rime/CLM/ASR). Extension should stay on voice
|
/// Host is mid heavy work (Rime/CLM/ASR). Extension should stay on voice
|
||||||
/// and skip typing engine prepare until this clears.
|
/// and skip typing engine prepare until this clears.
|
||||||
public static let hostHeavy = "flow.hostHeavy.v1"
|
public static let hostHeavy = "flow.hostHeavy.v1"
|
||||||
|
/// Wall-clock timestamp paired with `hostHeavy` (seconds since 1970).
|
||||||
|
/// Lets the keyboard ignore a sticky flag left behind when the host died
|
||||||
|
/// mid-warmup without ever clearing App Group state.
|
||||||
|
public static let hostHeavyAt = "flow.hostHeavyAt.v1"
|
||||||
|
|
||||||
|
/// `hostHeavy` older than this is treated as stale (host likely jetsammed
|
||||||
|
/// or force-quit before `setHostHeavy(false)`). Rime/CLM/ASR bursts are
|
||||||
|
/// expected well under this window.
|
||||||
|
public static let hostHeavyMaxAge: TimeInterval = 120
|
||||||
|
|
||||||
/// Heartbeat older than this → host is not actively reachable for recording.
|
/// Heartbeat older than this → host is not actively reachable for recording.
|
||||||
public static let heartbeatStaleInterval: TimeInterval = 3
|
public static let heartbeatStaleInterval: TimeInterval = 3
|
||||||
|
|||||||
@@ -211,6 +211,10 @@ public enum PolishPromptComposer {
|
|||||||
dictionaryBlock,
|
dictionaryBlock,
|
||||||
useChineseGuidance: useChineseGuidance
|
useChineseGuidance: useChineseGuidance
|
||||||
)
|
)
|
||||||
|
let emojiOverride = emojiOverrideSection(
|
||||||
|
allowsAddedEmoji: style.effectiveAllowsAddedEmoji,
|
||||||
|
useChineseGuidance: useChineseGuidance
|
||||||
|
)
|
||||||
if usesHeavyFunPipeline {
|
if usesHeavyFunPipeline {
|
||||||
let formatting = useChineseGuidance
|
let formatting = useChineseGuidance
|
||||||
? chineseFunFormattingPrompt
|
? chineseFunFormattingPrompt
|
||||||
@@ -226,6 +230,7 @@ public enum PolishPromptComposer {
|
|||||||
\(personality)
|
\(personality)
|
||||||
|
|
||||||
\(outputInstruction)
|
\(outputInstruction)
|
||||||
|
\(emojiOverride)
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +267,7 @@ public enum PolishPromptComposer {
|
|||||||
fieldHints: context.fieldHints,
|
fieldHints: context.fieldHints,
|
||||||
useChineseGuidance: true
|
useChineseGuidance: true
|
||||||
))用户消息即为待处理的转写文本。只输出处理后的文本。
|
))用户消息即为待处理的转写文本。只输出处理后的文本。
|
||||||
|
\(emojiOverride)
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +290,7 @@ public enum PolishPromptComposer {
|
|||||||
fieldHints: context.fieldHints,
|
fieldHints: context.fieldHints,
|
||||||
useChineseGuidance: false
|
useChineseGuidance: false
|
||||||
))The user message is the transcript to process. Output the processed text only.
|
))The user message is the transcript to process. Output the processed text only.
|
||||||
|
\(emojiOverride)
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +353,8 @@ public enum PolishPromptComposer {
|
|||||||
useChineseGuidance: Bool
|
useChineseGuidance: Bool
|
||||||
) -> String {
|
) -> String {
|
||||||
let body = PolishStylePackCatalog.runtimePersonality(for: style)
|
let body = PolishStylePackCatalog.runtimePersonality(for: style)
|
||||||
|
// Emoji override is appended at the end of compose(), not here —
|
||||||
|
// it must outrank core R5 and later context premises.
|
||||||
if style.kind == .user {
|
if style.kind == .user {
|
||||||
return useChineseGuidance
|
return useChineseGuidance
|
||||||
? """
|
? """
|
||||||
@@ -370,6 +379,30 @@ public enum PolishPromptComposer {
|
|||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When a style opts in, override global R5 so the model may add emoji.
|
||||||
|
/// Placed last in the composed prompt so it wins over core R5.
|
||||||
|
private static func emojiOverrideSection(
|
||||||
|
allowsAddedEmoji: Bool,
|
||||||
|
useChineseGuidance: Bool
|
||||||
|
) -> String {
|
||||||
|
guard allowsAddedEmoji else { return "" }
|
||||||
|
return useChineseGuidance
|
||||||
|
? """
|
||||||
|
|
||||||
|
# Emoji 覆盖(本风格开启 · 最终优先级)
|
||||||
|
本风格允许新增 emoji,优先级高于全局 R5「不新增 emoji」以及上文任何「不要加 emoji」表述。
|
||||||
|
仅按原文已表达的情绪点缀 0–2 个贴合语气的 emoji;中性安排、正式通知与极短确认词不加。
|
||||||
|
原文已有 emoji 时只整理文字,不替换、不堆叠。禁止无关装饰与 emoji 墙。
|
||||||
|
"""
|
||||||
|
: """
|
||||||
|
|
||||||
|
# Emoji override (enabled for this style · final priority)
|
||||||
|
This style may add emojis and outranks global R5 (“add no emojis”) and any earlier “do not add emojis” guidance.
|
||||||
|
Add 0–2 tone-matching emojis only when the draft already expresses emotion; skip neutral schedules, formal notices, and ultra-short acks.
|
||||||
|
If the draft already has emojis, keep them and do not replace or stack. No decorative spam or emoji walls.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
/// Neutralize envelope-breaking tags inside user-controlled transcript text.
|
/// Neutralize envelope-breaking tags inside user-controlled transcript text.
|
||||||
internal static func sanitizeEnvelopeContent(_ text: String) -> String {
|
internal static func sanitizeEnvelopeContent(_ text: String) -> String {
|
||||||
let maxCharacters = 16_000
|
let maxCharacters = 16_000
|
||||||
|
|||||||
@@ -285,7 +285,15 @@ public actor PolishingService {
|
|||||||
|
|
||||||
// One prompt, one model request. Deterministic validation may reject a
|
// One prompt, one model request. Deterministic validation may reject a
|
||||||
// result locally, but it never starts a second polish request.
|
// result locally, but it never starts a second polish request.
|
||||||
let firstCandidate = TranscriptPostProcessor.process(original: trimmed, llmOutput: first)
|
let activeStyle = PolishStylePackCatalog.resolve(
|
||||||
|
id: store.activePolishStyleId,
|
||||||
|
userCatalog: store.polishStyleCatalog
|
||||||
|
)
|
||||||
|
let firstCandidate = TranscriptPostProcessor.process(
|
||||||
|
original: trimmed,
|
||||||
|
llmOutput: first,
|
||||||
|
allowsAddedEmoji: activeStyle.effectiveAllowsAddedEmoji
|
||||||
|
)
|
||||||
let firstViolations = PolishOutputValidator.validate(
|
let firstViolations = PolishOutputValidator.validate(
|
||||||
input: trimmed,
|
input: trimmed,
|
||||||
output: firstCandidate,
|
output: firstCandidate,
|
||||||
|
|||||||
@@ -169,9 +169,17 @@ public enum TranscriptPostProcessor: Sendable {
|
|||||||
// MARK: - Post-LLM pipeline
|
// MARK: - Post-LLM pipeline
|
||||||
|
|
||||||
/// Apply deterministic cleanup and quality gate to LLM output.
|
/// Apply deterministic cleanup and quality gate to LLM output.
|
||||||
public static func process(original: String, llmOutput: String) -> String {
|
public static func process(
|
||||||
|
original: String,
|
||||||
|
llmOutput: String,
|
||||||
|
allowsAddedEmoji: Bool = false
|
||||||
|
) -> String {
|
||||||
let trimmedOriginal = original.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedOriginal = original.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let decision = qualityGate(original: trimmedOriginal, candidate: llmOutput)
|
let decision = qualityGate(
|
||||||
|
original: trimmedOriginal,
|
||||||
|
candidate: llmOutput,
|
||||||
|
allowsAddedEmoji: allowsAddedEmoji
|
||||||
|
)
|
||||||
switch decision {
|
switch decision {
|
||||||
case .accept(let text):
|
case .accept(let text):
|
||||||
return text
|
return text
|
||||||
@@ -191,7 +199,11 @@ public enum TranscriptPostProcessor: Sendable {
|
|||||||
/// back when the model returned genuinely unusable output (empty, or
|
/// back when the model returned genuinely unusable output (empty, or
|
||||||
/// pure explanation), and even then we prefer a cleaned candidate
|
/// pure explanation), and even then we prefer a cleaned candidate
|
||||||
/// over the raw transcript.
|
/// over the raw transcript.
|
||||||
public static func qualityGate(original: String, candidate: String) -> GateDecision {
|
public static func qualityGate(
|
||||||
|
original: String,
|
||||||
|
candidate: String,
|
||||||
|
allowsAddedEmoji: Bool = false
|
||||||
|
) -> GateDecision {
|
||||||
var text = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
|
var text = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
if text.isEmpty {
|
if text.isEmpty {
|
||||||
@@ -201,7 +213,9 @@ public enum TranscriptPostProcessor: Sendable {
|
|||||||
text = stripExplanatoryPrefix(from: text)
|
text = stripExplanatoryPrefix(from: text)
|
||||||
text = stripPauseMarkers(from: text)
|
text = stripPauseMarkers(from: text)
|
||||||
text = unwrapSurroundingQuotes(text)
|
text = unwrapSurroundingQuotes(text)
|
||||||
text = stripAddedEmojis(original: original, output: text)
|
if !allowsAddedEmoji {
|
||||||
|
text = stripAddedEmojis(original: original, output: text)
|
||||||
|
}
|
||||||
text = repairMidSentenceLineBreaks(text)
|
text = repairMidSentenceLineBreaks(text)
|
||||||
text = normalizeWhitespaceAndPunctuation(text)
|
text = normalizeWhitespaceAndPunctuation(text)
|
||||||
text = normalizeNumberedLists(text)
|
text = normalizeNumberedLists(text)
|
||||||
|
|||||||
@@ -42,20 +42,22 @@ public enum TypingAutocapitalization: Sendable {
|
|||||||
private static func needsSentenceCapitalization(_ preceding: String?) -> Bool {
|
private static func needsSentenceCapitalization(_ preceding: String?) -> Bool {
|
||||||
guard let preceding, !preceding.isEmpty else { return true }
|
guard let preceding, !preceding.isEmpty else { return true }
|
||||||
|
|
||||||
// Walk backward past trailing whitespace / newlines; capitalize when
|
// Walk backward from the caret. Trailing spaces are ignored; a newline
|
||||||
// the field is empty or the previous visible character ends a sentence.
|
// itself starts a new line (system .sentences behavior, e.g. Notes
|
||||||
|
// after Return). Otherwise capitalize only after a sentence terminator.
|
||||||
var index = preceding.endIndex
|
var index = preceding.endIndex
|
||||||
var sawContent = false
|
|
||||||
while index > preceding.startIndex {
|
while index > preceding.startIndex {
|
||||||
index = preceding.index(before: index)
|
index = preceding.index(before: index)
|
||||||
let character = preceding[index]
|
let character = preceding[index]
|
||||||
if character.isWhitespace || character.isNewline {
|
if character.isNewline {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if character.isWhitespace {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sawContent = true
|
|
||||||
return isSentenceTerminator(character)
|
return isSentenceTerminator(character)
|
||||||
}
|
}
|
||||||
return !sawContent
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func isSentenceTerminator(_ character: Character) -> Bool {
|
private static func isSentenceTerminator(_ character: Character) -> Bool {
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
private var shiftPrimedByUser = false
|
private var shiftPrimedByUser = false
|
||||||
/// True if any key was typed while the current Shift hold was active.
|
/// True if any key was typed while the current Shift hold was active.
|
||||||
private var typedWhileShiftHeld = false
|
private var typedWhileShiftHeld = false
|
||||||
|
/// Local caret-prefix mirror so autocap survives stale `documentContextBeforeInput`
|
||||||
|
/// (common in Notes). Capped; reseeds from the proxy when it looks fresh.
|
||||||
|
private var precedingShadow = ""
|
||||||
|
private static let precedingShadowLimit = 400
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
engine: (@MainActor () -> RimeEngineBridging)? = nil,
|
engine: (@MainActor () -> RimeEngineBridging)? = nil,
|
||||||
@@ -287,6 +291,14 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
return handleEnglishCharacter(ch)
|
return handleEnglishCharacter(ch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chinese + Shift: insert Latin directly (iOS-style mix-in), leave Rime
|
||||||
|
// composition untouched. Rime's alphabet is lowercase-only, so uppercase
|
||||||
|
// keycodes would otherwise be rejected with no output.
|
||||||
|
if isShiftEnabled, ch.isLetter {
|
||||||
|
clearOneShotShiftIfNeeded()
|
||||||
|
return .insert(String(ch))
|
||||||
|
}
|
||||||
|
|
||||||
// Chinese letters → compose
|
// Chinese letters → compose
|
||||||
let committed = engine.processCharacter(ch) ?? ""
|
let committed = engine.processCharacter(ch) ?? ""
|
||||||
composition = engine.composition
|
composition = engine.composition
|
||||||
@@ -490,17 +502,94 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
|
|
||||||
/// Arms Shift for sentence / word starts using the host field traits.
|
/// Arms Shift for sentence / word starts using the host field traits.
|
||||||
/// Manual one-shot, hold, and Caps Lock always win over autocapitalization.
|
/// Manual one-shot, hold, and Caps Lock always win over autocapitalization.
|
||||||
public func syncAutocapitalization() {
|
/// - Parameters:
|
||||||
|
/// - insert: Text just written through the document proxy (may not be
|
||||||
|
/// reflected in `documentContextBeforeInput` yet).
|
||||||
|
/// - deleteCount: Characters just deleted before `insert` (replace path).
|
||||||
|
public func syncAutocapitalization(
|
||||||
|
accountingForInsert insert: String = "",
|
||||||
|
deleteCount: Int = 0
|
||||||
|
) {
|
||||||
guard language == .english, page == .letters else { return }
|
guard language == .english, page == .letters else { return }
|
||||||
guard !capsLock, !shiftHeld, !shiftPrimedByUser else { return }
|
guard !capsLock, !shiftHeld, !shiftPrimedByUser else { return }
|
||||||
let mode = autocapitalizationModeProvider?() ?? .sentences
|
let mode = autocapitalizationModeProvider?() ?? .sentences
|
||||||
let preceding = precedingTextProvider?()
|
let preceding = resolvedPrecedingText(
|
||||||
|
accountingForInsert: insert,
|
||||||
|
deleteCount: deleteCount
|
||||||
|
)
|
||||||
shiftActive = TypingAutocapitalization.shouldCapitalize(
|
shiftActive = TypingAutocapitalization.shouldCapitalize(
|
||||||
precedingText: preceding,
|
precedingText: preceding,
|
||||||
mode: mode
|
mode: mode
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prefer a fresh proxy; when the host lags (Notes), merge our just-applied edit.
|
||||||
|
private func resolvedPrecedingText(
|
||||||
|
accountingForInsert insert: String,
|
||||||
|
deleteCount: Int
|
||||||
|
) -> String? {
|
||||||
|
// Host-driven refresh (appear / textDidChange): reseed from proxy.
|
||||||
|
if deleteCount == 0, insert.isEmpty {
|
||||||
|
if let proxy = precedingTextProvider?() {
|
||||||
|
return storePrecedingShadow(proxy)
|
||||||
|
}
|
||||||
|
return precedingShadow.isEmpty ? nil : precedingShadow
|
||||||
|
}
|
||||||
|
|
||||||
|
let proxy = precedingTextProvider?()
|
||||||
|
|
||||||
|
if deleteCount > 0, !insert.isEmpty {
|
||||||
|
if let proxy {
|
||||||
|
if proxy.hasSuffix(insert) {
|
||||||
|
return storePrecedingShadow(proxy)
|
||||||
|
}
|
||||||
|
if proxy.count >= deleteCount {
|
||||||
|
return storePrecedingShadow(String(proxy.dropLast(deleteCount)) + insert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trimPrecedingShadow(by: deleteCount)
|
||||||
|
return storePrecedingShadow(precedingShadow + insert)
|
||||||
|
}
|
||||||
|
|
||||||
|
if deleteCount > 0 {
|
||||||
|
if let proxy, proxy.count + deleteCount == precedingShadow.count
|
||||||
|
|| (precedingShadow.count >= deleteCount
|
||||||
|
&& String(precedingShadow.dropLast(deleteCount)) == proxy) {
|
||||||
|
return storePrecedingShadow(proxy)
|
||||||
|
}
|
||||||
|
if precedingShadow.count >= deleteCount {
|
||||||
|
return storePrecedingShadow(String(precedingShadow.dropLast(deleteCount)))
|
||||||
|
}
|
||||||
|
if let proxy { return storePrecedingShadow(proxy) }
|
||||||
|
precedingShadow = ""
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert only
|
||||||
|
if let proxy {
|
||||||
|
if proxy.hasSuffix(insert) {
|
||||||
|
return storePrecedingShadow(proxy)
|
||||||
|
}
|
||||||
|
return storePrecedingShadow(proxy + insert)
|
||||||
|
}
|
||||||
|
return storePrecedingShadow(precedingShadow + insert)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func storePrecedingShadow(_ value: String) -> String {
|
||||||
|
precedingShadow = String(value.suffix(Self.precedingShadowLimit))
|
||||||
|
return precedingShadow
|
||||||
|
}
|
||||||
|
|
||||||
|
private func trimPrecedingShadow(by count: Int) {
|
||||||
|
guard count > 0 else { return }
|
||||||
|
if precedingShadow.count >= count {
|
||||||
|
precedingShadow.removeLast(count)
|
||||||
|
} else {
|
||||||
|
precedingShadow = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Shift
|
// MARK: - Shift
|
||||||
|
|
||||||
/// Tap cycle: off → one-shot → Caps Lock → off (iOS-style second tap).
|
/// Tap cycle: off → one-shot → Caps Lock → off (iOS-style second tap).
|
||||||
@@ -536,6 +625,7 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
shiftHeld = false
|
shiftHeld = false
|
||||||
shiftPrimedByUser = false
|
shiftPrimedByUser = false
|
||||||
typedWhileShiftHeld = false
|
typedWhileShiftHeld = false
|
||||||
|
precedingShadow = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clearEnglishWordState(keepPrevious: Bool) {
|
private func clearEnglishWordState(keepPrevious: Bool) {
|
||||||
|
|||||||
@@ -158,6 +158,8 @@
|
|||||||
"mac.styles.error" = "Couldn’t Save Style";
|
"mac.styles.error" = "Couldn’t Save Style";
|
||||||
"mac.styles.validation" = "Check the name, prompt length, and the 8-style limit.";
|
"mac.styles.validation" = "Check the name, prompt length, and the 8-style limit.";
|
||||||
"mac.styles.name" = "Style name";
|
"mac.styles.name" = "Style name";
|
||||||
|
"mac.styles.allowsAddedEmoji" = "Allow mood emojis";
|
||||||
|
"mac.styles.allowsAddedEmoji.hint" = "When on, polish may add a few emojis that match the draft’s emotion, and keeps them on screen. Off by default.";
|
||||||
"mac.styles.prompt" = "Complete prompt";
|
"mac.styles.prompt" = "Complete prompt";
|
||||||
"mac.styles.hint" = "Use {{DICTIONARY}} to place the personal dictionary. System rules are appended automatically.";
|
"mac.styles.hint" = "Use {{DICTIONARY}} to place the personal dictionary. System rules are appended automatically.";
|
||||||
"mac.section.settings" = "Settings";
|
"mac.section.settings" = "Settings";
|
||||||
@@ -262,8 +264,11 @@
|
|||||||
"mac.settings.translationOff" = "Don't translate";
|
"mac.settings.translationOff" = "Don't translate";
|
||||||
"mac.settings.volcengineAppId" = "APP ID";
|
"mac.settings.volcengineAppId" = "APP ID";
|
||||||
"mac.settings.volcengineAccessToken" = "Access Token";
|
"mac.settings.volcengineAccessToken" = "Access Token";
|
||||||
"mac.settings.volcengineResourceId" = "Resource ID";
|
"mac.settings.volcengineApiKey" = "API Key";
|
||||||
"mac.settings.volcengineNote" = "Secret Key is not required. Resource ID defaults to volc.seedasr.sauc.duration.";
|
"mac.settings.volcengineApiKeyMode" = "New API Key auth";
|
||||||
|
"mac.settings.volcengineApiKeyModeSubtitle" = "On for the new console; off keeps APP ID + Access Token.";
|
||||||
|
"mac.settings.volcengineNoteAppToken" = "Legacy console: APP ID + Access Token. Secret Key not required. Resource fixed to Doubao streaming 2.0 (volc.seedasr.sauc.duration).";
|
||||||
|
"mac.settings.volcengineNoteApiKey" = "New console: API Key only. Resource fixed to Doubao streaming 2.0 (volc.seedasr.sauc.duration).";
|
||||||
"mac.settings.recognition" = "RECOGNITION METHOD";
|
"mac.settings.recognition" = "RECOGNITION METHOD";
|
||||||
"mac.settings.cloudEngine" = "Cloud Engine & AI Refinement";
|
"mac.settings.cloudEngine" = "Cloud Engine & AI Refinement";
|
||||||
"mac.settings.cloudEngineDesc" = "Premium transcription via your provider's API, plus AI grammar and style polishing.";
|
"mac.settings.cloudEngineDesc" = "Premium transcription via your provider's API, plus AI grammar and style polishing.";
|
||||||
|
|||||||
@@ -157,6 +157,8 @@
|
|||||||
"mac.styles.error" = "无法保存风格";
|
"mac.styles.error" = "无法保存风格";
|
||||||
"mac.styles.validation" = "请检查名称、提示词长度及 8 个风格的数量上限。";
|
"mac.styles.validation" = "请检查名称、提示词长度及 8 个风格的数量上限。";
|
||||||
"mac.styles.name" = "风格名称";
|
"mac.styles.name" = "风格名称";
|
||||||
|
"mac.styles.allowsAddedEmoji" = "允许按情绪添加 emoji";
|
||||||
|
"mac.styles.allowsAddedEmoji.hint" = "开启后,润色可按原文情绪点缀少量 emoji,并保留上屏。默认关闭。";
|
||||||
"mac.styles.prompt" = "完整提示词";
|
"mac.styles.prompt" = "完整提示词";
|
||||||
"mac.styles.hint" = "使用 {{DICTIONARY}} 指定个人词典位置;系统规则会自动追加。";
|
"mac.styles.hint" = "使用 {{DICTIONARY}} 指定个人词典位置;系统规则会自动追加。";
|
||||||
"mac.section.settings" = "设置";
|
"mac.section.settings" = "设置";
|
||||||
@@ -261,8 +263,11 @@
|
|||||||
"mac.settings.translationOff" = "不翻译";
|
"mac.settings.translationOff" = "不翻译";
|
||||||
"mac.settings.volcengineAppId" = "APP ID";
|
"mac.settings.volcengineAppId" = "APP ID";
|
||||||
"mac.settings.volcengineAccessToken" = "Access Token";
|
"mac.settings.volcengineAccessToken" = "Access Token";
|
||||||
"mac.settings.volcengineResourceId" = "Resource ID";
|
"mac.settings.volcengineApiKey" = "API Key";
|
||||||
"mac.settings.volcengineNote" = "Secret Key 当前无需填写。Resource ID 默认使用 volc.seedasr.sauc.duration。";
|
"mac.settings.volcengineApiKeyMode" = "使用新版 API Key 鉴权";
|
||||||
|
"mac.settings.volcengineApiKeyModeSubtitle" = "新控制台请打开;已有 AppID + Token 可保持关闭。";
|
||||||
|
"mac.settings.volcengineNoteAppToken" = "旧版控制台:填写 APP ID 与 Access Token。Secret Key 无需填写。识别资源固定为豆包流式 2.0(volc.seedasr.sauc.duration)。";
|
||||||
|
"mac.settings.volcengineNoteApiKey" = "新版控制台:只需填写 API Key。识别资源固定为豆包流式 2.0(volc.seedasr.sauc.duration)。";
|
||||||
"mac.settings.recognition" = "识别方式";
|
"mac.settings.recognition" = "识别方式";
|
||||||
"mac.settings.cloudEngine" = "云端引擎与 AI 润色";
|
"mac.settings.cloudEngine" = "云端引擎与 AI 润色";
|
||||||
"mac.settings.cloudEngineDesc" = "通过服务商 API 进行高质量转写,并自动润色语法与风格。";
|
"mac.settings.cloudEngineDesc" = "通过服务商 API 进行高质量转写,并自动润色语法与风格。";
|
||||||
|
|||||||
@@ -130,7 +130,10 @@ final class CloudASRTests: XCTestCase {
|
|||||||
let fields = VolcengineASRFields.parse(apiKey: json, resourceFallback: "")
|
let fields = VolcengineASRFields.parse(apiKey: json, resourceFallback: "")
|
||||||
XCTAssertEqual(fields.appID, "app-1")
|
XCTAssertEqual(fields.appID, "app-1")
|
||||||
XCTAssertEqual(fields.accessToken, "tok-2")
|
XCTAssertEqual(fields.accessToken, "tok-2")
|
||||||
XCTAssertEqual(fields.resourceID, "res-3")
|
XCTAssertEqual(fields.authMode, .appToken)
|
||||||
|
// Custom resource IDs are ignored; product is locked to SAUC 2.0 duration.
|
||||||
|
XCTAssertEqual(fields.resourceID, VolcengineASRFields.fixedResourceID)
|
||||||
|
XCTAssertTrue(fields.hasUsableCredentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testVolcengineASRFieldsColonParsing() {
|
func testVolcengineASRFieldsColonParsing() {
|
||||||
@@ -140,8 +143,70 @@ final class CloudASRTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
XCTAssertEqual(fields.appID, "app-1")
|
XCTAssertEqual(fields.appID, "app-1")
|
||||||
XCTAssertEqual(fields.accessToken, "tok-2")
|
XCTAssertEqual(fields.accessToken, "tok-2")
|
||||||
XCTAssertEqual(fields.resourceID, "res-3")
|
XCTAssertEqual(fields.authMode, .appToken)
|
||||||
|
XCTAssertEqual(fields.resourceID, VolcengineASRFields.fixedResourceID)
|
||||||
XCTAssertTrue(fields.encodedAPIKey.contains("app-1"))
|
XCTAssertTrue(fields.encodedAPIKey.contains("app-1"))
|
||||||
|
XCTAssertTrue(fields.encodedAPIKey.contains("auth_mode"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVolcengineASRFieldsAPIKeyModeParsing() {
|
||||||
|
let json = #"{"auth_mode":"api_key","api_key":"vk-new-console"}"#
|
||||||
|
let fields = VolcengineASRFields.parse(apiKey: json)
|
||||||
|
XCTAssertEqual(fields.authMode, .apiKey)
|
||||||
|
XCTAssertEqual(fields.apiKeyCredential, "vk-new-console")
|
||||||
|
XCTAssertTrue(fields.hasUsableCredentials)
|
||||||
|
XCTAssertEqual(fields.resourceID, VolcengineASRFields.fixedResourceID)
|
||||||
|
|
||||||
|
var request = URLRequest(url: URL(string: "wss://example.invalid")!)
|
||||||
|
fields.applyWebSocketAuthHeaders(to: &request, connectID: "conn-1")
|
||||||
|
XCTAssertEqual(request.value(forHTTPHeaderField: "X-Api-Key"), "vk-new-console")
|
||||||
|
XCTAssertNil(request.value(forHTTPHeaderField: "X-Api-App-Key"))
|
||||||
|
XCTAssertNil(request.value(forHTTPHeaderField: "X-Api-Access-Key"))
|
||||||
|
XCTAssertEqual(
|
||||||
|
request.value(forHTTPHeaderField: "X-Api-Resource-Id"),
|
||||||
|
VolcengineASRFields.fixedResourceID
|
||||||
|
)
|
||||||
|
XCTAssertEqual(request.value(forHTTPHeaderField: "X-Api-Connect-Id"), "conn-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVolcengineASRFieldsAppTokenHeaders() {
|
||||||
|
let fields = VolcengineASRFields(
|
||||||
|
authMode: .appToken,
|
||||||
|
appID: "app-1",
|
||||||
|
accessToken: "tok-2"
|
||||||
|
)
|
||||||
|
var request = URLRequest(url: URL(string: "wss://example.invalid")!)
|
||||||
|
fields.applyWebSocketAuthHeaders(to: &request, connectID: "conn-2")
|
||||||
|
XCTAssertEqual(request.value(forHTTPHeaderField: "X-Api-App-Key"), "app-1")
|
||||||
|
XCTAssertEqual(request.value(forHTTPHeaderField: "X-Api-Access-Key"), "tok-2")
|
||||||
|
XCTAssertNil(request.value(forHTTPHeaderField: "X-Api-Key"))
|
||||||
|
XCTAssertEqual(
|
||||||
|
request.value(forHTTPHeaderField: "X-Api-Resource-Id"),
|
||||||
|
VolcengineASRFields.fixedResourceID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVolcengineASRFieldsTogglePreservesBothCredentialSets() {
|
||||||
|
var fields = VolcengineASRFields(
|
||||||
|
authMode: .appToken,
|
||||||
|
appID: "app-1",
|
||||||
|
accessToken: "tok-2",
|
||||||
|
apiKeyCredential: "vk-keep"
|
||||||
|
)
|
||||||
|
fields.authMode = .apiKey
|
||||||
|
let encoded = fields.encodedAPIKey
|
||||||
|
let parsed = VolcengineASRFields.parse(apiKey: encoded)
|
||||||
|
XCTAssertEqual(parsed.authMode, .apiKey)
|
||||||
|
XCTAssertEqual(parsed.apiKeyCredential, "vk-keep")
|
||||||
|
XCTAssertEqual(parsed.appID, "app-1")
|
||||||
|
XCTAssertEqual(parsed.accessToken, "tok-2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVolcengineASRFieldsEmptyAPIKeyModeIsNotUsable() {
|
||||||
|
let json = #"{"auth_mode":"api_key"}"#
|
||||||
|
let fields = VolcengineASRFields.parse(apiKey: json)
|
||||||
|
XCTAssertEqual(fields.authMode, .apiKey)
|
||||||
|
XCTAssertFalse(fields.hasUsableCredentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPersonalDictionaryASRHotwordsDedupesTerms() {
|
func testPersonalDictionaryASRHotwordsDedupesTerms() {
|
||||||
|
|||||||
@@ -42,6 +42,90 @@ final class FlowKeyboardPoliciesTests: XCTestCase {
|
|||||||
XCTAssertTrue(warming)
|
XCTAssertTrue(warming)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testRecentReadyGraceDoesNotForcePreparingSession() {
|
||||||
|
// withinReadyGrace used to OR into warming and flash yellow after each
|
||||||
|
// utterance; sticky ready must hold green instead.
|
||||||
|
let warming = FlowKeyboardHostWarming.isHostWarming(
|
||||||
|
hostReady: false,
|
||||||
|
hostBusy: false,
|
||||||
|
sessionActive: true,
|
||||||
|
hostReachable: false,
|
||||||
|
isPendingFlowStart: false,
|
||||||
|
withinReadyGrace: true,
|
||||||
|
snapshotReason: .starting
|
||||||
|
)
|
||||||
|
// Still warming via reason=.starting when not yet proven ready on the
|
||||||
|
// keyboard side; grace alone must not be the trigger.
|
||||||
|
XCTAssertTrue(warming)
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
FlowKeyboardHostWarming.shouldHoldReady(
|
||||||
|
hostReady: false,
|
||||||
|
hostBusy: false,
|
||||||
|
sessionActive: true,
|
||||||
|
sessionProvenReady: true,
|
||||||
|
isPendingFlowStart: false,
|
||||||
|
snapshotReason: .starting
|
||||||
|
),
|
||||||
|
"proven-ready session must hold green through starting flaps"
|
||||||
|
)
|
||||||
|
XCTAssertTrue(
|
||||||
|
FlowKeyboardHostWarming.shouldHoldReady(
|
||||||
|
hostReady: false,
|
||||||
|
hostBusy: true,
|
||||||
|
sessionActive: true,
|
||||||
|
sessionProvenReady: true,
|
||||||
|
isPendingFlowStart: false,
|
||||||
|
snapshotReason: .awaitingDelivery
|
||||||
|
),
|
||||||
|
"ack lag awaitingDelivery must not drop to preparingSession"
|
||||||
|
)
|
||||||
|
XCTAssertFalse(
|
||||||
|
FlowKeyboardHostWarming.shouldHoldReady(
|
||||||
|
hostReady: false,
|
||||||
|
hostBusy: true,
|
||||||
|
sessionActive: true,
|
||||||
|
sessionProvenReady: true,
|
||||||
|
isPendingFlowStart: false,
|
||||||
|
snapshotReason: .recording
|
||||||
|
),
|
||||||
|
"live recording must not be masked as sticky ready"
|
||||||
|
)
|
||||||
|
XCTAssertFalse(
|
||||||
|
FlowKeyboardHostWarming.shouldHoldReady(
|
||||||
|
hostReady: false,
|
||||||
|
hostBusy: false,
|
||||||
|
sessionActive: true,
|
||||||
|
sessionProvenReady: false,
|
||||||
|
isPendingFlowStart: true,
|
||||||
|
snapshotReason: .starting
|
||||||
|
),
|
||||||
|
"cold start pending must still show preparing"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHeldReadySuppressesWarming() {
|
||||||
|
let hold = FlowKeyboardHostWarming.shouldHoldReady(
|
||||||
|
hostReady: false,
|
||||||
|
hostBusy: false,
|
||||||
|
sessionActive: true,
|
||||||
|
sessionProvenReady: true,
|
||||||
|
isPendingFlowStart: false,
|
||||||
|
snapshotReason: .starting
|
||||||
|
)
|
||||||
|
XCTAssertTrue(hold)
|
||||||
|
let warming = FlowKeyboardHostWarming.isHostWarming(
|
||||||
|
hostReady: true, // effective ready after hold
|
||||||
|
hostBusy: false,
|
||||||
|
sessionActive: true,
|
||||||
|
hostReachable: true,
|
||||||
|
isPendingFlowStart: false,
|
||||||
|
withinReadyGrace: true,
|
||||||
|
snapshotReason: .starting
|
||||||
|
)
|
||||||
|
XCTAssertFalse(warming)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Adopt busy
|
// MARK: - Adopt busy
|
||||||
|
|
||||||
func testReAdoptsRecordingAfterExtensionProcessLoss() {
|
func testReAdoptsRecordingAfterExtensionProcessLoss() {
|
||||||
|
|||||||
@@ -220,4 +220,14 @@ final class FlowReliabilityTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testCaptureVoiceProcessingUsesSpeechOrientedSessionMode() {
|
||||||
|
XCTAssertEqual(FlowCaptureVoiceProcessing.captureMode, .voiceChat)
|
||||||
|
XCTAssertTrue(
|
||||||
|
FlowCaptureVoiceProcessing.captureOptions.contains(.allowBluetoothHFP)
|
||||||
|
)
|
||||||
|
XCTAssertTrue(
|
||||||
|
FlowCaptureVoiceProcessing.captureOptions.contains(.defaultToSpeaker)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -428,6 +428,41 @@ final class FlowSessionBridgeTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testHostHeavyClearsOnFlowStateReset() {
|
||||||
|
let defaults = makeDefaults()
|
||||||
|
FlowSessionBridge.setHostHeavy(true, defaults: defaults)
|
||||||
|
XCTAssertTrue(FlowSessionBridge.isHostHeavy(defaults: defaults))
|
||||||
|
|
||||||
|
FlowSessionBridge.clearFlowState(defaults: defaults)
|
||||||
|
XCTAssertFalse(FlowSessionBridge.isHostHeavy(defaults: defaults))
|
||||||
|
|
||||||
|
FlowSessionBridge.setHostHeavy(true, defaults: defaults)
|
||||||
|
FlowSessionBridge.setPendingHostBundleId("com.example.host", defaults: defaults)
|
||||||
|
FlowSessionBridge.clearFlowStateOnHostLaunch(defaults: defaults)
|
||||||
|
XCTAssertFalse(FlowSessionBridge.isHostHeavy(defaults: defaults))
|
||||||
|
XCTAssertEqual(
|
||||||
|
FlowSessionBridge.pendingHostBundleId(defaults: defaults),
|
||||||
|
"com.example.host"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStaleHostHeavyDoesNotBlockTyping() {
|
||||||
|
let defaults = makeDefaults()
|
||||||
|
// Legacy sticky bool with no timestamp — must not brick 中文/EN.
|
||||||
|
defaults.set(true, forKey: FlowSessionKeys.hostHeavy)
|
||||||
|
XCTAssertFalse(FlowSessionBridge.isHostHeavy(defaults: defaults))
|
||||||
|
XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.hostHeavy))
|
||||||
|
|
||||||
|
FlowSessionBridge.setHostHeavy(true, defaults: defaults)
|
||||||
|
XCTAssertTrue(FlowSessionBridge.isHostHeavy(defaults: defaults))
|
||||||
|
|
||||||
|
// Expired timestamp → treat as clear so cold keyboard can switch.
|
||||||
|
let expired = Date().timeIntervalSince1970 - FlowSessionKeys.hostHeavyMaxAge - 1
|
||||||
|
defaults.set(expired, forKey: FlowSessionKeys.hostHeavyAt)
|
||||||
|
XCTAssertFalse(FlowSessionBridge.isHostHeavy(defaults: defaults))
|
||||||
|
XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.hostHeavy))
|
||||||
|
}
|
||||||
|
|
||||||
func testRotateHostGenerationReturnsPreviousToken() {
|
func testRotateHostGenerationReturnsPreviousToken() {
|
||||||
let defaults = makeDefaults()
|
let defaults = makeDefaults()
|
||||||
XCTAssertNil(FlowSessionBridge.rotateHostGeneration(defaults: defaults))
|
XCTAssertNil(FlowSessionBridge.rotateHostGeneration(defaults: defaults))
|
||||||
|
|||||||
@@ -340,6 +340,54 @@ final class IntelligentPolishTests: XCTestCase {
|
|||||||
XCTAssertTrue(result.contains("完成"))
|
XCTAssertTrue(result.contains("完成"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPolishServiceKeepsAddedEmojiWhenStyleAllows() async throws {
|
||||||
|
store.setEngineMode("local")
|
||||||
|
var catalog = PolishStyleCatalog()
|
||||||
|
let pack = PolishStylePack(
|
||||||
|
id: "user.emoji",
|
||||||
|
name: "Emoji",
|
||||||
|
prompt: "保持口语,可按情绪加 emoji。",
|
||||||
|
allowsAddedEmoji: true
|
||||||
|
)
|
||||||
|
try catalog.upsert(pack)
|
||||||
|
store.setPolishStyleCatalog(catalog)
|
||||||
|
store.setActivePolishStyleId(pack.id)
|
||||||
|
|
||||||
|
let emojiClient = FixedResponseLLMClient(response: "今天太开心了,终于搞定了😆")
|
||||||
|
let service = PolishingService(store: store, client: emojiClient)
|
||||||
|
let result = try await service.polish(
|
||||||
|
"今天太开心了终于搞定了",
|
||||||
|
context: PolishContext()
|
||||||
|
)
|
||||||
|
XCTAssertTrue(result.contains("😆"), "Allowed-emoji styles must keep model-added emoji. Got: \(result)")
|
||||||
|
XCTAssertTrue(result.contains("开心"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPolishServiceKeepsAddedEmojiWhenPromptOptsInWithoutToggle() async throws {
|
||||||
|
store.setEngineMode("local")
|
||||||
|
var catalog = PolishStyleCatalog()
|
||||||
|
let pack = PolishStylePack(
|
||||||
|
id: "user.paste-emoji",
|
||||||
|
name: "PasteEmoji",
|
||||||
|
prompt: "本风格允许新增 emoji。按情绪点缀合适表情。",
|
||||||
|
allowsAddedEmoji: false
|
||||||
|
)
|
||||||
|
try catalog.upsert(pack)
|
||||||
|
store.setPolishStyleCatalog(catalog)
|
||||||
|
store.setActivePolishStyleId(pack.id)
|
||||||
|
|
||||||
|
let emojiClient = FixedResponseLLMClient(response: "辛苦你了,真的谢谢🙏")
|
||||||
|
let service = PolishingService(store: store, client: emojiClient)
|
||||||
|
let result = try await service.polish(
|
||||||
|
"辛苦你了真的谢谢",
|
||||||
|
context: PolishContext()
|
||||||
|
)
|
||||||
|
XCTAssertTrue(
|
||||||
|
result.contains("🙏"),
|
||||||
|
"Prompt opt-in must keep emoji even when toggle is off. Got: \(result)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func testPolishServiceFallsBackWhenOutputEmpty() async throws {
|
func testPolishServiceFallsBackWhenOutputEmpty() async throws {
|
||||||
store.setEngineMode("local")
|
store.setEngineMode("local")
|
||||||
let emptyClient = FixedResponseLLMClient(response: " ")
|
let emptyClient = FixedResponseLLMClient(response: " ")
|
||||||
@@ -410,6 +458,30 @@ final class IntelligentPolishTests: XCTestCase {
|
|||||||
XCTAssertEqual(result, "好的")
|
XCTAssertEqual(result, "好的")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testQualityGateKeepsAddedEmojiWhenAllowed() {
|
||||||
|
let decision = TranscriptPostProcessor.qualityGate(
|
||||||
|
original: "今天太开心了",
|
||||||
|
candidate: "今天太开心了😆",
|
||||||
|
allowsAddedEmoji: true
|
||||||
|
)
|
||||||
|
guard case .accept(let text) = decision else {
|
||||||
|
return XCTFail("Expected accept")
|
||||||
|
}
|
||||||
|
XCTAssertTrue(text.contains("😆"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testQualityGateStripsAddedEmojiByDefault() {
|
||||||
|
let decision = TranscriptPostProcessor.qualityGate(
|
||||||
|
original: "今天太开心了",
|
||||||
|
candidate: "今天太开心了😆",
|
||||||
|
allowsAddedEmoji: false
|
||||||
|
)
|
||||||
|
guard case .accept(let text) = decision else {
|
||||||
|
return XCTFail("Expected accept")
|
||||||
|
}
|
||||||
|
XCTAssertFalse(text.contains("😆"))
|
||||||
|
}
|
||||||
|
|
||||||
func testQualityGateStripsResidualPauseMarkers() {
|
func testQualityGateStripsResidualPauseMarkers() {
|
||||||
let result = TranscriptPostProcessor.process(
|
let result = TranscriptPostProcessor.process(
|
||||||
original: "第一段 ⟨0.8s⟩ 第二段",
|
original: "第一段 ⟨0.8s⟩ 第二段",
|
||||||
|
|||||||
@@ -206,6 +206,90 @@ final class PolishStylePackTests: XCTestCase {
|
|||||||
XCTAssertTrue(prompt.contains("词典命中优先于同音猜测"))
|
XCTAssertTrue(prompt.contains("词典命中优先于同音猜测"))
|
||||||
XCTAssertTrue(prompt.contains("风格接入(纠错之后)"))
|
XCTAssertTrue(prompt.contains("风格接入(纠错之后)"))
|
||||||
XCTAssertFalse(prompt.contains("原始内容"))
|
XCTAssertFalse(prompt.contains("原始内容"))
|
||||||
|
XCTAssertFalse(prompt.contains("Emoji 覆盖"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testComposerInjectsEmojiOverrideWhenStyleAllows() {
|
||||||
|
let style = PolishStylePack(
|
||||||
|
id: "user.emoji",
|
||||||
|
name: "Emoji",
|
||||||
|
prompt: "ROLE",
|
||||||
|
allowsAddedEmoji: true
|
||||||
|
)
|
||||||
|
let prompt = PolishPromptComposer.compose(
|
||||||
|
text: "今天太开心了",
|
||||||
|
style: style,
|
||||||
|
context: PolishContext(),
|
||||||
|
dictionaryBlock: "",
|
||||||
|
useChineseGuidance: true
|
||||||
|
)
|
||||||
|
XCTAssertTrue(prompt.contains("Emoji 覆盖(本风格开启 · 最终优先级)"))
|
||||||
|
XCTAssertTrue(prompt.contains("优先级高于全局 R5"))
|
||||||
|
// Override must appear after core R5 so it wins.
|
||||||
|
let r5 = prompt.range(of: "R5 不新增 emoji")
|
||||||
|
let override = prompt.range(of: "Emoji 覆盖(本风格开启 · 最终优先级)")
|
||||||
|
XCTAssertNotNil(r5)
|
||||||
|
XCTAssertNotNil(override)
|
||||||
|
XCTAssertLessThan(r5!.lowerBound, override!.lowerBound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPromptOptInEnablesEmojiWithoutToggle() async throws {
|
||||||
|
let style = PolishStylePack(
|
||||||
|
id: "user.paste",
|
||||||
|
name: "Paste",
|
||||||
|
prompt: """
|
||||||
|
# 最高优先级覆盖
|
||||||
|
本风格允许新增 emoji。当与全局「不新增 emoji」规则冲突时,以本风格为准。
|
||||||
|
""",
|
||||||
|
allowsAddedEmoji: false
|
||||||
|
)
|
||||||
|
XCTAssertTrue(style.effectiveAllowsAddedEmoji)
|
||||||
|
|
||||||
|
let composed = PolishPromptComposer.compose(
|
||||||
|
text: "今天太开心了",
|
||||||
|
style: style,
|
||||||
|
context: PolishContext(),
|
||||||
|
dictionaryBlock: "",
|
||||||
|
useChineseGuidance: true
|
||||||
|
)
|
||||||
|
XCTAssertTrue(composed.contains("Emoji 覆盖(本风格开启 · 最终优先级)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLegacyPackDecodeDefaultsAllowsAddedEmojiToFalse() throws {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
// Encode without the new key by decoding a minimal legacy payload.
|
||||||
|
let legacyJSON = """
|
||||||
|
{
|
||||||
|
"id": "user.legacy",
|
||||||
|
"name": "Legacy",
|
||||||
|
"prompt": "ROLE",
|
||||||
|
"kind": "user",
|
||||||
|
"createdAt": 0,
|
||||||
|
"updatedAt": 0
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .secondsSince1970
|
||||||
|
let pack = try decoder.decode(PolishStylePack.self, from: Data(legacyJSON.utf8))
|
||||||
|
XCTAssertFalse(pack.allowsAddedEmoji)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpsertPreservesAllowsAddedEmoji() throws {
|
||||||
|
var catalog = PolishStyleCatalog()
|
||||||
|
let pack = PolishStylePack(
|
||||||
|
name: "Emoji",
|
||||||
|
prompt: "ROLE",
|
||||||
|
allowsAddedEmoji: true
|
||||||
|
)
|
||||||
|
try catalog.upsert(pack)
|
||||||
|
XCTAssertEqual(catalog.entries.first?.allowsAddedEmoji, true)
|
||||||
|
|
||||||
|
var updated = pack
|
||||||
|
updated.prompt = "ROLE 2"
|
||||||
|
try catalog.upsert(updated)
|
||||||
|
XCTAssertEqual(catalog.entries.first?.allowsAddedEmoji, true)
|
||||||
|
XCTAssertEqual(catalog.entries.first?.prompt, "ROLE 2")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testComposerKeepsHomophoneRepairWhenDictionaryPresent() {
|
func testComposerKeepsHomophoneRepairWhenDictionaryPresent() {
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ wiring; jetsam behavior is device-only.
|
|||||||
- **Do not** stack CLM + Rime + ASR in the same second after onboarding.
|
- **Do not** stack CLM + Rime + ASR in the same second after onboarding.
|
||||||
- ASR warmup runs on **first mic press** (`beginUtterance`), gated by
|
- ASR warmup runs on **first mic press** (`beginUtterance`), gated by
|
||||||
`HostMemoryBudget` (~260 MB RSS). `hostHeavy` is set only while heavy work
|
`HostMemoryBudget` (~260 MB RSS). `hostHeavy` is set only while heavy work
|
||||||
actually runs, then cleared.
|
actually runs, then cleared. A sticky `hostHeavy` (host died mid-work)
|
||||||
|
expires after `hostHeavyMaxAge` (~120 s) and is cleared on
|
||||||
|
`clearFlowState` / host-launch reconciliation so typing 中文/EN is not
|
||||||
|
permanently blocked.
|
||||||
|
|
||||||
## Console checklist
|
## Console checklist
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -51,8 +51,8 @@ settings:
|
|||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
||||||
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
||||||
MARKETING_VERSION: "1.6.1"
|
MARKETING_VERSION: "1.6.2"
|
||||||
CURRENT_PROJECT_VERSION: "45"
|
CURRENT_PROJECT_VERSION: "47"
|
||||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||||
|
|
||||||
# 项目级签名 xcconfig,适用于所有 target
|
# 项目级签名 xcconfig,适用于所有 target
|
||||||
|
|||||||
Reference in New Issue
Block a user