Refine adaptive polish and cross-platform account support

Improve learned-style and clipboard skill presentation while keeping shared account clients buildable in the source-composed macOS target.
This commit is contained in:
Rocky
2026-08-22 17:06:22 +08:00
parent fe8f657c8d
commit 7df5bbdaa0
16 changed files with 243 additions and 66 deletions
+1
View File
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- **Skill catalog layout**: replace the two-column cards with scroll-safe installed and uninstalled lists; selecting a row now opens a full detail page where installation, Shortcut setup, and custom-skill editing are managed. / **技能目录布局**:将双列卡片改为可稳定滚动的已安装与未安装列表;点击列表项进入完整详情页,并统一管理安装、捷径配置与自定义技能编辑。
- **Conversational skill replies**: make every built-in reply action favor short everyday chat language over formal email or support templates, and apply the active user-owned learned style only as a bounded wording-and-rhythm layer. / **口语化技能回复**:所有内置回复操作优先使用简短日常聊天表达,避免正式邮件与客服模板;当前用户自有的学习风格仅作为受限的用词与节奏层应用。
### Fixed
- **Repeatable OOBE practice**: scope each free feature to one use per short-lived guided session, so returning to OOBE starts a fresh four-page experience without misreporting consumed-page conflicts as weak network; also simplify lesson titles, add a voice sample to read aloud, and streamline the completion page. / **可重复的 OOBE 体验**:将每项免费功能限制为每个短期引导会话使用一次,让用户重新进入 OOBE 时可以重新体验四个页面,并避免把页面已完成冲突误报为弱网;同时精简各环节标题,加入语音朗读示例,并简化完成页。
+2 -2
View File
@@ -29,7 +29,7 @@
<ul>
<li><strong>Voice audio</strong> — captured only while you actively record. On a local engine, audio is transcribed on-device and raw audio is not uploaded. With a user-configured cloud engine, recordings are sent directly to that ASR provider. If you explicitly choose managed credits, recordings are sent through the OSGKeyboard managed gateway at <code>account.osglab.com</code> to the managed ASR provider for transcription. OSGKeyboard does not keep raw recordings after the request is processed.</li>
<li><strong>Transcribed text and cursor context</strong> — after ASR, the transcript (not audio) may be sent for polish when you use a configured LLM or managed credits. A small amount of text around the cursor may be included to continue naturally. Secure fields are never captured, and cursor context is not written to logs or voice history. User-configured requests go directly to that provider; managed-credit requests go through <code>account.osglab.com</code>.</li>
<li><strong>Speaking-style learning</strong> — only after you tap “Generate My Style,” eligible pairs of pre-polish and final text, together with the polish-style prompts that produced those examples, are sent to your configured AI service. Native speech and explicit user edits are used to infer recurring habits; historical prompts are supplied only to distinguish and remove AI-added style. User-configured requests go directly to that provider; managed-credit requests go through <code>account.osglab.com</code>. The generated prompt is shown for review and is not saved or activated until you confirm it.</li>
<li><strong>Speaking-style learning</strong> — only after you tap “Generate My Style,” eligible pairs of pre-polish and final text, together with the polish-style prompts that produced those examples, are sent to your configured AI service. Native speech and explicit user edits are used to infer recurring habits; historical prompts are supplied only to distinguish and remove AI-added style. The generated prompt is shown for review and is not saved or activated until you confirm it. When you later invoke a conversational clipboard-reply skill while a reviewed user-created style is active, that style prompt is sent with the copied text so the reply can follow your wording and rhythm; built-in polish personalities are not attached. User-configured requests go directly to that provider; managed-credit requests go through <code>account.osglab.com</code>.</li>
<li><strong>AI mode questions</strong> — in AI keyboard mode, your spoken question text—or a question you tap from an idle suggestion—is sent to the selected AI service to generate an answer. User-configured requests go directly to that provider; managed-credit requests go through <code>account.osglab.com</code>. When the selected service supports server-side web search, it may retrieve public web results for time-sensitive answers.</li>
<li><strong>Anonymous onboarding AI trial</strong> — before you sign in, onboarding can issue a short-lived, feature-limited credential after Apple App Attest verifies a genuine app installation. The four fixed lessons may send one voice transcript, an explicitly copied lesson sample for translation and reply, and one transcribed AI question through <code>account.osglab.com</code>. This flow does not create an account, process an Apple identity token, use credits, or upload raw voice audio. The copied lesson sample is temporary and does not enable or populate clipboard history. The service keeps only pseudonymous integrity, request-state, feature, and metering records needed to enforce the one-use limits and investigate abuse; it does not retain request or response bodies.</li>
<li><strong>AI suggestions and OSGKeyboard web pages</strong> — the main app may request public suggestion titles from <code>key.osglab.com</code>; no voice, transcript, clipboard body, API key, or ordinary keystroke content is included. Opening the update page requests public content from <code>download.osglab.com</code>. Those hosts may process ordinary connection metadata such as IP address and request time.</li>
@@ -82,7 +82,7 @@
<ul>
<li><strong>语音音频</strong> — 仅在你主动录音时采集。使用本地引擎时,音频在设备端转写且原始录音不会上传。使用用户自备云端服务时,录音会直接发送到你配置的识别服务商;若你明确选择「使用积分」,录音会经 <code>account.osglab.com</code> 的 OSGKeyboard 托管网关发送到托管识别服务商。请求处理完成后,OSGKeyboard 不保留原始录音。</li>
<li><strong>转写文字与光标上下文</strong> — 使用已配置的 LLM 或托管积分时,转写文字(非音频)可能发送润色,并可能包含光标附近少量文字以自然衔接。密码框不会采集,光标上下文不会写入日志或语音历史。用户自备请求直达所选服务商;托管积分请求经 <code>account.osglab.com</code> 处理。</li>
<li><strong>说话风格学习</strong> — 只有当你主动点击「生成我的风格」后,符合条件的润色前后文本对及产出这些示例时使用的润色风格 Prompt,才会发送给你配置的 AI 服务。原生口述与用户明确编辑用于归纳反复出现的习惯;历史 Prompt 只用于识别并排除 AI 附加风格。用户自备请求直达所选服务商;托管积分请求经 <code>account.osglab.com</code> 处理。生成结果会先展示供你检查,确认保存前不会写入或启用。</li>
<li><strong>说话风格学习</strong> — 只有当你主动点击「生成我的风格」后,符合条件的润色前后文本对及产出这些示例时使用的润色风格 Prompt,才会发送给你配置的 AI 服务。原生口述与用户明确编辑用于归纳反复出现的习惯;历史 Prompt 只用于识别并排除 AI 附加风格。生成结果会先展示供你检查,确认保存前不会写入或启用。之后,当你启用了经检查的用户自定义风格并主动使用口语回复类剪贴板技能时,该风格 Prompt 会随复制文本一起发送,用于贴近你的用词与节奏;内置润色人格不会附加。用户自备请求直达所选服务商;托管积分请求经 <code>account.osglab.com</code> 处理。</li>
<li><strong>AI 模式问题</strong> — 在 AI 键盘模式下,语音转写后的问题文字或你点选的建议会发送到当前选择的 AI 服务。用户自备请求直达所选服务商;托管积分请求经 <code>account.osglab.com</code> 处理。所选服务支持联网搜索时,可能检索公开网页结果回答时效性问题。</li>
<li><strong>匿名首次体验</strong> — 登录前,开机引导可在 Apple App Attest 验证正版 App 后签发短时、限功能的匿名凭证。四项固定教学会经 <code>account.osglab.com</code> 分别发送一次语音转写文字、由你明确复制的教学示例(用于翻译与回复),以及一次语音转写后的 AI 问题;不会创建账号、处理 Apple 身份令牌、使用积分或上传原始语音。教学示例仅短时使用,不会开启或写入剪贴板历史。服务端只保留执行一次性限制和调查滥用所需的匿名完整性、请求状态、功能类型及计量记录,不保留请求或回答正文。</li>
<li><strong>AI 建议与 OSGKeyboard 网页</strong> — 主 App 可能从 <code>key.osglab.com</code> 请求公开建议标题;请求不包含语音、转写、剪贴板正文、API Key 或普通击键内容。打开更新页时会从 <code>download.osglab.com</code> 请求公开内容。相关主机可能处理 IP 地址、请求时间等普通连接元数据。</li>
+23 -5
View File
@@ -109,10 +109,12 @@ struct PolishStylesView: View {
let corpus = styleLearningCorpus
let required = PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
let reachedLimit = catalog.entries.count >= PolishStyleLimits.maximumUserPacks
let isActionAvailable = corpus.isReady && !reachedLimit
let canGenerate = isActionAvailable && !isGeneratingLearnedStyle
return VStack(alignment: .leading, spacing: Spacing.md) {
HStack(alignment: .top, spacing: Spacing.md) {
Image(systemName: "waveform.badge.sparkles")
Image(systemName: "waveform")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(palette.accent)
.frame(width: 42, height: 42)
@@ -170,6 +172,7 @@ struct PolishStylesView: View {
if isGeneratingLearnedStyle {
ProgressView()
.controlSize(.small)
.tint(isActionAvailable ? palette.textOnAccent : palette.textSecondary)
} else {
Image(systemName: "sparkles")
}
@@ -179,11 +182,26 @@ struct PolishStylesView: View {
: AppL10n.string("polishStyles.learn.action")
)
}
.frame(maxWidth: .infinity)
.font(TypeStyle.bodyEmph)
.foregroundStyle(
isActionAvailable ? palette.textOnAccent : palette.textSecondary
)
.frame(maxWidth: .infinity, minHeight: 48)
.background(
isActionAvailable ? palette.accent : palette.surfaceElevated,
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(
isActionAvailable ? Color.clear : palette.dividerStrong,
lineWidth: 0.5
)
)
.contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
}
.buttonStyle(.borderedProminent)
.tint(palette.accent)
.disabled(!corpus.isReady || isGeneratingLearnedStyle || reachedLimit)
.buttonStyle(.plain)
.disabled(!canGenerate)
Text(
reachedLimit
+8 -8
View File
@@ -641,14 +641,14 @@
"polishStyles.custom.section" = "My styles";
"polishStyles.intro.title" = "Choose a writing personality";
"polishStyles.intro.body" = "The selected style shapes every polished dictation. Your custom styles sync through iCloud when settings sync is enabled.";
"polishStyles.learn.title" = "Learn and generate my speaking style";
"polishStyles.learn.body" = "Learn your recurring speaking habits while using earlier polish prompts only to filter out AI-added style.";
"polishStyles.learn.progress" = "%lld / %lld effective characters";
"polishStyles.learn.remaining" = "%lld more needed";
"polishStyles.learn.ready" = "Ready to generate";
"polishStyles.learn.action" = "Generate My Style";
"polishStyles.learn.generating" = "Learning…";
"polishStyles.learn.privacy" = "Only after you tap Generate, eligible before-and-after text and prior style prompts are sent to your configured AI service. You can review the result before saving.";
"polishStyles.learn.title" = "Generate Your Style";
"polishStyles.learn.body" = "Learn how you naturally express yourself from past dictation.";
"polishStyles.learn.progress" = "%lld / %lld characters";
"polishStyles.learn.remaining" = "%lld to go";
"polishStyles.learn.ready" = "Ready";
"polishStyles.learn.action" = "Generate Style";
"polishStyles.learn.generating" = "Generating…";
"polishStyles.learn.privacy" = "Sent to your configured AI only when you generate. Review and edit before saving.";
"polishStyles.learn.limit" = "Delete a custom style before generating another one.";
"polishStyles.learn.error.insufficient" = "Keep dictating until 5,000 effective characters are available.";
"polishStyles.learn.error.invalidResponse" = "The AI did not return a valid writing style. Please try again.";
@@ -640,14 +640,14 @@
"polishStyles.custom.section" = "我的风格";
"polishStyles.intro.title" = "选择完整写作人格";
"polishStyles.intro.body" = "选中的风格会影响每次听写润色。开启设置同步后,自定义风格会通过 iCloud 保存。";
"polishStyles.learn.title" = "学习并生成我的说话风格";
"polishStyles.learn.body" = "学习你反复出现的说话习惯;历史润色 Prompt 只用于排除 AI 附加的风格。";
"polishStyles.learn.progress" = "%lld / %lld 个有效字符";
"polishStyles.learn.remaining" = "还 %lld ";
"polishStyles.learn.ready" = "可以开始生成";
"polishStyles.learn.action" = "生成我的风格";
"polishStyles.learn.generating" = "正在学习…";
"polishStyles.learn.privacy" = "只有点击生成后,符合条件的润色前后文本和历史风格 Prompt 才会发送给你配置的 AI 服务;保存前可以先检查和修改。";
"polishStyles.learn.title" = "生成专属风格";
"polishStyles.learn.body" = "从历史听写中学习你的表达习惯。";
"polishStyles.learn.progress" = "%lld / %lld ";
"polishStyles.learn.remaining" = "还 %lld ";
"polishStyles.learn.ready" = "可以生成";
"polishStyles.learn.action" = "生成风格";
"polishStyles.learn.generating" = "生成中…";
"polishStyles.learn.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。";
"polishStyles.learn.limit" = "请先删除一个自定义风格,再生成新风格。";
"polishStyles.learn.error.insufficient" = "请继续听写,累积到 5,000 个有效字符后再生成。";
"polishStyles.learn.error.invalidResponse" = "AI 没有返回有效的润色风格,请重试。";
@@ -115,7 +115,8 @@ final class AIKeyboardCoordinator {
var instruction = AIClipboardSkillCatalog.instruction(
for: skill,
locale: AIHintLocaleResolver.packLocale(),
translationTargetLocaleId: state.translationTargetLocaleId
translationTargetLocaleId: state.translationTargetLocaleId,
replyStyle: state.clipboardReplyStyle
)
if skill.kind == .export {
instruction += "\nPreserve the source language, addresses, names, and proper nouns."
@@ -124,6 +124,12 @@ public struct AppGroupPersistor {
userCatalog: store.agentUserSkillCatalog,
uiLanguage: language
)
let activeStyle = store.activePolishStyle
// Built-in formal/corporate personalities must not make ordinary
// clipboard replies sound less like the user.
state.clipboardReplyStyle = AIClipboardReplyStyleContext.resolve(
activeStyle: activeStyle
)
}
/// Cloud without ASR/LLM keys blocks the mic. Local ASR still works when
@@ -4,7 +4,9 @@
// The single HTTP exit for account, auth, and integrity traffic.
import Foundation
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
public protocol AccountHTTPTransport: Sendable {
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
@@ -5,7 +5,9 @@
// mutating an AccountSession.
import Foundation
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
public actor OOBEGrantProvisioningCoordinator {
private let apiClient: AccountAPIClient
+1 -1
View File
@@ -128,7 +128,7 @@ struct MacPolishStylesView: View {
return VStack(alignment: .leading, spacing: Spacing.md) {
HStack(alignment: .top, spacing: Spacing.md) {
Image(systemName: "waveform.badge.sparkles")
Image(systemName: "waveform")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(palette.accent)
.frame(width: 42, height: 42)
+108 -23
View File
@@ -15,6 +15,30 @@ public enum AIClipboardSkillKind: String, Codable, Sendable {
case export
}
/// Optional user-owned style distilled from dictation history or authored in
/// the Styles page. Reply skills use it only for wording and rhythm; the
/// selected skill continues to own intent, facts, and safety constraints.
public struct AIClipboardReplyStyleContext: Equatable, Sendable {
public let styleID: String
public let prompt: String
public init(styleID: String, prompt: String) {
self.styleID = styleID
self.prompt = prompt
}
/// Only user-owned packs are eligible. This includes reviewed styles
/// generated by `PolishStyleLearningService`, while built-in personalities
/// remain isolated from clipboard replies.
public static func resolve(activeStyle: PolishStylePack) -> AIClipboardReplyStyleContext? {
guard activeStyle.kind == .user else { return nil }
let prompt = PolishStylePackCatalog.runtimePersonality(for: activeStyle)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !prompt.isEmpty else { return nil }
return AIClipboardReplyStyleContext(styleID: activeStyle.id, prompt: prompt)
}
}
public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
public let id: String
public let systemImage: String
@@ -44,6 +68,9 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
public var requiresShortcut: Bool { kind == .export && shortcutName != nil }
public var isUserCreated: Bool { id.hasPrefix("user.") }
public var isOfficial: Bool { id.hasPrefix("official.") }
public var supportsReplyStyle: Bool {
AIClipboardSkillCatalog.replyStyleSkillIDs.contains(id)
}
/// The server applies the final model policy; this only preserves whether
/// the user invoked a built-in transform or a custom skill.
public var managedGatewayTaskKind: ManagedGatewayTaskKind {
@@ -99,6 +126,17 @@ public enum AIClipboardSkillCatalog: Sendable {
public static let askForDetailsID = "askForDetails"
public static let businessReplyID = "businessReply"
public static let organizeListID = "organizeList"
public static let replyStyleSkillIDs: Set<String> = [
replyID,
replyInSourceLanguageID,
acceptInvitationID,
declineInvitationID,
acceptTaskID,
clarifyRequestID,
empathyReplyID,
askForDetailsID,
businessReplyID
]
public static let extractTodosID = "extractTodos"
public static let extractTodosShortcutName = "OSGExtractTodos"
public static let extractTodosResourceName = "OSGExtractTodos"
@@ -341,18 +379,27 @@ public enum AIClipboardSkillCatalog: Sendable {
for skill: AIClipboardSkill,
locale: String,
translationTargetLocaleId: String,
replyStyle: AIClipboardReplyStyleContext? = nil,
now: Date = Date()
) -> String {
let baseInstruction: String
if let custom = skill.customPrompt?
.trimmingCharacters(in: .whitespacesAndNewlines),
!custom.isEmpty {
return custom
baseInstruction = custom
} else {
baseInstruction = instruction(
skillID: skill.id,
locale: locale,
translationTargetLocaleId: translationTargetLocaleId,
now: now
)
}
return instruction(
skillID: skill.id,
guard skill.supportsReplyStyle else { return baseInstruction }
return replyInstruction(
baseInstruction,
locale: locale,
translationTargetLocaleId: translationTargetLocaleId,
now: now
style: replyStyle
)
}
@@ -386,12 +433,12 @@ public enum AIClipboardSkillCatalog: Sendable {
switch skillID {
case replyID:
return zh
? "请根据剪贴板内容起草一段礼貌、简洁的回复,使用原文的主要语言,语气自然可直接发送。"
: "Draft a concise, polite reply in the clipboard text's primary language that the user can send."
? "请根据剪贴板内容用原文的主要语言起草一段简短、自然可直接发送的聊天回复。像本人顺手回消息,不要写成正式邮件或客服话术"
: "Draft a short, natural chat reply in the clipboard text's primary language. Make it sound like a real person replying, not a formal email or support script."
case replyInSourceLanguageID:
return zh
? "请理解剪贴板内容,并严格使用原文的主要语言起草自然、简洁、可直接发送的回复。不要翻译,不要解释"
: "Understand the clipboard text and draft a natural, concise, sendable reply strictly in its primary language. Do not translate or explain."
? "请理解剪贴板内容,并严格使用原文的主要语言写一段简短、口语化、可直接发送的回复。不要翻译、解释或使用正式套话"
: "Understand the clipboard text and write a short, conversational reply strictly in its primary language. Do not translate, explain, or use formal boilerplate."
case summarizeID:
return zh
? "请概括剪贴板内容的核心意思,保留关键事实与结论,不要改写成可发送的短消息。"
@@ -407,32 +454,32 @@ public enum AIClipboardSkillCatalog: Sendable {
)
case acceptInvitationID:
return zh
? "根据剪贴板中的邀约,起草一段自然、简洁的接受回复,复述必要的时间或地点以便确认。不要虚构用户没有表达的安排。"
: "Draft a natural, concise acceptance of the invitation. Confirm any necessary time or place, without inventing the user's plans."
? "自然、爽快地接受剪贴板中的邀约,像聊天一样确认必要的时间或地点。不要客套过头,也不要虚构用户的安排。"
: "Accept the invitation in a relaxed, natural chat tone and confirm any necessary time or place. Avoid excessive pleasantries and invented plans."
case declineInvitationID:
return zh
? "根据剪贴板中的邀约,起草一段礼貌、真诚的婉拒回复;表达感谢但不过度解释,也不要虚构理由。"
: "Draft a polite, sincere decline to the invitation. Express appreciation without overexplaining or inventing a reason."
? "用自然、不端着的口吻婉拒剪贴板中的邀约。可以简单表达感谢但不过度道歉、长篇解释或虚构理由。"
: "Decline the invitation naturally without sounding stiff. A brief thank-you is fine; avoid excessive apology, long explanations, or invented reasons."
case acceptTaskID:
return zh
? "剪贴板中的任务或行动请求起草确认回复,明确已理解的事项和截止时间;不要承诺原文未要求或用户无法确认的结果"
: "Draft an acknowledgement of the task or action request, confirming the understood deliverable and deadline. Do not invent commitments."
? "像聊天一样简短确认收到剪贴板中的任务或行动请求,可自然带上事项和截止时间。不要写成正式回执,也不要虚构承诺"
: "Acknowledge the task or action request in a short chat-style reply, naturally confirming the work and deadline. Do not sound like a formal receipt or invent commitments."
case clarifyRequestID:
return zh
? "请找出剪贴板内容中阻碍执行或回答的关键信息缺口,并起草一段简洁回复,最多提出两个最必要的澄清问题。"
: "Identify the key missing information needed to act or answer, then draft a concise reply with at most two essential clarifying questions."
? "请找出执行或回答前最缺的关键信息,用自然聊天口吻追问,最多两个最必要的问题,不要像表单或审问"
: "Find the key missing information needed to act or answer, then ask at most two essential questions in a natural chat tone, not like a form or interrogation."
case empathyReplyID:
return zh
? "针对剪贴板中的不满、投诉或负面反馈起草回复:先表达理解,再确认核心问题,最后给出稳妥下一步;不要推诿或过度承诺。"
: "Reply to the complaint or negative feedback with empathy, acknowledgement of the core issue, and a safe next step. Do not deflect or overpromise."
? "先用日常口语接住对方的不满,再确认核心问题给出稳妥下一步。避免“深表歉意”“给您带来不便”等客服模板,不推诿或过度承诺。"
: "Respond to the frustration in everyday language, acknowledge the core issue, and give a safe next step. Avoid canned support phrases, deflection, and overpromising."
case askForDetailsID:
return zh
? "针对剪贴板描述的问题起草一段追问回复,只询问定位或处理问题所必需的细节,问题清晰且不重复"
: "Draft a follow-up that asks only for the details necessary to diagnose or resolve the issue. Keep questions clear and non-repetitive."
? "用自然聊天口吻追问定位或处理问题真正需要的细节,问题简短、不重复,不要像客服问卷"
: "Ask only for the details truly needed to diagnose or resolve the issue, using a short natural chat tone rather than a support questionnaire."
case businessReplyID:
return zh
? "根据剪贴板内容起草一段专业、克制、清晰的商务回复,保留人名、组织名、时间和承诺边界,可直接发送。"
: "Draft a professional, measured, clear business reply. Preserve names, organizations, dates, and commitment boundaries; make it sendable."
? "写一段专业但不官腔的商务聊天回复,表达直接、自然,保留人名、组织名、时间和承诺边界,可直接发送。不要套用正式邮件开场和结尾。"
: "Write a professional but conversational business reply. Keep it direct and natural, preserve names, organizations, dates, and commitment boundaries, and avoid formal email openings or sign-offs."
case organizeListID:
return zh
? "请把剪贴板中的清单、议程或步骤整理成结构清晰、顺序合理的列表。合并重复项,保留原意,不新增任务。"
@@ -537,6 +584,44 @@ public enum AIClipboardSkillCatalog: Sendable {
: "It is now \(stamp) (device local timezone)."
}
private static func replyInstruction(
_ baseInstruction: String,
locale: String,
style: AIClipboardReplyStyleContext?
) -> String {
let zh = locale == "zh"
let conversationalBaseline = zh
? """
线 AI使 13
"""
: """
Voice baseline: sound like a real person replying in chat, not a formal memo, support template, or AI. Prefer short sentences and everyday wording. Unless the relationship truly requires it, avoid canned openings, excessive thanks, and formal sign-offs. Usually write 13 sentences with no title, quotation marks, or explanation.
"""
guard let style,
!style.prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return "\(baseInstruction)\n\(conversationalBaseline)"
}
let boundedStyle = String(
style.prompt
.trimmingCharacters(in: .whitespacesAndNewlines)
.prefix(PolishStyleLimits.maximumPromptCharacters)
)
let personalStyle = zh
? """
<user_reply_style id="\(style.styleID)">
\(boundedStyle)
</user_reply_style>
"""
: """
<user_reply_style id="\(style.styleID)">
\(boundedStyle)
</user_reply_style>
Apply only stable wording, rhythm, and expression habits from this style. It must not change the selected skill's intent, facts, safety boundaries, or output language; the selected skill wins on conflict.
"""
return "\(baseInstruction)\n\(conversationalBaseline)\n\(personalStyle)"
}
/// Uses the keyboard translation target when set; otherwise Chinese English.
private static func translateInstruction(
locale: String,
@@ -154,6 +154,9 @@ public final class KeyboardState: ObservableObject {
/// visibility is independent from the Skills page installation state.
@Published public var clipboardSkillCatalog: [AIClipboardSkill] =
AIClipboardSkillCatalog.catalog
/// User-owned speaking style injected only into conversational reply skills.
/// Built-in polish personalities intentionally leave this nil.
@Published public var clipboardReplyStyle: AIClipboardReplyStyleContext?
/// Export skills whose companion Shortcut setup the user confirmed.
@Published public var confirmedClipboardShortcutIDs: [String] = []
/// App language captured with the same App Group snapshot as skill copy.
+8 -8
View File
@@ -188,14 +188,14 @@
"mac.styles.allowsAddedEmoji.hint" = "When on, polish may add a few emojis that match the drafts emotion, and keeps them on screen. Off by default.";
"mac.styles.prompt" = "Complete prompt";
"mac.styles.hint" = "Use {{DICTIONARY}} to place the personal dictionary. System rules are appended automatically.";
"mac.styles.learn.title" = "Learn and generate my speaking style";
"mac.styles.learn.body" = "Learn your recurring speaking habits while using earlier polish prompts only to filter out AI-added style.";
"mac.styles.learn.progress" = "%lld / %lld effective characters";
"mac.styles.learn.remaining" = "%lld more needed";
"mac.styles.learn.ready" = "Ready to generate";
"mac.styles.learn.action" = "Generate My Style";
"mac.styles.learn.generating" = "Learning…";
"mac.styles.learn.privacy" = "Eligible text and prior style prompts are sent to your configured AI only after you generate. Review before saving.";
"mac.styles.learn.title" = "Generate Your Style";
"mac.styles.learn.body" = "Learn how you naturally express yourself from past dictation.";
"mac.styles.learn.progress" = "%lld / %lld characters";
"mac.styles.learn.remaining" = "%lld to go";
"mac.styles.learn.ready" = "Ready";
"mac.styles.learn.action" = "Generate Style";
"mac.styles.learn.generating" = "Generating…";
"mac.styles.learn.privacy" = "Sent to your configured AI only when you generate. Review and edit before saving.";
"mac.styles.learn.limit" = "Delete a custom style before generating another one.";
"mac.styles.learn.error.insufficient" = "Keep dictating until 5,000 effective characters are available.";
"mac.styles.learn.error.invalidResponse" = "The AI did not return a valid writing style. Please try again.";
@@ -187,14 +187,14 @@
"mac.styles.allowsAddedEmoji.hint" = "开启后,润色可按原文情绪点缀少量 emoji,并保留上屏。默认关闭。";
"mac.styles.prompt" = "完整提示词";
"mac.styles.hint" = "使用 {{DICTIONARY}} 指定个人词典位置;系统规则会自动追加。";
"mac.styles.learn.title" = "学习并生成我的说话风格";
"mac.styles.learn.body" = "学习你反复出现的说话习惯;历史润色 Prompt 只用于排除 AI 附加的风格。";
"mac.styles.learn.progress" = "%lld / %lld 个有效字符";
"mac.styles.learn.remaining" = "还 %lld ";
"mac.styles.learn.ready" = "可以开始生成";
"mac.styles.learn.action" = "生成我的风格";
"mac.styles.learn.generating" = "正在学习…";
"mac.styles.learn.privacy" = "只有点击生成后,符合条件的文本和历史风格 Prompt 才会发送给你配置的 AI保存前可以先检查。";
"mac.styles.learn.title" = "生成专属风格";
"mac.styles.learn.body" = "从历史听写中学习你的表达习惯。";
"mac.styles.learn.progress" = "%lld / %lld ";
"mac.styles.learn.remaining" = "还 %lld ";
"mac.styles.learn.ready" = "可以生成";
"mac.styles.learn.action" = "生成风格";
"mac.styles.learn.generating" = "生成中…";
"mac.styles.learn.privacy" = "仅在生成时发送给你配置的 AI保存前可预览和修改。";
"mac.styles.learn.limit" = "请先删除一个自定义风格,再生成新风格。";
"mac.styles.learn.error.insufficient" = "请继续听写,累积到 5,000 个有效字符后再生成。";
"mac.styles.learn.error.invalidResponse" = "AI 没有返回有效的润色风格,请重试。";
@@ -260,6 +260,65 @@ final class AIClipboardSkillTests: XCTestCase {
XCTAssertFalse(prompts.contains { $0.contains("用户选择的操作") })
}
func testReplyUsesConversationalBaselineAndOptionalLearnedStyle() throws {
let skill = try XCTUnwrap(
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.replyID)
)
let instruction = AIClipboardSkillCatalog.instruction(
for: skill,
locale: "zh",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
replyStyle: AIClipboardReplyStyleContext(
styleID: "user.learned",
prompt: "喜欢短句,常用“行”“可以”,不说客套话。"
)
)
XCTAssertTrue(instruction.contains("真实的人在聊天软件里"))
XCTAssertTrue(instruction.contains("<user_reply_style"))
XCTAssertTrue(instruction.contains("喜欢短句"))
XCTAssertTrue(instruction.contains("不能改变当前技能的意图"))
}
func testReplyStyleIsNotInjectedIntoNonReplySkill() throws {
let skill = try XCTUnwrap(
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.summarizeID)
)
let instruction = AIClipboardSkillCatalog.instruction(
for: skill,
locale: "zh",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
replyStyle: AIClipboardReplyStyleContext(
styleID: "user.learned",
prompt: "这是个人回复风格"
)
)
XCTAssertFalse(instruction.contains("user_reply_style"))
XCTAssertFalse(instruction.contains("这是个人回复风格"))
}
func testReplyStyleResolverAcceptsOnlyUserOwnedStyle() {
let user = PolishStylePack(
id: "user.learned",
name: "我的风格",
prompt: "喜欢短句",
kind: .user
)
let builtIn = PolishStylePack(
id: "builtin.formal",
name: "正式",
prompt: "使用正式表达",
kind: .builtin
)
XCTAssertEqual(
AIClipboardReplyStyleContext.resolve(activeStyle: user)?.prompt,
"喜欢短句"
)
XCTAssertNil(AIClipboardReplyStyleContext.resolve(activeStyle: builtIn))
}
func testExtractTodosAsksForNONEWhenEmpty() {
let prompt = AIClipboardSkillCatalog.instruction(
skillID: AIClipboardSkillCatalog.extractTodosID,
+2 -2
View File
@@ -40,7 +40,7 @@
<ul>
<li><strong>Voice audio</strong> — captured only while you actively record. On a <strong>local engine</strong>, audio is transcribed on-device and raw audio is not uploaded. With a user-configured cloud engine, recordings go directly to that provider. If you explicitly select managed credits, recordings go through the OSGKeyboard managed gateway at <code>account.osglab.com</code> to the managed ASR provider. Before the first switch to managed credits, the app explains that audio, text, nearby cursor context, and account data may leave the device and requires explicit agreement. OSGKeyboard does not retain raw recordings after processing.</li>
<li><strong>Transcribed text and cursor context</strong> — after ASR, the transcript (not audio) may be sent for polish when you use a configured LLM or managed credits. To continue naturally at the insertion point, a small amount of text immediately before and after the cursor may be included. Secure fields are never captured; cursor context is not written to logs or voice history. User-configured requests go directly to that provider; managed-credit requests go through <code>account.osglab.com</code>. Without either path, raw local ASR text can still be inserted.</li>
<li><strong>Speaking-style learning</strong> — voice history can retain the displayed final text and, when available, the corrected ASR text from before AI polishing plus a deduplicated snapshot of the polish-style prompt used for that take. The pre-polish text is not shown in History. Only after you tap “Generate My Style” are eligible before-and-after pairs and their prior style prompts sent to your configured AI service. Native speech and explicit user edits are used to infer recurring habits; historical prompts are supplied only to distinguish and remove AI-added style. User-configured requests go directly to that provider; managed-credit requests go through <code>account.osglab.com</code>. The generated prompt is shown for review and is not saved or activated until you confirm it.</li>
<li><strong>Speaking-style learning</strong> — voice history can retain the displayed final text and, when available, the corrected ASR text from before AI polishing plus a deduplicated snapshot of the polish-style prompt used for that take. The pre-polish text is not shown in History. Only after you tap “Generate My Style” are eligible before-and-after pairs and their prior style prompts sent to your configured AI service. Native speech and explicit user edits are used to infer recurring habits; historical prompts are supplied only to distinguish and remove AI-added style. The generated prompt is shown for review and is not saved or activated until you confirm it. When you later invoke a conversational clipboard-reply skill while a reviewed user-created style is active, that style prompt is sent with the copied text so the reply can follow your wording and rhythm; built-in polish personalities are not attached. User-configured requests go directly to that provider; managed-credit requests go through <code>account.osglab.com</code>.</li>
<li><strong>AI mode questions</strong> — in AI keyboard mode, your spoken question text—or a question you tap from an idle suggestion—is sent to the selected AI service. User-configured requests go directly to that provider; managed-credit requests go through <code>account.osglab.com</code>. When the selected service supports server-side web search, it may retrieve public web results for time-sensitive answers.</li>
<li><strong>AI suggestions and OSGKeyboard web pages</strong> — the main app may request public suggestion titles from <code>key.osglab.com</code>; no voice, transcript, clipboard body, API key, or ordinary keystroke content is included in that request. Opening the update page requests public content from <code>download.osglab.com</code>. As with ordinary HTTPS sites, those hosts and their infrastructure may process connection metadata such as IP address and request time.</li>
<li><strong>AI Agent skills, Shortcuts, and maps</strong> — when you deliberately run a skill, the selected or copied text and skill prompt are sent to your configured LLM provider. For Reminders, Calendar, or Notes exports, the generated result is passed on-device to the Apple Shortcut you install; OSGKeyboard does not receive the resulting item. For navigation, an address or route is placed in a URL opened by Apple Maps, Amap, or Baidu Maps, and the selected map provider's policy applies.</li>
@@ -105,7 +105,7 @@
<ul>
<li><strong>语音音频</strong> — 仅在你主动录音时采集。使用<strong>本地引擎</strong>时,音频在设备端完成转写,原始录音不会上传。使用用户自备云端服务时,录音会直接发送到你配置的识别服务商;若你明确选择「使用积分」,录音会经 <code>account.osglab.com</code> 的 OSGKeyboard 托管网关发送到托管识别服务商。首次切换托管积分前,App 会说明语音、文字、光标附近上下文与账号数据可能离开设备,并要求你明确同意。请求处理完成后,OSGKeyboard 不保留原始录音。</li>
<li><strong>转写文字与光标上下文</strong> — 使用已配置的 LLM 或托管积分时,转写文字(非音频)可能发送润色,并可能包含光标附近少量文字以自然衔接。密码框不会采集,光标上下文不会写入日志或语音历史。用户自备请求直达所选服务商;托管积分请求经 <code>account.osglab.com</code> 处理。未选择任一路径时,仍可直接插入本地 ASR 原始文字。</li>
<li><strong>说话风格学习</strong> — 语音历史可同时保留界面展示的最终文本、可用时保留 AI 润色前且已经过确定性纠错的 ASR 文本,以及当次使用的润色风格 Prompt 去重快照;润色前文本不会在历史界面显示。只有当你主动点击「生成我的风格」后,符合条件的润色前后文本对及对应历史 Prompt 才会发送给你配置的 AI 服务。原生口述与用户明确编辑用于归纳反复出现的习惯;历史 Prompt 只用于识别并排除 AI 附加风格。用户自备请求直达所选服务商;托管积分请求经 <code>account.osglab.com</code> 处理。生成结果会先展示供你检查,确认保存前不会写入或启用。</li>
<li><strong>说话风格学习</strong> — 语音历史可同时保留界面展示的最终文本、可用时保留 AI 润色前且已经过确定性纠错的 ASR 文本,以及当次使用的润色风格 Prompt 去重快照;润色前文本不会在历史界面显示。只有当你主动点击「生成我的风格」后,符合条件的润色前后文本对及对应历史 Prompt 才会发送给你配置的 AI 服务。原生口述与用户明确编辑用于归纳反复出现的习惯;历史 Prompt 只用于识别并排除 AI 附加风格。生成结果会先展示供你检查,确认保存前不会写入或启用。之后,当你启用了经检查的用户自定义风格并主动使用口语回复类剪贴板技能时,该风格 Prompt 会随复制文本一起发送,用于贴近你的用词与节奏;内置润色人格不会附加。用户自备请求直达所选服务商;托管积分请求经 <code>account.osglab.com</code> 处理。</li>
<li><strong>AI 模式问题</strong> — 在 AI 键盘模式下,语音转写后的问题文字或你点选的建议会发送到当前选择的 AI 服务。用户自备请求直达所选服务商;托管积分请求经 <code>account.osglab.com</code> 处理。所选服务支持联网搜索时,可能检索公开网页结果回答时效性问题。</li>
<li><strong>AI 建议与 OSGKeyboard 网页</strong> — 主 App 可能从 <code>key.osglab.com</code> 请求公开建议标题;该请求不包含语音、转写、剪贴板正文、API Key 或普通击键内容。打开更新页时会从 <code>download.osglab.com</code> 请求公开内容。与普通 HTTPS 网站相同,相关主机与基础设施可能处理 IP 地址、请求时间等连接元数据。</li>
<li><strong>AI Agent 技能、快捷指令与地图</strong> — 仅在你主动运行技能时,所选或复制的文字及技能提示词才会发送到你配置的 LLM 服务商。导出提醒事项、日历或备忘录时,生成结果会在设备端交给你安装的 Apple 快捷指令;OSGKeyboard 不会收到最终创建的项目。导航时,地址或路线会写入 URL 并交给 Apple 地图、高德地图或百度地图,相应地图服务商的政策适用。</li>