Improve autocomplete UX (inline + external overlay), add serve mode, and add macOS ARM64 release workflow (#66)

* Update CLAUDE.md to clarify pull request workflow and enhance documentation

- Added a note to emphasize the importance of opening pull requests against the upstream repository instead of a fork's default remote, ensuring better collaboration and code management.
- Improved the overall clarity of the Git workflow section, contributing to a more streamlined development process.

* Refactor REPL session handling and remove deprecated chat methods

- Removed the `repl_session_chat` functionality from the schema and related files, streamlining the REPL session management.
- Introduced a new `start_chat` method in the web channel provider for handling chat interactions, enhancing the chat system's architecture.
- Updated socket handling to improve event emission with alias support, ensuring better communication across the application.
- Enhanced error handling and logging for chat operations, providing clearer feedback during interactions.
- Consolidated chat-related logic into the new web channel module, improving maintainability and organization.

* Refactor apiClient to improve token management and eliminate circular dependencies

- Renamed `_getToken` to `authTokenGetterRef` for clarity and to avoid clashing with transpiled private method names.
- Updated the `setStoreForApiClient` function to use the new `authTokenGetterRef` variable, ensuring the `apiClient` remains free of direct imports from `store/index`.
- Enhanced the `ApiClient` class by renaming the `getToken` method to `resolveAuthToken`, improving readability and consistency in token retrieval logic.
- Adjusted comments in `apiClient.ts` and `store/index.ts` to reflect the changes and clarify the purpose of the lazy store accessor.

* Enhance API client integration with store for improved token management

- Introduced `setStoreForApiClient` in `main.tsx` to bind the Redux store's auth token for API requests, ensuring seamless token access.
- Updated comments in `apiClient.ts` to clarify the lazy token accessor's purpose and prevent circular dependencies.
- Removed the previous direct call to `setStoreForApiClient` from `store/index.ts`, centralizing the setup in `main.tsx` for better organization.
- Enhanced test setup to include the new store binding, ensuring consistent token retrieval during testing.

* Refactor web channel event handling and enhance chat functionality

- Updated `WebChatSseEvent` structure to include new fields: `tool_name`, `skill_id`, `args`, `output`, `success`, and `round`, improving the granularity of event data.
- Refactored event emission logic in `emit_web_chat_event` to utilize the new fields, ensuring more informative payloads for tool calls and results.
- Enhanced the `start_chat` and `cancel_chat` functions to accommodate the new event structure, improving chat session management.
- Introduced a new function `publish_tool_events_from_history` to streamline the emission of tool call and result events from chat history, enhancing the overall chat experience.

* Refactor web channel integration and enhance controller management

- Updated references from the deprecated `web_channel` module to the new `channels::providers::web` structure, improving code organization and maintainability.
- Refactored the `build_registered_controllers` and `build_declared_controller_schemas` functions to utilize the new web channel controller methods, ensuring consistency across the application.
- Removed the obsolete `web_channel` module and its associated files, streamlining the codebase and enhancing clarity in the channel management system.
- Introduced new functions for managing web channel events and schemas, improving the overall architecture of the web channel integration.

* Refactor main.tsx and test setup for improved organization

- Reordered imports in `main.tsx` to enhance clarity and maintainability, ensuring that `setStoreForApiClient` is called with the correct store reference.
- Removed unnecessary whitespace in `setup.ts`, streamlining the test setup file for better readability.

* Fix service gate false blocking when service is running

* Support soft-pass daemon gate and harden macOS service detection

* Extend list-files fallback trigger phrases in agent loop

* Replace list-files fallback with tool-call repair retry

* Log full system prompt and drop tool-call repair flow

* Add tracing-log dependency and enhance CLI logging

- Introduced `tracing-log` as a dependency to bridge `log` and `tracing` for improved logging capabilities.
- Added a `--verbose` flag to the CLI for enhanced logging detail, initializing the logging level based on this flag.
- Implemented an HTTP request logging middleware to capture and log request details.
- Updated CLI help output to reflect the new `--verbose` option and improved logging messages throughout the application.

* Tighten system prompt for native tool-calling

* Auto-create missing workspace context markdown files

* Seed workspace prompt files from canonical agent prompt templates

* Enhance logging with nu-ansi-term and improve CLI output

- Added `nu-ansi-term` dependency for colored terminal output in logs.
- Implemented a custom logging format for better readability in CLI.
- Updated logging initialization to conditionally use ANSI colors based on terminal support.
- Added debug logging for agent responses to aid in troubleshooting.

* Harden OpenAI-compatible native tool-call parsing

* Format provider tool-call parsing updates

* Implement inline autocomplete suggestions in Conversations component

- Added functionality for inline suggestions based on user input in the Conversations component.
- Introduced debounce logic for autocomplete requests to optimize performance.
- Enhanced user experience by allowing suggestions to be accepted via the Tab key.
- Updated UI to display inline completion suffixes in the input area.
- Modified backend to support new autocomplete features and improved error handling.

* Add macOS ARM64 build workflow and enhance release process

- Introduced a new GitHub Actions workflow for building signed macOS ARM64 bundles.
- Updated the release workflow to validate signing prerequisites for macOS builds.
- Enhanced the Tauri configuration preparation to include updater settings.
- Added necessary secrets to the example secrets file for macOS signing.
- Implemented CLI autocomplete functionality for macOS, including options for debounce and process management.
This commit is contained in:
Steven Enamakel
2026-03-29 22:09:08 -07:00
committed by GitHub
parent 22435ed631
commit 652bb6e2f9
18 changed files with 1279 additions and 74 deletions
+138
View File
@@ -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
+43 -10
View File
@@ -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: |
Generated
+19 -1
View File
@@ -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",
+2
View File
@@ -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"
+101 -10
View File
@@ -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<string | null>(null);
const [isPlayingReply, setIsPlayingReply] = useState(false);
const [inlineSuggestionValue, setInlineSuggestionValue] = useState('');
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState('neocortex-mk1');
@@ -102,6 +129,8 @@ const Conversations = () => {
const audioChunksRef = useRef<Blob[]>([]);
const replyAudioRef = useRef<HTMLAudioElement | null>(null);
const lastSpokenMessageIdRef = useRef<string | null>(null);
const autocompleteDebounceRef = useRef<number | null>(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<HTMLTextAreaElement>) => {
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 (
<div className="h-full relative z-10 flex overflow-hidden">
@@ -903,15 +986,23 @@ const Conversations = () => {
{inputMode === 'text' ? (
<div className="flex items-end gap-2">
<textarea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder="Type a message..."
rows={1}
disabled={isSending || !rustChat}
className="flex-1 resize-none bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-sm placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all max-h-32 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<div className="relative flex-1 rounded-xl border border-white/10 bg-white/5 focus-within:ring-1 focus-within:ring-primary-500/50 focus-within:border-primary-500/50 transition-all">
<div
aria-hidden
className="pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words px-4 py-2.5 text-sm leading-normal">
<span className="invisible">{inputValue}</span>
<span className="text-stone-500/50">{inlineCompletionSuffix}</span>
</div>
<textarea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder="Type a message..."
rows={1}
disabled={isSending || !rustChat}
className="relative z-10 w-full resize-none border-0 bg-transparent px-4 py-2.5 text-sm placeholder:text-stone-500 focus:outline-none focus:ring-0 max-h-32 disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
<button
onClick={() => {
void handleSendMessage();
+2
View File
@@ -7,6 +7,7 @@
"APPLE_SIGNING_IDENTITY": "",
"APPLE_TEAM_ID": "",
"GITHUB_TOKEN": "",
"UPDATER_PUBLIC_KEY": "",
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD": "",
"TAURI_SIGNING_PRIVATE_KEY": "",
"XGH_TOKEN": "",
@@ -15,6 +16,7 @@
},
"vars": {
"BASE_URL": "https://localhost",
"UPDATER_ENDPOINT": "",
"VITE_BACKEND_URL": "https://localhost:5005",
"VITE_SKILLS_GITHUB_REPO": "",
"VITE_SENTRY_DSN": "",
+21 -7
View File
@@ -1,9 +1,6 @@
export default function prepareTauriConfig() {
// For production builds, use the dist directory path
// BASE_URL is only used for updater endpoints, not for frontendDist
const frontendDist = process.env.BASE_URL?.startsWith('http')
? '../dist'
: process.env.BASE_URL || '../dist';
// Production frontend always ships from dist; BASE_URL is for updater URLs.
const frontendDist = '../dist';
const config = {
build: { frontendDist, devUrl: null },
@@ -12,11 +9,28 @@ export default function prepareTauriConfig() {
};
if (process.env.WITH_UPDATER === 'true') {
const repoSlug = process.env.UPDATER_REPO || 'tinyhumansai/openhuman';
const baseUrl =
process.env.BASE_URL ||
`https://github.com/${repoSlug}/releases/latest/download`;
const normalizedBaseUrl = String(baseUrl).replace(/\/+$/, '');
const updaterEndpoint =
process.env.UPDATER_ENDPOINT ||
process.env.UPDATER_GIST_URL ||
`${normalizedBaseUrl}/latest.json`;
const updaterPublicKey = process.env.UPDATER_PUBLIC_KEY;
if (!updaterPublicKey) {
throw new Error(
'WITH_UPDATER=true requires UPDATER_PUBLIC_KEY to be set',
);
}
config.plugins = {
updater: {
dialog: false,
endpoints: [process.env.UPDATER_GIST_URL],
pubkey: process.env.UPDATER_PUBLIC_KEY,
endpoints: [updaterEndpoint],
pubkey: updaterPublicKey,
},
};
+6 -4
View File
@@ -5,10 +5,12 @@
# - Uses scripts/ci-secrets.example.json for secrets/vars.
# - Runs in dry-run mode unless --run is passed.
#
# For --run: set XGH_TOKEN in scripts/ci-secrets.json (PAT with repo scope). prepare-release uses
# XGH_TOKEN for checkout/push. Do not put a bad GITHUB_TOKEN in ci-secrets.json — act uses it to
# clone action repos and an invalid PAT breaks even public clones. github-script steps use
# secrets.XGH_TOKEN (see release.yml).
# For --run: set GitHub App credentials in scripts/ci-secrets.json:
# - XGITHUB_APP_ID
# - XGITHUB_APP_PRIVATE_KEY
# prepare-release uses those to mint a token for checkout/push.
# Do not put a bad GITHUB_TOKEN in ci-secrets.json — act uses it to clone action repos and an
# invalid PAT breaks even public clones.
#
# Usage:
# ./scripts/test-release-act.sh
+85 -2
View File
@@ -5,6 +5,7 @@ use std::collections::BTreeMap;
use crate::core::all;
use crate::core::jsonrpc::{default_state, invoke_method, parse_json_params};
use crate::core::{ControllerSchema, TypeSchema};
use crate::openhuman::autocomplete::ops::{autocomplete_start_cli, AutocompleteStartCliOptions};
const CLI_BANNER: &str = r#"
@@ -37,6 +38,7 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> {
fn run_server_command(args: &[String]) -> Result<()> {
let mut port: Option<u16> = None;
let mut socketio_enabled = true;
let mut verbose = false;
let mut i = 0usize;
while i < args.len() {
match args[i].as_str() {
@@ -54,14 +56,28 @@ fn run_server_command(args: &[String]) -> Result<()> {
socketio_enabled = false;
i += 1;
}
"-v" | "--verbose" => {
verbose = true;
i += 1;
}
"-h" | "--help" => {
println!("Usage: openhuman run [--port <u16>] [--jsonrpc-only]");
println!("Usage: openhuman run [--port <u16>] [--jsonrpc-only] [-v|--verbose]");
println!();
println!(
" --port <u16> Listen address port (default: 7788 or OPENHUMAN_CORE_PORT)"
);
println!(" --jsonrpc-only HTTP JSON-RPC only; disable Socket.IO");
println!(" -v, --verbose Shorthand for RUST_LOG=debug when RUST_LOG is unset");
println!();
println!("Logging: set RUST_LOG (e.g. RUST_LOG=debug openhuman run). Default level is info.");
return Ok(());
}
other => return Err(anyhow::anyhow!("unknown run arg: {other}")),
}
}
crate::core::logging::init_for_cli_run(verbose);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
@@ -136,6 +152,22 @@ fn run_namespace_command(
));
};
if namespace == "autocomplete" && function == "start" {
if args.len() > 1 && is_help(&args[1]) {
print_autocomplete_start_help();
return Ok(());
}
let cli_options = parse_autocomplete_start_cli_options(&args[1..])?;
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let value = rt
.block_on(async { autocomplete_start_cli(cli_options).await })
.map_err(anyhow::Error::msg)?;
println!("{}", serde_json::to_string_pretty(&value)?);
return Ok(());
}
if args.len() > 1 && is_help(&args[1]) {
print_function_help(namespace, &schema);
return Ok(());
@@ -156,6 +188,57 @@ fn run_namespace_command(
Ok(())
}
fn parse_autocomplete_start_cli_options(args: &[String]) -> Result<AutocompleteStartCliOptions> {
let mut debounce_ms: Option<u64> = None;
let mut serve = false;
let mut spawn = false;
let mut i = 0usize;
while i < args.len() {
match args[i].as_str() {
"--debounce-ms" => {
let raw = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing value for --debounce-ms"))?;
debounce_ms = Some(
raw.parse::<u64>()
.map_err(|e| anyhow::anyhow!("invalid --debounce-ms: {e}"))?,
);
i += 2;
}
"--serve" => {
serve = true;
i += 1;
}
"--spawn" => {
spawn = true;
i += 1;
}
other => return Err(anyhow::anyhow!("unknown autocomplete start arg: {other}")),
}
}
if serve && spawn {
return Err(anyhow::anyhow!(
"--serve and --spawn are mutually exclusive"
));
}
Ok(AutocompleteStartCliOptions {
debounce_ms,
serve,
spawn,
})
}
fn print_autocomplete_start_help() {
println!("Usage: openhuman autocomplete start [--debounce-ms <u64>] [--serve|--spawn]");
println!();
println!(" --debounce-ms <u64> Override debounce in milliseconds.");
println!(" --serve Run autocomplete loop in the current foreground process.");
println!(" --spawn Spawn autocomplete loop as a background process.");
}
fn parse_function_params(
schema: &ControllerSchema,
args: &[String],
@@ -238,7 +321,7 @@ fn grouped_schemas() -> BTreeMap<String, Vec<ControllerSchema>> {
fn print_general_help(grouped: &BTreeMap<String, Vec<ControllerSchema>>) {
println!("OpenHuman core CLI\n");
println!("Usage:");
println!(" openhuman run [--port <u16>]");
println!(" openhuman run [--port <u16>] [--jsonrpc-only] [--verbose]");
println!(" openhuman call --method <name> [--params '<json>']");
println!(" openhuman <namespace> <function> [--param value ...]\n");
println!("Available namespaces:");
+29 -3
View File
@@ -95,6 +95,7 @@ pub fn build_core_http_router(socketio_enabled: bool) -> Router {
.route("/events", get(events_handler))
.route("/rpc", post(rpc_handler))
.fallback(not_found_handler)
.layer(middleware::from_fn(http_request_log_middleware))
.layer(middleware::from_fn(cors_middleware))
.with_state(AppState {
core_version: env!("CARGO_PKG_VERSION").to_string(),
@@ -109,6 +110,28 @@ pub fn build_core_http_router(socketio_enabled: bool) -> Router {
router
}
async fn http_request_log_middleware(req: Request, next: Next) -> Response {
let method = req.method().clone();
let path = req.uri().path().to_string();
let query_len = req.uri().query().map(str::len).unwrap_or(0);
let started = std::time::Instant::now();
let response = next.run(req).await;
let status = response.status().as_u16();
let ms = started.elapsed().as_millis();
log::info!(
"[http] {} {}{} -> {} ({}ms)",
method,
path,
if query_len > 0 { "?…" } else { "" },
status,
ms
);
response
}
async fn cors_middleware(req: Request, next: Next) -> Response {
if req.method() == Method::OPTIONS {
return with_cors_headers(StatusCode::NO_CONTENT.into_response());
@@ -230,10 +253,13 @@ pub async fn run_server(port: Option<u16>, socketio_enabled: bool) -> anyhow::Re
let app = build_core_http_router(socketio_enabled);
log::info!("[core] listening on http://{bind_addr}");
log::info!("[rpc:http] JSON-RPC server running — POST http://{bind_addr}/rpc (JSON-RPC 2.0)");
log::info!(
"[core] OpenHuman core is ready — listening on http://{bind_addr} (version {})",
env!("CARGO_PKG_VERSION")
);
log::info!("[rpc:http] JSON-RPC — POST http://{bind_addr}/rpc (JSON-RPC 2.0)");
if socketio_enabled {
log::info!("[rpc:socketio] Socket.IO server running — ws://{bind_addr}/socket.io/");
log::info!("[rpc:socketio] Socket.IO — ws://{bind_addr}/socket.io/ (same HTTP server)");
} else {
log::info!("[rpc:socketio] disabled (--jsonrpc-only)");
}
+100
View File
@@ -0,0 +1,100 @@
//! Logging for `openhuman run` (and other CLI paths that need stderr output).
//!
//! Without initializing a subscriber, `log::` and `tracing::` macros are no-ops.
use std::fmt;
use std::io::{self, IsTerminal};
use std::sync::Once;
use nu_ansi_term::{Color, Style};
use tracing::{Event, Level};
use tracing_subscriber::fmt::format::{FormatEvent, FormatFields, Writer};
use tracing_subscriber::fmt::FmtContext;
use tracing_subscriber::registry::LookupSpan;
static INIT: Once = Once::new();
/// `14:32:01 <INFO> (jsonrpc) message…` — colors when stderr is a TTY.
struct CleanCliFormat;
impl<S, N> FormatEvent<S, N> for CleanCliFormat
where
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
N: for<'a> FormatFields<'a> + 'static,
{
fn format_event(
&self,
ctx: &FmtContext<'_, S, N>,
mut writer: Writer<'_>,
event: &Event<'_>,
) -> fmt::Result {
let meta = event.metadata();
let time = chrono::Local::now().format("%H:%M:%S");
let level = level_tag(meta.level());
let target = short_target(meta.target());
if writer.has_ansi_escapes() {
let time_styled = Style::new().dimmed().paint(time.to_string());
write!(writer, "{time_styled} ")?;
let tag = format!("<{level}>");
let level_styled = match *meta.level() {
Level::ERROR => Style::new().fg(Color::Red).bold().paint(tag),
Level::WARN => Style::new().fg(Color::Yellow).bold().paint(tag),
Level::INFO => Style::new().fg(Color::Green).paint(tag),
Level::DEBUG => Style::new().fg(Color::Cyan).paint(tag),
Level::TRACE => Style::new().fg(Color::Magenta).dimmed().paint(tag),
};
write!(writer, "{level_styled} ")?;
let scope = format!("({target})");
let scope_styled = Style::new().fg(Color::Fixed(247)).paint(scope);
write!(writer, "{scope_styled} ")?;
} else {
write!(writer, "{time} <{level}> ({target}) ")?;
}
ctx.field_format().format_fields(writer.by_ref(), event)?;
writeln!(writer)
}
}
fn level_tag(level: &Level) -> &'static str {
match *level {
Level::ERROR => "ERROR",
Level::WARN => "WARN",
Level::INFO => "INFO",
Level::DEBUG => "DEBUG",
Level::TRACE => "TRACE",
}
}
fn short_target(target: &str) -> &str {
target.rsplit("::").next().unwrap_or(target)
}
/// Initialize `tracing` + bridge the `log` crate so existing `log::info!` calls appear.
///
/// - If `RUST_LOG` is unset: uses `info`, or `debug` when `verbose` is true.
/// - Safe to call once; subsequent calls are ignored.
pub fn init_for_cli_run(verbose: bool) {
INIT.call_once(|| {
if std::env::var_os("RUST_LOG").is_none() {
std::env::set_var("RUST_LOG", if verbose { "debug" } else { "info" });
}
let filter = tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::new(if verbose { "debug" } else { "info" })
});
let use_color = io::stderr().is_terminal();
let _ = tracing_subscriber::fmt()
.with_ansi(use_color)
.with_env_filter(filter)
.event_format(CleanCliFormat)
.try_init();
let _ = tracing_log::LogTracer::init();
});
}
+1
View File
@@ -5,6 +5,7 @@ pub mod all;
pub mod cli;
pub mod dispatch;
pub mod jsonrpc;
pub mod logging;
pub mod rpc_log;
pub mod socketio;
pub mod types;
+10 -7
View File
@@ -59,14 +59,14 @@ struct ChatCancelPayload {
pub fn attach_socketio() -> (socketioxide::layer::SocketIoLayer, SocketIo) {
let (layer, io) = SocketIo::new_layer();
log::debug!(
"[socketio] configured with req_path={}",
log::info!(
"[socketio] engine ready (namespace /, path {})",
io.config().engine_config.req_path
);
io.ns("/", |socket: SocketRef| {
let client_id = socket.id.to_string();
log::debug!("[socketio] connect client_id={client_id}");
log::info!("[socketio] client connected id={client_id}");
let _ = socket.join(client_id.clone());
let ready_payload = json!({ "sid": client_id });
log::debug!("[socketio] emit event=ready to_client={}", socket.id);
@@ -74,11 +74,14 @@ pub fn attach_socketio() -> (socketioxide::layer::SocketIoLayer, SocketIo) {
socket.on("rpc:request", |socket: SocketRef, Data(payload): Data<SocketRpcRequest>| async move {
let client_id = socket.id.to_string();
log::debug!(
"[socketio] recv event=rpc:request client_id={} id={} method={} params_type={} params_bytes={}",
client_id,
payload.id,
log::info!(
"[socketio] rpc:request method={} id={} client={}",
payload.method,
payload.id,
client_id
);
log::debug!(
"[socketio] rpc:request params_type={} params_bytes={}",
json_type_name(&payload.params),
payload.params.to_string().len()
);
+1
View File
@@ -479,6 +479,7 @@ impl Agent {
resp.text.as_ref().map_or(0, |t| t.chars().count()),
resp.tool_calls.len()
);
log::debug!("[agent_loop] provider response: {resp:?}");
resp
}
Err(err) => return Err(err),
+9 -1
View File
@@ -221,7 +221,15 @@ impl ToolDispatcher for NativeToolDispatcher {
}
fn prompt_instructions(&self, _tools: &[Box<dyn Tool>]) -> String {
String::new()
[
"## Tool Use Protocol",
"",
"When a tool is needed, emit tool calls directly via the model's native tool-calling output.",
"Do not only narrate intent (for example, avoid \"Let me check...\") without emitting the tool call.",
"After tool results are provided, continue reasoning and then produce the final answer.",
"",
]
.join("\n")
}
fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec<ChatMessage> {
+68 -1
View File
@@ -224,6 +224,13 @@ impl PromptSection for DateTimeSection {
fn inject_workspace_file(prompt: &mut String, workspace_dir: &Path, filename: &str) {
let path = workspace_dir.join(filename);
if !path.exists() {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, default_workspace_file_content(filename));
}
match std::fs::read_to_string(&path) {
Ok(content) => {
let trimmed = content.trim();
@@ -251,11 +258,28 @@ fn inject_workspace_file(prompt: &mut String, workspace_dir: &Path, filename: &s
}
}
Err(_) => {
let _ = writeln!(prompt, "### {filename}\n\n[File not found: {filename}]\n");
// Keep prompt focused: missing optional identity/bootstrap files should not
// add noisy placeholders that dilute tool-calling instructions.
}
}
}
fn default_workspace_file_content(filename: &str) -> &'static str {
match filename {
"AGENTS.md" => include_str!("prompts/AGENTS.md"),
"SOUL.md" => include_str!("prompts/SOUL.md"),
"TOOLS.md" => include_str!("prompts/TOOLS.md"),
"IDENTITY.md" => include_str!("prompts/IDENTITY.md"),
"USER.md" => include_str!("prompts/USER.md"),
"HEARTBEAT.md" => {
"# Periodic Tasks\n\n# Add tasks below (one per line, starting with `- `)\n"
}
"BOOTSTRAP.md" => include_str!("prompts/BOOTSTRAP.md"),
"MEMORY.md" => include_str!("prompts/MEMORY.md"),
_ => "",
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -349,6 +373,49 @@ mod tests {
assert!(prompt.contains("instr"));
}
#[test]
fn identity_section_creates_missing_workspace_files() {
let workspace =
std::env::temp_dir().join(format!("openhuman_prompt_create_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&workspace).unwrap();
let tools: Vec<Box<dyn Tool>> = vec![];
let ctx = PromptContext {
workspace_dir: &workspace,
model_name: "test-model",
tools: &tools,
skills: &[],
identity_config: None,
dispatcher_instructions: "",
};
let section = IdentitySection;
let _ = section.build(&ctx).unwrap();
for file in [
"AGENTS.md",
"SOUL.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
"BOOTSTRAP.md",
"MEMORY.md",
] {
assert!(
workspace.join(file).exists(),
"expected workspace file to be created: {file}"
);
}
let agents = std::fs::read_to_string(workspace.join("AGENTS.md")).unwrap();
assert!(
agents.starts_with("# OpenHuman Agents"),
"AGENTS.md should be seeded from src/openhuman/agent/prompts/AGENTS.md"
);
let _ = std::fs::remove_dir_all(workspace);
}
#[test]
fn datetime_section_includes_timestamp_and_timezone() {
let tools: Vec<Box<dyn Tool>> = vec![];
+441 -11
View File
@@ -4,6 +4,14 @@ use chrono::Utc;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
#[cfg(target_os = "macos")]
use std::{
fs,
io::Write,
path::PathBuf,
process::{Child, ChildStdin, Command, Stdio},
};
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tokio::time::{self, Duration, Instant};
@@ -108,6 +116,15 @@ struct FocusedTextContext {
text: String,
selected_text: Option<String>,
raw_error: Option<String>,
bounds: Option<FocusedElementBounds>,
}
#[derive(Debug, Clone, Copy)]
struct FocusedElementBounds {
x: i32,
y: i32,
width: i32,
height: i32,
}
fn is_text_role(role: Option<&str>) -> bool {
@@ -301,7 +318,7 @@ impl AutocompleteEngine {
state.last_error.clone()
};
if let Some(error_message) = error_message {
show_overflow_badge("error", None, Some(&error_message), None);
show_overflow_badge("error", None, Some(&error_message), None, None);
}
} else {
let mut state = engine.inner.lock().await;
@@ -328,6 +345,8 @@ impl AutocompleteEngine {
if let Some(task) = state.task.take() {
task.abort();
}
#[cfg(target_os = "macos")]
let _ = overlay_helper_quit();
AutocompleteStopResult { stopped: true }
}
@@ -395,7 +414,7 @@ impl AutocompleteEngine {
state.updated_at_ms = Some(Utc::now().timestamp_millis());
state.last_overlay_signature = None;
}
show_overflow_badge("accepted", Some(&cleaned), None, None);
show_overflow_badge("accepted", Some(&cleaned), None, None, None);
Ok(AutocompleteAcceptResult {
accepted: true,
@@ -462,6 +481,8 @@ impl AutocompleteEngine {
}
state.suggestion = None;
state.last_overlay_signature = None;
#[cfg(target_os = "macos")]
let _ = overlay_helper_quit();
}
Ok(AutocompleteSetStyleResult {
@@ -491,6 +512,7 @@ impl AutocompleteEngine {
text: context,
selected_text: None,
raw_error: None,
bounds: None,
}
} else {
let focused = focused_text_context_verbose()?;
@@ -591,7 +613,13 @@ impl AutocompleteEngine {
if state.last_overlay_signature.as_deref() != Some(ready_signature.as_str()) {
state.last_overlay_signature = Some(ready_signature);
drop(state);
show_overflow_badge("ready", Some(&suggestion), None, app_name.as_deref());
show_overflow_badge(
"ready",
Some(&suggestion),
None,
app_name.as_deref(),
focused.bounds.as_ref(),
);
return Ok(());
}
Ok(())
@@ -635,7 +663,7 @@ impl AutocompleteEngine {
state.updated_at_ms = Some(Utc::now().timestamp_millis());
state.last_overlay_signature = None;
}
show_overflow_badge("accepted", Some(&cleaned), None, None);
show_overflow_badge("accepted", Some(&cleaned), None, None, None);
}
}
@@ -660,7 +688,7 @@ impl AutocompleteEngine {
}
};
if let Some(value) = rejected {
show_overflow_badge("rejected", Some(&value), None, None);
show_overflow_badge("rejected", Some(&value), None, None, None);
}
Ok(())
}
@@ -673,6 +701,20 @@ pub fn global_engine() -> Arc<AutocompleteEngine> {
AUTOCOMPLETE_ENGINE.clone()
}
#[cfg(target_os = "macos")]
static LAST_OVERFLOW_BADGE: Lazy<StdMutex<Option<(String, i64)>>> =
Lazy::new(|| StdMutex::new(None));
#[cfg(target_os = "macos")]
struct OverlayHelperProcess {
child: Child,
stdin: ChildStdin,
}
#[cfg(target_os = "macos")]
static OVERLAY_HELPER_PROCESS: Lazy<StdMutex<Option<OverlayHelperProcess>>> =
Lazy::new(|| StdMutex::new(None));
fn truncate_tail(text: &str, max_chars: usize) -> String {
let chars: Vec<char> = text.chars().collect();
if chars.len() <= max_chars {
@@ -696,11 +738,343 @@ fn sanitize_suggestion(text: &str) -> String {
}
fn show_overflow_badge(
_kind: &str,
_suggestion: Option<&str>,
_error: Option<&str>,
_app_name: Option<&str>,
kind: &str,
suggestion: Option<&str>,
error: Option<&str>,
app_name: Option<&str>,
anchor_bounds: Option<&FocusedElementBounds>,
) {
#[cfg(target_os = "macos")]
{
const READY_THROTTLE_MS: i64 = 1_200;
let now_ms = Utc::now().timestamp_millis();
let signature = format!(
"{}:{}:{}:{}",
kind,
app_name.unwrap_or_default(),
suggestion.unwrap_or_default(),
error.unwrap_or_default()
);
if let Ok(mut guard) = LAST_OVERFLOW_BADGE.lock() {
if let Some((last_signature, last_ms)) = guard.as_ref() {
if *last_signature == signature {
return;
}
if kind == "ready" && (now_ms - *last_ms) < READY_THROTTLE_MS {
return;
}
}
*guard = Some((signature, now_ms));
}
if kind == "ready" {
if let (Some(bounds), Some(suggestion_text)) = (anchor_bounds, suggestion) {
if overlay_helper_show(bounds, suggestion_text).is_ok() {
return;
}
}
} else {
let _ = overlay_helper_hide();
}
let title = match kind {
"ready" => "OpenHuman suggestion",
"accepted" => "OpenHuman applied",
"rejected" => "OpenHuman dismissed",
"error" => "OpenHuman autocomplete error",
_ => "OpenHuman autocomplete",
};
let mut body = match kind {
"ready" => suggestion.unwrap_or_default().to_string(),
"accepted" => format!("Inserted: {}", suggestion.unwrap_or_default()),
"rejected" => "Suggestion dismissed.".to_string(),
"error" => error.unwrap_or("Autocomplete failed").to_string(),
_ => suggestion.unwrap_or_default().to_string(),
};
if body.trim().is_empty() {
body = "No suggestion".to_string();
}
body = truncate_tail(&body, 140);
let subtitle = app_name.unwrap_or_default().trim().to_string();
let escaped_title = escape_osascript_text(title);
let escaped_body = escape_osascript_text(&body);
let escaped_subtitle = escape_osascript_text(&subtitle);
let script = if subtitle.is_empty() {
format!(
r#"display notification "{}" with title "{}""#,
escaped_body, escaped_title
)
} else {
format!(
r#"display notification "{}" with title "{}" subtitle "{}""#,
escaped_body, escaped_title, escaped_subtitle
)
};
std::thread::spawn(move || {
let _ = std::process::Command::new("osascript")
.arg("-e")
.arg(script)
.output();
});
}
}
#[cfg(target_os = "macos")]
fn escape_osascript_text(raw: &str) -> String {
raw.replace('\\', "\\\\")
.replace('\"', "\\\"")
.replace('\n', " ")
.replace('\r', " ")
}
#[cfg(target_os = "macos")]
fn overlay_helper_show(bounds: &FocusedElementBounds, text: &str) -> Result<(), String> {
let message = serde_json::json!({
"type": "show",
"x": bounds.x,
"y": bounds.y,
"w": bounds.width,
"h": bounds.height,
"text": truncate_tail(text, 96),
"ttl_ms": 1100
})
.to_string();
overlay_helper_send_line(&message)
}
#[cfg(target_os = "macos")]
fn overlay_helper_hide() -> Result<(), String> {
overlay_helper_send_line(r#"{"type":"hide"}"#)
}
#[cfg(target_os = "macos")]
fn overlay_helper_quit() -> Result<(), String> {
let mut guard = OVERLAY_HELPER_PROCESS
.lock()
.map_err(|_| "overlay helper lock poisoned".to_string())?;
if let Some(mut helper) = guard.take() {
let _ = helper.stdin.write_all(br#"{"type":"quit"}"#);
let _ = helper.stdin.write_all(b"\n");
let _ = helper.stdin.flush();
let _ = helper.child.kill();
let _ = helper.child.wait();
}
Ok(())
}
#[cfg(target_os = "macos")]
fn overlay_helper_send_line(line: &str) -> Result<(), String> {
ensure_overlay_helper_running()?;
let mut guard = OVERLAY_HELPER_PROCESS
.lock()
.map_err(|_| "overlay helper lock poisoned".to_string())?;
let Some(helper) = guard.as_mut() else {
return Err("overlay helper unavailable".to_string());
};
helper
.stdin
.write_all(line.as_bytes())
.and_then(|_| helper.stdin.write_all(b"\n"))
.and_then(|_| helper.stdin.flush())
.map_err(|e| format!("failed to write overlay helper stdin: {e}"))?;
Ok(())
}
#[cfg(target_os = "macos")]
fn ensure_overlay_helper_running() -> Result<(), String> {
let mut guard = OVERLAY_HELPER_PROCESS
.lock()
.map_err(|_| "overlay helper lock poisoned".to_string())?;
if let Some(helper) = guard.as_mut() {
if helper
.child
.try_wait()
.map_err(|e| format!("failed to query overlay helper state: {e}"))?
.is_none()
{
return Ok(());
}
*guard = None;
}
let binary_path = ensure_overlay_helper_binary()?;
let mut child = Command::new(&binary_path)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("failed to spawn overlay helper: {e}"))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| "failed to capture overlay helper stdin".to_string())?;
*guard = Some(OverlayHelperProcess { child, stdin });
Ok(())
}
#[cfg(target_os = "macos")]
fn ensure_overlay_helper_binary() -> Result<PathBuf, String> {
let cache_dir = std::env::temp_dir().join("openhuman-autocomplete-overlay");
fs::create_dir_all(&cache_dir).map_err(|e| format!("failed to create cache dir: {e}"))?;
let source_path = cache_dir.join("overlay_helper.swift");
let binary_path = cache_dir.join("overlay_helper_bin");
let source = overlay_helper_swift_source();
let needs_write = match fs::read_to_string(&source_path) {
Ok(existing) => existing != source,
Err(_) => true,
};
if needs_write {
fs::write(&source_path, source)
.map_err(|e| format!("failed to write overlay helper source: {e}"))?;
}
let needs_compile = needs_write || !binary_path.exists();
if needs_compile {
let output = Command::new("xcrun")
.arg("swiftc")
.arg("-O")
.arg(&source_path)
.arg("-o")
.arg(&binary_path)
.output()
.or_else(|_| {
Command::new("swiftc")
.arg("-O")
.arg(&source_path)
.arg("-o")
.arg(&binary_path)
.output()
})
.map_err(|e| format!("failed to invoke swiftc: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!(
"failed to compile overlay helper: {}",
if stderr.is_empty() {
"swiftc returned non-zero exit status".to_string()
} else {
stderr
}
));
}
}
Ok(binary_path)
}
#[cfg(target_os = "macos")]
fn overlay_helper_swift_source() -> &'static str {
r#"import Cocoa
import Foundation
final class OverlayController {
private var panel: NSPanel?
private var textField: NSTextField?
private var hideWorkItem: DispatchWorkItem?
func show(x: CGFloat, yTop: CGFloat, width: CGFloat, height: CGFloat, text: String, ttlMs: Int) {
let screen = NSScreen.main ?? NSScreen.screens.first
let screenHeight = screen?.frame.height ?? 900
let panelWidth = min(420, max(140, CGFloat(text.count) * 7 + 26))
let panelHeight: CGFloat = 26
let originX = x + max(8, min(width - panelWidth - 8, 28))
let originYTop = yTop + max(5, min(height - panelHeight - 4, 10))
let originYCocoa = max(6, screenHeight - originYTop - panelHeight)
if panel == nil {
let p = NSPanel(
contentRect: NSRect(x: originX, y: originYCocoa, width: panelWidth, height: panelHeight),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
p.level = .statusBar
p.hasShadow = false
p.isOpaque = false
p.backgroundColor = .clear
p.ignoresMouseEvents = true
p.collectionBehavior = [.canJoinAllSpaces, .transient]
let content = NSView(frame: NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight))
content.wantsLayer = true
content.layer?.cornerRadius = 6
content.layer?.backgroundColor = NSColor(white: 0.08, alpha: 0.35).cgColor
p.contentView = content
let label = NSTextField(labelWithString: text)
label.frame = NSRect(x: 8, y: 4, width: panelWidth - 12, height: 18)
label.textColor = NSColor(white: 1.0, alpha: 0.46)
label.font = NSFont.systemFont(ofSize: 13)
label.lineBreakMode = .byTruncatingTail
content.addSubview(label)
panel = p
textField = label
}
panel?.setFrame(NSRect(x: originX, y: originYCocoa, width: panelWidth, height: panelHeight), display: true)
panel?.contentView?.frame = NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight)
textField?.frame = NSRect(x: 8, y: 4, width: panelWidth - 12, height: 18)
textField?.stringValue = text
panel?.orderFrontRegardless()
hideWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.hide()
}
hideWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(max(120, ttlMs)), execute: work)
}
func hide() {
panel?.orderOut(nil)
}
}
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
let controller = OverlayController()
DispatchQueue.global(qos: .utility).async {
while let line = readLine() {
guard let data = line.data(using: .utf8),
let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let kind = payload["type"] as? String else {
continue
}
if kind == "show" {
let x = CGFloat((payload["x"] as? NSNumber)?.doubleValue ?? 0)
let y = CGFloat((payload["y"] as? NSNumber)?.doubleValue ?? 0)
let w = CGFloat((payload["w"] as? NSNumber)?.doubleValue ?? 0)
let h = CGFloat((payload["h"] as? NSNumber)?.doubleValue ?? 0)
let text = (payload["text"] as? String) ?? ""
let ttl = (payload["ttl_ms"] as? NSNumber)?.intValue ?? 900
DispatchQueue.main.async {
controller.show(x: x, yTop: y, width: w, height: h, text: text, ttlMs: ttl)
}
} else if kind == "hide" {
DispatchQueue.main.async {
controller.hide()
}
} else if kind == "quit" {
DispatchQueue.main.async {
controller.hide()
NSApplication.shared.terminate(nil)
}
break
}
}
}
app.run()
"#
}
fn is_no_text_candidate_error(err: &str) -> bool {
@@ -729,6 +1103,10 @@ fn focused_text_context_verbose() -> Result<FocusedTextContext, String> {
set textValue to ""
set selectedValue to ""
set errValue to ""
set posX to ""
set posY to ""
set sizeW to ""
set sizeH to ""
set targetRoles to {"AXTextArea", "AXTextField", "AXSearchField", "AXComboBox", "AXEditableText"}
try
@@ -739,6 +1117,16 @@ fn focused_text_context_verbose() -> Result<FocusedTextContext, String> {
try
set textValue to value of attribute "AXValue" of focusedElement as text
end try
try
set p to value of attribute "AXPosition" of focusedElement
set posX to item 1 of p as text
set posY to item 2 of p as text
end try
try
set s to value of attribute "AXSize" of focusedElement
set sizeW to item 1 of s as text
set sizeH to item 2 of s as text
end try
if textValue is "missing value" then set textValue to ""
if textValue is "" then
try
@@ -774,6 +1162,20 @@ fn focused_text_context_verbose() -> Result<FocusedTextContext, String> {
try
set childValue to value of attribute "AXValue" of childElem as text
end try
set childPosX to ""
set childPosY to ""
set childSizeW to ""
set childSizeH to ""
try
set cp to value of attribute "AXPosition" of childElem
set childPosX to item 1 of cp as text
set childPosY to item 2 of cp as text
end try
try
set cs to value of attribute "AXSize" of childElem
set childSizeW to item 1 of cs as text
set childSizeH to item 2 of cs as text
end try
if childValue is "missing value" then set childValue to ""
if childValue is "" then
try
@@ -785,6 +1187,10 @@ fn focused_text_context_verbose() -> Result<FocusedTextContext, String> {
if childValue is not "" then
set roleValue to childRole
set textValue to childValue
if childPosX is not "" then set posX to childPosX
if childPosY is not "" then set posY to childPosY
if childSizeW is not "" then set sizeW to childSizeW
if childSizeH is not "" then set sizeH to childSizeH
exit repeat
end if
end if
@@ -826,7 +1232,7 @@ fn focused_text_context_verbose() -> Result<FocusedTextContext, String> {
set errValue to "ERROR:no_text_candidate_found"
end if
return appName & sep & roleValue & sep & textValue & sep & selectedValue & sep & errValue
return appName & sep & roleValue & sep & textValue & sep & selectedValue & sep & errValue & sep & posX & sep & posY & sep & sizeW & sep & sizeH
end tell
"##;
@@ -845,7 +1251,7 @@ fn focused_text_context_verbose() -> Result<FocusedTextContext, String> {
let text = String::from_utf8_lossy(&output.stdout);
let trimmed = text.trim_end_matches(['\r', '\n']);
let mut segments = trimmed.splitn(5, '\u{1f}');
let mut segments = trimmed.splitn(9, '\u{1f}');
let app_name = segments
.next()
.map(|s| normalize_ax_value(s.trim()))
@@ -863,6 +1269,10 @@ fn focused_text_context_verbose() -> Result<FocusedTextContext, String> {
.next()
.map(|s| normalize_ax_value(s.trim()))
.filter(|s| !s.is_empty());
let pos_x = segments.next().and_then(parse_ax_number);
let pos_y = segments.next().and_then(parse_ax_number);
let size_w = segments.next().and_then(parse_ax_number);
let size_h = segments.next().and_then(parse_ax_number);
let allow_terminal_text_value =
is_terminal_app(app_name.as_deref()) && !value.trim().is_empty();
@@ -880,6 +1290,17 @@ fn focused_text_context_verbose() -> Result<FocusedTextContext, String> {
text: value,
selected_text,
raw_error,
bounds: match (pos_x, pos_y, size_w, size_h) {
(Some(x), Some(y), Some(width), Some(height)) if width > 0 && height > 0 => {
Some(FocusedElementBounds {
x,
y,
width,
height,
})
}
_ => None,
},
})
}
@@ -902,6 +1323,15 @@ fn normalize_ax_value(raw: &str) -> String {
}
}
fn parse_ax_number(raw: &str) -> Option<i32> {
let trimmed = normalize_ax_value(raw);
if trimmed.is_empty() {
return None;
}
let cleaned = trimmed.replace(',', ".");
cleaned.parse::<f64>().ok().map(|v| v.round() as i32)
}
#[cfg(target_os = "macos")]
fn apply_text_to_focused_field(text: &str) -> Result<(), String> {
let escaped = text
+203 -17
View File
@@ -329,6 +329,8 @@ struct ResponseMessage {
reasoning_content: Option<String>,
#[serde(default)]
tool_calls: Option<Vec<ToolCall>>,
#[serde(default)]
function_call: Option<Function>,
}
impl ResponseMessage {
@@ -379,7 +381,7 @@ struct ToolCall {
#[derive(Debug, Deserialize, Serialize)]
struct Function {
name: Option<String>,
arguments: Option<String>,
arguments: Option<serde_json::Value>,
}
#[derive(Debug, Serialize)]
@@ -680,6 +682,81 @@ fn parse_chat_response_body(provider_name: &str, body: &str) -> anyhow::Result<A
})
}
fn normalize_function_arguments(arguments: Option<serde_json::Value>) -> String {
match arguments {
Some(serde_json::Value::String(raw)) => {
if raw.trim().is_empty() {
"{}".to_string()
} else {
raw
}
}
Some(serde_json::Value::Null) | None => "{}".to_string(),
Some(other) => serde_json::to_string(&other).unwrap_or_else(|_| "{}".to_string()),
}
}
fn parse_provider_tool_call_from_value(value: &serde_json::Value) -> Option<ProviderToolCall> {
if let Ok(call) = serde_json::from_value::<ProviderToolCall>(value.clone()) {
if !call.name.trim().is_empty() {
return Some(ProviderToolCall {
id: if call.id.trim().is_empty() {
uuid::Uuid::new_v4().to_string()
} else {
call.id
},
name: call.name,
arguments: if call.arguments.trim().is_empty() {
"{}".to_string()
} else {
call.arguments
},
});
}
}
let function = value.get("function")?;
let name = function.get("name").and_then(serde_json::Value::as_str)?;
if name.trim().is_empty() {
return None;
}
let id = value
.get("id")
.and_then(serde_json::Value::as_str)
.map(ToString::to_string)
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
Some(ProviderToolCall {
id,
name: name.to_string(),
arguments: normalize_function_arguments(function.get("arguments").cloned()),
})
}
fn parse_tool_calls_from_content_json(
content: &str,
) -> Option<(Option<String>, Vec<ProviderToolCall>)> {
let value = serde_json::from_str::<serde_json::Value>(content).ok()?;
let tool_calls_value = value.get("tool_calls")?.as_array()?;
let tool_calls: Vec<ProviderToolCall> = tool_calls_value
.iter()
.filter_map(parse_provider_tool_call_from_value)
.collect();
if tool_calls.is_empty() {
return None;
}
let text = value
.get("content")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string);
Some((text, tool_calls))
}
fn parse_responses_response_body(
provider_name: &str,
body: &str,
@@ -785,7 +862,7 @@ impl OpenAiCompatibleProvider {
kind: Some("function".to_string()),
function: Some(Function {
name: Some(tc.name),
arguments: Some(tc.arguments),
arguments: Some(serde_json::Value::String(tc.arguments)),
}),
})
.collect::<Vec<_>>();
@@ -865,14 +942,15 @@ impl OpenAiCompatibleProvider {
}
fn parse_native_response(message: ResponseMessage) -> ProviderChatResponse {
let tool_calls = message
let mut text = message.effective_content_optional();
let mut tool_calls = message
.tool_calls
.unwrap_or_default()
.into_iter()
.filter_map(|tc| {
let function = tc.function?;
let name = function.name?;
let arguments = function.arguments.unwrap_or_else(|| "{}".to_string());
let arguments = normalize_function_arguments(function.arguments);
Some(ProviderToolCall {
id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
name,
@@ -881,10 +959,35 @@ impl OpenAiCompatibleProvider {
})
.collect::<Vec<_>>();
ProviderChatResponse {
text: message.content,
tool_calls,
if tool_calls.is_empty() {
if let Some(function) = message.function_call.as_ref() {
if let Some(name) = function
.name
.as_ref()
.filter(|name| !name.trim().is_empty())
{
tool_calls.push(ProviderToolCall {
id: uuid::Uuid::new_v4().to_string(),
name: name.clone(),
arguments: normalize_function_arguments(function.arguments.clone()),
});
}
}
}
// Some providers return OpenAI-style tool_calls encoded as a JSON string
// inside message.content. Recover those here so native tool-calling still works.
if let Some(content) = message.content.as_deref() {
if let Some((json_text, json_tool_calls)) = parse_tool_calls_from_content_json(content)
{
if !json_tool_calls.is_empty() {
tool_calls = json_tool_calls;
text = json_text.or(text);
}
}
}
ProviderChatResponse { text, tool_calls }
}
fn is_native_tool_schema_unsupported(status: reqwest::StatusCode, error: &str) -> bool {
@@ -1242,9 +1345,9 @@ impl Provider for OpenAiCompatibleProvider {
.filter_map(|tc| {
let function = tc.function?;
let name = function.name?;
let arguments = function.arguments.unwrap_or_else(|| "{}".to_string());
let arguments = normalize_function_arguments(function.arguments);
Some(ProviderToolCall {
id: uuid::Uuid::new_v4().to_string(),
id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
name,
arguments,
})
@@ -1918,9 +2021,12 @@ mod tests {
kind: Some("function".to_string()),
function: Some(Function {
name: Some("shell".to_string()),
arguments: Some(r#"{"command":"pwd"}"#.to_string()),
arguments: Some(serde_json::Value::String(
r#"{"command":"pwd"}"#.to_string(),
)),
}),
}]),
function_call: None,
reasoning_content: None,
};
@@ -2120,16 +2226,96 @@ mod tests {
Some("get_weather")
);
assert_eq!(
tool_calls[0]
.function
.as_ref()
.unwrap()
.arguments
.as_deref(),
Some("{\"location\":\"London\"}")
tool_calls[0].function.as_ref().unwrap().arguments.as_ref(),
Some(&serde_json::Value::String(
"{\"location\":\"London\"}".to_string()
))
);
}
#[test]
fn response_with_tool_call_object_arguments_deserializes() {
let json = r#"{
"choices": [{
"message": {
"content": null,
"tool_calls": [{
"id": "call_456",
"type": "function",
"function": {
"name": "get_weather",
"arguments": {"location":"London","unit":"c"}
}
}]
}
}]
}"#;
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
let msg = &resp.choices[0].message;
let tool_calls = msg.tool_calls.as_ref().unwrap();
assert_eq!(
tool_calls[0].function.as_ref().unwrap().arguments.as_ref(),
Some(&serde_json::json!({"location":"London","unit":"c"}))
);
let parsed = OpenAiCompatibleProvider::parse_native_response(ResponseMessage {
content: None,
reasoning_content: None,
tool_calls: Some(vec![ToolCall {
id: Some("call_456".to_string()),
kind: Some("function".to_string()),
function: Some(Function {
name: Some("get_weather".to_string()),
arguments: Some(serde_json::json!({"location":"London","unit":"c"})),
}),
}]),
function_call: None,
});
assert_eq!(parsed.tool_calls.len(), 1);
assert_eq!(parsed.tool_calls[0].id, "call_456");
assert_eq!(
parsed.tool_calls[0].arguments,
r#"{"location":"London","unit":"c"}"#
);
}
#[test]
fn parse_native_response_recovers_tool_calls_from_json_content() {
let content = r#"{"content":"Checking files...","tool_calls":[{"id":"call_json_1","function":{"name":"shell","arguments":"{\"command\":\"ls -la\"}"}}]}"#;
let parsed = OpenAiCompatibleProvider::parse_native_response(ResponseMessage {
content: Some(content.to_string()),
reasoning_content: None,
tool_calls: None,
function_call: None,
});
assert_eq!(parsed.text.as_deref(), Some("Checking files..."));
assert_eq!(parsed.tool_calls.len(), 1);
assert_eq!(parsed.tool_calls[0].id, "call_json_1");
assert_eq!(parsed.tool_calls[0].name, "shell");
assert_eq!(parsed.tool_calls[0].arguments, r#"{"command":"ls -la"}"#);
}
#[test]
fn parse_native_response_supports_legacy_function_call() {
let parsed = OpenAiCompatibleProvider::parse_native_response(ResponseMessage {
content: Some("Let me check".to_string()),
reasoning_content: None,
tool_calls: None,
function_call: Some(Function {
name: Some("shell".to_string()),
arguments: Some(serde_json::Value::String(
r#"{"command":"pwd"}"#.to_string(),
)),
}),
});
assert_eq!(parsed.tool_calls.len(), 1);
assert_eq!(parsed.tool_calls[0].name, "shell");
assert_eq!(parsed.tool_calls[0].arguments, r#"{"command":"pwd"}"#);
}
#[test]
fn response_with_multiple_tool_calls() {
let json = r#"{