diff --git a/.github/workflows/macos-arm64-build.yml b/.github/workflows/macos-arm64-build.yml new file mode 100644 index 000000000..6282aa895 --- /dev/null +++ b/.github/workflows/macos-arm64-build.yml @@ -0,0 +1,138 @@ +name: macOS ARM64 Build + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: macos-arm64-build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-signed-macos-arm64: + name: Build signed macOS ARM64 bundle + runs-on: macos-latest + environment: Production + env: + TARGET: aarch64-apple-darwin + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + submodules: true + + - name: Set Xcode version + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Setup Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + cache: yarn + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@1.93.0 + with: + targets: aarch64-apple-darwin + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Validate signing prerequisites + shell: bash + env: + UPDATER_PUBLIC_KEY: ${{ secrets.UPDATER_PUBLIC_KEY || vars.UPDATER_PUBLIC_KEY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY || secrets.UPDATER_PRIVATE_KEY }} + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + for var in UPDATER_PUBLIC_KEY TAURI_SIGNING_PRIVATE_KEY APPLE_CERTIFICATE_BASE64 APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do + if [ -z "${!var}" ]; then + echo "Missing required secret/variable: $var" + exit 1 + fi + done + + - name: Define Tauri configuration overrides + id: config-overrides + uses: actions/github-script@v7 + env: + BASE_URL: ${{ vars.BASE_URL }} + UPDATER_PUBLIC_KEY: ${{ secrets.UPDATER_PUBLIC_KEY || vars.UPDATER_PUBLIC_KEY }} + UPDATER_ENDPOINT: ${{ vars.UPDATER_ENDPOINT }} + UPDATER_REPO: tinyhumansai/openhuman + WITH_UPDATER: "true" + with: + script: | + const workspacePath = process.env.GITHUB_WORKSPACE.replace(/\\/g, '/'); + const prefix = workspacePath.startsWith('/') ? 'file://' : 'file:///'; + const moduleUrl = `${prefix}${workspacePath}/scripts/prepareTauriConfig.js`; + const { default: prepareTauriConfig } = await import(moduleUrl); + const config = prepareTauriConfig(); + core.setOutput('json', JSON.stringify(config)); + + - name: Build frontend + run: yarn workspace openhuman-app build + env: + NODE_ENV: production + VITE_BACKEND_URL: ${{ vars.VITE_BACKEND_URL }} + VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} + VITE_DEBUG: ${{ vars.VITE_DEBUG }} + + - name: Build sidecar core binary + run: cargo build --manifest-path Cargo.toml --release --target "$TARGET" --bin openhuman + + - name: Stage sidecar for Tauri bundler + shell: bash + run: | + mkdir -p app/src-tauri/binaries + cp "target/$TARGET/release/openhuman" "app/src-tauri/binaries/openhuman-$TARGET" + chmod +x "app/src-tauri/binaries/openhuman-$TARGET" + + - name: Build signed app and dmg + working-directory: app + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY || secrets.UPDATER_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || secrets.UPDATER_PRIVATE_KEY_PASSWORD }} + MACOSX_DEPLOYMENT_TARGET: "10.15" + run: | + yarn tauri build -c '${{ steps.config-overrides.outputs.json }}' --target "$TARGET" --bundles app,dmg -- --bin OpenHuman + + - name: Verify macOS app bundle sidecar layout + shell: bash + run: | + APP_PATH="target/$TARGET/release/bundle/macos/OpenHuman.app" + echo "Inspecting bundle at: $APP_PATH" + ls -la "$APP_PATH/Contents/MacOS" + ls -la "$APP_PATH/Contents/Resources" | grep openhuman || true + if [ -f "$APP_PATH/Contents/MacOS/openhuman" ]; then + echo "Unexpected standalone core binary found in MacOS dir" + exit 1 + fi + if ! ls "$APP_PATH/Contents/Resources"/openhuman-* >/dev/null 2>&1; then + echo "Sidecar core binary missing from app resources" + exit 1 + fi + + - name: Upload signed macOS artifacts + uses: actions/upload-artifact@v4 + with: + name: macos-arm64-signed-bundles + path: | + target/${{ env.TARGET }}/release/bundle/macos/*.app + target/${{ env.TARGET }}/release/bundle/dmg/*.dmg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 359a0de1b..a1c4f08ec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -209,15 +209,14 @@ jobs: fail-fast: false matrix: settings: - # macOS builds are disabled for now to ensure the flow works first - # - platform: macos-latest - # args: --target aarch64-apple-darwin - # target: aarch64-apple-darwin - # artifact_suffix: aarch64-apple-darwin - # - platform: macos-latest - # args: --target x86_64-apple-darwin - # target: x86_64-apple-darwin - # artifact_suffix: x86_64-apple-darwin + - platform: macos-latest + args: --target aarch64-apple-darwin + target: aarch64-apple-darwin + artifact_suffix: aarch64-apple-darwin + - platform: macos-latest + args: --target x86_64-apple-darwin + target: x86_64-apple-darwin + artifact_suffix: x86_64-apple-darwin - platform: ubuntu-22.04 args: --target x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu @@ -275,12 +274,46 @@ jobs: - name: Install dependencies run: yarn install --frozen-lockfile + - name: Validate signing prerequisites + shell: bash + env: + MATRIX_PLATFORM: ${{ matrix.settings.platform }} + UPDATER_PUBLIC_KEY: ${{ secrets.UPDATER_PUBLIC_KEY || vars.UPDATER_PUBLIC_KEY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY || secrets.UPDATER_PRIVATE_KEY }} + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + if [ -z "$UPDATER_PUBLIC_KEY" ]; then + echo "Missing UPDATER_PUBLIC_KEY (set as secret or repo/environment variable)." + exit 1 + fi + + if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then + echo "Missing TAURI_SIGNING_PRIVATE_KEY (or fallback UPDATER_PRIVATE_KEY)." + exit 1 + fi + + if [ "$MATRIX_PLATFORM" = "macos-latest" ]; then + for var in APPLE_CERTIFICATE_BASE64 APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do + if [ -z "${!var}" ]; then + echo "Missing required macOS signing secret: $var" + exit 1 + fi + done + fi + - name: Define Tauri configuration overrides id: config-overrides uses: actions/github-script@v7 env: BASE_URL: ${{ vars.BASE_URL }} - UPDATER_PUBLIC_KEY: ${{ secrets.UPDATER_PUBLIC_KEY }} + UPDATER_PUBLIC_KEY: ${{ secrets.UPDATER_PUBLIC_KEY || vars.UPDATER_PUBLIC_KEY }} + UPDATER_ENDPOINT: ${{ vars.UPDATER_ENDPOINT }} + UPDATER_REPO: tinyhumansai/openhuman WITH_UPDATER: "true" with: script: | diff --git a/Cargo.lock b/Cargo.lock index 043deb049..7f86fc02d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4753,6 +4753,16 @@ dependencies = [ "nom 8.0.0", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -5070,6 +5080,7 @@ dependencies = [ "log", "mail-parser", "matrix-sdk", + "nu-ansi-term 0.46.0", "nusb 0.2.3", "once_cell", "openssl", @@ -5110,6 +5121,7 @@ dependencies = [ "toml 1.1.0+spec-1.1.0", "tower", "tracing", + "tracing-log", "tracing-subscriber", "url", "urlencoding", @@ -5243,6 +5255,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "pango" version = "0.18.3" @@ -8570,7 +8588,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", - "nu-ansi-term", + "nu-ansi-term 0.50.3", "once_cell", "regex-automata", "sharded-slab", diff --git a/Cargo.toml b/Cargo.toml index a2c100e64..cd52499c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ tokio = { version = "1", features = ["full", "sync"] } once_cell = "1.19" parking_lot = "0.12" log = "0.4" +nu-ansi-term = "0.46" env_logger = "0.11" base64 = "0.22" aes-gcm = "0.10" @@ -44,6 +45,7 @@ toml = "1.0" shellexpand = "3.1" schemars = "1.2" tracing = { version = "0.1", default-features = false } +tracing-log = "0.2" tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "ansi", "env-filter"] } prometheus = { version = "0.14", default-features = false } urlencoding = "2.1" diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 69d47d6d2..97a192cde 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -26,13 +26,20 @@ import { setSelectedThread, } from '../store/threadSlice'; import type { ThreadMessage } from '../types/thread'; -import { openhumanLocalAiTranscribeBytes, openhumanLocalAiTts } from '../utils/tauriCommands'; +import { + isTauri, + openhumanAutocompleteAccept, + openhumanAutocompleteCurrent, + openhumanLocalAiTranscribeBytes, + openhumanLocalAiTts, +} from '../utils/tauriCommands'; const DEFAULT_THREAD_ID = 'default-thread'; const DEFAULT_THREAD_TITLE = 'Conversation'; type ToolTimelineEntryStatus = 'running' | 'success' | 'error'; type InputMode = 'text' | 'voice'; type ReplyMode = 'text' | 'voice'; +const AUTOCOMPLETE_POLL_DEBOUNCE_MS = 180; interface ToolTimelineEntry { id: string; @@ -54,6 +61,25 @@ function formatRelativeTime(dateStr: string): string { return `${days}d ago`; } +function getInlineCompletionSuffix(input: string, suggestion: string): string { + const normalizedInput = input; + const normalizedSuggestion = suggestion; + + if (!normalizedInput || !normalizedSuggestion) { + return ''; + } + + if (normalizedSuggestion === normalizedInput) { + return ''; + } + + if (normalizedSuggestion.startsWith(normalizedInput)) { + return normalizedSuggestion.slice(normalizedInput.length); + } + + return ''; +} + const Conversations = () => { const dispatch = useAppDispatch(); const navigate = useNavigate(); @@ -76,6 +102,7 @@ const Conversations = () => { const [isTranscribing, setIsTranscribing] = useState(false); const [voiceStatus, setVoiceStatus] = useState(null); const [isPlayingReply, setIsPlayingReply] = useState(false); + const [inlineSuggestionValue, setInlineSuggestionValue] = useState(''); const [availableModels, setAvailableModels] = useState([]); const [selectedModel, setSelectedModel] = useState('neocortex-mk1'); @@ -102,6 +129,8 @@ const Conversations = () => { const audioChunksRef = useRef([]); const replyAudioRef = useRef(null); const lastSpokenMessageIdRef = useRef(null); + const autocompleteDebounceRef = useRef(null); + const autocompleteRequestSeqRef = useRef(0); const getAudioExtension = (mimeType: string): string => { const lower = mimeType.toLowerCase(); @@ -184,6 +213,45 @@ const Conversations = () => { } }, [inputValue, sendError]); + useEffect(() => { + if ( + !isTauri() || + !rustChat || + inputMode !== 'text' || + isSending || + inputValue.trim().length === 0 + ) { + setInlineSuggestionValue(''); + return; + } + + if (autocompleteDebounceRef.current !== null) { + window.clearTimeout(autocompleteDebounceRef.current); + } + + autocompleteDebounceRef.current = window.setTimeout(() => { + const requestSeq = autocompleteRequestSeqRef.current + 1; + autocompleteRequestSeqRef.current = requestSeq; + + void openhumanAutocompleteCurrent({ context: inputValue }) + .then(response => { + if (autocompleteRequestSeqRef.current !== requestSeq) return; + setInlineSuggestionValue(response.result.suggestion?.value ?? ''); + }) + .catch(() => { + if (autocompleteRequestSeqRef.current !== requestSeq) return; + setInlineSuggestionValue(''); + }); + }, AUTOCOMPLETE_POLL_DEBOUNCE_MS); + + return () => { + if (autocompleteDebounceRef.current !== null) { + window.clearTimeout(autocompleteDebounceRef.current); + autocompleteDebounceRef.current = null; + } + }; + }, [inputValue, inputMode, isSending, rustChat]); + useEffect(() => { return () => { mediaRecorderRef.current?.stop(); @@ -508,6 +576,20 @@ const Conversations = () => { }, [messages, replyMode, rustChat]); const handleInputKeyDown = (e: React.KeyboardEvent) => { + const inlineSuffix = getInlineCompletionSuffix(inputValue, inlineSuggestionValue); + + if (e.key === 'Tab' && inlineSuffix.length > 0) { + e.preventDefault(); + setInputValue(prev => prev + inlineSuffix); + setInlineSuggestionValue(''); + if (isTauri()) { + void openhumanAutocompleteAccept({ suggestion: inputValue + inlineSuffix }).catch(() => { + // Keep local UX smooth even if accept RPC fails. + }); + } + return; + } + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void handleSendMessage(); @@ -528,6 +610,7 @@ const Conversations = () => { const selectedThreadToolTimeline = selectedThreadId ? (toolTimelineByThread[selectedThreadId] ?? []) : []; + const inlineCompletionSuffix = getInlineCompletionSuffix(inputValue, inlineSuggestionValue); return (
@@ -903,15 +986,23 @@ const Conversations = () => { {inputMode === 'text' ? (
-