mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
* refactor: remove mobile platform support and update documentation - Updated CONTRIBUTING.md and SECURITY.md to reflect the removal of Android and iOS support, focusing on desktop platforms only. - Modified AGENTS.md and MEMORY.md to clarify the platform capabilities of OpenHuman as a desktop application. - Adjusted various Rust files to eliminate references to mobile platforms, including socket management and platform detection logic. - Removed mobile-specific code from SkillsGrid and Skills components, ensuring the application is tailored for desktop usage. - Deleted unused iOS and Android assets and configurations from the Tauri project structure. * chore: remove zeroclaw mentions and mobile support paths
320 lines
12 KiB
TypeScript
320 lines
12 KiB
TypeScript
import { invoke } from '@tauri-apps/api/core';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
|
|
import {
|
|
DefaultIcon,
|
|
SKILL_ICONS,
|
|
SkillActionButton,
|
|
type SkillListEntry,
|
|
STATUS_DISPLAY,
|
|
STATUS_PRIORITY,
|
|
} from '../components/skills/shared';
|
|
import SkillSetupModal from '../components/skills/SkillSetupModal';
|
|
import { deriveConnectionStatus, useSkillConnectionStatus } from '../lib/skills/hooks';
|
|
import { skillManager } from '../lib/skills/manager';
|
|
import type { SkillConnectionStatus, SkillHostConnectionState } from '../lib/skills/types';
|
|
import { useAppSelector } from '../store/hooks';
|
|
import { IS_DEV } from '../utils/config';
|
|
import { deriveSkillSyncUiState } from './skillsSyncUi';
|
|
|
|
/** Format large numbers: 1200 → "1.2K", 1200000 → "1.2M" */
|
|
function formatNumber(n: number): string {
|
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
return String(n);
|
|
}
|
|
|
|
/** Status dot color for skill connection status */
|
|
function statusDotClass(status: SkillConnectionStatus): string {
|
|
switch (status) {
|
|
case 'connected':
|
|
return 'bg-sage-400';
|
|
case 'connecting':
|
|
return 'bg-amber-400 animate-pulse';
|
|
case 'error':
|
|
return 'bg-coral-400';
|
|
default:
|
|
return 'bg-stone-600';
|
|
}
|
|
}
|
|
|
|
// ─── Skill Card (used in the skills list) ───────────────────────────────────
|
|
|
|
interface SkillCardProps {
|
|
skill: SkillListEntry;
|
|
onSetup: () => void;
|
|
}
|
|
|
|
function SkillCard({ skill, onSetup }: SkillCardProps) {
|
|
const connectionStatus = useSkillConnectionStatus(skill.id);
|
|
const statusDisplay = STATUS_DISPLAY[connectionStatus] || STATUS_DISPLAY.offline;
|
|
const skillState = useAppSelector(state => state.skills.skillStates[skill.id]) as
|
|
| (SkillHostConnectionState & Record<string, unknown>)
|
|
| undefined;
|
|
const [manualSyncing, setManualSyncing] = useState(false);
|
|
const syncUi = useMemo(
|
|
() => deriveSkillSyncUiState(skill.id, skillState),
|
|
[skill.id, skillState]
|
|
);
|
|
const isSyncing = manualSyncing || syncUi.isSyncing;
|
|
|
|
const handleSync = async (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
setManualSyncing(true);
|
|
try {
|
|
await skillManager.triggerSync(skill.id);
|
|
} catch (err) {
|
|
console.error(`Sync failed for ${skill.id}:`, err);
|
|
} finally {
|
|
setManualSyncing(false);
|
|
}
|
|
};
|
|
|
|
// Build subtitle from skill state (sync time, chat/message counts)
|
|
const subtitleParts: string[] = [];
|
|
if (skillState) {
|
|
const chatCount = skillState.chat_count as number | undefined;
|
|
const msgCount = skillState.message_count as number | undefined;
|
|
const lastSync = skillState.last_sync as string | undefined;
|
|
if (chatCount != null) subtitleParts.push(`${formatNumber(chatCount)} chats`);
|
|
if (msgCount != null) subtitleParts.push(`${formatNumber(msgCount)} msgs`);
|
|
if (lastSync) subtitleParts.push(`Synced ${lastSync}`);
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/[0.03] border border-white/[0.06] hover:bg-white/[0.06] transition-colors">
|
|
{/* Icon */}
|
|
<div className="w-8 h-8 flex items-center justify-center text-white opacity-70 flex-shrink-0">
|
|
{skill.icon || <DefaultIcon />}
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-white truncate">{skill.name}</span>
|
|
<div
|
|
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${statusDotClass(connectionStatus)}`}
|
|
/>
|
|
<span className={`text-xs flex-shrink-0 ${statusDisplay.color}`}>
|
|
{statusDisplay.text}
|
|
</span>
|
|
</div>
|
|
{subtitleParts.length > 0 && (
|
|
<p className="text-[11px] text-stone-500 truncate mt-0.5">{subtitleParts.join(' · ')}</p>
|
|
)}
|
|
{isSyncing && (
|
|
<div className="mt-1.5">
|
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-stone-800">
|
|
{syncUi.progressPercent != null ? (
|
|
<div
|
|
className="h-full rounded-full bg-primary-400 transition-all duration-300"
|
|
style={{ width: `${syncUi.progressPercent}%` }}
|
|
/>
|
|
) : (
|
|
<div className="h-full w-1/2 rounded-full bg-primary-400/80 animate-pulse" />
|
|
)}
|
|
</div>
|
|
{syncUi.progressMessage && (
|
|
<p className="text-[11px] text-primary-300 truncate mt-1">{syncUi.progressMessage}</p>
|
|
)}
|
|
{syncUi.metricsText && (
|
|
<p className="text-[11px] text-stone-500 truncate mt-0.5">{syncUi.metricsText}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
{connectionStatus === 'connected' && (
|
|
<>
|
|
{/* Sync */}
|
|
<button
|
|
onClick={isSyncing ? undefined : handleSync}
|
|
disabled={isSyncing}
|
|
className="w-7 h-7 flex items-center justify-center rounded-lg text-stone-400 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-40"
|
|
title="Sync">
|
|
<svg
|
|
className={`w-3.5 h-3.5 ${isSyncing ? 'animate-spin' : ''}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
{/* Settings */}
|
|
<button
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
onSetup();
|
|
}}
|
|
className="w-7 h-7 flex items-center justify-center rounded-lg text-stone-400 hover:text-white hover:bg-white/10 transition-colors"
|
|
title="Settings">
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
|
/>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</>
|
|
)}
|
|
<SkillActionButton
|
|
skill={skill}
|
|
connectionStatus={connectionStatus}
|
|
onOpenModal={onSetup}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Main Skills Page ───────────────────────────────────────────────────────
|
|
|
|
export default function Skills() {
|
|
// Skills state
|
|
const [skillsList, setSkillsList] = useState<SkillListEntry[]>([]);
|
|
const [skillsLoading, setSkillsLoading] = useState(true);
|
|
const skillsState = useAppSelector(state => state.skills.skills);
|
|
const skillStates = useAppSelector(state => state.skills.skillStates);
|
|
|
|
// Modal state
|
|
const [setupModalOpen, setSetupModalOpen] = useState(false);
|
|
const [activeSkillId, setActiveSkillId] = useState<string | null>(null);
|
|
const [activeSkillName, setActiveSkillName] = useState('');
|
|
const [activeSkillDescription, setActiveSkillDescription] = useState('');
|
|
const [activeSkillHasSetup, setActiveSkillHasSetup] = useState(false);
|
|
|
|
// Load skills
|
|
useEffect(() => {
|
|
const loadSkills = async () => {
|
|
try {
|
|
const manifests = await invoke<Array<Record<string, unknown>>>('runtime_discover_skills');
|
|
const ALLOWED_SKILLS = new Set(['gmail', 'notion']);
|
|
const validManifests = manifests.filter(m => {
|
|
const id = m.id as string;
|
|
if (id.includes('_')) return false;
|
|
return ALLOWED_SKILLS.has(id);
|
|
});
|
|
|
|
const processed: SkillListEntry[] = validManifests
|
|
.map(m => {
|
|
const setup = m.setup as Record<string, unknown> | undefined;
|
|
return {
|
|
id: m.id as string,
|
|
name:
|
|
(m.name as string) ||
|
|
(m.id as string).charAt(0).toUpperCase() + (m.id as string).slice(1),
|
|
description: (m.description as string) || '',
|
|
icon: SKILL_ICONS[m.id as string],
|
|
ignoreInProduction: (m.ignoreInProduction as boolean) ?? false,
|
|
hasSetup: !!(setup && setup.required),
|
|
};
|
|
})
|
|
.filter(s => IS_DEV || !s.ignoreInProduction);
|
|
|
|
setSkillsList(processed);
|
|
} catch {
|
|
// Skills unavailable
|
|
} finally {
|
|
setSkillsLoading(false);
|
|
}
|
|
};
|
|
loadSkills();
|
|
}, []);
|
|
|
|
// Sort skills by connection status
|
|
const sortedSkillsList = useMemo(() => {
|
|
return [...skillsList]
|
|
.sort((a, b) => {
|
|
const skillA = skillsState[a.id];
|
|
const skillB = skillsState[b.id];
|
|
const stateA = skillStates[a.id];
|
|
const stateB = skillStates[b.id];
|
|
|
|
const statusA = deriveConnectionStatus(skillA?.status, skillA?.setupComplete, stateA);
|
|
const statusB = deriveConnectionStatus(skillB?.status, skillB?.setupComplete, stateB);
|
|
|
|
const priorityA = STATUS_PRIORITY[statusA] ?? 999;
|
|
const priorityB = STATUS_PRIORITY[statusB] ?? 999;
|
|
|
|
if (priorityA === priorityB) return a.name.localeCompare(b.name);
|
|
return priorityA - priorityB;
|
|
})
|
|
.filter(s => IS_DEV || !s.ignoreInProduction);
|
|
}, [skillsList, skillsState, skillStates]);
|
|
|
|
const openSkillSetup = (skill: SkillListEntry) => {
|
|
setActiveSkillId(skill.id);
|
|
setActiveSkillName(skill.name);
|
|
setActiveSkillDescription(skill.description);
|
|
setActiveSkillHasSetup(skill.hasSetup);
|
|
setSetupModalOpen(true);
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-full relative">
|
|
<div className="relative z-10 min-h-full flex flex-col">
|
|
<div className="flex-1 p-6">
|
|
<div className="max-w-2xl mx-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h1 className="text-xl font-bold text-white">Skills</h1>
|
|
</div>
|
|
|
|
{/* Active Skills */}
|
|
<div className="animate-fade-up" style={{ animationDelay: '100ms' }}>
|
|
<div className="mb-3">
|
|
<h2 className="text-sm font-semibold text-white opacity-80">Active Skills</h2>
|
|
</div>
|
|
|
|
{skillsLoading ? (
|
|
<div className="glass rounded-2xl p-6 text-center">
|
|
<p className="text-sm text-stone-500">Loading skills...</p>
|
|
</div>
|
|
) : sortedSkillsList.length === 0 ? (
|
|
<div className="glass rounded-2xl p-6 text-center">
|
|
<p className="text-sm text-stone-500">No skills discovered</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{sortedSkillsList.map(skill => (
|
|
<SkillCard key={skill.id} skill={skill} onSetup={() => openSkillSetup(skill)} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Setup modal */}
|
|
{setupModalOpen && activeSkillId && (
|
|
<SkillSetupModal
|
|
skillId={activeSkillId}
|
|
skillName={activeSkillName}
|
|
skillDescription={activeSkillDescription}
|
|
hasSetup={activeSkillHasSetup}
|
|
onClose={() => {
|
|
setSetupModalOpen(false);
|
|
setActiveSkillId(null);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|