Files
edgeever/apps/ios/EdgeEver/Features/Workspace/AiAssistantSheet.swift
T
Dariusz Koryto 80dadb3194 feat(i18n): add Polish as an in-app locale (#473)
* feat(editor): add setting to turn off spellcheck underlines

Add a device-local "Underline spelling mistakes" switch to the editing
preferences. The rich-text editor and the mobile plain-text editor now
set spellcheck from this preference and update live when it changes.
Defaults to enabled, so existing behavior is unchanged.

Refs #468

* feat(i18n): add Polish as an in-app locale

Add Polish (pl) across clients, mirroring how Japanese was added:

- Shared: full pl catalog, locale matching for pl/pl-*, Polish memo
  templates and factory AI prompts, mobile editor strings, and Polish as
  an AI translation target and selection-translate chip.
- Web: "Polski" in the language picker; the catalog is lazy-loaded in its
  own i18n-pl chunk and kept out of the PWA precache like ja.
- Android: pl locale option, mobile-only strings, web-clip labels.
  Messages that only had an English branch now use English for every
  non-Chinese locale, so Japanese no longer falls back to Chinese there.
- iOS: .polish UI language (pl-PL), pl argument for every t() call,
  pl in CFBundleLocalizations and Localizable.xcstrings.
- Companion: accepts locale "pl" and detects Polish requests.
- Browser extension: _locales/pl messages.
- Marketing site: /pl/ pages, hreflang, sitemap, language switcher.
- Google Play listing copy for pl-PL (manual publish only).

* fix(ios): show the note editor in Polish and Japanese

The iOS editor received every interface language except English as
Chinese, so with Polish or Japanese selected its toolbar and image
gallery controls were labeled in Chinese. Pass Polish and Japanese
through to the embedded editor, label its toolbar and the shared image
gallery controls in both languages, and rebuild the checked-in
EditorBundle. The Android editor uses the same gallery labels.

Also translate the last iOS strings that had no Polish text: the
follow-system language label, note created/updated times and the
current-device marker.

Refs #473

* fix(companion): recognize Polish typed without diacritics

Requests in Latin letters without Polish diacritics were always treated
as English, so with a Polish interface "Napisz plan na jutro" got an
English reply. With a Polish interface, such text is now told apart by
common English and Polish words; when it is still unclear, the recent
conversation and then the interface language decide. Other interface
languages keep treating Latin text as English.

Refs #473

* chore(ios): regenerate the Xcode project for Polish

Run xcodegen so the shipped Info.plist lists Polish in
CFBundleLocalizations and the project knows the pl region. The
hand-written AiDirectStream.swift entries are replaced by the IDs
xcodegen generates. MARKETING_VERSION and CURRENT_PROJECT_VERSION stay
in the project file because the release script and Xcode Cloud update
them there.

Refs #473

* i18n(pl): translate the sidebar shortcut, desktop menus and video scope

Add Polish for features that reached main while this branch was open:
the show/hide sidebar shortcut label, the desktop application and
editing context menus, and the new "ai:generate" MCP scope. The desktop
menus switch to Polish for pl and pl-* locales; other locales are
unchanged.

Refs #473
2026-10-06 16:18:38 +08:00

681 lines
28 KiB
Swift

import SwiftUI
import UIKit
enum AiDraftApplyMode: Sendable {
case append
case replace
}
private enum AssistantTone: String, CaseIterable, Identifiable {
case professional
case friendly
case casual
case direct
var id: String { rawValue }
}
private enum AssistantTargetLanguage: String, CaseIterable, Identifiable {
case english = "en"
case simplifiedChinese = "zh-CN"
case traditionalChinese = "zh-TW"
case japanese = "ja"
case korean = "ko"
case spanish = "es"
case french = "fr"
case german = "de"
case portuguese = "pt"
case polish = "pl"
var id: String { rawValue }
}
struct AiAssistantSheet: View {
@Environment(AppEnvironment.self) private var env
@Environment(\.dismiss) private var dismiss
let title: String
let sourceMarkdown: String
let isSelection: Bool
let onApply: (String, AiDraftApplyMode) async throws -> Void
init(memo: MemoDetail, onApply: @escaping (String, AiDraftApplyMode) async throws -> Void) {
title = memo.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
sourceMarkdown = memo.contentMarkdown
isSelection = false
self.onApply = onApply
}
init(
title: String,
selectedMarkdown: String,
onApply: @escaping (String, AiDraftApplyMode) async throws -> Void
) {
self.title = title.trimmingCharacters(in: .whitespacesAndNewlines)
sourceMarkdown = selectedMarkdown
isSelection = true
self.onApply = onApply
}
@State private var action: AiAction = .summarize
@State private var prompts: [AiPromptTemplate] = []
@State private var selectedPromptID: String?
@State private var targetLanguage: AssistantTargetLanguage = .english
@State private var tone: AssistantTone = .professional
@State private var customInstruction = ""
@State private var refineInstruction = ""
@State private var output = ""
@State private var error: String?
@State private var isGenerating = false
@State private var isApplying = false
@State private var didSetLanguageDefault = false
@State private var generationID = UUID()
@State private var streamTask: Task<Void, Never>?
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
Text(env.preferences.t(
"选择 AI 要做的事。输出只会作为草稿,确认后才会修改笔记。",
en: "Choose what AI should do. Output remains a draft until you apply it.",
pl: "Wybierz, co ma zrobić AI. Wynik pozostaje szkicem, dopóki go nie zastosujesz."
))
.font(.system(size: 13))
.foregroundStyle(AppTheme.muted)
actionPicker
if needsTargetLanguage {
languagePicker
}
if needsTone {
tonePicker
}
if selectedPrompt == nil, action == .custom {
VStack(alignment: .leading, spacing: 7) {
Text(env.preferences.t("告诉 AI 你想怎么处理", en: "Tell AI what to do", pl: "Powiedz AI, co ma zrobić"))
.font(.system(size: 13, weight: .bold))
TextField(
env.preferences.t(
"例如:改成适合周报的结构,保留所有数据",
en: "For example: Restructure this for a weekly report and keep all data",
pl: "Na przykład: przebuduj to na raport tygodniowy i zachowaj wszystkie dane"
),
text: $customInstruction,
axis: .vertical
)
.lineLimit(3...6)
.textInputAutocapitalization(.sentences)
.padding(12)
.background(AppTheme.card)
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 9, style: .continuous)
.stroke(AppTheme.cardBorder, lineWidth: 1)
}
}
}
HStack {
Text(env.preferences.t("AI 草稿", en: "AI draft", pl: "Szkic AI"))
.font(.system(size: 13, weight: .bold))
Spacer()
if isGenerating {
ProgressView()
.controlSize(.small)
Text(env.preferences.t("生成中…", en: "Generating…", pl: "Generowanie…"))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(AppTheme.accent)
}
}
Text(output.isEmpty
? env.preferences.t("生成的草稿会显示在这里。", en: "The generated draft will appear here.", pl: "Tutaj pojawi się wygenerowany szkic.")
: output)
.font(.system(size: 15))
.foregroundStyle(output.isEmpty ? AppTheme.muted : AppTheme.body)
.frame(maxWidth: .infinity, minHeight: 230, alignment: .topLeading)
.padding(14)
.background(AppTheme.card)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(AppTheme.cardBorder, lineWidth: 1)
}
.textSelection(.enabled)
if !output.isEmpty, !isGenerating {
VStack(alignment: .leading, spacing: 7) {
Text(env.preferences.t("继续调整", en: "Refine result", pl: "Dopracuj wynik"))
.font(.system(size: 13, weight: .bold))
HStack(spacing: 8) {
TextField(
env.preferences.t("例如:再简洁一点", en: "For example: Make it more concise", pl: "Na przykład: napisz to zwięźlej"),
text: $refineInstruction
)
.submitLabel(.send)
.onSubmit(refine)
.padding(.horizontal, 12)
.frame(height: 44)
.background(AppTheme.card)
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 9, style: .continuous)
.stroke(AppTheme.cardBorder, lineWidth: 1)
}
Button(env.preferences.t("调整", en: "Refine", pl: "Dopracuj"), action: refine)
.font(.system(size: 14, weight: .bold))
.buttonStyle(.borderedProminent)
.tint(AppTheme.accent)
.frame(height: 44)
.disabled(refineInstruction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
if let error, !error.isEmpty {
Text(error)
.font(.system(size: 13))
.foregroundStyle(AppTheme.dangerStrong)
}
}
.padding(16)
}
.background(AppTheme.background)
.navigationTitle(env.preferences.t("AI 笔记助手", en: "AI note assistant", pl: "Asystent AI notatek"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(env.preferences.t("关闭", en: "Close", pl: "Zamknij")) { dismiss() }
.disabled(isApplying)
}
}
.safeAreaInset(edge: .bottom) {
footer
}
}
.interactiveDismissDisabled(isApplying)
.onAppear {
guard !didSetLanguageDefault else { return }
applyStoredOrDefaultAction(from: prompts, allowPromptMatch: false)
didSetLanguageDefault = true
}
.onDisappear { streamTask?.cancel() }
.task { await loadPrompts() }
}
private var actionPicker: some View {
pickerField(
env.preferences.t("处理方式", en: "Action", pl: "Działanie"),
selection: selectedPrompt?.name ?? actionTitle(action)
) {
if prompts.isEmpty {
ForEach(availableActions) { item in
Button {
selectAction(item)
} label: {
if selectedPromptID == nil, action == item {
Label(actionTitle(item), systemImage: "checkmark")
} else {
Text(actionTitle(item))
}
}
}
} else {
ForEach(prompts) { prompt in
Button {
streamTask?.cancel()
selectedPromptID = prompt.id
action = prompt.action
output = ""
error = nil
persistLastAction()
} label: {
if selectedPromptID == prompt.id {
Label(prompt.name, systemImage: "checkmark")
} else {
Text(prompt.name)
}
}
}
Button {
selectAction(.custom)
} label: {
if selectedPromptID == nil, action == .custom {
Label(actionTitle(.custom), systemImage: "checkmark")
} else {
Text(actionTitle(.custom))
}
}
}
}
}
private var languagePicker: some View {
pickerField(env.preferences.t("目标语言", en: "Target language", pl: "Język docelowy"), selection: languageTitle(targetLanguage)) {
ForEach(AssistantTargetLanguage.allCases) { item in
Button {
targetLanguage = item
output = ""
error = nil
persistLastAction()
} label: {
if targetLanguage == item {
Label(languageTitle(item), systemImage: "checkmark")
} else {
Text(languageTitle(item))
}
}
}
}
}
private var tonePicker: some View {
pickerField(env.preferences.t("语气", en: "Tone", pl: "Ton"), selection: toneTitle(tone)) {
ForEach(AssistantTone.allCases) { item in
Button {
tone = item
output = ""
error = nil
persistLastAction()
} label: {
if tone == item {
Label(toneTitle(item), systemImage: "checkmark")
} else {
Text(toneTitle(item))
}
}
}
}
}
private func pickerField<MenuContent: View>(
_ title: String,
selection: String,
@ViewBuilder content: () -> MenuContent
) -> some View {
VStack(alignment: .leading, spacing: 7) {
Text(title)
.font(.system(size: 13, weight: .bold))
Menu(content: content) {
HStack {
Text(selection)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(AppTheme.body)
Spacer()
Image(systemName: "chevron.down")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(AppTheme.muted)
}
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, minHeight: 46)
.background(AppTheme.card)
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 9, style: .continuous)
.stroke(AppTheme.cardBorder, lineWidth: 1)
}
}
.buttonStyle(.plain)
}
}
private var footer: some View {
VStack(spacing: 10) {
HStack(spacing: 8) {
secondaryButton(env.preferences.t("复制", en: "Copy", pl: "Kopiuj"), systemImage: "doc.on.doc") {
UIPasteboard.general.string = output
}
if canAppendSource {
secondaryButton(
isSelection
? env.preferences.t("插入到选区后", en: "Insert after", pl: "Wstaw za")
: env.preferences.t("追加", en: "Append", pl: "Dołącz"),
systemImage: "text.append"
) {
apply(.append)
}
}
if canReplaceSource {
secondaryButton(
isSelection
? env.preferences.t("替换选中内容", en: "Replace selection", pl: "Zastąp zaznaczenie")
: env.preferences.t("替换", en: "Replace", pl: "Zastąp"),
systemImage: "arrow.triangle.2.circlepath"
) {
apply(.replace)
}
}
}
if isGenerating {
Button {
streamTask?.cancel()
} label: {
Label(env.preferences.t("停止", en: "Stop", pl: "Zatrzymaj"), systemImage: "stop.fill")
.font(.system(size: 15, weight: .bold))
.frame(maxWidth: .infinity, minHeight: 44)
}
.buttonStyle(.borderedProminent)
.tint(AppTheme.accent)
} else {
Button {
generate()
} label: {
Label(
output.isEmpty
? env.preferences.t("生成", en: "Generate", pl: "Generuj")
: env.preferences.t("重新生成", en: "Regenerate", pl: "Generuj ponownie"),
systemImage: output.isEmpty ? "sparkles" : "arrow.clockwise"
)
.font(.system(size: 15, weight: .bold))
.frame(maxWidth: .infinity, minHeight: 44)
}
.buttonStyle(.borderedProminent)
.tint(AppTheme.accent)
.disabled(isApplying || (selectedPrompt == nil && action == .custom && customInstruction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty))
}
}
.padding(.horizontal, 12)
.padding(.top, 10)
.padding(.bottom, 6)
.background(.regularMaterial)
.overlay(alignment: .top) { Rectangle().fill(AppTheme.cardBorder).frame(height: 1) }
}
private func secondaryButton(
_ title: String,
systemImage: String,
disabled: Bool = false,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Label(title, systemImage: systemImage)
.font(.system(size: 13, weight: .semibold))
.frame(maxWidth: .infinity, minHeight: 40)
}
.buttonStyle(.bordered)
.tint(AppTheme.body)
.disabled(disabled || output.isEmpty || isGenerating || isApplying)
}
private func actionTitle(_ action: AiAction) -> String {
switch action {
case .improveWriting: env.preferences.t("润色表达", en: "Polish", pl: "Popraw styl")
case .fixSpellingGrammar: env.preferences.t("修正拼写与语法", en: "Fix spelling & grammar", pl: "Popraw pisownię i gramatykę")
case .summarize: env.preferences.t("精简总结", en: "Summarize", pl: "Podsumuj")
case .extractKeyPoints: env.preferences.t("提炼要点", en: "Key points", pl: "Najważniejsze punkty")
case .extractTodos: env.preferences.t("提取待办", en: "Extract tasks", pl: "Wyodrębnij zadania")
case .rewriteProofread: env.preferences.t("改写与校对", en: "Rewrite & proofread", pl: "Przeredaguj i sprawdź")
case .makeShorter: env.preferences.t("精炼表达", en: "Make concise", pl: "Skróć")
case .makeLonger: env.preferences.t("扩写内容", en: "Make longer", pl: "Rozwiń")
case .simplifyLanguage: env.preferences.t("简化表达", en: "Simplify language", pl: "Uprość język")
case .changeTone: env.preferences.t("调整语气", en: "Change tone", pl: "Zmień ton")
case .translate: env.preferences.t("全文翻译", en: "Translate", pl: "Przetłumacz")
case .continueWriting: env.preferences.t("继续写作", en: "Continue writing", pl: "Pisz dalej")
case .custom: env.preferences.t("自定义指令", en: "Custom prompt", pl: "Własny prompt")
}
}
private func languageTitle(_ language: AssistantTargetLanguage) -> String {
switch language {
case .english: env.preferences.t("英语", en: "English", pl: "Angielski")
case .simplifiedChinese: env.preferences.t("简体中文", en: "Simplified Chinese", pl: "Chiński uproszczony")
case .traditionalChinese: env.preferences.t("繁体中文", en: "Traditional Chinese", pl: "Chiński tradycyjny")
case .japanese: env.preferences.t("日语", en: "Japanese", pl: "Japoński")
case .korean: env.preferences.t("韩语", en: "Korean", pl: "Koreański")
case .spanish: env.preferences.t("西班牙语", en: "Spanish", pl: "Hiszpański")
case .french: env.preferences.t("法语", en: "French", pl: "Francuski")
case .german: env.preferences.t("德语", en: "German", pl: "Niemiecki")
case .portuguese: env.preferences.t("葡萄牙语", en: "Portuguese", pl: "Portugalski")
case .polish: env.preferences.t("波兰语", en: "Polish", ja: "ポーランド語", pl: "Polski")
}
}
private func toneTitle(_ tone: AssistantTone) -> String {
switch tone {
case .professional: env.preferences.t("专业", en: "Professional", pl: "Profesjonalny")
case .friendly: env.preferences.t("友好", en: "Friendly", pl: "Przyjazny")
case .casual: env.preferences.t("轻松", en: "Casual", pl: "Swobodny")
case .direct: env.preferences.t("直接", en: "Direct", pl: "Bezpośredni")
}
}
private var availableActions: [AiAction] {
if isSelection {
return [
.summarize,
.translate,
.improveWriting,
.makeShorter,
.rewriteProofread,
.simplifyLanguage,
.custom,
]
}
return [
.summarize,
.translate,
.improveWriting,
.makeShorter,
.rewriteProofread,
.simplifyLanguage,
.custom,
]
}
private var canReplaceSource: Bool {
if let selectedPrompt {
return selectedPrompt.resultMode == .replace || selectedPrompt.resultMode == .both
}
switch action {
case .summarize, .extractKeyPoints, .extractTodos, .continueWriting:
return false
default:
return true
}
}
private var canAppendSource: Bool {
guard let selectedPrompt else { return true }
return selectedPrompt.resultMode == .append || selectedPrompt.resultMode == .both
}
private var selectedPrompt: AiPromptTemplate? {
guard let selectedPromptID else { return nil }
return prompts.first { $0.id == selectedPromptID }
}
private var defaultTargetLanguage: AssistantTargetLanguage {
switch env.preferences.uiLanguage {
case .chinese: return .english
case .japanese: return .japanese
case .polish: return .english
case .english: return .simplifiedChinese
}
}
private var promptLocale: String { env.preferences.apiLocale }
private var needsTargetLanguage: Bool {
selectedPrompt?.parameterKind == .targetLanguage || (selectedPrompt == nil && action == .translate)
}
private var needsTone: Bool {
selectedPrompt?.parameterKind == .tone || (selectedPrompt == nil && action == .changeTone)
}
private func request(for source: String, refinement: String? = nil) -> AiGenerateInput {
if let refinement, !refinement.isEmpty {
return AiGenerateInput(
action: .custom,
locale: env.preferences.apiLocale,
title: title,
contentMarkdown: source,
targetLanguage: nil,
instruction: refinement
)
}
return AiGenerateInput(
action: selectedPrompt?.action ?? action,
promptId: selectedPrompt?.id,
locale: env.preferences.apiLocale,
title: title,
contentMarkdown: source,
targetLanguage: needsTargetLanguage ? targetLanguage.rawValue : nil,
tone: needsTone ? tone.rawValue : nil,
instruction: selectedPrompt == nil && action == .custom
? customInstruction.trimmingCharacters(in: .whitespacesAndNewlines)
: nil
)
}
private func selectAction(_ nextAction: AiAction) {
streamTask?.cancel()
selectedPromptID = nil
action = nextAction
output = ""
error = nil
persistLastAction()
}
private func persistLastAction() {
env.preferences.setLastAiAssistantAction(
AiAssistantLastActionPreference(
action: action,
promptId: selectedPromptID,
seedKey: selectedPrompt?.seedKey,
targetLanguage: targetLanguage.rawValue,
tone: tone.rawValue
),
isSelection: isSelection
)
}
private func applyStoredOrDefaultAction(from loaded: [AiPromptTemplate], allowPromptMatch: Bool) {
let stored = isSelection ? env.preferences.lastAiAssistantAction(isSelection: true) : nil
let fallback: AiAction = isSelection ? .improveWriting : .custom
if let stored {
if allowPromptMatch, let promptId = stored.promptId, let match = loaded.first(where: { $0.id == promptId }) {
selectedPromptID = match.id
action = match.action
applyStoredParameters(stored)
return
}
if allowPromptMatch, let seedKey = stored.seedKey, let match = loaded.first(where: { $0.seedKey == seedKey }) {
selectedPromptID = match.id
action = match.action
applyStoredParameters(stored)
return
}
if stored.action == .custom || loaded.isEmpty {
selectedPromptID = nil
action = stored.action
applyStoredParameters(stored)
return
}
}
if fallback == .custom {
selectedPromptID = nil
action = .custom
targetLanguage = defaultTargetLanguage
return
}
if allowPromptMatch, let preferred = loaded.first(where: { $0.seedKey == fallback.rawValue }) ?? loaded.first {
selectedPromptID = preferred.id
action = preferred.action
return
}
selectedPromptID = nil
action = fallback
targetLanguage = defaultTargetLanguage
}
private func applyStoredParameters(_ stored: AiAssistantLastActionPreference) {
if let language = stored.targetLanguage.flatMap(AssistantTargetLanguage.init(rawValue:)) {
targetLanguage = language
} else {
targetLanguage = defaultTargetLanguage
}
if let storedTone = stored.tone.flatMap(AssistantTone.init(rawValue:)) {
tone = storedTone
}
}
@MainActor
private func loadPrompts() async {
do {
let locale = promptLocale
let loaded = try await env.session.client.listAiPrompts(locale: locale)
prompts = loaded
applyStoredOrDefaultAction(from: loaded, allowPromptMatch: true)
} catch {
// Keep the built-in action list as an offline/older-server fallback.
}
}
private func generate(source: String? = nil, refinement: String? = nil) {
streamTask?.cancel()
output = ""
error = nil
isGenerating = true
let requestID = UUID()
generationID = requestID
let input = request(for: source ?? sourceMarkdown, refinement: refinement)
streamTask = Task {
do {
let stream = await env.session.client.streamAiGeneration(input)
for try await event in stream {
try Task.checkCancellation()
switch event.type {
case "text-delta": output += event.text ?? ""
case "error": error = event.message ?? env.preferences.t("AI 生成失败。", en: "AI generation failed.", pl: "Generowanie AI nie powiodło się.")
default: break
}
}
} catch is CancellationError {
// User stopped generation.
} catch let apiError as APIError where apiError.code == "ai_not_configured" {
error = env.preferences.t(
"请先在 Web 或桌面端的“模型与代理”中配置模型。",
en: "Configure a model in Models and agents on the web or desktop app first.",
pl: "Najpierw skonfiguruj model w sekcji „Modele i agenci” w aplikacji webowej lub desktopowej."
)
} catch {
self.error = error.localizedDescription
}
if generationID == requestID {
isGenerating = false
streamTask = nil
}
}
}
private func refine() {
let instruction = refineInstruction.trimmingCharacters(in: .whitespacesAndNewlines)
guard !output.isEmpty, !instruction.isEmpty else { return }
let source = output
refineInstruction = ""
generate(source: source, refinement: instruction)
}
private func apply(_ mode: AiDraftApplyMode) {
guard !output.isEmpty, !isApplying else { return }
isApplying = true
error = nil
Task {
do {
try await onApply(output, mode)
dismiss()
} catch {
self.error = error.localizedDescription
isApplying = false
}
}
}
}