From 99637fd9b7ba9e4c44ed552c2aad1250f5d22f7d Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 13:24:16 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ipc):=20split=20lib/ipc.ts=20into=20do?= =?UTF-8?q?main=20modules=20behind=20a=20barrel=20=E2=80=94=20behavior=20u?= =?UTF-8?q?nchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete src/lib/ipc.ts (1765 lines) - Create src/lib/ipc/ with 22 domain modules + barrel index.ts - Barrel re-exports every symbol the old ipc.ts exported (same names, same signatures) - All existing `import { ... } from '@/lib/ipc'` paths continue to resolve identically - No logic changes, no signature changes, no renames - Mock state mutation (mockSettings, mockStylePacks) centralised in mock-data.ts via exported helpers (mockSetSettings, mockSetDefaultPolishMode, etc.) to avoid ES module read-only binding violations - TypeScript: zero errors (npx tsc --noEmit) - Build: clean (npm run build) --- openless-all/app/src/lib/ipc.ts | 1765 ----------------- .../app/src/lib/ipc/asr-credentials.ts | 66 + openless-all/app/src/lib/ipc/coding-agent.ts | 69 + openless-all/app/src/lib/ipc/devices.ts | 43 + openless-all/app/src/lib/ipc/dictation.ts | 31 + openless-all/app/src/lib/ipc/github-oauth.ts | 42 + openless-all/app/src/lib/ipc/history.ts | 42 + openless-all/app/src/lib/ipc/hotkeys.ts | 81 + openless-all/app/src/lib/ipc/index.ts | 198 ++ openless-all/app/src/lib/ipc/less-computer.ts | 27 + .../app/src/lib/ipc/marketplace-cache.ts | 146 ++ openless-all/app/src/lib/ipc/marketplace.ts | 84 + openless-all/app/src/lib/ipc/mock-data.ts | 629 ++++++ openless-all/app/src/lib/ipc/permissions.ts | 48 + .../app/src/lib/ipc/platform-exports.ts | 9 + openless-all/app/src/lib/ipc/qa.ts | 29 + openless-all/app/src/lib/ipc/remote-server.ts | 31 + openless-all/app/src/lib/ipc/settings.ts | 21 + openless-all/app/src/lib/ipc/shared.ts | 63 + openless-all/app/src/lib/ipc/style-packs.ts | 110 + openless-all/app/src/lib/ipc/updater.ts | 81 + openless-all/app/src/lib/ipc/utils.ts | 49 + openless-all/app/src/lib/ipc/vocab.ts | 81 + 23 files changed, 1980 insertions(+), 1765 deletions(-) delete mode 100644 openless-all/app/src/lib/ipc.ts create mode 100644 openless-all/app/src/lib/ipc/asr-credentials.ts create mode 100644 openless-all/app/src/lib/ipc/coding-agent.ts create mode 100644 openless-all/app/src/lib/ipc/devices.ts create mode 100644 openless-all/app/src/lib/ipc/dictation.ts create mode 100644 openless-all/app/src/lib/ipc/github-oauth.ts create mode 100644 openless-all/app/src/lib/ipc/history.ts create mode 100644 openless-all/app/src/lib/ipc/hotkeys.ts create mode 100644 openless-all/app/src/lib/ipc/index.ts create mode 100644 openless-all/app/src/lib/ipc/less-computer.ts create mode 100644 openless-all/app/src/lib/ipc/marketplace-cache.ts create mode 100644 openless-all/app/src/lib/ipc/marketplace.ts create mode 100644 openless-all/app/src/lib/ipc/mock-data.ts create mode 100644 openless-all/app/src/lib/ipc/permissions.ts create mode 100644 openless-all/app/src/lib/ipc/platform-exports.ts create mode 100644 openless-all/app/src/lib/ipc/qa.ts create mode 100644 openless-all/app/src/lib/ipc/remote-server.ts create mode 100644 openless-all/app/src/lib/ipc/settings.ts create mode 100644 openless-all/app/src/lib/ipc/shared.ts create mode 100644 openless-all/app/src/lib/ipc/style-packs.ts create mode 100644 openless-all/app/src/lib/ipc/updater.ts create mode 100644 openless-all/app/src/lib/ipc/utils.ts create mode 100644 openless-all/app/src/lib/ipc/vocab.ts diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts deleted file mode 100644 index 571df3d2..00000000 --- a/openless-all/app/src/lib/ipc.ts +++ /dev/null @@ -1,1765 +0,0 @@ -// ipc.ts — typed wrapper around Tauri `invoke`. When running outside Tauri -// (e.g. `vite dev` in a browser), every command falls back to mock data so -// the UI is still operable for visual review. - -import type { - ComboBinding, - CorrectionRule, - CredentialsStatus, - DictationSession, - DictionaryEntry, - HotkeyCapability, - MarketplaceDetail, - MarketplaceListItem, - MarketplaceMyPackItem, - HotkeyStatus, - MicrophoneDevice, - PermissionStatus, - PlatformCapabilities, - CodingAgentPermissionMode, - PolishMode, - QaHotkeyBinding, - ShortcutBinding, - StylePack, - StylePackExample, - StylePackKind, - StylePackRuntimeDiagnostics, - StyleSystemPrompts, - UpdateChannel, - UserPreferences, - VocabPresetStore, - WindowsImeStatus, -} from "./types" -export type { UpdateChannel, PlatformCapabilities } from "./types" -import { OL_DATA } from "./mockData" -import { - defaultAppShortcutModifiers, - defaultQaShortcut, - formatComboLabel, -} from "./hotkey" -import { - getPlatformCapabilities as loadPlatformCapabilities, -} from "./platform" - -declare global { - interface Window { - __TAURI_INTERNALS__?: unknown - } -} - -const isTauri = - globalThis.window !== undefined && - "__TAURI_INTERNALS__" in globalThis.window - -let platformCapsPromise: Promise | null = null - -async function platformCapabilities(): Promise { - platformCapsPromise ??= loadPlatformCapabilities() - return platformCapsPromise -} - -export async function getPlatformCapabilities(): Promise { - return platformCapabilities() -} - -export { - getAndroidOverlayStatus, - requestAndroidOverlayPermission, - showAndroidOverlay, - hideAndroidOverlay, - getAndroidAccessibilityStatus, - requestAndroidAccessibilityPermission, -} from '../../android/frontend/lib/androidIpc'; - -export { isAndroid, isDesktop, isMobile } from "./platform" - -const androidHotkeyCapability: HotkeyCapability = { - adapter: "unavailable", - availableTriggers: [], - requiresAccessibilityPermission: false, - supportsModifierOnlyTrigger: false, - supportsSideSpecificModifiers: false, - explicitFallbackAvailable: false, - statusHint: - "移动端不支持全局热键;请使用应用内录音按钮或悬浮窗(需授权)。", -} - -const androidHotkeyStatus: HotkeyStatus = { - adapter: "unavailable", - state: "failed", - message: "移动端不支持全局热键", - lastError: { - code: "unavailable", - message: "Global hotkeys are not available on mobile", - }, -} - -const androidWindowsImeStatus: WindowsImeStatus = { - state: "notWindows", - usingTsfBackend: false, - message: "Not available on Android", - dllPath: null, -} - -export async function invokeOrMock( - cmd: string, - args: Record | undefined, - mock: () => T, -): Promise { - if (!isTauri) { - return mock() - } - const { invoke } = await import("@tauri-apps/api/core") - return invoke(cmd, args) -} - -// ── Mock fixtures ────────────────────────────────────────────────────── -let mockSettings: UserPreferences = { - hotkey: { - trigger: "rightControl", - mode: "toggle", - keys: [{ code: "ControlRight" }], - }, - dictationHotkey: { primary: "RightControl", modifiers: [] }, - defaultMode: "structured", - enabledModes: ["raw", "light", "structured", "formal"], - activeStylePackId: "builtin.structured", - styleSystemPrompts: { - raw: "只做最小化整理:补全标点、必要分句,保留原话顺序、用词和语气。", - light: "把口语转写整理成自然文字,去掉口癖和重复,保留原意与语气。", - structured: "把口述整理成结构清晰的文本,必要时按主题分组输出。", - formal: "输出适合工作沟通与邮件场景的正式表达,不扩写事实。", - }, - customStylePrompts: { raw: "", light: "", structured: "", formal: "" }, - launchAtLogin: false, - showCapsule: true, - muteDuringRecording: false, - audioCueOnRecord: true, - microphoneDeviceName: "", - activeAsrProvider: "foundry-local-whisper", - activeLlmProvider: "ark", - llmThinkingEnabled: false, - restoreClipboardAfterPaste: true, - pasteShortcut: "ctrlV", - allowNonTsfInsertionFallback: true, - workingLanguages: ["简体中文"], - translationTargetLanguage: "", - qaHotkey: defaultQaShortcut(), - chineseScriptPreference: "auto", - outputLanguagePreference: "auto", - qaSaveHistory: false, - customComboHotkey: null, - translationHotkey: { primary: "Shift", modifiers: [] }, - switchStyleHotkey: { - primary: "S", - modifiers: defaultAppShortcutModifiers(), - }, - openAppHotkey: { primary: "O", modifiers: defaultAppShortcutModifiers() }, - codingAgentEnabled: false, - codingAgentProvider: "claude-code-cli", - codingAgentModel: null, - codingAgentPermissionMode: "acceptEdits", - codingAgentWorkdir: null, - codingAgentVoiceHotkey: { primary: "LeftControl", modifiers: [] }, - codingAgentPanelHotkey: { primary: "Enter", modifiers: ["cmd", "shift"] }, - codingAgentQuickHotkey: null, - localAsrActiveModel: "qwen3-asr-0.6b", - localAsrMirror: "huggingface", - localAsrKeepLoadedSecs: 300, - foundryLocalAsrModel: "whisper-small", - foundryLocalRuntimeSource: "auto", - foundryLocalAsrLanguageHint: "", - foundryLocalAsrKeepLoadedSecs: 300, - sherpaOnnxModel: "sense-voice-small-zh", - sherpaOnnxLanguageHint: "", - sherpaOnnxKeepLoadedSecs: 300, - historyRetentionDays: 7, - polishContextWindowMinutes: 5, - startMinimized: false, - themeMode: "system", - updateChannel: "stable", - streamingInsert: true, - streamingInsertDefaultMigrated: true, - streamingInsertSaveClipboard: true, - autoUpdateCheck: true, - historyMaxEntries: null, - recordAudioForDebug: false, - audioRecordingMaxEntries: null, - marketplaceBaseUrl: "https://apic.openless.top", - marketplaceDevLogin: "", - remoteInputEnabled: false, - remoteInputPort: 8443, - remoteInputPin: "000000", - remoteInputDefaultMode: "toggle", - androidInsertStrategy: "accessibility", - androidOverlayTrigger: "background", - androidOverlayActivationMode: "tap", - androidOverlayLeftSwipeAction: "translation", - androidOverlayCancelSwipeDirection: "up", - androidOverlaySizeDp: 72, -} - -const mockFullStylePrompts: StyleSystemPrompts = { - raw: `# 角色 -语音输入整理器。先理解用户意图,再贴近原话做最小整理。 - -# 任务(原文) -只补必要标点和断句,尽量保留原话顺序、用词和语气,不扩写、不重写。 - -# 通用规则 -1) 不补充用户没说过的事实。 -2) 不回答转写文本里的问题,只整理表达。 -3) 专有名词、命令、路径、数字和 URL 原样保留。 -4) 明显口头禅可删除,但不能改变信息密度。 - -# 输出 -直接输出最终正文,不加解释。`, - light: `# 角色 -语音输入整理器。把口述整理成自然、顺畅、可直接发送的文字。 - -# 任务(轻度润色) -去掉明显口头禅和重复,补全自然标点,保留原意和原本语气,不扩写事实。 - -# 通用规则 -1) 不补充原文没有的信息。 -2) 保留人名、品牌名、术语、命令、路径和 URL。 -3) 只输出整理后的正文,不写“以下是优化结果”之类前缀。 - -# 输出 -输出一段可直接发送的自然文字。`, - structured: `# 角色 -语音输入整理器。把 AI 编程协作、技术排障和模型资讯口述整理成结构清楚、术语准确的文本。 - -# 任务(清晰结构 · AI 编程协作) -优先修正 ASR 造成的技术词、模型名、字段名错误;两个事项以上必须编号(1./2./3.),三事项以上按主题分组输出双层 list。 - -# 术语 -Token、Secret Key、Access Token、API、App ID、Claude、Gemini、Cappuccino、Coder、LongCat、Codex、MCP、SSE、PR、CI、ASR、LLM、SOTA、FP8。保留命令、路径、环境变量、URL、true / false / null 和模型版本号。 - -# 输出 -直接输出最终正文。顶层用 1./2./3.,子项用缩进 3 个空格的 (a)(b)(c)。不加解释。`, - formal: `# 角色 -语音输入整理器。把口述整理成适合邮件、同步和正式沟通的专业表达。 - -# 任务(正式表达) -补足句式与标点,让表达更完整、克制、专业,但不添加空泛客套,也不擅自扩写事实。 - -# 通用规则 -1) 不承诺用户没说过的内容。 -2) 保留专有名词、数字、时间、路径和术语。 -3) 只输出最终正文,不附带解释或 markdown 围栏。 - -# 输出 -输出可直接发送的正式文本。`, -} - -mockSettings = { - ...mockSettings, - styleSystemPrompts: mockFullStylePrompts, - workingLanguages: ["简体中文"], -} - -const mockDefaultStyleSystemPrompts: StyleSystemPrompts = { - ...mockSettings.styleSystemPrompts, -} - -const mockBuiltinExamples: Record = { - raw: [ - { - title: "最小整理", - input: "今天下午那个会先别取消我晚点再确认一下然后把下周二也先空出来", - output: "今天下午那个会先别取消,我晚点再确认一下。然后把下周二也先空出来。", - }, - ], - light: [ - { - title: "聊天消息", - input: "你帮我跟设计那边说一下这个首页先别上线我晚上再过一遍", - output: "你帮我跟设计那边说一下,这个首页先别上线,我今晚再过一遍。", - }, - ], - structured: [ - { - title: "AI 编程任务", - input: "帮我给 codex 提个任务先把登录页 bug 修掉然后补一下 README 里面的环境变量说明还有那个西克瑞特 key 别写死到代码里", - output: "帮忙给 Codex 提个任务,主要包含以下内容:\n\n1. 登录页修复\n (a) 修复登录页相关 bug。\n2. 文档与配置\n (a) 补充 README 中的环境变量说明。\n (b) 确认 Secret Key 不被硬编码到代码里。", - }, - ], - formal: [ - { - title: "工作同步", - input: "你帮我发个消息说这个需求今天先不上了等测试和产品都确认完我们再一起推进", - output: "麻烦帮我同步一下:这个需求今天先不上线,待测试和产品都确认完成后,我们再统一推进。", - }, - ], -} - -function makeMockStylePack( - id: string, - kind: StylePackKind, - baseMode: PolishMode, - name: string, - description: string, - prompt: string, - tags: string[], -): StylePack { - return { - id, - name, - description, - author: "OpenLess", - version: "1.0.0", - kind, - baseMode, - prompt, - examples: mockBuiltinExamples[baseMode].map((example) => ({ - ...example, - })), - tags, - iconPath: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - enabled: true, - active: false, - recommendedModel: null, - compatibleAppVersion: "1.0.0", - } -} - -let mockStylePacks: StylePack[] = [ - makeMockStylePack( - "builtin.raw", - "builtin", - "raw", - "原文", - "尽量保留原话顺序和语气,只做必要的断句与标点整理。", - mockSettings.styleSystemPrompts.raw, - ["原文", "最小改写"], - ), - makeMockStylePack( - "builtin.light", - "builtin", - "light", - "轻度润色", - "把口述整理成顺畅、自然、可直接发送的文字,不扩写事实。", - mockSettings.styleSystemPrompts.light, - ["沟通", "自然"], - ), - makeMockStylePack( - "builtin.structured", - "builtin", - "structured", - "清晰结构", - "适合多事项和多主题口述,自动整理为层次清楚的结构化输出。", - mockSettings.styleSystemPrompts.structured, - ["结构化", "条理"], - ), - makeMockStylePack( - "builtin.formal", - "builtin", - "formal", - "正式表达", - "适合邮件、同步和工作沟通场景,语气更完整、专业、克制。", - mockSettings.styleSystemPrompts.formal, - ["正式", "工作沟通"], - ), - { - ...makeMockStylePack( - "imported.creator-note", - "imported", - "light", - "创作者口播", - "给短视频口播和社区帖文使用,句子更紧凑,保留情绪和节奏。", - "你是一个负责整理创作者口播稿的编辑。请把输入整理成适合发帖和口播的自然文本,保留节奏感,不要补充原文没有的信息。", - ["社区", "口播", "节奏感"], - ), - author: "Demo Community", - }, -] - -function cloneStylePack(stylePack: StylePack): StylePack { - return { - ...stylePack, - tags: [...stylePack.tags], - examples: stylePack.examples.map((example) => ({ ...example })), - } -} - -function cloneMockStylePacks(): StylePack[] { - return mockStylePacks.map(cloneStylePack) -} - -function composeMockStylePackRuntimeDiagnostics( - stylePack: StylePack, -): StylePackRuntimeDiagnostics { - const trimmedPrompt = stylePack.prompt.trimEnd() - const contextPremise = mockSettings.workingLanguages.length - ? [ - "# Context", - `Working languages: ${mockSettings.workingLanguages.join(", ")}`, - ].join("\n") - : "" - const hotwordLines = [`GitHub`, `OpenLess`] - const hotwordBlock = - hotwordLines.length > 0 - ? [ - "Hotwords (keep the spelling below when they appear in the transcript):", - ...hotwordLines.map((word) => `- ${word}`), - ].join("\n") - : "" - const singleTurnPrompt = [contextPremise, trimmedPrompt, hotwordBlock] - .filter(Boolean) - .join("\n\n") - const historyInstruction = - "When prior turns exist, do not repeat previous assistant outputs. Only polish the current transcript." - const multiTurnPrompt = `${singleTurnPrompt}\n\n${historyInstruction}` - return { - packId: stylePack.id, - packName: stylePack.name, - packPrompt: stylePack.prompt, - packPromptChars: stylePack.prompt.length, - contextPremise, - contextPremiseChars: contextPremise.length, - hotwordBlock, - hotwordBlockChars: hotwordBlock.length, - historyInstruction, - historyInstructionChars: historyInstruction.length, - singleTurnPrompt, - singleTurnPromptChars: singleTurnPrompt.length, - multiTurnPrompt, - multiTurnPromptChars: multiTurnPrompt.length, - workingLanguages: [...mockSettings.workingLanguages], - hotwords: [...hotwordLines], - contextWindowMinutes: mockSettings.polishContextWindowMinutes, - includesContextPremise: Boolean(contextPremise), - includesHotwordBlock: hotwordLines.length > 0, - includesHistoryInstruction: true, - previewOmitsFrontApp: true, - } -} - -function syncMockSettingsFromStylePacks() { - const enabled = mockStylePacks.filter((pack) => pack.enabled) - const active = - mockStylePacks.find( - (pack) => - pack.id === mockSettings.activeStylePackId && pack.enabled, - ) ?? - enabled[0] ?? - mockStylePacks[0] - mockStylePacks = mockStylePacks.map((pack) => ({ - ...pack, - active: pack.id === active.id, - })) - mockSettings = { - ...mockSettings, - activeStylePackId: active.id, - defaultMode: active.baseMode, - enabledModes: ["raw", "light", "structured", "formal"].filter((mode) => - mockStylePacks.some( - (pack) => pack.enabled && pack.baseMode === mode, - ), - ) as PolishMode[], - styleSystemPrompts: { - raw: - mockStylePacks.find((pack) => pack.id === "builtin.raw") - ?.prompt ?? mockSettings.styleSystemPrompts.raw, - light: - mockStylePacks.find((pack) => pack.id === "builtin.light") - ?.prompt ?? mockSettings.styleSystemPrompts.light, - structured: - mockStylePacks.find((pack) => pack.id === "builtin.structured") - ?.prompt ?? mockSettings.styleSystemPrompts.structured, - formal: - mockStylePacks.find((pack) => pack.id === "builtin.formal") - ?.prompt ?? mockSettings.styleSystemPrompts.formal, - }, - } -} - -syncMockSettingsFromStylePacks() - -const mockHotkeyCapability: HotkeyCapability = { - adapter: "windowsLowLevel", - availableTriggers: [ - "rightControl", - "rightAlt", - "leftControl", - "rightCommand", - "custom", - ], - requiresAccessibilityPermission: false, - supportsModifierOnlyTrigger: true, - supportsSideSpecificModifiers: true, - explicitFallbackAvailable: false, - statusHint: - "默认建议使用“右Ctrl + 单击”;若更习惯按住说话,可在录音设置里切回“按住”。若无响应,可在权限页查看 hook 安装状态。", -} - -const mockCredentialsStatus: CredentialsStatus = { - activeAsrProvider: "foundry-local-whisper", - activeLlmProvider: "ark", - asrConfigured: true, - llmConfigured: true, - volcengineConfigured: true, - arkConfigured: true, -} - -export interface ProviderCheckResult { - ok: boolean -} - -export interface ProviderModelsResult { - models: string[] -} - -const mockHotkeyStatus: HotkeyStatus = { - adapter: "windowsLowLevel", - state: "installed", - message: "Windows 低层键盘 hook 已安装", - lastError: null, -} - -const mockWindowsImeStatus: WindowsImeStatus = { - state: "notWindows", - usingTsfBackend: false, - message: "Browser dev mock", - dllPath: null, -} - -const mockMicrophoneDevices: MicrophoneDevice[] = [ - { name: "Built-in Microphone", isDefault: true }, - { name: "USB Microphone", isDefault: false }, -] - -const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({ - id: `mock-${i}`, - createdAt: new Date().toISOString(), - rawTranscript: h.preview, - finalText: h.preview, - mode: "structured", - stylePackId: "builtin.structured", - translationActive: false, - polishSource: null, - appBundleId: null, - appName: "VS Code", - insertStatus: "inserted", - errorCode: null, - durationMs: 600, - dictionaryEntryCount: 28, - hasAudioRecording: null, -})) - -const mockVocab: DictionaryEntry[] = OL_DATA.vocab.map((v, i) => ({ - id: `vocab-${i}`, - phrase: v.word, - note: null, - enabled: true, - hits: v.count, - createdAt: new Date().toISOString(), -})) - -const mockCorrectionRules: CorrectionRule[] = [ - { - id: "rule-quantity-classifier", - pattern: "{num}粒", - replacement: "{num}例", - enabled: true, - createdAt: new Date().toISOString(), - }, -] - -// ── Settings ─────────────────────────────────────────────────────────── -export function getSettings(): Promise { - return invokeOrMock("get_settings", undefined, () => ({ ...mockSettings })) -} - -export function getDefaultStyleSystemPrompts(): Promise { - return invokeOrMock("get_default_style_system_prompts", undefined, () => ({ - ...mockDefaultStyleSystemPrompts, - })) -} - -export function setSettings(prefs: UserPreferences): Promise { - return invokeOrMock("set_settings", { prefs }, () => { - mockSettings = { ...prefs } - mockStylePacks = mockStylePacks.map((pack) => { - if (pack.kind === "builtin") { - return { - ...pack, - enabled: prefs.enabledModes.includes(pack.baseMode), - prompt: prefs.styleSystemPrompts[pack.baseMode], - } - } - return { ...pack } - }) - syncMockSettingsFromStylePacks() - return undefined - }) -} - -// ── Remote input (局域网手机录音) ────────────────────────────────────── -export interface RemoteInputStatus { - running: boolean - port: number - pin: string - urls: string[] -} - -export function getRemoteInputStatus(): Promise { - return invokeOrMock("get_remote_input_status", undefined, () => ({ - running: false, - port: 8443, - pin: "000000", - urls: [], - })) -} - -export function listLocalIps(): Promise { - return invokeOrMock("list_local_ips", undefined, () => ["192.168.1.100"]) -} - -export function regenerateRemotePin(): Promise { - return invokeOrMock("regenerate_remote_pin", undefined, () => "123456") -} - -/** 把 PC 端界面语言同步给远程输入服务,H5 录音页据此显示对应语言。 */ -export function setRemoteLocale(locale: string): Promise { - return invokeOrMock("set_remote_locale", { locale }, () => undefined) -} - -// ── Release channel (Beta opt-in) ────────────────────────────────────── -// 渠道偏好与 fetch_latest_beta_release 实际效果只在 Tauri runtime 内有意义; -// 浏览器开发模式下走 mock,避免设置页因 invoke 抛错而白屏。 -// UpdateChannel 类型搬到 types.ts(UserPreferences.updateChannel 字段使用), -// 这里 re-export 保持外部模块(SettingsModal 等)import 路径不变。 - -export interface LatestBetaRelease { - tagName: string - htmlUrl: string - publishedAt: string -} - -export interface AppUpdateMetadata { - rid: number - currentVersion: string - version: string - date?: string | null - body?: string | null - rawJson: Record -} - -export function getUpdateChannel(): Promise { - return platformCapabilities().then((caps) => { - if (!caps.supportsAutoUpdate) { - return "stable" as UpdateChannel - } - return invokeOrMock( - "get_update_channel", - undefined, - () => "stable" as UpdateChannel, - ) - }) -} - -export function setUpdateChannel(channel: UpdateChannel): Promise { - return platformCapabilities().then((caps) => { - if (!caps.supportsAutoUpdate) { - return undefined - } - return invokeOrMock("set_update_channel", { channel }, () => undefined) - }) -} - -export function fetchLatestBetaRelease(): Promise { - return platformCapabilities().then((caps) => { - if (!caps.supportsAutoUpdate) { - return null - } - return invokeOrMock("fetch_latest_beta_release", undefined, () => null) - }) -} - -export function appCheckUpdateWithChannel( - timeoutMs: number, - channel?: UpdateChannel | null, -): Promise { - return platformCapabilities().then((caps) => { - if (!caps.supportsAutoUpdate) { - return null - } - return invokeOrMock( - "app_check_update_with_channel", - { timeoutMs, channel: channel ?? null }, - () => null, - ) - }) -} - -export function appDownloadAndInstallAndroidUpdate(args: { - url: string - signature: string - version: string -}): Promise { - return platformCapabilities().then((caps) => { - if (caps.platform !== "android") { - return Promise.reject(new Error("Android-only update install")) - } - return invokeOrMock( - "app_download_and_install_android_update", - args, - () => undefined, - ) - }) -} - -export function getHotkeyStatus(): Promise { - return platformCapabilities().then((caps) => { - if (!caps.supportsDesktopHotkey) { - return androidHotkeyStatus - } - return invokeOrMock("get_hotkey_status", undefined, () => mockHotkeyStatus) - }) -} - -export function getHotkeyCapability(): Promise { - return platformCapabilities().then((caps) => { - if (!caps.supportsDesktopHotkey) { - return androidHotkeyCapability - } - return invokeOrMock( - "get_hotkey_capability", - undefined, - () => mockHotkeyCapability, - ) - }) -} - -export function getWindowsImeStatus(): Promise { - return platformCapabilities().then((caps) => { - if (caps.platform === "android") { - return androidWindowsImeStatus - } - return invokeOrMock( - "get_windows_ime_status", - undefined, - () => mockWindowsImeStatus, - ) - }) -} - -export interface NetworkCheckResult { - online: boolean; - latencyMs: number | null; -} - -export function checkNetwork(): Promise { - return invokeOrMock('check_network', undefined, () => ({ - online: true, - latencyMs: 42, - })); -} - -export function listMicrophoneDevices(): Promise { - return invokeOrMock( - "list_microphone_devices", - undefined, - () => mockMicrophoneDevices, - ) -} - -export function startMicrophoneLevelMonitor(deviceName: string): Promise { - return invokeOrMock( - "start_microphone_level_monitor", - { deviceName }, - () => undefined, - ) -} - -export function stopMicrophoneLevelMonitor(): Promise { - return invokeOrMock( - "stop_microphone_level_monitor", - undefined, - () => undefined, - ) -} - -export function isWaylandCliMode(): Promise { - return invokeOrMock("is_wayland_cli_mode", undefined, () => false) -} - -// ── Credentials ──────────────────────────────────────────────────────── -export function getCredentials(): Promise { - return invokeOrMock( - "get_credentials", - undefined, - () => mockCredentialsStatus, - ) -} - -export function setCredential(account: string, value: string): Promise { - return invokeOrMock("set_credential", { account, value }, () => undefined) -} - -export function setActiveAsrProvider(provider: string): Promise { - return invokeOrMock( - "set_active_asr_provider", - { provider }, - () => undefined, - ) -} - -export function setActiveLlmProvider(provider: string): Promise { - return invokeOrMock( - "set_active_llm_provider", - { provider }, - () => undefined, - ) -} - -export function readCredential(account: string): Promise { - return invokeOrMock( - "read_credential", - { account }, - () => null, - ) -} - -export function validateProviderCredentials( - kind: "llm" | "asr", -): Promise { - return invokeOrMock("validate_provider_credentials", { kind }, () => ({ - ok: true, - })) -} - -export function listProviderModels( - kind: "llm" | "asr", -): Promise { - return invokeOrMock("list_provider_models", { kind }, () => ({ - models: - kind === "llm" - ? ["gpt-4o", "deepseek-v4-flash", "deepseek-v4-pro"] - : ["whisper-1"], - })) -} - -// ── History ──────────────────────────────────────────────────────────── -export function listHistory(): Promise { - return invokeOrMock("list_history", undefined, () => mockHistory) -} - -export function deleteHistoryEntry(id: string): Promise { - return invokeOrMock("delete_history_entry", { id }, () => undefined) -} - -export function clearHistory(): Promise { - return invokeOrMock("clear_history", undefined, () => undefined) -} - -/** 读取某次会话的原始麦克风 wav 字节流。仅当 prefs.recordAudioForDebug 当时打开 - * 并且文件没被 retention 清理掉时才有内容;其他情况后端会返回 "recording not found" 错。 - * 调用方应仅在 session.hasAudioRecording === true 时触发,避免无效 IPC。 */ -export function readAudioRecording(sessionId: string): Promise { - return invokeOrMock( - "read_audio_recording", - { sessionId }, - () => new Uint8Array(), - ).then((value) => { - // Tauri 默认把 Vec 序列化为 number[],前端拿到的是普通数组;统一转 Uint8Array。 - if (value instanceof Uint8Array) return value - if (Array.isArray(value)) return new Uint8Array(value as number[]) - return new Uint8Array(value as ArrayBuffer) - }) -} - -/** 用当前 ASR provider 对一条「转录失败」历史条目的归档录音重新转录(issue #613)。 - * 成功时后端原地回写该条历史的 rawTranscript / finalText 并清除错误码,返回更新后的整条记录。 - * 失败时抛出错误(如「重新转录仍未识别到语音」/「recording not found」),录音保留不丢。 */ -export function retranscribeRecording(sessionId: string): Promise { - return invokeOrMock( - "retranscribe_recording", - { sessionId }, - () => mockHistory[0], - ) as Promise -} - -// ── Vocab ────────────────────────────────────────────────────────────── -export function listVocab(): Promise { - return invokeOrMock("list_vocab", undefined, () => mockVocab) -} - -export function addVocab( - phrase: string, - note?: string, -): Promise { - return invokeOrMock("add_vocab", { phrase, note }, () => ({ - id: `vocab-new-${Date.now()}`, - phrase, - note: note ?? null, - enabled: true, - hits: 0, - createdAt: new Date().toISOString(), - })) -} - -export function removeVocab(id: string): Promise { - return invokeOrMock("remove_vocab", { id }, () => undefined) -} - -export function setVocabEnabled(id: string, enabled: boolean): Promise { - return invokeOrMock("set_vocab_enabled", { id, enabled }, () => undefined) -} - -export function listCorrectionRules(): Promise { - return invokeOrMock( - "list_correction_rules", - undefined, - () => mockCorrectionRules, - ) -} - -export function addCorrectionRule( - pattern: string, - replacement: string, -): Promise { - return invokeOrMock( - "add_correction_rule", - { pattern, replacement }, - () => ({ - id: `rule-new-${Date.now()}`, - pattern, - replacement, - enabled: true, - createdAt: new Date().toISOString(), - }), - ) -} - -export function removeCorrectionRule(id: string): Promise { - return invokeOrMock("remove_correction_rule", { id }, () => undefined) -} - -export function setCorrectionRuleEnabled( - id: string, - enabled: boolean, -): Promise { - return invokeOrMock( - "set_correction_rule_enabled", - { id, enabled }, - () => undefined, - ) -} - -export function listVocabPresets(): Promise { - return invokeOrMock("list_vocab_presets", undefined, () => ({ - custom: [], - overrides: [], - disabledBuiltinPresetIds: [], - })) -} - -export function saveVocabPresets(store: VocabPresetStore): Promise { - return invokeOrMock("save_vocab_presets", { store }, () => undefined) -} - -// ── Dictation lifecycle ──────────────────────────────────────────────── -export function startDictation(): Promise { - return invokeOrMock("start_dictation", undefined, () => undefined) -} - -export function stopDictation(): Promise { - return invokeOrMock("stop_dictation", undefined, () => undefined) -} - -export function cancelDictation(): Promise { - return invokeOrMock("cancel_dictation", undefined, () => undefined) -} - -export function handleWindowHotkeyEvent( - eventType: "keydown" | "keyup", - key: string, - code: string, - repeat: boolean, -): Promise { - return platformCapabilities().then((caps) => { - if (!caps.supportsDesktopHotkey) { - return undefined - } - return invokeOrMock( - "handle_window_hotkey_event", - { event_type: eventType, key, code, repeat }, - () => undefined, - ) - }) -} - -// ── Polish ───────────────────────────────────────────────────────────── -export function repolish(rawText: string, mode: PolishMode): Promise { - return invokeOrMock("repolish", { rawText, mode }, () => rawText) -} - -export function setDefaultPolishMode(mode: PolishMode): Promise { - return invokeOrMock("set_default_polish_mode", { mode }, () => { - const packId = `builtin.${mode}` - mockStylePacks = mockStylePacks.map((pack) => ({ - ...pack, - enabled: pack.id === packId ? true : pack.enabled, - active: pack.id === packId, - })) - mockSettings = { ...mockSettings, activeStylePackId: packId } - syncMockSettingsFromStylePacks() - return undefined - }) -} - -export function setStyleEnabled( - mode: PolishMode, - enabled: boolean, -): Promise { - return invokeOrMock("set_style_enabled", { mode, enabled }, () => { - const packId = `builtin.${mode}` - mockStylePacks = mockStylePacks.map((pack) => - pack.id === packId ? { ...pack, enabled } : { ...pack }, - ) - syncMockSettingsFromStylePacks() - return undefined - }) -} - -export function listStylePacks(): Promise { - return invokeOrMock("list_style_packs", undefined, () => - cloneMockStylePacks(), - ) -} - -export function saveStylePack(stylePack: StylePack): Promise { - return invokeOrMock("save_style_pack", { stylePack }, () => { - mockStylePacks = mockStylePacks.map((pack) => - pack.id === stylePack.id ? cloneStylePack(stylePack) : pack, - ) - syncMockSettingsFromStylePacks() - return cloneStylePack( - mockStylePacks.find((pack) => pack.id === stylePack.id) ?? - stylePack, - ) - }) -} - -export function createStylePackFromTemplate( - template: StylePack, -): Promise { - return invokeOrMock("create_style_pack_from_template", { template }, () => { - const created: StylePack = { - ...cloneStylePack(template), - id: `imported-mock-${Date.now()}`, - kind: "imported", - active: false, - enabled: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } - mockStylePacks = [...mockStylePacks, created] - return cloneStylePack(created) - }) -} - -export function previewStylePackRuntime( - stylePack: StylePack, -): Promise { - return invokeOrMock("preview_style_pack_runtime", { stylePack }, () => - composeMockStylePackRuntimeDiagnostics(stylePack), - ) -} - -export function setActiveStylePack(id: string): Promise { - return invokeOrMock("set_active_style_pack", { id }, () => { - mockStylePacks = mockStylePacks.map((pack) => ({ - ...pack, - enabled: pack.id === id ? true : pack.enabled, - active: pack.id === id, - })) - mockSettings = { ...mockSettings, activeStylePackId: id } - syncMockSettingsFromStylePacks() - return cloneStylePack(mockStylePacks.find((pack) => pack.id === id)!) - }) -} - -export function setStylePackEnabled( - id: string, - enabled: boolean, -): Promise { - return invokeOrMock("set_style_pack_enabled", { id, enabled }, () => { - mockStylePacks = mockStylePacks.map((pack) => - pack.id === id ? { ...pack, enabled } : { ...pack }, - ) - syncMockSettingsFromStylePacks() - return cloneMockStylePacks() - }) -} - -export function resetBuiltinStylePack(id: string): Promise { - return invokeOrMock("reset_builtin_style_pack", { id }, () => { - const builtinDefaults: Record = { - "builtin.raw": makeMockStylePack( - "builtin.raw", - "builtin", - "raw", - "原文", - "尽量保留原话顺序和语气,只做必要的断句与标点整理。", - mockDefaultStyleSystemPrompts.raw, - ["原文", "最小改写"], - ), - "builtin.light": makeMockStylePack( - "builtin.light", - "builtin", - "light", - "轻度润色", - "把口述整理成顺畅、自然、可直接发送的文字,不扩写事实。", - "把口述整理成自然、顺畅、可直接发送的文字,去掉口头禅和重复,保留原意与语气。", - ["沟通", "自然"], - ), - "builtin.structured": makeMockStylePack( - "builtin.structured", - "builtin", - "structured", - "清晰结构", - "面向 AI 编程协作、技术排障和模型资讯,优先保证术语与结构准确。", - mockDefaultStyleSystemPrompts.structured, - ["AI 编程", "技术结构化"], - ), - "builtin.formal": makeMockStylePack( - "builtin.formal", - "builtin", - "formal", - "正式表达", - "适合邮件、同步和工作沟通场景,语气更完整、专业、克制。", - "输出适合工作沟通、邮件和汇报场景的正式表达,不扩写事实。", - ["正式", "工作沟通"], - ), - } - const current = mockStylePacks.find((pack) => pack.id === id) - const reset = builtinDefaults[id] - if (!current || !reset) { - throw new Error(`style pack not found: ${id}`) - } - mockStylePacks = mockStylePacks.map((pack) => - pack.id === id - ? { - ...reset, - enabled: current.enabled, - active: current.active, - } - : pack, - ) - syncMockSettingsFromStylePacks() - return cloneStylePack(mockStylePacks.find((pack) => pack.id === id)!) - }) -} - -export function deleteStylePack(id: string): Promise { - return invokeOrMock("delete_style_pack", { id }, () => { - mockStylePacks = mockStylePacks.filter((pack) => pack.id !== id) - syncMockSettingsFromStylePacks() - return undefined - }) -} - -export function importStylePackFromZip(zipPath: string): Promise { - return invokeOrMock("import_style_pack_from_zip", { zipPath }, () => { - const seed = Date.now() - const pack = { - ...makeMockStylePack( - `imported.mock-${seed}`, - "imported", - "light", - "导入风格包", - `从 ${zipPath.split(/[\\\\/]/).pop() || "ZIP"} 导入的风格包`, - "你是一个负责把口述整理成清晰、利落、适合社区分享文本的编辑,请完整保留事实,不要补充原文没有的信息。", - ["导入", "ZIP"], - ), - author: "Imported ZIP", - } - mockStylePacks = [pack, ...mockStylePacks] - syncMockSettingsFromStylePacks() - return cloneStylePack(pack) - }) -} - -export function exportStylePackToZip( - id: string, - targetPath: string, -): Promise { - return invokeOrMock( - "export_style_pack_to_zip", - { id, targetPath }, - () => targetPath, - ) -} - -// ── Permissions ──────────────────────────────────────────────────────── -export function checkAccessibilityPermission(): Promise { - return invokeOrMock( - "check_accessibility_permission", - undefined, - () => "granted" as const, - ) -} - -export function requestAccessibilityPermission(): Promise { - return invokeOrMock( - "request_accessibility_permission", - undefined, - () => "granted" as const, - ) -} - -export function checkMicrophonePermission(): Promise { - return invokeOrMock( - "check_microphone_permission", - undefined, - () => "granted" as const, - ) -} - -export function requestMicrophonePermission(): Promise { - return invokeOrMock( - "request_microphone_permission", - undefined, - () => "granted" as const, - ) -} - -export function openSystemSettings( - pane: "accessibility" | "microphone" | "overlay", -): Promise { - return invokeOrMock("open_system_settings", { pane }, () => undefined) -} - -export function triggerMicrophonePrompt(): Promise { - return invokeOrMock("trigger_microphone_prompt", undefined, () => undefined) -} - -export function restartApp(): Promise { - return invokeOrMock("restart_app", undefined, () => undefined) -} - -// ── QA (划词语音问答) ─────────────────────────────────────────────────── -// 详见 issue #118。后端会发 `qa:state` / `qa:dismiss` 事件;前端通过下面四个 -// 命令查询与控制 QA 浮窗。 -export function getQaHotkeyLabel(): Promise { - return invokeOrMock("get_qa_hotkey_label", undefined, () => - formatComboLabel(defaultQaShortcut()), - ) -} - -export function setQaHotkey(binding: QaHotkeyBinding | null): Promise { - return invokeOrMock("set_qa_hotkey", { binding }, () => undefined) -} - -export function qaWindowDismiss(): Promise { - return invokeOrMock("qa_window_dismiss", undefined, () => undefined) -} - -export function qaWindowPin(pinned: boolean): Promise { - return invokeOrMock("qa_window_pin", { pinned }, () => undefined) -} - -export function qaToggleRecording(): Promise { - return invokeOrMock("qa_toggle_recording", undefined, () => undefined) -} - -export function qaSubmitText(text: string): Promise { - return invokeOrMock("qa_submit_text", { text }, () => undefined) -} - -// ── Less Computer 浮窗 ──────────────────────────────────────────────── -/** 用户点 ✕ / 按 Esc 关闭 Less Computer 浮窗(隐藏窗口)。 */ -export function lessComputerWindowDismiss(): Promise { - return invokeOrMock("less_computer_window_dismiss", undefined, () => undefined) -} - -/** 内联审批卡的 Approve / Deny 回执。token 关联到等待中的拦截动作。 */ -export function lessComputerApprove( - token: string, - approved: boolean, -): Promise { - return invokeOrMock( - "less_computer_approve", - { token, approved }, - () => undefined, - ) -} - -/** 前端按内容测高后回传,后端 clamp + bottom-anchored 重新摆放浮窗。 */ -export function lessComputerWindowResize(height: number): Promise { - return invokeOrMock( - "less_computer_window_resize", - { height }, - () => undefined, - ) -} - -// ── Combo Hotkey (自定义录音组合键) ─────────────────────────────────── -export function validateComboHotkey(binding: ComboBinding): Promise { - return invokeOrMock("validate_combo_hotkey", { binding }, () => undefined) -} - -export function setComboHotkey(binding: ComboBinding): Promise { - return invokeOrMock("set_combo_hotkey", { binding }, () => undefined) -} - -export function validateShortcutBinding( - binding: ShortcutBinding, -): Promise { - return invokeOrMock( - "validate_shortcut_binding", - { binding }, - () => undefined, - ) -} - -export function setDictationHotkey(binding: ShortcutBinding): Promise { - return invokeOrMock("set_dictation_hotkey", { binding }, () => undefined) -} - -export function setTranslationHotkey(binding: ShortcutBinding): Promise { - return invokeOrMock("set_translation_hotkey", { binding }, () => undefined) -} - -// binding = null 表示停用(清空全局键),与 set_qa_hotkey 一致(issue #576)。 -export function setSwitchStyleHotkey(binding: ShortcutBinding | null): Promise { - return invokeOrMock("set_switch_style_hotkey", { binding }, () => undefined) -} - -export function setOpenAppHotkey(binding: ShortcutBinding | null): Promise { - return invokeOrMock("set_open_app_hotkey", { binding }, () => undefined) -} - -export function setShortcutRecordingActive(active: boolean): Promise { - return invokeOrMock( - "set_shortcut_recording_active", - { active }, - () => undefined, - ) -} - -export async function openExternal(url: string): Promise { - if (!isTauri) { - window.open(url, "_blank", "noopener,noreferrer") - return - } - try { - const { open } = await import("@tauri-apps/plugin-shell") - await open(url) - return - } catch (error) { - console.warn("[external-open] shell plugin failed", error) - } - try { - const { invoke } = await import("@tauri-apps/api/core") - await invoke("open_external_url", { url }) - return - } catch (error) { - console.warn("[external-open] native fallback failed", error) - } - window.open(url, "_blank", "noopener,noreferrer") -} - -/** - * 让用户选 save 路径并把当前会话日志(openless.log)复制过去。 - * 浏览器开发模式下走 mock 不实际写盘。返回最终 save 的绝对路径,取消选择则返回 null。 - */ -export async function exportErrorLog( - suggestedFileName: string, -): Promise { - if (!isTauri) { - return `~/Downloads/${suggestedFileName}` - } - const { save } = await import("@tauri-apps/plugin-dialog") - const target = await save({ - defaultPath: suggestedFileName, - filters: [{ name: "Log", extensions: ["log", "txt"] }], - }) - if (!target) return null - await invokeOrMock( - "export_error_log", - { targetPath: target }, - () => undefined, - ) - return target -} - -export { isTauri } - -// ── Coding Agent / Claude 控制台 ─────────────────────────────────────── -export type { CodingAgentPermissionMode } - -export type McpHealth = "connected" | "failed" | "needs_auth" | "unknown" - -export interface McpServerStatus { - name: string - detail: string - health: McpHealth -} - -export interface ClaudeDetection { - installed: boolean - version: string | null - exe: string - mcpServers: McpServerStatus[] - hasComputerUse: boolean -} - -/** 无头 Claude 运行事件,由后端 `coding-agent:test` 流式推送(tag 为 `kind`)。 */ -export type CodingAgentEvent = - | { kind: "started"; session_id: string } - | { kind: "delta"; session_id: string; text: string } - | { kind: "tool_use"; session_id: string; name: string } - | { - kind: "completed" - session_id: string - text: string - cost_usd: number | null - duration_ms: number | null - } - | { kind: "cancelled"; session_id: string } - | { kind: "error"; session_id: string; message: string } - -export function codingAgentDetect(exe?: string): Promise { - return invokeOrMock( - "coding_agent_detect", - { exe }, - () => ({ - installed: false, - version: null, - exe: exe || "claude", - mcpServers: [], - hasComputerUse: false, - }), - ) -} - -export interface CodingAgentRunTestArgs { - prompt: string - exe?: string - permissionMode?: CodingAgentPermissionMode - workdir?: string - model?: string - maxBudgetUsd?: number -} - -export function codingAgentRunTest(args: CodingAgentRunTestArgs): Promise { - return invokeOrMock("coding_agent_run_test", { ...args }, () => undefined) -} - -export function codingAgentCancelTest(): Promise { - return invokeOrMock("coding_agent_cancel_test", undefined, () => undefined) -} - -export function codingAgentCommandRisk(command: string): Promise { - return invokeOrMock("coding_agent_command_risk", { command }, () => null) -} - -// ── Marketplace (Phase A) ───────────────────────────────────────────── -// 5 个 IPC wrapper —— marketplace-backend HTTP 通过 Rust IPC 转发。Mock fallback -// 让 vite dev 在浏览器里也能预览 UI(返回空列表 / 假数据)。 - -const MOCK_MARKETPLACE: MarketplaceListItem[] = [ - { - id: "00000000-0000-0000-0000-000000000001", - slug: "demo-pack", - name: "示范风格包", - description: "Mock 数据 - vite dev 模式下显示", - authorLogin: "demo", - version: "1.0.0", - baseMode: "structured", - tags: ["demo"], - likeCount: 12, - downloadCount: 50, - publishedAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, -] - -export function listMarketplace( - options: { query?: string; sort?: "new" | "popular"; limit?: number } = {}, -): Promise { - return invokeOrMock("marketplace_list", options, () => MOCK_MARKETPLACE) -} - -export function fetchMarketplaceDetail( - packId: string, -): Promise { - return invokeOrMock("marketplace_detail", { packId }, () => ({ - ...MOCK_MARKETPLACE[0], - prompt: "# 角色\n你是测试用 polish 助手。\n\n# 任务\n按整体意图整理转写。", - state: "approved" as const, - })) -} - -export function installMarketplacePack(packId: string): Promise { - return invokeOrMock( - "marketplace_install", - { packId }, - () => mockStylePacks[0], - ) -} - -export function uploadMarketplacePack( - packId: string, - originPackId?: string | null, -): Promise<{ id: string; state: string; message: string }> { - return invokeOrMock( - "marketplace_upload", - { packId, originPackId: originPackId ?? null }, - () => ({ - id: "mock-uploaded", - state: "pending", - message: "Mock 上传成功(vite dev)", - }), - ) -} - -export function likeMarketplacePack( - packId: string, -): Promise<{ likeCount: number; alreadyLiked: boolean }> { - return invokeOrMock("marketplace_like", { packId }, () => ({ - likeCount: 13, - alreadyLiked: false, - })) -} - -/** 拉当前登录用户赞过的所有 pack id(用于红心 + 「我赞过的」过滤)。 */ -export function marketplaceMyLikes(): Promise { - return invokeOrMock("marketplace_my_likes", undefined, () => []) -} - -/** 拉当前登录用户发布过的所有 pack(含审核中/已撤回),用于「我的发布」。 */ -export function marketplaceMyPacks(): Promise { - return invokeOrMock( - "marketplace_my_packs", - undefined, - () => [], - ) -} - -/** 撤回自己发布的 pack(后端软删 state='withdrawn')。仅允许原作者。 */ -export function marketplaceDelete(packId: string): Promise { - return invokeOrMock("marketplace_delete", { packId }, () => undefined) -} - -// ─────────────────────── GitHub OAuth Device Flow (Phase 1) ─────────────── -// 客户端直连 GitHub OAuth Device Flow 拿 login,自动写进 prefs.marketplaceDevLogin。 -// marketplace backend 不动(继续走 X-Dev-User header;Phase 2 才接 JWT 验证)。 -// -// 后端 Rust 实现:commands.rs:github_device_flow_start / github_device_flow_poll -// 需要预先配置 GITHUB_OAUTH_CLIENT_ID(OAuth App client_id,非敏感,可硬编码)。 - -export interface GithubDeviceStartResponse { - deviceCode: string - userCode: string - verificationUri: string - interval: number - expiresIn: number -} - -export type GithubDevicePollResult = - | { kind: "authorized"; login: string } - | { kind: "pending" } - | { kind: "slowDown" } - | { kind: "error"; message: string } - -export function githubDeviceFlowStart(): Promise { - return invokeOrMock( - "github_device_flow_start", - undefined, - () => ({ - deviceCode: "mock-device-code-xxxxxxxx", - userCode: "MOCK-CODE", - verificationUri: "https://github.com/login/device", - interval: 5, - expiresIn: 900, - }), - ) -} - -export function githubDeviceFlowPoll( - deviceCode: string, -): Promise { - return invokeOrMock( - "github_device_flow_poll", - { deviceCode }, - () => ({ - kind: "authorized" as const, - login: "mock-user", - }), - ) -} - -// ─────────────────────── Marketplace 差量缓存(localStorage) ──────────────── -// -// 设计:两段式分发。 -// 1) List = 轻量元数据(id + version + updatedAt + 名称 / 计数 / tag),无 prompt 正文。 -// 本机持久化,重开 marketplace 秒呈现;后台 refresh 校准。 -// 2) Detail = 含 prompt 正文,按 (id, version, updatedAt) 三元组缓存。 -// 三元组等价于「内容版本签名」—— version+updatedAt 任一变化 = 内容变了 → 必须重拉。 -// 命中 = 复用本机,不发请求;未命中 = fetchMarketplaceDetail 再写回。 -// 3) 当 list 里某 pack 消失(被下架 / 撤回)或它的版本签名变了 → 驱逐对应 detail 缓存。 -// -// 安全审查(防止恶意服务端 / 缓存投毒 / OOM): -// - ID 必须是 UUID v4(backend 已强制此约束;客户端镜像校验防 key 注入)。 -// - detail.id 必须与请求 packId 一致(防服务端返回错位内容)。 -// - 单条 detail 的 prompt 长度上限 200KB(防 OOM via 巨型注入)。 -// - detail 缓存条数上限 64,按 LRU 淘汰(防 localStorage 配额耗尽)。 -// - List items 在读取 / 写入时按合法 ID 过滤,丢弃格式异常项。 - -const MARKETPLACE_LIST_CACHE_KEY = "ol-marketplace-list-cache-v2" -const MARKETPLACE_DETAIL_CACHE_KEY = "ol-marketplace-detail-cache-v2" -const MARKETPLACE_LIST_TTL_MS = 24 * 60 * 60 * 1000 // 24h —— list 本来变动稀,refresh 也会自动覆盖 -const MARKETPLACE_DETAIL_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天 —— detail 已经按版本三元组锁定,TTL 只是兜底 -const MARKETPLACE_DETAIL_MAX_ENTRIES = 64 -const MARKETPLACE_DETAIL_MAX_PROMPT_CHARS = 200_000 - -const PACK_ID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - -function isValidMarketplacePackId(id: unknown): id is string { - return typeof id === "string" && PACK_ID_RE.test(id) -} - -function detailCacheKey( - id: string, - version: string, - updatedAt: string, -): string { - // version + updatedAt 任一字段为空也能拼出确定 key(refetch 会自然覆盖)。 - return `${id}::${version ?? ""}::${updatedAt ?? ""}` -} - -export function readMarketplaceListCache(): MarketplaceListItem[] | null { - try { - const raw = localStorage.getItem(MARKETPLACE_LIST_CACHE_KEY) - if (!raw) return null - const parsed = JSON.parse(raw) as { - items: MarketplaceListItem[] - ts: number - } - if (!parsed || !Array.isArray(parsed.items)) return null - if (Date.now() - parsed.ts > MARKETPLACE_LIST_TTL_MS) return null - return parsed.items.filter( - (it) => it && isValidMarketplacePackId(it.id), - ) - } catch { - return null - } -} - -export function writeMarketplaceListCache(items: MarketplaceListItem[]): void { - try { - const sanitized = items.filter( - (it) => it && isValidMarketplacePackId(it.id), - ) - localStorage.setItem( - MARKETPLACE_LIST_CACHE_KEY, - JSON.stringify({ items: sanitized, ts: Date.now() }), - ) - // 服务端最新视图里没有的 (id, version, updatedAt) 一律驱逐 —— - // 这是「云端哈希被移除时本机也移除」的执行点。 - const keepKeys = new Set( - sanitized.map((it) => - detailCacheKey(it.id, it.version ?? "", it.updatedAt ?? ""), - ), - ) - pruneMarketplaceDetailCache(keepKeys) - } catch { - // quota exceeded / disabled — silent - } -} - -type MarketplaceDetailCacheEntry = { - key: string - detail: MarketplaceDetail - ts: number -} - -function readMarketplaceDetailStore(): Record< - string, - MarketplaceDetailCacheEntry -> { - try { - const raw = localStorage.getItem(MARKETPLACE_DETAIL_CACHE_KEY) - if (!raw) return {} - const parsed = JSON.parse(raw) as Record< - string, - MarketplaceDetailCacheEntry - > | null - return parsed && typeof parsed === "object" ? parsed : {} - } catch { - return {} - } -} - -function writeMarketplaceDetailStore( - store: Record, -): void { - try { - localStorage.setItem( - MARKETPLACE_DETAIL_CACHE_KEY, - JSON.stringify(store), - ) - } catch { - // 配额耗尽 — 下次 read 时按 entries 数清理,命中失败会重新走网络。 - } -} - -export function readMarketplaceDetailCache( - packId: string, - version: string, - updatedAt: string, -): MarketplaceDetail | null { - if (!isValidMarketplacePackId(packId)) return null - const store = readMarketplaceDetailStore() - const entry = store[detailCacheKey(packId, version, updatedAt)] - if (!entry) return null - if (Date.now() - entry.ts > MARKETPLACE_DETAIL_TTL_MS) return null - if (!entry.detail || entry.detail.id !== packId) return null - return entry.detail -} - -export function writeMarketplaceDetailCache(detail: MarketplaceDetail): void { - if (!isValidMarketplacePackId(detail.id)) return - if ( - typeof detail.prompt === "string" && - detail.prompt.length > MARKETPLACE_DETAIL_MAX_PROMPT_CHARS - ) { - // 巨型 prompt 拒收 —— 防 OOM / 防服务端被攻陷后用大 payload 拖慢客户端。 - return - } - const store = readMarketplaceDetailStore() - const key = detailCacheKey( - detail.id, - detail.version ?? "", - detail.updatedAt ?? "", - ) - store[key] = { key, detail, ts: Date.now() } - // LRU: 旧的优先丢 - const entries = Object.values(store).sort((a, b) => a.ts - b.ts) - while (entries.length > MARKETPLACE_DETAIL_MAX_ENTRIES) { - const oldest = entries.shift() - if (oldest) delete store[oldest.key] - } - writeMarketplaceDetailStore(store) -} - -function pruneMarketplaceDetailCache(keepKeys: Set): void { - const store = readMarketplaceDetailStore() - let changed = false - for (const key of Object.keys(store)) { - if (!keepKeys.has(key)) { - delete store[key] - changed = true - } - } - if (changed) writeMarketplaceDetailStore(store) -} diff --git a/openless-all/app/src/lib/ipc/asr-credentials.ts b/openless-all/app/src/lib/ipc/asr-credentials.ts new file mode 100644 index 00000000..77d9edf0 --- /dev/null +++ b/openless-all/app/src/lib/ipc/asr-credentials.ts @@ -0,0 +1,66 @@ +import type { CredentialsStatus } from "../types" +import { invokeOrMock } from "./shared" +import { mockCredentialsStatus } from "./mock-data" + +export interface ProviderCheckResult { + ok: boolean +} + +export interface ProviderModelsResult { + models: string[] +} + +export function getCredentials(): Promise { + return invokeOrMock( + "get_credentials", + undefined, + () => mockCredentialsStatus, + ) +} + +export function setCredential(account: string, value: string): Promise { + return invokeOrMock("set_credential", { account, value }, () => undefined) +} + +export function setActiveAsrProvider(provider: string): Promise { + return invokeOrMock( + "set_active_asr_provider", + { provider }, + () => undefined, + ) +} + +export function setActiveLlmProvider(provider: string): Promise { + return invokeOrMock( + "set_active_llm_provider", + { provider }, + () => undefined, + ) +} + +export function readCredential(account: string): Promise { + return invokeOrMock( + "read_credential", + { account }, + () => null, + ) +} + +export function validateProviderCredentials( + kind: "llm" | "asr", +): Promise { + return invokeOrMock("validate_provider_credentials", { kind }, () => ({ + ok: true, + })) +} + +export function listProviderModels( + kind: "llm" | "asr", +): Promise { + return invokeOrMock("list_provider_models", { kind }, () => ({ + models: + kind === "llm" + ? ["gpt-4o", "deepseek-v4-flash", "deepseek-v4-pro"] + : ["whisper-1"], + })) +} diff --git a/openless-all/app/src/lib/ipc/coding-agent.ts b/openless-all/app/src/lib/ipc/coding-agent.ts new file mode 100644 index 00000000..2746f97f --- /dev/null +++ b/openless-all/app/src/lib/ipc/coding-agent.ts @@ -0,0 +1,69 @@ +import type { CodingAgentPermissionMode } from "../types" +export type { CodingAgentPermissionMode } +import { invokeOrMock } from "./shared" + +export type McpHealth = "connected" | "failed" | "needs_auth" | "unknown" + +export interface McpServerStatus { + name: string + detail: string + health: McpHealth +} + +export interface ClaudeDetection { + installed: boolean + version: string | null + exe: string + mcpServers: McpServerStatus[] + hasComputerUse: boolean +} + +/** 无头 Claude 运行事件,由后端 `coding-agent:test` 流式推送(tag 为 `kind`)。 */ +export type CodingAgentEvent = + | { kind: "started"; session_id: string } + | { kind: "delta"; session_id: string; text: string } + | { kind: "tool_use"; session_id: string; name: string } + | { + kind: "completed" + session_id: string + text: string + cost_usd: number | null + duration_ms: number | null + } + | { kind: "cancelled"; session_id: string } + | { kind: "error"; session_id: string; message: string } + +export function codingAgentDetect(exe?: string): Promise { + return invokeOrMock( + "coding_agent_detect", + { exe }, + () => ({ + installed: false, + version: null, + exe: exe || "claude", + mcpServers: [], + hasComputerUse: false, + }), + ) +} + +export interface CodingAgentRunTestArgs { + prompt: string + exe?: string + permissionMode?: CodingAgentPermissionMode + workdir?: string + model?: string + maxBudgetUsd?: number +} + +export function codingAgentRunTest(args: CodingAgentRunTestArgs): Promise { + return invokeOrMock("coding_agent_run_test", { ...args }, () => undefined) +} + +export function codingAgentCancelTest(): Promise { + return invokeOrMock("coding_agent_cancel_test", undefined, () => undefined) +} + +export function codingAgentCommandRisk(command: string): Promise { + return invokeOrMock("coding_agent_command_risk", { command }, () => null) +} diff --git a/openless-all/app/src/lib/ipc/devices.ts b/openless-all/app/src/lib/ipc/devices.ts new file mode 100644 index 00000000..0d82fcd8 --- /dev/null +++ b/openless-all/app/src/lib/ipc/devices.ts @@ -0,0 +1,43 @@ +import type { MicrophoneDevice } from "../types" +import { invokeOrMock } from "./shared" +import { mockMicrophoneDevices } from "./mock-data" + +export interface NetworkCheckResult { + online: boolean + latencyMs: number | null +} + +export function checkNetwork(): Promise { + return invokeOrMock("check_network", undefined, () => ({ + online: true, + latencyMs: 42, + })) +} + +export function listMicrophoneDevices(): Promise { + return invokeOrMock( + "list_microphone_devices", + undefined, + () => mockMicrophoneDevices, + ) +} + +export function startMicrophoneLevelMonitor(deviceName: string): Promise { + return invokeOrMock( + "start_microphone_level_monitor", + { deviceName }, + () => undefined, + ) +} + +export function stopMicrophoneLevelMonitor(): Promise { + return invokeOrMock( + "stop_microphone_level_monitor", + undefined, + () => undefined, + ) +} + +export function isWaylandCliMode(): Promise { + return invokeOrMock("is_wayland_cli_mode", undefined, () => false) +} diff --git a/openless-all/app/src/lib/ipc/dictation.ts b/openless-all/app/src/lib/ipc/dictation.ts new file mode 100644 index 00000000..313dec2c --- /dev/null +++ b/openless-all/app/src/lib/ipc/dictation.ts @@ -0,0 +1,31 @@ +import { invokeOrMock, platformCapabilities } from "./shared" + +export function startDictation(): Promise { + return invokeOrMock("start_dictation", undefined, () => undefined) +} + +export function stopDictation(): Promise { + return invokeOrMock("stop_dictation", undefined, () => undefined) +} + +export function cancelDictation(): Promise { + return invokeOrMock("cancel_dictation", undefined, () => undefined) +} + +export function handleWindowHotkeyEvent( + eventType: "keydown" | "keyup", + key: string, + code: string, + repeat: boolean, +): Promise { + return platformCapabilities().then((caps) => { + if (!caps.supportsDesktopHotkey) { + return undefined + } + return invokeOrMock( + "handle_window_hotkey_event", + { event_type: eventType, key, code, repeat }, + () => undefined, + ) + }) +} diff --git a/openless-all/app/src/lib/ipc/github-oauth.ts b/openless-all/app/src/lib/ipc/github-oauth.ts new file mode 100644 index 00000000..a010e8f8 --- /dev/null +++ b/openless-all/app/src/lib/ipc/github-oauth.ts @@ -0,0 +1,42 @@ +import { invokeOrMock } from "./shared" + +export interface GithubDeviceStartResponse { + deviceCode: string + userCode: string + verificationUri: string + interval: number + expiresIn: number +} + +export type GithubDevicePollResult = + | { kind: "authorized"; login: string } + | { kind: "pending" } + | { kind: "slowDown" } + | { kind: "error"; message: string } + +export function githubDeviceFlowStart(): Promise { + return invokeOrMock( + "github_device_flow_start", + undefined, + () => ({ + deviceCode: "mock-device-code-xxxxxxxx", + userCode: "MOCK-CODE", + verificationUri: "https://github.com/login/device", + interval: 5, + expiresIn: 900, + }), + ) +} + +export function githubDeviceFlowPoll( + deviceCode: string, +): Promise { + return invokeOrMock( + "github_device_flow_poll", + { deviceCode }, + () => ({ + kind: "authorized" as const, + login: "mock-user", + }), + ) +} diff --git a/openless-all/app/src/lib/ipc/history.ts b/openless-all/app/src/lib/ipc/history.ts new file mode 100644 index 00000000..2fd153ee --- /dev/null +++ b/openless-all/app/src/lib/ipc/history.ts @@ -0,0 +1,42 @@ +import type { DictationSession } from "../types" +import { invokeOrMock } from "./shared" +import { mockHistory } from "./mock-data" + +export function listHistory(): Promise { + return invokeOrMock("list_history", undefined, () => mockHistory) +} + +export function deleteHistoryEntry(id: string): Promise { + return invokeOrMock("delete_history_entry", { id }, () => undefined) +} + +export function clearHistory(): Promise { + return invokeOrMock("clear_history", undefined, () => undefined) +} + +/** 读取某次会话的原始麦克风 wav 字节流。仅当 prefs.recordAudioForDebug 当时打开 + * 并且文件没被 retention 清理掉时才有内容;其他情况后端会返回 "recording not found" 错。 + * 调用方应仅在 session.hasAudioRecording === true 时触发,避免无效 IPC。 */ +export function readAudioRecording(sessionId: string): Promise { + return invokeOrMock( + "read_audio_recording", + { sessionId }, + () => new Uint8Array(), + ).then((value) => { + // Tauri 默认把 Vec 序列化为 number[],前端拿到的是普通数组;统一转 Uint8Array。 + if (value instanceof Uint8Array) return value + if (Array.isArray(value)) return new Uint8Array(value as number[]) + return new Uint8Array(value as ArrayBuffer) + }) +} + +/** 用当前 ASR provider 对一条「转录失败」历史条目的归档录音重新转录(issue #613)。 + * 成功时后端原地回写该条历史的 rawTranscript / finalText 并清除错误码,返回更新后的整条记录。 + * 失败时抛出错误(如「重新转录仍未识别到语音」/「recording not found」),录音保留不丢。 */ +export function retranscribeRecording(sessionId: string): Promise { + return invokeOrMock( + "retranscribe_recording", + { sessionId }, + () => mockHistory[0], + ) as Promise +} diff --git a/openless-all/app/src/lib/ipc/hotkeys.ts b/openless-all/app/src/lib/ipc/hotkeys.ts new file mode 100644 index 00000000..77148dcf --- /dev/null +++ b/openless-all/app/src/lib/ipc/hotkeys.ts @@ -0,0 +1,81 @@ +import type { ComboBinding, HotkeyCapability, HotkeyStatus, ShortcutBinding, WindowsImeStatus } from "../types" +import { invokeOrMock, platformCapabilities, androidHotkeyStatus, androidHotkeyCapability, androidWindowsImeStatus } from "./shared" +import { mockHotkeyStatus, mockHotkeyCapability, mockWindowsImeStatus } from "./mock-data" + +export function getHotkeyStatus(): Promise { + return platformCapabilities().then((caps) => { + if (!caps.supportsDesktopHotkey) { + return androidHotkeyStatus + } + return invokeOrMock("get_hotkey_status", undefined, () => mockHotkeyStatus) + }) +} + +export function getHotkeyCapability(): Promise { + return platformCapabilities().then((caps) => { + if (!caps.supportsDesktopHotkey) { + return androidHotkeyCapability + } + return invokeOrMock( + "get_hotkey_capability", + undefined, + () => mockHotkeyCapability, + ) + }) +} + +export function getWindowsImeStatus(): Promise { + return platformCapabilities().then((caps) => { + if (caps.platform === "android") { + return androidWindowsImeStatus + } + return invokeOrMock( + "get_windows_ime_status", + undefined, + () => mockWindowsImeStatus, + ) + }) +} + +export function validateComboHotkey(binding: ComboBinding): Promise { + return invokeOrMock("validate_combo_hotkey", { binding }, () => undefined) +} + +export function setComboHotkey(binding: ComboBinding): Promise { + return invokeOrMock("set_combo_hotkey", { binding }, () => undefined) +} + +export function validateShortcutBinding( + binding: ShortcutBinding, +): Promise { + return invokeOrMock( + "validate_shortcut_binding", + { binding }, + () => undefined, + ) +} + +export function setDictationHotkey(binding: ShortcutBinding): Promise { + return invokeOrMock("set_dictation_hotkey", { binding }, () => undefined) +} + +export function setTranslationHotkey(binding: ShortcutBinding): Promise { + return invokeOrMock("set_translation_hotkey", { binding }, () => undefined) +} + +// binding = null 表示停用(清空全局键),与 set_qa_hotkey 一致(issue #576)。 +export function setSwitchStyleHotkey(binding: ShortcutBinding | null): Promise { + return invokeOrMock("set_switch_style_hotkey", { binding }, () => undefined) +} + +export function setOpenAppHotkey(binding: ShortcutBinding | null): Promise { + return invokeOrMock("set_open_app_hotkey", { binding }, () => undefined) +} + +export function setShortcutRecordingActive(active: boolean): Promise { + return invokeOrMock( + "set_shortcut_recording_active", + { active }, + () => undefined, + ) +} diff --git a/openless-all/app/src/lib/ipc/index.ts b/openless-all/app/src/lib/ipc/index.ts new file mode 100644 index 00000000..b0afaf42 --- /dev/null +++ b/openless-all/app/src/lib/ipc/index.ts @@ -0,0 +1,198 @@ +// Barrel — re-exports every public symbol from the domain modules. +// Must preserve identical exports to the old src/lib/ipc.ts. + +export type { UpdateChannel, PlatformCapabilities } from "../types" + +// platform & android +export { isAndroid, isDesktop, isMobile } from "./platform-exports" +export { + getAndroidOverlayStatus, + requestAndroidOverlayPermission, + showAndroidOverlay, + hideAndroidOverlay, + getAndroidAccessibilityStatus, + requestAndroidAccessibilityPermission, +} from "./platform-exports" + +// shared +export { isTauri, invokeOrMock, getPlatformCapabilities } from "./shared" + +// settings +export { getSettings, getDefaultStyleSystemPrompts, setSettings } from "./settings" + +// asr-credentials +export type { ProviderCheckResult, ProviderModelsResult } from "./asr-credentials" +export { + getCredentials, + setCredential, + setActiveAsrProvider, + setActiveLlmProvider, + readCredential, + validateProviderCredentials, + listProviderModels, +} from "./asr-credentials" + +// history +export { + listHistory, + deleteHistoryEntry, + clearHistory, + readAudioRecording, + retranscribeRecording, +} from "./history" + +// vocab +export { + listVocab, + addVocab, + removeVocab, + setVocabEnabled, + listCorrectionRules, + addCorrectionRule, + removeCorrectionRule, + setCorrectionRuleEnabled, + listVocabPresets, + saveVocabPresets, +} from "./vocab" + +// dictation +export { + startDictation, + stopDictation, + cancelDictation, + handleWindowHotkeyEvent, +} from "./dictation" + +// style-packs +export { + repolish, + setDefaultPolishMode, + setStyleEnabled, + listStylePacks, + saveStylePack, + createStylePackFromTemplate, + previewStylePackRuntime, + setActiveStylePack, + setStylePackEnabled, + resetBuiltinStylePack, + deleteStylePack, + importStylePackFromZip, + exportStylePackToZip, +} from "./style-packs" + +// permissions +export { + checkAccessibilityPermission, + requestAccessibilityPermission, + checkMicrophonePermission, + requestMicrophonePermission, + openSystemSettings, + triggerMicrophonePrompt, + restartApp, +} from "./permissions" + +// hotkeys +export { + getHotkeyStatus, + getHotkeyCapability, + getWindowsImeStatus, + validateComboHotkey, + setComboHotkey, + validateShortcutBinding, + setDictationHotkey, + setTranslationHotkey, + setSwitchStyleHotkey, + setOpenAppHotkey, + setShortcutRecordingActive, +} from "./hotkeys" + +// devices +export type { NetworkCheckResult } from "./devices" +export { + checkNetwork, + listMicrophoneDevices, + startMicrophoneLevelMonitor, + stopMicrophoneLevelMonitor, + isWaylandCliMode, +} from "./devices" + +// qa +export { + getQaHotkeyLabel, + setQaHotkey, + qaWindowDismiss, + qaWindowPin, + qaToggleRecording, + qaSubmitText, +} from "./qa" + +// less-computer +export { + lessComputerWindowDismiss, + lessComputerApprove, + lessComputerWindowResize, +} from "./less-computer" + +// updater +export type { LatestBetaRelease, AppUpdateMetadata } from "./updater" +export { + getUpdateChannel, + setUpdateChannel, + fetchLatestBetaRelease, + appCheckUpdateWithChannel, + appDownloadAndInstallAndroidUpdate, +} from "./updater" + +// remote-server +export type { RemoteInputStatus } from "./remote-server" +export { + getRemoteInputStatus, + listLocalIps, + regenerateRemotePin, + setRemoteLocale, +} from "./remote-server" + +// coding-agent +export type { + CodingAgentPermissionMode, + McpHealth, + CodingAgentEvent, +} from "./coding-agent" +export type { + McpServerStatus, + ClaudeDetection, + CodingAgentRunTestArgs, +} from "./coding-agent" +export { + codingAgentDetect, + codingAgentRunTest, + codingAgentCancelTest, + codingAgentCommandRisk, +} from "./coding-agent" + +// marketplace +export { + listMarketplace, + fetchMarketplaceDetail, + installMarketplacePack, + uploadMarketplacePack, + likeMarketplacePack, + marketplaceMyLikes, + marketplaceMyPacks, + marketplaceDelete, +} from "./marketplace" + +// github-oauth +export type { GithubDeviceStartResponse, GithubDevicePollResult } from "./github-oauth" +export { githubDeviceFlowStart, githubDeviceFlowPoll } from "./github-oauth" + +// marketplace-cache +export { + readMarketplaceListCache, + writeMarketplaceListCache, + readMarketplaceDetailCache, + writeMarketplaceDetailCache, +} from "./marketplace-cache" + +// utils +export { openExternal, exportErrorLog } from "./utils" diff --git a/openless-all/app/src/lib/ipc/less-computer.ts b/openless-all/app/src/lib/ipc/less-computer.ts new file mode 100644 index 00000000..2889551d --- /dev/null +++ b/openless-all/app/src/lib/ipc/less-computer.ts @@ -0,0 +1,27 @@ +import { invokeOrMock } from "./shared" + +/** 用户点 ✕ / 按 Esc 关闭 Less Computer 浮窗(隐藏窗口)。 */ +export function lessComputerWindowDismiss(): Promise { + return invokeOrMock("less_computer_window_dismiss", undefined, () => undefined) +} + +/** 内联审批卡的 Approve / Deny 回执。token 关联到等待中的拦截动作。 */ +export function lessComputerApprove( + token: string, + approved: boolean, +): Promise { + return invokeOrMock( + "less_computer_approve", + { token, approved }, + () => undefined, + ) +} + +/** 前端按内容测高后回传,后端 clamp + bottom-anchored 重新摆放浮窗。 */ +export function lessComputerWindowResize(height: number): Promise { + return invokeOrMock( + "less_computer_window_resize", + { height }, + () => undefined, + ) +} diff --git a/openless-all/app/src/lib/ipc/marketplace-cache.ts b/openless-all/app/src/lib/ipc/marketplace-cache.ts new file mode 100644 index 00000000..09ebdea3 --- /dev/null +++ b/openless-all/app/src/lib/ipc/marketplace-cache.ts @@ -0,0 +1,146 @@ +import type { MarketplaceDetail, MarketplaceListItem } from "../types" + +const MARKETPLACE_LIST_CACHE_KEY = "ol-marketplace-list-cache-v2" +const MARKETPLACE_DETAIL_CACHE_KEY = "ol-marketplace-detail-cache-v2" +const MARKETPLACE_LIST_TTL_MS = 24 * 60 * 60 * 1000 +const MARKETPLACE_DETAIL_TTL_MS = 30 * 24 * 60 * 60 * 1000 +const MARKETPLACE_DETAIL_MAX_ENTRIES = 64 +const MARKETPLACE_DETAIL_MAX_PROMPT_CHARS = 200_000 + +const PACK_ID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +function isValidMarketplacePackId(id: unknown): id is string { + return typeof id === "string" && PACK_ID_RE.test(id) +} + +function detailCacheKey( + id: string, + version: string, + updatedAt: string, +): string { + return `${id}::${version ?? ""}::${updatedAt ?? ""}` +} + +export function readMarketplaceListCache(): MarketplaceListItem[] | null { + try { + const raw = localStorage.getItem(MARKETPLACE_LIST_CACHE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as { + items: MarketplaceListItem[] + ts: number + } + if (!parsed || !Array.isArray(parsed.items)) return null + if (Date.now() - parsed.ts > MARKETPLACE_LIST_TTL_MS) return null + return parsed.items.filter( + (it) => it && isValidMarketplacePackId(it.id), + ) + } catch { + return null + } +} + +export function writeMarketplaceListCache(items: MarketplaceListItem[]): void { + try { + const sanitized = items.filter( + (it) => it && isValidMarketplacePackId(it.id), + ) + localStorage.setItem( + MARKETPLACE_LIST_CACHE_KEY, + JSON.stringify({ items: sanitized, ts: Date.now() }), + ) + const keepKeys = new Set( + sanitized.map((it) => + detailCacheKey(it.id, it.version ?? "", it.updatedAt ?? ""), + ), + ) + pruneMarketplaceDetailCache(keepKeys) + } catch { + // quota exceeded / disabled — silent + } +} + +type MarketplaceDetailCacheEntry = { + key: string + detail: MarketplaceDetail + ts: number +} + +function readMarketplaceDetailStore(): Record< + string, + MarketplaceDetailCacheEntry +> { + try { + const raw = localStorage.getItem(MARKETPLACE_DETAIL_CACHE_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) as Record< + string, + MarketplaceDetailCacheEntry + > | null + return parsed && typeof parsed === "object" ? parsed : {} + } catch { + return {} + } +} + +function writeMarketplaceDetailStore( + store: Record, +): void { + try { + localStorage.setItem( + MARKETPLACE_DETAIL_CACHE_KEY, + JSON.stringify(store), + ) + } catch { + // quota exceeded + } +} + +export function readMarketplaceDetailCache( + packId: string, + version: string, + updatedAt: string, +): MarketplaceDetail | null { + if (!isValidMarketplacePackId(packId)) return null + const store = readMarketplaceDetailStore() + const entry = store[detailCacheKey(packId, version, updatedAt)] + if (!entry) return null + if (Date.now() - entry.ts > MARKETPLACE_DETAIL_TTL_MS) return null + if (!entry.detail || entry.detail.id !== packId) return null + return entry.detail +} + +export function writeMarketplaceDetailCache(detail: MarketplaceDetail): void { + if (!isValidMarketplacePackId(detail.id)) return + if ( + typeof detail.prompt === "string" && + detail.prompt.length > MARKETPLACE_DETAIL_MAX_PROMPT_CHARS + ) { + return + } + const store = readMarketplaceDetailStore() + const key = detailCacheKey( + detail.id, + detail.version ?? "", + detail.updatedAt ?? "", + ) + store[key] = { key, detail, ts: Date.now() } + const entries = Object.values(store).sort((a, b) => a.ts - b.ts) + while (entries.length > MARKETPLACE_DETAIL_MAX_ENTRIES) { + const oldest = entries.shift() + if (oldest) delete store[oldest.key] + } + writeMarketplaceDetailStore(store) +} + +function pruneMarketplaceDetailCache(keepKeys: Set): void { + const store = readMarketplaceDetailStore() + let changed = false + for (const key of Object.keys(store)) { + if (!keepKeys.has(key)) { + delete store[key] + changed = true + } + } + if (changed) writeMarketplaceDetailStore(store) +} diff --git a/openless-all/app/src/lib/ipc/marketplace.ts b/openless-all/app/src/lib/ipc/marketplace.ts new file mode 100644 index 00000000..590d14b1 --- /dev/null +++ b/openless-all/app/src/lib/ipc/marketplace.ts @@ -0,0 +1,84 @@ +import type { MarketplaceDetail, MarketplaceListItem, MarketplaceMyPackItem, StylePack } from "../types" +import { invokeOrMock } from "./shared" +import { mockStylePacks } from "./mock-data" + +const MOCK_MARKETPLACE: MarketplaceListItem[] = [ + { + id: "00000000-0000-0000-0000-000000000001", + slug: "demo-pack", + name: "示范风格包", + description: "Mock 数据 - vite dev 模式下显示", + authorLogin: "demo", + version: "1.0.0", + baseMode: "structured", + tags: ["demo"], + likeCount: 12, + downloadCount: 50, + publishedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, +] + +export function listMarketplace( + options: { query?: string; sort?: "new" | "popular"; limit?: number } = {}, +): Promise { + return invokeOrMock("marketplace_list", options, () => MOCK_MARKETPLACE) +} + +export function fetchMarketplaceDetail( + packId: string, +): Promise { + return invokeOrMock("marketplace_detail", { packId }, () => ({ + ...MOCK_MARKETPLACE[0], + prompt: "# 角色\n你是测试用 polish 助手。\n\n# 任务\n按整体意图整理转写。", + state: "approved" as const, + })) +} + +export function installMarketplacePack(packId: string): Promise { + return invokeOrMock( + "marketplace_install", + { packId }, + () => mockStylePacks[0], + ) +} + +export function uploadMarketplacePack( + packId: string, + originPackId?: string | null, +): Promise<{ id: string; state: string; message: string }> { + return invokeOrMock( + "marketplace_upload", + { packId, originPackId: originPackId ?? null }, + () => ({ + id: "mock-uploaded", + state: "pending", + message: "Mock 上传成功(vite dev)", + }), + ) +} + +export function likeMarketplacePack( + packId: string, +): Promise<{ likeCount: number; alreadyLiked: boolean }> { + return invokeOrMock("marketplace_like", { packId }, () => ({ + likeCount: 13, + alreadyLiked: false, + })) +} + +export function marketplaceMyLikes(): Promise { + return invokeOrMock("marketplace_my_likes", undefined, () => []) +} + +export function marketplaceMyPacks(): Promise { + return invokeOrMock( + "marketplace_my_packs", + undefined, + () => [], + ) +} + +export function marketplaceDelete(packId: string): Promise { + return invokeOrMock("marketplace_delete", { packId }, () => undefined) +} diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts new file mode 100644 index 00000000..402f8cc8 --- /dev/null +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -0,0 +1,629 @@ +import type { + CorrectionRule, + DictationSession, + DictionaryEntry, + HotkeyCapability, + HotkeyStatus, + PolishMode, + StylePack, + StylePackExample, + StylePackKind, + StylePackRuntimeDiagnostics, + StyleSystemPrompts, + UserPreferences, + WindowsImeStatus, + CredentialsStatus, + MicrophoneDevice, +} from "../types" +import { OL_DATA } from "../mockData" +import { + defaultAppShortcutModifiers, + defaultQaShortcut, +} from "../hotkey" + +export let mockSettings: UserPreferences = { + hotkey: { + trigger: "rightControl", + mode: "toggle", + keys: [{ code: "ControlRight" }], + }, + dictationHotkey: { primary: "RightControl", modifiers: [] }, + defaultMode: "structured", + enabledModes: ["raw", "light", "structured", "formal"], + activeStylePackId: "builtin.structured", + styleSystemPrompts: { + raw: "只做最小化整理:补全标点、必要分句,保留原话顺序、用词和语气。", + light: "把口语转写整理成自然文字,去掉口癖和重复,保留原意与语气。", + structured: "把口述整理成结构清晰的文本,必要时按主题分组输出。", + formal: "输出适合工作沟通与邮件场景的正式表达,不扩写事实。", + }, + customStylePrompts: { raw: "", light: "", structured: "", formal: "" }, + launchAtLogin: false, + showCapsule: true, + muteDuringRecording: false, + audioCueOnRecord: true, + microphoneDeviceName: "", + activeAsrProvider: "foundry-local-whisper", + activeLlmProvider: "ark", + llmThinkingEnabled: false, + restoreClipboardAfterPaste: true, + pasteShortcut: "ctrlV", + allowNonTsfInsertionFallback: true, + workingLanguages: ["简体中文"], + translationTargetLanguage: "", + qaHotkey: defaultQaShortcut(), + chineseScriptPreference: "auto", + outputLanguagePreference: "auto", + qaSaveHistory: false, + customComboHotkey: null, + translationHotkey: { primary: "Shift", modifiers: [] }, + switchStyleHotkey: { + primary: "S", + modifiers: defaultAppShortcutModifiers(), + }, + openAppHotkey: { primary: "O", modifiers: defaultAppShortcutModifiers() }, + codingAgentEnabled: false, + codingAgentProvider: "claude-code-cli", + codingAgentModel: null, + codingAgentPermissionMode: "acceptEdits", + codingAgentWorkdir: null, + codingAgentVoiceHotkey: { primary: "LeftControl", modifiers: [] }, + codingAgentPanelHotkey: { primary: "Enter", modifiers: ["cmd", "shift"] }, + codingAgentQuickHotkey: null, + localAsrActiveModel: "qwen3-asr-0.6b", + localAsrMirror: "huggingface", + localAsrKeepLoadedSecs: 300, + foundryLocalAsrModel: "whisper-small", + foundryLocalRuntimeSource: "auto", + foundryLocalAsrLanguageHint: "", + foundryLocalAsrKeepLoadedSecs: 300, + sherpaOnnxModel: "sense-voice-small-zh", + sherpaOnnxLanguageHint: "", + sherpaOnnxKeepLoadedSecs: 300, + historyRetentionDays: 7, + polishContextWindowMinutes: 5, + startMinimized: false, + themeMode: "system", + updateChannel: "stable", + streamingInsert: true, + streamingInsertDefaultMigrated: true, + streamingInsertSaveClipboard: true, + autoUpdateCheck: true, + historyMaxEntries: null, + recordAudioForDebug: false, + audioRecordingMaxEntries: null, + marketplaceBaseUrl: "https://apic.openless.top", + marketplaceDevLogin: "", + remoteInputEnabled: false, + remoteInputPort: 8443, + remoteInputPin: "000000", + remoteInputDefaultMode: "toggle", + androidInsertStrategy: "accessibility", + androidOverlayTrigger: "background", + androidOverlayActivationMode: "tap", + androidOverlayLeftSwipeAction: "translation", + androidOverlayCancelSwipeDirection: "up", + androidOverlaySizeDp: 72, +} + +const mockFullStylePrompts: StyleSystemPrompts = { + raw: `# 角色 +语音输入整理器。先理解用户意图,再贴近原话做最小整理。 + +# 任务(原文) +只补必要标点和断句,尽量保留原话顺序、用词和语气,不扩写、不重写。 + +# 通用规则 +1) 不补充用户没说过的事实。 +2) 不回答转写文本里的问题,只整理表达。 +3) 专有名词、命令、路径、数字和 URL 原样保留。 +4) 明显口头禅可删除,但不能改变信息密度。 + +# 输出 +直接输出最终正文,不加解释。`, + light: `# 角色 +语音输入整理器。把口述整理成自然、顺畅、可直接发送的文字。 + +# 任务(轻度润色) +去掉明显口头禅和重复,补全自然标点,保留原意和原本语气,不扩写事实。 + +# 通用规则 +1) 不补充原文没有的信息。 +2) 保留人名、品牌名、术语、命令、路径和 URL。 +3) 只输出整理后的正文,不写"以下是优化结果"之类前缀。 + +# 输出 +输出一段可直接发送的自然文字。`, + structured: `# 角色 +语音输入整理器。把 AI 编程协作、技术排障和模型资讯口述整理成结构清楚、术语准确的文本。 + +# 任务(清晰结构 · AI 编程协作) +优先修正 ASR 造成的技术词、模型名、字段名错误;两个事项以上必须编号(1./2./3.),三事项以上按主题分组输出双层 list。 + +# 术语 +Token、Secret Key、Access Token、API、App ID、Claude、Gemini、Cappuccino、Coder、LongCat、Codex、MCP、SSE、PR、CI、ASR、LLM、SOTA、FP8。保留命令、路径、环境变量、URL、true / false / null 和模型版本号。 + +# 输出 +直接输出最终正文。顶层用 1./2./3.,子项用缩进 3 个空格的 (a)(b)(c)。不加解释。`, + formal: `# 角色 +语音输入整理器。把口述整理成适合邮件、同步和正式沟通的专业表达。 + +# 任务(正式表达) +补足句式与标点,让表达更完整、克制、专业,但不添加空泛客套,也不擅自扩写事实。 + +# 通用规则 +1) 不承诺用户没说过的内容。 +2) 保留专有名词、数字、时间、路径和术语。 +3) 只输出最终正文,不附带解释或 markdown 围栏。 + +# 输出 +输出可直接发送的正式文本。`, +} + +mockSettings = { + ...mockSettings, + styleSystemPrompts: mockFullStylePrompts, + workingLanguages: ["简体中文"], +} + +export const mockDefaultStyleSystemPrompts: StyleSystemPrompts = { + ...mockSettings.styleSystemPrompts, +} + +const mockBuiltinExamples: Record = { + raw: [ + { + title: "最小整理", + input: "今天下午那个会先别取消我晚点再确认一下然后把下周二也先空出来", + output: "今天下午那个会先别取消,我晚点再确认一下。然后把下周二也先空出来。", + }, + ], + light: [ + { + title: "聊天消息", + input: "你帮我跟设计那边说一下这个首页先别上线我晚上再过一遍", + output: "你帮我跟设计那边说一下,这个首页先别上线,我今晚再过一遍。", + }, + ], + structured: [ + { + title: "AI 编程任务", + input: "帮我给 codex 提个任务先把登录页 bug 修掉然后补一下 README 里面的环境变量说明还有那个西克瑞特 key 别写死到代码里", + output: "帮忙给 Codex 提个任务,主要包含以下内容:\n\n1. 登录页修复\n (a) 修复登录页相关 bug。\n2. 文档与配置\n (a) 补充 README 中的环境变量说明。\n (b) 确认 Secret Key 不被硬编码到代码里。", + }, + ], + formal: [ + { + title: "工作同步", + input: "你帮我发个消息说这个需求今天先不上了等测试和产品都确认完我们再一起推进", + output: "麻烦帮我同步一下:这个需求今天先不上线,待测试和产品都确认完成后,我们再统一推进。", + }, + ], +} + +export function makeMockStylePack( + id: string, + kind: StylePackKind, + baseMode: PolishMode, + name: string, + description: string, + prompt: string, + tags: string[], +): StylePack { + return { + id, + name, + description, + author: "OpenLess", + version: "1.0.0", + kind, + baseMode, + prompt, + examples: mockBuiltinExamples[baseMode].map((example) => ({ + ...example, + })), + tags, + iconPath: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + enabled: true, + active: false, + recommendedModel: null, + compatibleAppVersion: "1.0.0", + } +} + +export let mockStylePacks: StylePack[] = [ + makeMockStylePack( + "builtin.raw", + "builtin", + "raw", + "原文", + "尽量保留原话顺序和语气,只做必要的断句与标点整理。", + mockSettings.styleSystemPrompts.raw, + ["原文", "最小改写"], + ), + makeMockStylePack( + "builtin.light", + "builtin", + "light", + "轻度润色", + "把口述整理成顺畅、自然、可直接发送的文字,不扩写事实。", + mockSettings.styleSystemPrompts.light, + ["沟通", "自然"], + ), + makeMockStylePack( + "builtin.structured", + "builtin", + "structured", + "清晰结构", + "适合多事项和多主题口述,自动整理为层次清楚的结构化输出。", + mockSettings.styleSystemPrompts.structured, + ["结构化", "条理"], + ), + makeMockStylePack( + "builtin.formal", + "builtin", + "formal", + "正式表达", + "适合邮件、同步和工作沟通场景,语气更完整、专业、克制。", + mockSettings.styleSystemPrompts.formal, + ["正式", "工作沟通"], + ), + { + ...makeMockStylePack( + "imported.creator-note", + "imported", + "light", + "创作者口播", + "给短视频口播和社区帖文使用,句子更紧凑,保留情绪和节奏。", + "你是一个负责整理创作者口播稿的编辑。请把输入整理成适合发帖和口播的自然文本,保留节奏感,不要补充原文没有的信息。", + ["社区", "口播", "节奏感"], + ), + author: "Demo Community", + }, +] + +export function cloneStylePack(stylePack: StylePack): StylePack { + return { + ...stylePack, + tags: [...stylePack.tags], + examples: stylePack.examples.map((example) => ({ ...example })), + } +} + +export function cloneMockStylePacks(): StylePack[] { + return mockStylePacks.map(cloneStylePack) +} + +export function composeMockStylePackRuntimeDiagnostics( + stylePack: StylePack, +): StylePackRuntimeDiagnostics { + const trimmedPrompt = stylePack.prompt.trimEnd() + const contextPremise = mockSettings.workingLanguages.length + ? [ + "# Context", + `Working languages: ${mockSettings.workingLanguages.join(", ")}`, + ].join("\n") + : "" + const hotwordLines = [`GitHub`, `OpenLess`] + const hotwordBlock = + hotwordLines.length > 0 + ? [ + "Hotwords (keep the spelling below when they appear in the transcript):", + ...hotwordLines.map((word) => `- ${word}`), + ].join("\n") + : "" + const singleTurnPrompt = [contextPremise, trimmedPrompt, hotwordBlock] + .filter(Boolean) + .join("\n\n") + const historyInstruction = + "When prior turns exist, do not repeat previous assistant outputs. Only polish the current transcript." + const multiTurnPrompt = `${singleTurnPrompt}\n\n${historyInstruction}` + return { + packId: stylePack.id, + packName: stylePack.name, + packPrompt: stylePack.prompt, + packPromptChars: stylePack.prompt.length, + contextPremise, + contextPremiseChars: contextPremise.length, + hotwordBlock, + hotwordBlockChars: hotwordBlock.length, + historyInstruction, + historyInstructionChars: historyInstruction.length, + singleTurnPrompt, + singleTurnPromptChars: singleTurnPrompt.length, + multiTurnPrompt, + multiTurnPromptChars: multiTurnPrompt.length, + workingLanguages: [...mockSettings.workingLanguages], + hotwords: [...hotwordLines], + contextWindowMinutes: mockSettings.polishContextWindowMinutes, + includesContextPremise: Boolean(contextPremise), + includesHotwordBlock: hotwordLines.length > 0, + includesHistoryInstruction: true, + previewOmitsFrontApp: true, + } +} + +export function syncMockSettingsFromStylePacks() { + const enabled = mockStylePacks.filter((pack) => pack.enabled) + const active = + mockStylePacks.find( + (pack) => + pack.id === mockSettings.activeStylePackId && pack.enabled, + ) ?? + enabled[0] ?? + mockStylePacks[0] + mockStylePacks = mockStylePacks.map((pack) => ({ + ...pack, + active: pack.id === active.id, + })) + mockSettings = { + ...mockSettings, + activeStylePackId: active.id, + defaultMode: active.baseMode, + enabledModes: ["raw", "light", "structured", "formal"].filter((mode) => + mockStylePacks.some( + (pack) => pack.enabled && pack.baseMode === mode, + ), + ) as PolishMode[], + styleSystemPrompts: { + raw: + mockStylePacks.find((pack) => pack.id === "builtin.raw") + ?.prompt ?? mockSettings.styleSystemPrompts.raw, + light: + mockStylePacks.find((pack) => pack.id === "builtin.light") + ?.prompt ?? mockSettings.styleSystemPrompts.light, + structured: + mockStylePacks.find((pack) => pack.id === "builtin.structured") + ?.prompt ?? mockSettings.styleSystemPrompts.structured, + formal: + mockStylePacks.find((pack) => pack.id === "builtin.formal") + ?.prompt ?? mockSettings.styleSystemPrompts.formal, + }, + } +} + +syncMockSettingsFromStylePacks() + +export const mockHotkeyCapability: HotkeyCapability = { + adapter: "windowsLowLevel", + availableTriggers: [ + "rightControl", + "rightAlt", + "leftControl", + "rightCommand", + "custom", + ], + requiresAccessibilityPermission: false, + supportsModifierOnlyTrigger: true, + supportsSideSpecificModifiers: true, + explicitFallbackAvailable: false, + statusHint: + "默认建议使用“右Ctrl + 单击”;若更习惯按住说话,可在录音设置里切回“按住”。若无响应,可在权限页查看 hook 安装状态。", +} + +export const mockCredentialsStatus: CredentialsStatus = { + activeAsrProvider: "foundry-local-whisper", + activeLlmProvider: "ark", + asrConfigured: true, + llmConfigured: true, + volcengineConfigured: true, + arkConfigured: true, +} + +export const mockHotkeyStatus: HotkeyStatus = { + adapter: "windowsLowLevel", + state: "installed", + message: "Windows 低层键盘 hook 已安装", + lastError: null, +} + +export const mockWindowsImeStatus: WindowsImeStatus = { + state: "notWindows", + usingTsfBackend: false, + message: "Browser dev mock", + dllPath: null, +} + +export const mockMicrophoneDevices: MicrophoneDevice[] = [ + { name: "Built-in Microphone", isDefault: true }, + { name: "USB Microphone", isDefault: false }, +] + +export const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({ + id: `mock-${i}`, + createdAt: new Date().toISOString(), + rawTranscript: h.preview, + finalText: h.preview, + mode: "structured", + stylePackId: "builtin.structured", + translationActive: false, + polishSource: null, + appBundleId: null, + appName: "VS Code", + insertStatus: "inserted", + errorCode: null, + durationMs: 600, + dictionaryEntryCount: 28, + hasAudioRecording: null, +})) + +export const mockVocab: DictionaryEntry[] = OL_DATA.vocab.map((v, i) => ({ + id: `vocab-${i}`, + phrase: v.word, + note: null, + enabled: true, + hits: v.count, + createdAt: new Date().toISOString(), +})) + +export const mockCorrectionRules: CorrectionRule[] = [ + { + id: "rule-quantity-classifier", + pattern: "{num}粒", + replacement: "{num}例", + enabled: true, + createdAt: new Date().toISOString(), + }, +] + +// ── Style pack mutation helpers ─────────────────────────────────────── + +export function mockSetSettings(prefs: UserPreferences): void { + mockSettings = { ...prefs } + mockStylePacks = mockStylePacks.map((pack) => { + if (pack.kind === "builtin") { + return { + ...pack, + enabled: prefs.enabledModes.includes(pack.baseMode), + prompt: prefs.styleSystemPrompts[pack.baseMode], + } + } + return { ...pack } + }) + syncMockSettingsFromStylePacks() +} + +export function mockSetDefaultPolishMode(mode: PolishMode): void { + const packId = `builtin.${mode}` + mockStylePacks = mockStylePacks.map((pack) => ({ + ...pack, + enabled: pack.id === packId ? true : pack.enabled, + active: pack.id === packId, + })) + mockSettings = { ...mockSettings, activeStylePackId: packId } + syncMockSettingsFromStylePacks() +} + +export function mockSetStyleEnabled(mode: PolishMode, enabled: boolean): void { + const packId = `builtin.${mode}` + mockStylePacks = mockStylePacks.map((pack) => + pack.id === packId ? { ...pack, enabled } : { ...pack }, + ) + syncMockSettingsFromStylePacks() +} + +export function mockSaveStylePack(stylePack: StylePack): StylePack { + mockStylePacks = mockStylePacks.map((pack) => + pack.id === stylePack.id ? cloneStylePack(stylePack) : pack, + ) + syncMockSettingsFromStylePacks() + return cloneStylePack( + mockStylePacks.find((pack) => pack.id === stylePack.id) ?? stylePack, + ) +} + +export function mockCreateStylePackFromTemplate(template: StylePack): StylePack { + const created: StylePack = { + ...cloneStylePack(template), + id: `imported-mock-${Date.now()}`, + kind: "imported", + active: false, + enabled: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + mockStylePacks = [...mockStylePacks, created] + return cloneStylePack(created) +} + +export function mockSetActiveStylePack(id: string): StylePack { + mockStylePacks = mockStylePacks.map((pack) => ({ + ...pack, + enabled: pack.id === id ? true : pack.enabled, + active: pack.id === id, + })) + mockSettings = { ...mockSettings, activeStylePackId: id } + syncMockSettingsFromStylePacks() + return cloneStylePack(mockStylePacks.find((pack) => pack.id === id)!) +} + +export function mockSetStylePackEnabled(id: string, enabled: boolean): StylePack[] { + mockStylePacks = mockStylePacks.map((pack) => + pack.id === id ? { ...pack, enabled } : { ...pack }, + ) + syncMockSettingsFromStylePacks() + return cloneMockStylePacks() +} + +export function mockResetBuiltinStylePack(id: string): StylePack { + const builtinDefaults: Record = { + "builtin.raw": makeMockStylePack( + "builtin.raw", + "builtin", + "raw", + "原文", + "尽量保留原话顺序和语气,只做必要的断句与标点整理。", + mockDefaultStyleSystemPrompts.raw, + ["原文", "最小改写"], + ), + "builtin.light": makeMockStylePack( + "builtin.light", + "builtin", + "light", + "轻度润色", + "把口述整理成顺畅、自然、可直接发送的文字,不扩写事实。", + "把口述整理成自然、顺畅、可直接发送的文字,去掉口头禅和重复,保留原意与语气。", + ["沟通", "自然"], + ), + "builtin.structured": makeMockStylePack( + "builtin.structured", + "builtin", + "structured", + "清晰结构", + "面向 AI 编程协作、技术排障和模型资讯,优先保证术语与结构准确。", + mockDefaultStyleSystemPrompts.structured, + ["AI 编程", "技术结构化"], + ), + "builtin.formal": makeMockStylePack( + "builtin.formal", + "builtin", + "formal", + "正式表达", + "适合邮件、同步和工作沟通场景,语气更完整、专业、克制。", + "输出适合工作沟通、邮件和汇报场景的正式表达,不扩写事实。", + ["正式", "工作沟通"], + ), + } + const current = mockStylePacks.find((pack) => pack.id === id) + const reset = builtinDefaults[id] + if (!current || !reset) { + throw new Error(`style pack not found: ${id}`) + } + mockStylePacks = mockStylePacks.map((pack) => + pack.id === id + ? { + ...reset, + enabled: current.enabled, + active: current.active, + } + : pack, + ) + syncMockSettingsFromStylePacks() + return cloneStylePack(mockStylePacks.find((pack) => pack.id === id)!) +} + +export function mockDeleteStylePack(id: string): void { + mockStylePacks = mockStylePacks.filter((pack) => pack.id !== id) + syncMockSettingsFromStylePacks() +} + +export function mockImportStylePackFromZip(zipPath: string): StylePack { + const seed = Date.now() + const pack = { + ...makeMockStylePack( + `imported.mock-${seed}`, + "imported", + "light", + "导入风格包", + `从 ${zipPath.split(/[/\\]/).pop() || "ZIP"} 导入的风格包`, + "你是一个负责把口述整理成清晰、利落、适合社区分享文本的编辑,请完整保留事实,不要补充原文没有的信息。", + ["导入", "ZIP"], + ), + author: "Imported ZIP", + } + mockStylePacks = [pack, ...mockStylePacks] + syncMockSettingsFromStylePacks() + return cloneStylePack(pack) +} diff --git a/openless-all/app/src/lib/ipc/permissions.ts b/openless-all/app/src/lib/ipc/permissions.ts new file mode 100644 index 00000000..b16f6930 --- /dev/null +++ b/openless-all/app/src/lib/ipc/permissions.ts @@ -0,0 +1,48 @@ +import type { PermissionStatus } from "../types" +import { invokeOrMock } from "./shared" + +export function checkAccessibilityPermission(): Promise { + return invokeOrMock( + "check_accessibility_permission", + undefined, + () => "granted" as const, + ) +} + +export function requestAccessibilityPermission(): Promise { + return invokeOrMock( + "request_accessibility_permission", + undefined, + () => "granted" as const, + ) +} + +export function checkMicrophonePermission(): Promise { + return invokeOrMock( + "check_microphone_permission", + undefined, + () => "granted" as const, + ) +} + +export function requestMicrophonePermission(): Promise { + return invokeOrMock( + "request_microphone_permission", + undefined, + () => "granted" as const, + ) +} + +export function openSystemSettings( + pane: "accessibility" | "microphone", +): Promise { + return invokeOrMock("open_system_settings", { pane }, () => undefined) +} + +export function triggerMicrophonePrompt(): Promise { + return invokeOrMock("trigger_microphone_prompt", undefined, () => undefined) +} + +export function restartApp(): Promise { + return invokeOrMock("restart_app", undefined, () => undefined) +} diff --git a/openless-all/app/src/lib/ipc/platform-exports.ts b/openless-all/app/src/lib/ipc/platform-exports.ts new file mode 100644 index 00000000..0767523b --- /dev/null +++ b/openless-all/app/src/lib/ipc/platform-exports.ts @@ -0,0 +1,9 @@ +export { isAndroid, isDesktop, isMobile } from "../platform" +export { + getAndroidOverlayStatus, + requestAndroidOverlayPermission, + showAndroidOverlay, + hideAndroidOverlay, + getAndroidAccessibilityStatus, + requestAndroidAccessibilityPermission, +} from '../../../android/frontend/lib/androidIpc' diff --git a/openless-all/app/src/lib/ipc/qa.ts b/openless-all/app/src/lib/ipc/qa.ts new file mode 100644 index 00000000..dc54b04c --- /dev/null +++ b/openless-all/app/src/lib/ipc/qa.ts @@ -0,0 +1,29 @@ +import type { QaHotkeyBinding } from "../types" +import { invokeOrMock } from "./shared" +import { formatComboLabel, defaultQaShortcut } from "../hotkey" + +export function getQaHotkeyLabel(): Promise { + return invokeOrMock("get_qa_hotkey_label", undefined, () => + formatComboLabel(defaultQaShortcut()), + ) +} + +export function setQaHotkey(binding: QaHotkeyBinding | null): Promise { + return invokeOrMock("set_qa_hotkey", { binding }, () => undefined) +} + +export function qaWindowDismiss(): Promise { + return invokeOrMock("qa_window_dismiss", undefined, () => undefined) +} + +export function qaWindowPin(pinned: boolean): Promise { + return invokeOrMock("qa_window_pin", { pinned }, () => undefined) +} + +export function qaToggleRecording(): Promise { + return invokeOrMock("qa_toggle_recording", undefined, () => undefined) +} + +export function qaSubmitText(text: string): Promise { + return invokeOrMock("qa_submit_text", { text }, () => undefined) +} diff --git a/openless-all/app/src/lib/ipc/remote-server.ts b/openless-all/app/src/lib/ipc/remote-server.ts new file mode 100644 index 00000000..6b87c5ab --- /dev/null +++ b/openless-all/app/src/lib/ipc/remote-server.ts @@ -0,0 +1,31 @@ +import { invokeOrMock } from "./shared" + +// ── Remote input (局域网手机录音) ────────────────────────────────────── +export interface RemoteInputStatus { + running: boolean + port: number + pin: string + urls: string[] +} + +export function getRemoteInputStatus(): Promise { + return invokeOrMock("get_remote_input_status", undefined, () => ({ + running: false, + port: 8443, + pin: "000000", + urls: [], + })) +} + +export function listLocalIps(): Promise { + return invokeOrMock("list_local_ips", undefined, () => ["192.168.1.100"]) +} + +export function regenerateRemotePin(): Promise { + return invokeOrMock("regenerate_remote_pin", undefined, () => "123456") +} + +/** 把 PC 端界面语言同步给远程输入服务,H5 录音页据此显示对应语言。 */ +export function setRemoteLocale(locale: string): Promise { + return invokeOrMock("set_remote_locale", { locale }, () => undefined) +} diff --git a/openless-all/app/src/lib/ipc/settings.ts b/openless-all/app/src/lib/ipc/settings.ts new file mode 100644 index 00000000..0ad0dad2 --- /dev/null +++ b/openless-all/app/src/lib/ipc/settings.ts @@ -0,0 +1,21 @@ +import type { StyleSystemPrompts, UserPreferences } from "../types" +export type { UpdateChannel } from "../types" +import { invokeOrMock } from "./shared" +import { mockSettings, mockDefaultStyleSystemPrompts, mockSetSettings } from "./mock-data" + +export function getSettings(): Promise { + return invokeOrMock("get_settings", undefined, () => ({ ...mockSettings })) +} + +export function getDefaultStyleSystemPrompts(): Promise { + return invokeOrMock("get_default_style_system_prompts", undefined, () => ({ + ...mockDefaultStyleSystemPrompts, + })) +} + +export function setSettings(prefs: UserPreferences): Promise { + return invokeOrMock("set_settings", { prefs }, () => { + mockSetSettings(prefs) + return undefined + }) +} diff --git a/openless-all/app/src/lib/ipc/shared.ts b/openless-all/app/src/lib/ipc/shared.ts new file mode 100644 index 00000000..2e7eed26 --- /dev/null +++ b/openless-all/app/src/lib/ipc/shared.ts @@ -0,0 +1,63 @@ +import type { HotkeyCapability, HotkeyStatus, PlatformCapabilities, WindowsImeStatus } from "../types" +import { getPlatformCapabilities as loadPlatformCapabilities } from "../platform" + +declare global { + interface Window { + __TAURI_INTERNALS__?: unknown + } +} + +export const isTauri = + globalThis.window !== undefined && + "__TAURI_INTERNALS__" in globalThis.window + +let platformCapsPromise: Promise | null = null + +export async function platformCapabilities(): Promise { + platformCapsPromise ??= loadPlatformCapabilities() + return platformCapsPromise +} + +export async function getPlatformCapabilities(): Promise { + return platformCapabilities() +} + +export async function invokeOrMock( + cmd: string, + args: Record | undefined, + mock: () => T, +): Promise { + if (!isTauri) { + return mock() + } + const { invoke } = await import("@tauri-apps/api/core") + return invoke(cmd, args) +} + +export const androidHotkeyCapability: HotkeyCapability = { + adapter: "unavailable", + availableTriggers: [], + requiresAccessibilityPermission: false, + supportsModifierOnlyTrigger: false, + supportsSideSpecificModifiers: false, + explicitFallbackAvailable: false, + statusHint: + "移动端不支持全局热键;请使用应用内录音按钮或悬浮窗(需授权)。", +} + +export const androidHotkeyStatus: HotkeyStatus = { + adapter: "unavailable", + state: "failed", + message: "移动端不支持全局热键", + lastError: { + code: "unavailable", + message: "Global hotkeys are not available on mobile", + }, +} + +export const androidWindowsImeStatus: WindowsImeStatus = { + state: "notWindows", + usingTsfBackend: false, + message: "Not available on Android", + dllPath: null, +} diff --git a/openless-all/app/src/lib/ipc/style-packs.ts b/openless-all/app/src/lib/ipc/style-packs.ts new file mode 100644 index 00000000..e5fe1d04 --- /dev/null +++ b/openless-all/app/src/lib/ipc/style-packs.ts @@ -0,0 +1,110 @@ +import type { PolishMode, StylePack, StylePackRuntimeDiagnostics } from "../types" +import { invokeOrMock } from "./shared" +import { + cloneMockStylePacks, + cloneStylePack, + composeMockStylePackRuntimeDiagnostics, + mockSetDefaultPolishMode, + mockSetStyleEnabled, + mockSaveStylePack, + mockCreateStylePackFromTemplate, + mockSetActiveStylePack, + mockSetStylePackEnabled, + mockResetBuiltinStylePack, + mockDeleteStylePack, + mockImportStylePackFromZip, +} from "./mock-data" + +export function setDefaultPolishMode(mode: PolishMode): Promise { + return invokeOrMock("set_default_polish_mode", { mode }, () => { + mockSetDefaultPolishMode(mode) + return undefined + }) +} + +export function setStyleEnabled( + mode: PolishMode, + enabled: boolean, +): Promise { + return invokeOrMock("set_style_enabled", { mode, enabled }, () => { + mockSetStyleEnabled(mode, enabled) + return undefined + }) +} + +export function listStylePacks(): Promise { + return invokeOrMock("list_style_packs", undefined, () => + cloneMockStylePacks(), + ) +} + +export function saveStylePack(stylePack: StylePack): Promise { + return invokeOrMock("save_style_pack", { stylePack }, () => + mockSaveStylePack(stylePack), + ) +} + +export function createStylePackFromTemplate( + template: StylePack, +): Promise { + return invokeOrMock("create_style_pack_from_template", { template }, () => + mockCreateStylePackFromTemplate(template), + ) +} + +export function previewStylePackRuntime( + stylePack: StylePack, +): Promise { + return invokeOrMock("preview_style_pack_runtime", { stylePack }, () => + composeMockStylePackRuntimeDiagnostics(stylePack), + ) +} + +export function setActiveStylePack(id: string): Promise { + return invokeOrMock("set_active_style_pack", { id }, () => + mockSetActiveStylePack(id), + ) +} + +export function setStylePackEnabled( + id: string, + enabled: boolean, +): Promise { + return invokeOrMock("set_style_pack_enabled", { id, enabled }, () => + mockSetStylePackEnabled(id, enabled), + ) +} + +export function resetBuiltinStylePack(id: string): Promise { + return invokeOrMock("reset_builtin_style_pack", { id }, () => + mockResetBuiltinStylePack(id), + ) +} + +export function deleteStylePack(id: string): Promise { + return invokeOrMock("delete_style_pack", { id }, () => { + mockDeleteStylePack(id) + return undefined + }) +} + +export function importStylePackFromZip(zipPath: string): Promise { + return invokeOrMock("import_style_pack_from_zip", { zipPath }, () => + mockImportStylePackFromZip(zipPath), + ) +} + +export function exportStylePackToZip( + id: string, + targetPath: string, +): Promise { + return invokeOrMock( + "export_style_pack_to_zip", + { id, targetPath }, + () => targetPath, + ) +} + +export function repolish(rawText: string, mode: PolishMode): Promise { + return invokeOrMock("repolish", { rawText, mode }, () => rawText) +} diff --git a/openless-all/app/src/lib/ipc/updater.ts b/openless-all/app/src/lib/ipc/updater.ts new file mode 100644 index 00000000..e8372ce6 --- /dev/null +++ b/openless-all/app/src/lib/ipc/updater.ts @@ -0,0 +1,81 @@ +import type { UpdateChannel } from "../types" +import { invokeOrMock, platformCapabilities } from "./shared" + +export interface LatestBetaRelease { + tagName: string + htmlUrl: string + publishedAt: string +} + +export interface AppUpdateMetadata { + rid: number + currentVersion: string + version: string + date?: string | null + body?: string | null + rawJson: Record +} + +export function getUpdateChannel(): Promise { + return platformCapabilities().then((caps) => { + if (!caps.supportsAutoUpdate) { + return "stable" as UpdateChannel + } + return invokeOrMock( + "get_update_channel", + undefined, + () => "stable" as UpdateChannel, + ) + }) +} + +export function setUpdateChannel(channel: UpdateChannel): Promise { + return platformCapabilities().then((caps) => { + if (!caps.supportsAutoUpdate) { + return undefined + } + return invokeOrMock("set_update_channel", { channel }, () => undefined) + }) +} + +export function fetchLatestBetaRelease(): Promise { + return platformCapabilities().then((caps) => { + if (!caps.supportsAutoUpdate) { + return null + } + return invokeOrMock("fetch_latest_beta_release", undefined, () => null) + }) +} + +export function appCheckUpdateWithChannel( + timeoutMs: number, + channel?: UpdateChannel | null, +): Promise { + return platformCapabilities().then((caps) => { + if (!caps.supportsAutoUpdate) { + return null + } + return invokeOrMock( + "app_check_update_with_channel", + { timeoutMs, channel: channel ?? null }, + () => null, + ) + }) +} + +export function appDownloadAndInstallAndroidUpdate(args: { + url: string + signature: string + version: string +}): Promise { + return platformCapabilities().then((caps) => { + if (caps.platform !== "android") { + return Promise.reject(new Error("Android-only update install")) + } + return invokeOrMock( + "app_download_and_install_android_update", + args, + () => undefined, + ) + }) +} diff --git a/openless-all/app/src/lib/ipc/utils.ts b/openless-all/app/src/lib/ipc/utils.ts new file mode 100644 index 00000000..3f2ef98f --- /dev/null +++ b/openless-all/app/src/lib/ipc/utils.ts @@ -0,0 +1,49 @@ +import { isTauri, invokeOrMock } from "./shared" + +export { isTauri } + +export async function openExternal(url: string): Promise { + if (!isTauri) { + window.open(url, "_blank", "noopener,noreferrer") + return + } + try { + const { open } = await import("@tauri-apps/plugin-shell") + await open(url) + return + } catch (error) { + console.warn("[external-open] shell plugin failed", error) + } + try { + const { invoke } = await import("@tauri-apps/api/core") + await invoke("open_external_url", { url }) + return + } catch (error) { + console.warn("[external-open] native fallback failed", error) + } + window.open(url, "_blank", "noopener,noreferrer") +} + +/** + * 让用户选 save 路径并把当前会话日志(openless.log)复制过去。 + * 浏览器开发模式下走 mock 不实际写盘。返回最终 save 的绝对路径,取消选择则返回 null。 + */ +export async function exportErrorLog( + suggestedFileName: string, +): Promise { + if (!isTauri) { + return `~/Downloads/${suggestedFileName}` + } + const { save } = await import("@tauri-apps/plugin-dialog") + const target = await save({ + defaultPath: suggestedFileName, + filters: [{ name: "Log", extensions: ["log", "txt"] }], + }) + if (!target) return null + await invokeOrMock( + "export_error_log", + { targetPath: target }, + () => undefined, + ) + return target +} diff --git a/openless-all/app/src/lib/ipc/vocab.ts b/openless-all/app/src/lib/ipc/vocab.ts new file mode 100644 index 00000000..b326010f --- /dev/null +++ b/openless-all/app/src/lib/ipc/vocab.ts @@ -0,0 +1,81 @@ +import type { CorrectionRule, DictionaryEntry, VocabPresetStore } from "../types" +import { invokeOrMock } from "./shared" +import { mockVocab, mockCorrectionRules } from "./mock-data" + +export function listVocab(): Promise { + return invokeOrMock("list_vocab", undefined, () => mockVocab) +} + +export function addVocab( + phrase: string, + note?: string, +): Promise { + return invokeOrMock("add_vocab", { phrase, note }, () => ({ + id: `vocab-new-${Date.now()}`, + phrase, + note: note ?? null, + enabled: true, + hits: 0, + createdAt: new Date().toISOString(), + })) +} + +export function removeVocab(id: string): Promise { + return invokeOrMock("remove_vocab", { id }, () => undefined) +} + +export function setVocabEnabled(id: string, enabled: boolean): Promise { + return invokeOrMock("set_vocab_enabled", { id, enabled }, () => undefined) +} + +export function listCorrectionRules(): Promise { + return invokeOrMock( + "list_correction_rules", + undefined, + () => mockCorrectionRules, + ) +} + +export function addCorrectionRule( + pattern: string, + replacement: string, +): Promise { + return invokeOrMock( + "add_correction_rule", + { pattern, replacement }, + () => ({ + id: `rule-new-${Date.now()}`, + pattern, + replacement, + enabled: true, + createdAt: new Date().toISOString(), + }), + ) +} + +export function removeCorrectionRule(id: string): Promise { + return invokeOrMock("remove_correction_rule", { id }, () => undefined) +} + +export function setCorrectionRuleEnabled( + id: string, + enabled: boolean, +): Promise { + return invokeOrMock( + "set_correction_rule_enabled", + { id, enabled }, + () => undefined, + ) +} + +export function listVocabPresets(): Promise { + return invokeOrMock("list_vocab_presets", undefined, () => ({ + custom: [], + overrides: [], + disabledBuiltinPresetIds: [], + })) +} + +export function saveVocabPresets(store: VocabPresetStore): Promise { + return invokeOrMock("save_vocab_presets", { store }, () => undefined) +}