Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
10 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,16 @@
3. Профиль и документы (PDF/DOCX/TXT).

## Горячие клавиши
| | |
|---|---|
| Показать / скрыть подсказки | `Ctrl+Alt+H` |
| Ответить сейчас | `Ctrl+Alt+Enter` |
| Пауза / продолжить | `Ctrl+Alt+L` |
| Снимок области экрана | `Ctrl+Alt+S` |
| Подробнее | `Ctrl+Alt+E` |
| Аварийная остановка | `Ctrl+Alt+Backspace` |
| | Windows | macOS |
|---|---|---|
| Показать / скрыть подсказки | `Ctrl+Alt+H` | `⌃⌥H` |
| Ответить сейчас | `Ctrl+Alt+Enter` | `⌃⌥↩` |
| Пауза / продолжить | `Ctrl+Alt+L` | `⌃⌥L` |
| Снимок области экрана | `Ctrl+Alt+S` | `⌃⌥S` |
| Подробнее | `Ctrl+Alt+E` | `⌃⌥E` |
| Аварийная остановка | `Ctrl+Alt+Backspace` | `⌃⌥⌫` |

Все сочетания меняются в «Настройки → Горячие клавиши».

## Для разработчиков
```powershell
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 30 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@
"test": "vitest run",
"dist": "electron-vite build && electron-builder --win nsis",
"icon": "node scripts/make-icon.ts",
"dist:dir": "electron-vite build && electron-builder --win dir"
"dist:dir": "electron-vite build && electron-builder --win dir",
"dist:mac": "electron-vite build && electron-builder --mac dmg --arm64"
},
"dependencies": {
"@anthropic-ai/sdk": "^1.3.0",
"@napi-rs/canvas": "^0.1.100",
"mammoth": "^1.13.0",
"onnxruntime-node": "^1.31.1",
"pdfjs-dist": "^5.7.284",
Expand Down Expand Up @@ -51,7 +53,8 @@
"asarUnpack": [
"node_modules/tesseract.js/**",
"node_modules/tesseract.js-core/**",
"node_modules/onnxruntime-node/**"
"node_modules/onnxruntime-node/**",
"node_modules/@napi-rs/**"
],
"extraResources": [
{
Expand All @@ -76,6 +79,31 @@
"shortcutName": "Copilot",
"deleteAppDataOnUninstall": false
},
"mac": {
"category": "public.app-category.productivity",
"icon": "resources/icon.png",
"target": [
{
"target": "dmg",
"arch": [
"arm64"
]
}
],
"identity": "-",
"hardenedRuntime": true,
"entitlements": "resources/entitlements.mac.plist",
"entitlementsInherit": "resources/entitlements.mac.plist",
"extendInfo": {
"NSMicrophoneUsageDescription": "Copilot слушает микрофон, чтобы локально распознавать вашу речь во время интервью и встреч.",
"NSScreenCaptureUsageDescription": "Copilot использует запись экрана для захвата системного звука собеседника и для выбора области экрана с задачей.",
"NSAudioCaptureUsageDescription": "Copilot захватывает системный звук, чтобы локально распознавать речь собеседника.",
"LSUIElement": true
}
},
"dmg": {
"artifactName": "Copilot-${version}-${arch}.${ext}"
},
"electronLanguages": [
"ru",
"uk",
Expand Down
17 changes: 17 additions & 0 deletions resources/entitlements.mac.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- V8 JIT under the hardened runtime -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- onnxruntime-node and @napi-rs/canvas ship native addons signed by other teams -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- without it the hardened runtime silently denies the microphone -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
20 changes: 20 additions & 0 deletions scripts/build-whisper-mac.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Builds whisper-server with Metal for Apple Silicon and installs it where Copilot looks first:
# ~/Library/Application Support/copilot/asr/metal/whisper-server
# Alternative without building: brew install whisper-cpp
set -euo pipefail

TAG="${WHISPER_TAG:-b5454}"
DEST="${HOME}/Library/Application Support/copilot/asr/metal"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT

command -v cmake >/dev/null || { echo "Нужен cmake: brew install cmake" >&2; exit 1; }

git clone --depth 1 --branch "$TAG" https://github.com/ggml-org/whisper.cpp "$WORK/whisper.cpp"
cmake -S "$WORK/whisper.cpp" -B "$WORK/build" -DCMAKE_BUILD_TYPE=Release -DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON -DBUILD_SHARED_LIBS=OFF -DWHISPER_BUILD_EXAMPLES=ON
cmake --build "$WORK/build" --config Release -j --target whisper-server

mkdir -p "$DEST"
install -m 755 "$WORK/build/bin/whisper-server" "$DEST/whisper-server"
echo "Готово: $DEST/whisper-server"
58 changes: 48 additions & 10 deletions src/main/asr/assets.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { createHash } from 'node:crypto'
import { createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { accessSync, constants, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs'
import { delimiter, join } from 'node:path'
import { spawn } from 'node:child_process'
import type { FetchFn } from '../providers/adapter'
import { t } from '@shared/i18n'

export type AsrBackend = 'cpu' | 'cuda'

Expand Down Expand Up @@ -56,6 +57,15 @@ export const BINARIES: Record<AsrBackend, { url: string; sha256: string; sizeMb:
}
}

// Apps started from Finder get a minimal PATH without Homebrew, so I also look in its prefixes explicitly.
const MAC_BIN_DIRS = ['/opt/homebrew/bin', '/usr/local/bin']

/** Homebrew's whisper-cpp is built with Metal on Apple Silicon; there is no prebuilt macOS release zip to download. */
export const macInstallHint = () =>
t(
'Движок whisper-server не найден. Установите его командой «brew install whisper-cpp» (сборка с Metal) или соберите whisper.cpp с -DGGML_METAL=ON (scripts/build-whisper-mac.sh), затем нажмите кнопку ещё раз.'
)

export interface Progress {
name: string
received: number
Expand All @@ -65,7 +75,9 @@ export interface Progress {
export class AsrAssets {
constructor(
private root: string,
private fetch: FetchFn
private fetch: FetchFn,
private platform: NodeJS.Platform = process.platform,
private env: NodeJS.ProcessEnv = process.env
) {
mkdirSync(join(root, 'models'), { recursive: true })
}
Expand All @@ -79,45 +91,62 @@ export class AsrAssets {
}

serverPath(backend: AsrBackend): string | null {
if (this.platform === 'darwin') return this.macServerPath()
const dir = join(this.root, backend)
if (!existsSync(dir)) return null
const found = findFile(dir, 'whisper-server.exe')
return found
}

/** On macOS one Metal build serves every backend: a local build in <root>/metal, then PATH, then Homebrew. */
private macServerPath(): string | null {
const local = join(this.root, 'metal')
if (existsSync(local)) {
const found = findFile(local, 'whisper-server')
if (found && isExecutable(found)) return found
}
const dirs = [...(this.env.PATH ?? '').split(delimiter).filter(Boolean), ...MAC_BIN_DIRS]
for (const d of dirs) {
const p = join(d, 'whisper-server')
if (isExecutable(p)) return p
}
return null
}

async downloadModel(name: string, onProgress: (p: Progress) => void, signal?: AbortSignal): Promise<void> {
const m = MODELS[name]
if (!m) throw new Error(`Неизвестная модель ${name}`)
if (!m) throw new Error(t('Неизвестная модель {name}', { name }))
const url = `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${m.file}`
const tmp = this.modelPath(name) + '.part'
const sha = await this.download(url, tmp, m.label, onProgress, signal)
const sha = await this.download(url, tmp, t(m.label), onProgress, signal)
if (sha !== m.sha256) {
rmSync(tmp, { force: true })
throw new Error(`Контрольная сумма модели не совпала (${sha.slice(0, 12)}…) — файл удалён`)
throw new Error(t('Контрольная сумма модели не совпала ({sha}…) — файл удалён', { sha: sha.slice(0, 12) }))
}
renameSync(tmp, this.modelPath(name))
}

async downloadBinary(backend: AsrBackend, onProgress: (p: Progress) => void, signal?: AbortSignal): Promise<void> {
if (this.platform !== 'win32') throw new Error(macInstallHint())
const b = BINARIES[backend]
const zip = join(this.root, `${backend}.zip.part`)
const sha = await this.download(b.url, zip, b.label, onProgress, signal)
if (sha !== b.sha256) {
rmSync(zip, { force: true })
throw new Error(`Контрольная сумма архива не совпала (${sha.slice(0, 12)}…) — файл удалён`)
throw new Error(t('Контрольная сумма архива не совпала ({sha}…) — файл удалён', { sha: sha.slice(0, 12) }))
}
const dir = join(this.root, backend)
rmSync(dir, { recursive: true, force: true })
mkdirSync(dir, { recursive: true })
// bsdtar ships with Windows 10 1803+ and reads zip; a Git/MSYS tar earlier in PATH does not.
await run(join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe'), ['-xf', zip, '-C', dir])
rmSync(zip, { force: true })
if (!this.serverPath(backend)) throw new Error('В архиве нет whisper-server.exe')
if (!this.serverPath(backend)) throw new Error(t('В архиве нет whisper-server.exe'))
}

private async download(url: string, dest: string, name: string, onProgress: (p: Progress) => void, signal?: AbortSignal): Promise<string> {
const res = await this.fetch(url, { signal, redirect: 'follow' })
if (!res.ok || !res.body) throw new Error(`Загрузка не удалась: HTTP ${res.status}`)
if (!res.ok || !res.body) throw new Error(t('Загрузка не удалась: HTTP {status}', { status: res.status }))
const total = Number(res.headers.get('content-length') ?? 0)
const hash = createHash('sha256')
const out = createWriteStream(dest)
Expand Down Expand Up @@ -159,10 +188,19 @@ function findFile(dir: string, name: string): string | null {
return null
}

function isExecutable(p: string): boolean {
try {
accessSync(p, constants.X_OK)
return statSync(p).isFile()
} catch {
return false
}
}

function run(cmd: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const p = spawn(cmd, args, { windowsHide: true })
p.on('error', reject)
p.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} завершился с кодом ${code}`))))
p.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(t('{cmd} завершился с кодом {code}', { cmd, code: String(code) })))))
})
}
Loading