mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
@@ -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
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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![];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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#"{
|
||||
|
||||
Reference in New Issue
Block a user