mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Merge pull request #103 from vezuresdotxyz/develop
chore: merge develop into main (v0.42.0)
This commit is contained in:
+1
-1
Submodule skills updated: dd49a534a7...9ed6851a11
@@ -570,6 +570,9 @@ async fn handle_message(
|
||||
"skill/ping" => {
|
||||
handle_js_call(rt, ctx, "onPing", "{}").await
|
||||
}
|
||||
"skill/sync" => {
|
||||
handle_js_call(rt, ctx, "onSync", "{}").await
|
||||
}
|
||||
"oauth/revoked" => {
|
||||
// Clear credential: set to empty string so it's clearly "disconnected"
|
||||
let clear_code = r#"(function() {
|
||||
|
||||
+10
-6
@@ -146,16 +146,20 @@ globalThis.fetch = async function (url, options = {}) {
|
||||
headersObj = headers;
|
||||
}
|
||||
|
||||
const result = await __ops.fetch(url.toString(), {
|
||||
// __ops.fetch expects a JSON string for options (not a JS object)
|
||||
const resultJson = await __ops.fetch(url.toString(), JSON.stringify({
|
||||
method,
|
||||
headers: headersObj,
|
||||
body: typeof body === 'string' ? body : body ? JSON.stringify(body) : null,
|
||||
});
|
||||
}));
|
||||
|
||||
return new Response(result.body, {
|
||||
status: result.status,
|
||||
statusText: result.statusText || '',
|
||||
headers: new Headers(result.headers),
|
||||
// __ops.fetch returns a JSON string — parse it to access status/headers/body
|
||||
const parsed = JSON.parse(resultJson);
|
||||
|
||||
return new Response(parsed.body, {
|
||||
status: parsed.status,
|
||||
statusText: parsed.statusText || '',
|
||||
headers: new Headers(parsed.headers || {}),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import PublicRoute from './components/PublicRoute';
|
||||
import Agents from './pages/Agents';
|
||||
import Conversations from './pages/Conversations';
|
||||
import Home from './pages/Home';
|
||||
import Intelligence from './pages/Intelligence';
|
||||
import Invites from './pages/Invites';
|
||||
import Login from './pages/Login';
|
||||
import Onboarding from './pages/onboarding/Onboarding';
|
||||
@@ -88,6 +89,16 @@ const AppRoutes = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Intelligence */}
|
||||
<Route
|
||||
path="/intelligence"
|
||||
element={
|
||||
<ProtectedRoute requireAuth={true}>
|
||||
<Intelligence />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Conversations */}
|
||||
<Route
|
||||
path="/conversations"
|
||||
|
||||
@@ -33,21 +33,21 @@ const navItems = [
|
||||
// </svg>
|
||||
// ),
|
||||
// },
|
||||
// {
|
||||
// id: 'agents',
|
||||
// label: 'Agents',
|
||||
// path: '/agents',
|
||||
// icon: (
|
||||
// <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
// <path
|
||||
// strokeLinecap="round"
|
||||
// strokeLinejoin="round"
|
||||
// strokeWidth={2}
|
||||
// d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
// />
|
||||
// </svg>
|
||||
// ),
|
||||
// },
|
||||
{
|
||||
id: 'intelligence',
|
||||
label: 'Intelligence',
|
||||
path: '/intelligence',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'invites',
|
||||
label: 'Invite Friends',
|
||||
@@ -99,6 +99,7 @@ const MiniSidebar = () => {
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/settings') return location.pathname.startsWith('/settings');
|
||||
if (path === '/conversations') return location.pathname.startsWith('/conversations');
|
||||
return location.pathname === path;
|
||||
};
|
||||
|
||||
|
||||
+11
-220
@@ -2,57 +2,19 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import { platform } from '@tauri-apps/plugin-os';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import GoogleIcon from '../assets/icons/GoogleIcon';
|
||||
import NotionIcon from '../assets/icons/notion.svg';
|
||||
import TelegramIcon from '../assets/icons/telegram.svg';
|
||||
import { useSkillConnectionStatus } from '../lib/skills/hooks';
|
||||
import { skillManager } from '../lib/skills/manager';
|
||||
import type { SkillConnectionStatus, SkillHostConnectionState } from '../lib/skills/types';
|
||||
import { deriveConnectionStatus, useSkillConnectionStatus } from '../lib/skills/hooks';
|
||||
import type { SkillConnectionStatus } from '../lib/skills/types';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import { IS_DEV } from '../utils/config';
|
||||
import SkillSetupModal from './skills/SkillSetupModal';
|
||||
|
||||
// Map skill IDs to icons
|
||||
const SKILL_ICONS: Record<string, React.ReactElement> = {
|
||||
telegram: <img src={TelegramIcon} alt="Telegram" className="w-5 h-5" />,
|
||||
email: <GoogleIcon className="w-5 h-5" />,
|
||||
notion: <img src={NotionIcon} alt="Notion" className="w-5 h-5" />,
|
||||
github: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
),
|
||||
otter: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
// Default icon for unknown skills
|
||||
const DefaultIcon = () => (
|
||||
<div className="w-5 h-5 rounded-full bg-primary-500/20 flex items-center justify-center">
|
||||
<svg className="w-3 h-3 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Status display text and colors
|
||||
const STATUS_DISPLAY: Record<SkillConnectionStatus, { text: string; color: string }> = {
|
||||
connected: { text: 'Connected', color: 'text-sage-400' },
|
||||
connecting: { text: 'Connecting', color: 'text-amber-400' },
|
||||
not_authenticated: { text: 'Not Auth', color: 'text-amber-400' },
|
||||
disconnected: { text: 'Disconnected', color: 'text-stone-400' },
|
||||
error: { text: 'Error', color: 'text-coral-400' },
|
||||
offline: { text: 'Offline', color: 'text-stone-500' },
|
||||
setup_required: { text: 'Setup', color: 'text-primary-400' },
|
||||
};
|
||||
import {
|
||||
DefaultIcon,
|
||||
SKILL_ICONS,
|
||||
SkillActionButton,
|
||||
STATUS_DISPLAY,
|
||||
STATUS_PRIORITY,
|
||||
type SkillListEntry,
|
||||
} from './skills/shared';
|
||||
|
||||
interface SkillRowProps {
|
||||
skillId: string;
|
||||
@@ -106,177 +68,6 @@ function SkillRow({ skillId, name, icon, onConnect }: SkillRowProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface SkillListEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ignoreInProduction?: boolean;
|
||||
icon?: React.ReactElement;
|
||||
hasSetup: boolean;
|
||||
}
|
||||
|
||||
// Helper function to derive connection status (same logic as in hooks.ts)
|
||||
function deriveConnectionStatus(
|
||||
lifecycleStatus: string | undefined,
|
||||
setupComplete: boolean | undefined,
|
||||
skillState: Record<string, unknown> | undefined
|
||||
): SkillConnectionStatus {
|
||||
if (!lifecycleStatus || lifecycleStatus === 'installed' || lifecycleStatus === 'stopping') {
|
||||
return 'offline';
|
||||
}
|
||||
if (lifecycleStatus === 'error') {
|
||||
return 'error';
|
||||
}
|
||||
if (lifecycleStatus === 'setup_required' || lifecycleStatus === 'setup_in_progress') {
|
||||
return 'setup_required';
|
||||
}
|
||||
if (lifecycleStatus === 'starting') {
|
||||
return 'connecting';
|
||||
}
|
||||
const hostState = skillState as SkillHostConnectionState | undefined;
|
||||
if (!hostState) {
|
||||
if (setupComplete && lifecycleStatus === 'ready') {
|
||||
return 'connected';
|
||||
}
|
||||
return 'connecting';
|
||||
}
|
||||
const connStatus = hostState.connection_status;
|
||||
const authStatus = hostState.auth_status;
|
||||
if (connStatus === 'error' || authStatus === 'error') {
|
||||
return 'error';
|
||||
}
|
||||
if (connStatus === 'connected' && authStatus === 'authenticated') {
|
||||
return 'connected';
|
||||
}
|
||||
if (connStatus === 'connecting' || authStatus === 'authenticating') {
|
||||
return 'connecting';
|
||||
}
|
||||
if (connStatus === 'connected' && authStatus === 'not_authenticated') {
|
||||
return 'not_authenticated';
|
||||
}
|
||||
if (connStatus === 'disconnected') {
|
||||
return setupComplete ? 'disconnected' : 'setup_required';
|
||||
}
|
||||
return 'connecting';
|
||||
}
|
||||
|
||||
// Priority order for sorting (lower number = higher priority)
|
||||
const STATUS_PRIORITY: Record<SkillConnectionStatus, number> = {
|
||||
connected: 1,
|
||||
connecting: 2,
|
||||
not_authenticated: 3,
|
||||
disconnected: 4,
|
||||
setup_required: 5,
|
||||
offline: 6,
|
||||
error: 7,
|
||||
};
|
||||
|
||||
// Contextual action button for skills in the management modal
|
||||
function SkillActionButton({
|
||||
skill,
|
||||
connectionStatus,
|
||||
onOpenModal,
|
||||
}: {
|
||||
skill: SkillListEntry;
|
||||
connectionStatus: SkillConnectionStatus;
|
||||
onOpenModal: () => void;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleEnable = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setLoading(true);
|
||||
try {
|
||||
await skillManager.startSkill({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
version: '0.0.0',
|
||||
description: skill.description,
|
||||
runtime: 'quickjs',
|
||||
});
|
||||
// If skill has setup, the manager will set setup_required status
|
||||
// and the grid will re-render with the "Setup" button
|
||||
if (skill.hasSetup) {
|
||||
onOpenModal();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to enable ${skill.id}:`, err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="px-4 py-1.5 text-xs font-medium text-stone-400 flex-shrink-0 ml-3">
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Offline / not started → Enable
|
||||
if (connectionStatus === 'offline') {
|
||||
return (
|
||||
<button
|
||||
onClick={handleEnable}
|
||||
className="px-4 py-1.5 text-xs font-medium text-sage-300 bg-sage-500/10 border border-sage-500/30 rounded-lg hover:bg-sage-500/20 transition-colors flex-shrink-0 ml-3">
|
||||
Enable
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Setup required → Setup
|
||||
if (connectionStatus === 'setup_required') {
|
||||
return (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onOpenModal();
|
||||
}}
|
||||
className="px-4 py-1.5 text-xs font-medium text-primary-300 bg-primary-500/10 border border-primary-500/30 rounded-lg hover:bg-primary-500/20 transition-colors flex-shrink-0 ml-3">
|
||||
Setup
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Error → Retry
|
||||
if (connectionStatus === 'error') {
|
||||
return (
|
||||
<button
|
||||
onClick={handleEnable}
|
||||
className="px-4 py-1.5 text-xs font-medium text-amber-300 bg-amber-500/10 border border-amber-500/30 rounded-lg hover:bg-amber-500/20 transition-colors flex-shrink-0 ml-3">
|
||||
Retry
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Running / ready / connected → Configure
|
||||
return (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onOpenModal();
|
||||
}}
|
||||
className="px-4 py-1.5 text-xs font-medium text-primary-300 bg-primary-500/10 border border-primary-500/30 rounded-lg hover:bg-primary-500/20 transition-colors flex-shrink-0 ml-3">
|
||||
Configure
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SkillsGrid() {
|
||||
const [skillsList, setSkillsList] = useState<SkillListEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -513,7 +304,7 @@ export default function SkillsGrid() {
|
||||
{sortedSkillsList.map(skill => {
|
||||
const skillState = skillsState[skill.id];
|
||||
const stateData = skillStates[skill.id];
|
||||
const connectionStatus = deriveConnectionStatus(
|
||||
const connectionStatus: SkillConnectionStatus = deriveConnectionStatus(
|
||||
skillState?.status,
|
||||
skillState?.setupComplete,
|
||||
stateData
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAppSelector } from "../../store/hooks";
|
||||
import {
|
||||
useSkillConnectionStatus,
|
||||
useSkillConnectionInfo,
|
||||
useSkillState,
|
||||
} from "../../lib/skills/hooks";
|
||||
import { skillManager } from "../../lib/skills/manager";
|
||||
import type {
|
||||
@@ -30,6 +31,20 @@ export default function SkillManagementPanel({
|
||||
const skill = useAppSelector((state) => state.skills.skills[skillId]);
|
||||
const connectionStatus = useSkillConnectionStatus(skillId);
|
||||
const connectionInfo = useSkillConnectionInfo(skillId);
|
||||
const skillState = useSkillState<{
|
||||
syncInProgress?: boolean;
|
||||
syncProgress?: number | null;
|
||||
syncProgressMessage?: string | null;
|
||||
syncCompleted?: boolean;
|
||||
syncError?: string | null;
|
||||
lastSyncTime?: number | null;
|
||||
storage?: {
|
||||
chatCount?: number;
|
||||
messageCount?: number;
|
||||
contactCount?: number;
|
||||
unreadCount?: number;
|
||||
};
|
||||
}>(skillId);
|
||||
|
||||
const [options, setOptions] = useState<SkillOptionDefinition[]>([]);
|
||||
const [togglingOption, setTogglingOption] = useState<string | null>(null);
|
||||
@@ -112,6 +127,23 @@ export default function SkillManagementPanel({
|
||||
}
|
||||
}, [skillId, onClose]);
|
||||
|
||||
const handleSync = useCallback(async () => {
|
||||
try {
|
||||
await skillManager.triggerSync(skillId);
|
||||
} catch (err) {
|
||||
console.error("[SkillManagementPanel] Sync trigger failed:", err);
|
||||
}
|
||||
}, [skillId]);
|
||||
|
||||
const syncInProgress = skillState?.syncInProgress ?? false;
|
||||
const syncProgress = skillState?.syncProgress ?? null;
|
||||
const syncProgressMessage = skillState?.syncProgressMessage ?? null;
|
||||
const syncCompleted = skillState?.syncCompleted ?? false;
|
||||
const syncError = skillState?.syncError ?? null;
|
||||
const lastSyncTime = skillState?.lastSyncTime ?? null;
|
||||
// const storage = skillState?.storage;
|
||||
const hasSyncSupport = syncCompleted || syncInProgress || syncError !== null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Error message */}
|
||||
@@ -121,6 +153,70 @@ export default function SkillManagementPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data Sync */}
|
||||
{hasSyncSupport && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between px-0.5">
|
||||
<div className="text-xs font-medium text-stone-400">Data Sync</div>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncInProgress || connectionStatus !== "connected"}
|
||||
className="px-2.5 py-1 text-[11px] font-medium text-primary-300 bg-primary-500/10 border border-primary-500/30 rounded-lg hover:bg-primary-500/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{syncInProgress ? "Syncing..." : "Sync Now"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{syncInProgress && (
|
||||
<div className="rounded-lg bg-stone-800/40 border border-stone-700/40 px-3 py-2.5 space-y-2">
|
||||
{syncProgressMessage && (
|
||||
<div className="text-[11px] text-stone-400 truncate">
|
||||
{syncProgressMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-stone-700/60 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary-500 rounded-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${syncProgress ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
{syncProgress !== null && (
|
||||
<span className="text-[11px] text-stone-500 tabular-nums w-8 text-right">
|
||||
{syncProgress}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!syncInProgress && syncError && (
|
||||
<div className="text-[11px] text-coral-400 bg-coral-500/10 border border-coral-500/20 rounded-lg px-3 py-2 break-words">
|
||||
Sync failed: {syncError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!syncInProgress && syncCompleted && (
|
||||
<div className="rounded-lg bg-stone-800/40 border border-stone-700/40 px-3 py-2">
|
||||
{lastSyncTime && (
|
||||
<div className="text-[11px] text-stone-400">
|
||||
Last synced: {new Date(lastSyncTime).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
{/* {storage && (storage.chatCount > 0 || storage.messageCount > 0 || storage.contactCount > 0) && (
|
||||
<div className="text-[11px] text-stone-500 mt-0.5">
|
||||
{[
|
||||
storage.chatCount > 0 && `${storage.chatCount} chats`,
|
||||
storage.messageCount > 0 && `${storage.messageCount.toLocaleString()} messages`,
|
||||
storage.contactCount > 0 && `${storage.contactCount} contacts`,
|
||||
].filter(Boolean).join(" \u00B7 ")}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options */}
|
||||
{options.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import GoogleIcon from '../../assets/icons/GoogleIcon';
|
||||
import NotionIcon from '../../assets/icons/notion.svg';
|
||||
import TelegramIcon from '../../assets/icons/telegram.svg';
|
||||
import { skillManager } from '../../lib/skills/manager';
|
||||
import type { SkillConnectionStatus } from '../../lib/skills/types';
|
||||
|
||||
// Map skill IDs to icons
|
||||
export const SKILL_ICONS: Record<string, React.ReactElement> = {
|
||||
telegram: <img src={TelegramIcon} alt="Telegram" className="w-5 h-5" />,
|
||||
email: <GoogleIcon className="w-5 h-5" />,
|
||||
notion: <img src={NotionIcon} alt="Notion" className="w-5 h-5" />,
|
||||
github: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
),
|
||||
otter: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
// Default icon for unknown skills
|
||||
export const DefaultIcon = () => (
|
||||
<div className="w-5 h-5 rounded-full bg-primary-500/20 flex items-center justify-center">
|
||||
<svg className="w-3 h-3 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Status display text and colors
|
||||
export const STATUS_DISPLAY: Record<SkillConnectionStatus, { text: string; color: string }> = {
|
||||
connected: { text: 'Connected', color: 'text-sage-400' },
|
||||
connecting: { text: 'Connecting', color: 'text-amber-400' },
|
||||
not_authenticated: { text: 'Not Auth', color: 'text-amber-400' },
|
||||
disconnected: { text: 'Disconnected', color: 'text-stone-400' },
|
||||
error: { text: 'Error', color: 'text-coral-400' },
|
||||
offline: { text: 'Offline', color: 'text-stone-500' },
|
||||
setup_required: { text: 'Setup', color: 'text-primary-400' },
|
||||
};
|
||||
|
||||
// Priority order for sorting (lower number = higher priority)
|
||||
export const STATUS_PRIORITY: Record<SkillConnectionStatus, number> = {
|
||||
connected: 1,
|
||||
connecting: 2,
|
||||
not_authenticated: 3,
|
||||
disconnected: 4,
|
||||
setup_required: 5,
|
||||
offline: 6,
|
||||
error: 7,
|
||||
};
|
||||
|
||||
export interface SkillListEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ignoreInProduction?: boolean;
|
||||
icon?: React.ReactElement;
|
||||
hasSetup: boolean;
|
||||
}
|
||||
|
||||
// Contextual action button for skills
|
||||
export function SkillActionButton({
|
||||
skill,
|
||||
connectionStatus,
|
||||
onOpenModal,
|
||||
}: {
|
||||
skill: SkillListEntry;
|
||||
connectionStatus: SkillConnectionStatus;
|
||||
onOpenModal: () => void;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleEnable = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setLoading(true);
|
||||
try {
|
||||
await skillManager.startSkill({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
version: '0.0.0',
|
||||
description: skill.description,
|
||||
runtime: 'quickjs',
|
||||
});
|
||||
if (skill.hasSetup) {
|
||||
onOpenModal();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to enable ${skill.id}:`, err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="px-4 py-1.5 text-xs font-medium text-stone-400 flex-shrink-0 ml-3">
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (connectionStatus === 'offline') {
|
||||
return (
|
||||
<button
|
||||
onClick={handleEnable}
|
||||
className="px-4 py-1.5 text-xs font-medium text-sage-300 bg-sage-500/10 border border-sage-500/30 rounded-lg hover:bg-sage-500/20 transition-colors flex-shrink-0 ml-3">
|
||||
Enable
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (connectionStatus === 'setup_required') {
|
||||
return (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onOpenModal();
|
||||
}}
|
||||
className="px-4 py-1.5 text-xs font-medium text-primary-300 bg-primary-500/10 border border-primary-500/30 rounded-lg hover:bg-primary-500/20 transition-colors flex-shrink-0 ml-3">
|
||||
Setup
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (connectionStatus === 'error') {
|
||||
return (
|
||||
<button
|
||||
onClick={handleEnable}
|
||||
className="px-4 py-1.5 text-xs font-medium text-amber-300 bg-amber-500/10 border border-amber-500/30 rounded-lg hover:bg-amber-500/20 transition-colors flex-shrink-0 ml-3">
|
||||
Retry
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onOpenModal();
|
||||
}}
|
||||
className="px-4 py-1.5 text-xs font-medium text-primary-300 bg-primary-500/10 border border-primary-500/30 rounded-lg hover:bg-primary-500/20 transition-colors flex-shrink-0 ml-3">
|
||||
Configure
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { apiClient } from '../services/apiClient';
|
||||
import type { AIStatus } from '../store/aiSlice';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
|
||||
interface SessionEntry {
|
||||
sessionId: string;
|
||||
updatedAt: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
compactionCount: number;
|
||||
memoryFlushAt?: number;
|
||||
}
|
||||
|
||||
interface SessionStats {
|
||||
total: number;
|
||||
totalTokens: number;
|
||||
compactions: number;
|
||||
memoryFlushes: number;
|
||||
}
|
||||
|
||||
export interface IntelligenceStats {
|
||||
sessions: SessionStats | null;
|
||||
memoryFiles: number | null;
|
||||
entities: Record<string, number> | null;
|
||||
entityError: boolean;
|
||||
aiStatus: AIStatus;
|
||||
isLoading: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
const ENTITY_TYPES = ['contact', 'chat', 'message', 'wallet', 'token', 'transaction'];
|
||||
|
||||
export function useIntelligenceStats(): IntelligenceStats {
|
||||
const aiStatus = useAppSelector(state => state.ai.status);
|
||||
const [sessions, setSessions] = useState<SessionStats | null>(null);
|
||||
const [memoryFiles, setMemoryFiles] = useState<number | null>(null);
|
||||
const [entities, setEntities] = useState<Record<string, number> | null>(null);
|
||||
const [entityError, setEntityError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
// Fetch local stats (Tauri invoke)
|
||||
try {
|
||||
const index = await invoke<Record<string, SessionEntry>>('ai_sessions_load_index');
|
||||
const entries = Object.values(index);
|
||||
setSessions({
|
||||
total: entries.length,
|
||||
totalTokens: entries.reduce((sum, e) => sum + (e.totalTokens || 0), 0),
|
||||
compactions: entries.reduce((sum, e) => sum + (e.compactionCount || 0), 0),
|
||||
memoryFlushes: entries.filter(e => e.memoryFlushAt).length,
|
||||
});
|
||||
} catch {
|
||||
setSessions(null);
|
||||
}
|
||||
|
||||
try {
|
||||
const files = await invoke<string[]>('ai_list_memory_files', { relativeDir: 'memory' });
|
||||
setMemoryFiles(files.length);
|
||||
} catch {
|
||||
setMemoryFiles(null);
|
||||
}
|
||||
|
||||
// Fetch entity counts from backend API (graceful degradation)
|
||||
try {
|
||||
const counts: Record<string, number> = {};
|
||||
const results = await Promise.allSettled(
|
||||
ENTITY_TYPES.map(async type => {
|
||||
const resp = await apiClient.get<{ count?: number; total?: number; data?: unknown[] }>(
|
||||
`/api/entity-graph/entities?type=${type}&limit=1`
|
||||
);
|
||||
return { type, count: resp.count ?? resp.total ?? (resp.data ? resp.data.length : 0) };
|
||||
})
|
||||
);
|
||||
|
||||
let anySuccess = false;
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
counts[result.value.type] = result.value.count;
|
||||
anySuccess = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (anySuccess) {
|
||||
setEntities(counts);
|
||||
setEntityError(false);
|
||||
} else {
|
||||
setEntities(null);
|
||||
setEntityError(true);
|
||||
}
|
||||
} catch {
|
||||
setEntities(null);
|
||||
setEntityError(true);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, [fetchStats, aiStatus]);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
memoryFiles,
|
||||
entities,
|
||||
entityError,
|
||||
aiStatus,
|
||||
isLoading,
|
||||
refetch: fetchStats,
|
||||
};
|
||||
}
|
||||
@@ -233,6 +233,18 @@ class SkillManager {
|
||||
return runtime.listOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a manual sync for a running skill.
|
||||
* Progress updates are published to Redux via the skill's state fields.
|
||||
*/
|
||||
async triggerSync(skillId: string): Promise<void> {
|
||||
const runtime = this.runtimes.get(skillId);
|
||||
if (!runtime) {
|
||||
throw new Error(`Skill ${skillId} is not running`);
|
||||
}
|
||||
await runtime.triggerSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a single option on a running skill.
|
||||
*/
|
||||
|
||||
@@ -152,6 +152,14 @@ export class SkillRuntime {
|
||||
return this.transport.request<PingResult | null>("skill/ping");
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger the skill's onSync lifecycle hook.
|
||||
* Progress updates flow via published state fields, not the RPC response.
|
||||
*/
|
||||
async triggerSync(): Promise<unknown> {
|
||||
return this.transport.request("skill/sync");
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger periodic tick.
|
||||
*/
|
||||
|
||||
+405
-22
@@ -1,29 +1,412 @@
|
||||
import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import {
|
||||
addOptimisticMessage,
|
||||
clearSelectedThread,
|
||||
createThread,
|
||||
fetchThreadMessages,
|
||||
fetchThreads,
|
||||
purgeThreads,
|
||||
sendMessage,
|
||||
setSelectedThread,
|
||||
} from '../store/threadSlice';
|
||||
|
||||
const MIN_PANEL_WIDTH = 200;
|
||||
const MAX_PANEL_WIDTH = 480;
|
||||
const DEFAULT_PANEL_WIDTH = 320;
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(dateStr).getTime();
|
||||
const diffMs = now - then;
|
||||
if (diffMs < 60_000) return 'just now';
|
||||
const mins = Math.floor(diffMs / 60_000);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
const Conversations = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const {
|
||||
threads,
|
||||
isLoading,
|
||||
selectedThreadId,
|
||||
messages,
|
||||
isLoadingMessages,
|
||||
createStatus,
|
||||
purgeStatus,
|
||||
sendStatus,
|
||||
sendError,
|
||||
} = useAppSelector(state => state.thread);
|
||||
|
||||
const [showPurgeConfirm, setShowPurgeConfirm] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
|
||||
const isDragging = useRef(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleResizePointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
isDragging.current = true;
|
||||
const startX = e.clientX;
|
||||
const startWidth = panelWidth;
|
||||
|
||||
const onPointerMove = (ev: globalThis.PointerEvent) => {
|
||||
const delta = ev.clientX - startX;
|
||||
const newWidth = Math.min(MAX_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, startWidth + delta));
|
||||
setPanelWidth(newWidth);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
isDragging.current = false;
|
||||
document.removeEventListener('pointermove', onPointerMove);
|
||||
document.removeEventListener('pointerup', onPointerUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
document.addEventListener('pointermove', onPointerMove);
|
||||
document.addEventListener('pointerup', onPointerUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}, [panelWidth]);
|
||||
|
||||
// Fetch threads on mount
|
||||
useEffect(() => {
|
||||
dispatch(fetchThreads());
|
||||
}, [dispatch]);
|
||||
|
||||
// Fetch messages when a thread is selected
|
||||
useEffect(() => {
|
||||
if (selectedThreadId) {
|
||||
dispatch(fetchThreadMessages(selectedThreadId));
|
||||
}
|
||||
}, [dispatch, selectedThreadId]);
|
||||
|
||||
// Auto-scroll to bottom when messages load
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const handleSelectThread = (threadId: string) => {
|
||||
if (threadId === selectedThreadId) return;
|
||||
dispatch(setSelectedThread(threadId));
|
||||
};
|
||||
|
||||
const handleNewThread = () => {
|
||||
dispatch(createThread(undefined));
|
||||
};
|
||||
|
||||
const handlePurge = async () => {
|
||||
const result = await dispatch(purgeThreads());
|
||||
if (purgeThreads.fulfilled.match(result)) {
|
||||
setShowPurgeConfirm(false);
|
||||
dispatch(clearSelectedThread());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = () => {
|
||||
const trimmed = inputValue.trim();
|
||||
if (!trimmed || !selectedThreadId || sendStatus === 'loading') return;
|
||||
dispatch(addOptimisticMessage({ content: trimmed }));
|
||||
setInputValue('');
|
||||
dispatch(sendMessage({ threadId: selectedThreadId, message: trimmed }));
|
||||
};
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const selectedThread = threads.find(t => t.id === selectedThreadId);
|
||||
|
||||
return (
|
||||
<div className="min-h-full relative">
|
||||
<div className="relative z-10 min-h-full flex flex-col">
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="glass rounded-3xl p-8 shadow-large animate-fade-up text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<svg
|
||||
className="w-12 h-12 text-primary-400 opacity-60"
|
||||
fill="none"
|
||||
<div className="h-full relative z-10 flex overflow-hidden">
|
||||
{/* Left Panel: Thread List */}
|
||||
<div className="flex-shrink-0 flex flex-col" style={{ width: panelWidth }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
|
||||
<h2 className="text-sm font-semibold">Conversations</h2>
|
||||
<button
|
||||
onClick={handleNewThread}
|
||||
disabled={createStatus === 'loading'}
|
||||
className="p-1.5 rounded-lg hover:bg-white/10 transition-colors text-stone-400 hover:text-stone-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="New Thread">
|
||||
{createStatus === 'loading' ? (
|
||||
<svg className="w-4.5 h-4.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold mb-2">Conversations</h1>
|
||||
<p className="text-sm opacity-60">Your conversations will appear here</p>
|
||||
</div>
|
||||
</div>
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4.5 h-4.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Thread list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="space-y-1 p-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-16 bg-white/5 rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : threads.length > 0 ? (
|
||||
<div className="py-1">
|
||||
{threads.map(thread => (
|
||||
<button
|
||||
key={thread.id}
|
||||
onClick={() => handleSelectThread(thread.id)}
|
||||
className={`w-full text-left py-3 px-4 transition-colors cursor-pointer ${
|
||||
thread.id === selectedThreadId
|
||||
? 'bg-white/10'
|
||||
: 'hover:bg-white/[0.07]'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
|
||||
thread.isActive ? 'bg-sage-500' : 'bg-stone-600'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium truncate">
|
||||
{thread.title || 'Untitled Thread'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pl-3.5">
|
||||
<span className="text-xs text-stone-500">
|
||||
{thread.messageCount} message{thread.messageCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span className="text-xs text-stone-600">
|
||||
{formatRelativeTime(thread.lastMessageAt || thread.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center py-16 px-4">
|
||||
<svg
|
||||
className="w-10 h-10 text-stone-600 mb-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-stone-500 mb-3">No conversations yet</p>
|
||||
<button
|
||||
onClick={handleNewThread}
|
||||
disabled={createStatus === 'loading'}
|
||||
className="text-xs text-primary-400 hover:text-primary-300 transition-colors disabled:opacity-50">
|
||||
Start a new conversation
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: Delete All */}
|
||||
{threads.length > 0 && (
|
||||
<div className="px-4 py-3 border-t border-white/10">
|
||||
{showPurgeConfirm ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-stone-400">Delete all threads?</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowPurgeConfirm(false)}
|
||||
className="text-xs text-stone-500 hover:text-stone-300 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePurge}
|
||||
disabled={purgeStatus === 'loading'}
|
||||
className="text-xs text-coral-500 hover:text-coral-400 transition-colors disabled:opacity-50">
|
||||
{purgeStatus === 'loading' ? 'Deleting...' : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowPurgeConfirm(true)}
|
||||
className="text-xs text-coral-500/70 hover:text-coral-500 transition-colors">
|
||||
Delete All Threads
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
onPointerDown={handleResizePointerDown}
|
||||
className="w-1 flex-shrink-0 cursor-col-resize bg-white/10 hover:bg-primary-500/40 active:bg-primary-500/60 transition-colors"
|
||||
/>
|
||||
|
||||
{/* Right Panel: Messages */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{selectedThread ? (
|
||||
<>
|
||||
{/* Thread header */}
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b border-white/10">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold truncate">
|
||||
{selectedThread.title || 'Untitled Thread'}
|
||||
</h3>
|
||||
{selectedThread.isActive && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-sage-500/20 text-sage-500 flex-shrink-0">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-stone-500 mt-0.5">
|
||||
Created {formatRelativeTime(selectedThread.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{isLoadingMessages ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex ${i % 2 === 0 ? 'justify-start' : 'justify-end'}`}>
|
||||
<div
|
||||
className={`h-12 rounded-2xl animate-pulse bg-white/5 ${
|
||||
i % 2 === 0 ? 'w-2/3' : 'w-1/2'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : messages.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{messages.map(msg => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.sender === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
className={`max-w-[75%] rounded-2xl px-4 py-2.5 ${
|
||||
msg.sender === 'user'
|
||||
? 'bg-primary-600/20 rounded-br-md'
|
||||
: 'bg-white/5 rounded-bl-md'
|
||||
}`}>
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{msg.content}</p>
|
||||
<p
|
||||
className={`text-[10px] mt-1 ${
|
||||
msg.sender === 'user' ? 'text-primary-400/50' : 'text-stone-600'
|
||||
}`}>
|
||||
{formatRelativeTime(msg.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center h-full">
|
||||
<p className="text-sm text-stone-600">No messages in this thread</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message Input */}
|
||||
<div className="flex-shrink-0 border-t border-white/10 px-4 py-3">
|
||||
{sendError && (
|
||||
<p className="text-xs text-coral-500 mb-2">{sendError}</p>
|
||||
)}
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder="Type a message..."
|
||||
rows={1}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!inputValue.trim() || sendStatus === 'loading'}
|
||||
className="p-2.5 rounded-xl bg-primary-600 hover:bg-primary-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex-shrink-0">
|
||||
{sendStatus === 'loading' ? (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 12h14M12 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Empty state — no thread selected */
|
||||
<div className="flex-1 flex flex-col items-center justify-center">
|
||||
<svg
|
||||
className="w-12 h-12 text-stone-700 mb-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-stone-600">Select a conversation</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { platform } from '@tauri-apps/plugin-os';
|
||||
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 { useIntelligenceStats } from '../hooks/useIntelligenceStats';
|
||||
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';
|
||||
|
||||
/** 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;
|
||||
onSync: () => 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 [syncing, setSyncing] = useState(false);
|
||||
|
||||
const handleSync = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSyncing(true);
|
||||
try {
|
||||
await skillManager.triggerSync(skill.id);
|
||||
} catch (err) {
|
||||
console.error(`Sync failed for ${skill.id}:`, err);
|
||||
} finally {
|
||||
setSyncing(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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{connectionStatus === 'connected' && (
|
||||
<>
|
||||
{/* Sync */}
|
||||
<button
|
||||
onClick={syncing ? undefined : handleSync}
|
||||
disabled={syncing}
|
||||
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 ${syncing ? '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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stat Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
// function StatCard({ title, value, subtitle }: { title: string; value: string; subtitle?: string }) {
|
||||
// return (
|
||||
// <div className="glass rounded-2xl p-4">
|
||||
// <div className="text-xs font-medium text-stone-400 uppercase tracking-wider">{title}</div>
|
||||
// <div className="text-2xl font-bold text-white font-mono mt-1">{value}</div>
|
||||
// {subtitle && <div className="text-[11px] text-stone-500 mt-0.5">{subtitle}</div>}
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// ─── Main Intelligence Page ─────────────────────────────────────────────────
|
||||
|
||||
export default function Intelligence() {
|
||||
// const { sessions, memoryFiles, entities, entityError, aiStatus, isLoading } =
|
||||
// useIntelligenceStats();
|
||||
const { aiStatus } = useIntelligenceStats();
|
||||
|
||||
// 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 [managementModalOpen, setManagementModalOpen] = 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 {
|
||||
// Check if mobile
|
||||
try {
|
||||
const p = await platform();
|
||||
if (p === 'android' || p === 'ios') {
|
||||
setSkillsLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// not Tauri env
|
||||
}
|
||||
|
||||
const manifests = await invoke<Array<Record<string, unknown>>>('runtime_discover_skills');
|
||||
const validManifests = manifests.filter(m => {
|
||||
const id = m.id as string;
|
||||
if (id.includes('_')) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
// AI status indicator
|
||||
const aiStatusLabel =
|
||||
aiStatus === 'ready'
|
||||
? 'AI Ready'
|
||||
: aiStatus === 'initializing'
|
||||
? 'Initializing...'
|
||||
: aiStatus === 'error'
|
||||
? 'AI Error'
|
||||
: 'AI Idle';
|
||||
const aiStatusDot =
|
||||
aiStatus === 'ready'
|
||||
? 'bg-sage-400'
|
||||
: aiStatus === 'initializing'
|
||||
? 'bg-amber-400 animate-pulse'
|
||||
: aiStatus === 'error'
|
||||
? 'bg-coral-400'
|
||||
: 'bg-stone-600';
|
||||
|
||||
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">Intelligence</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${aiStatusDot}`} />
|
||||
<span className="text-xs text-stone-400">{aiStatusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat Cards */}
|
||||
{/* <div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-6 animate-fade-up">
|
||||
<StatCard
|
||||
title="Sessions"
|
||||
value={isLoading ? '...' : sessions ? String(sessions.total) : '0'}
|
||||
subtitle="sessions"
|
||||
/>
|
||||
<StatCard
|
||||
title="Memory"
|
||||
value={isLoading ? '...' : memoryFiles != null ? String(memoryFiles) : '0'}
|
||||
subtitle="files"
|
||||
/>
|
||||
<StatCard
|
||||
title="Tokens"
|
||||
value={
|
||||
isLoading ? '...' : sessions ? formatNumber(sessions.totalTokens) : '0'
|
||||
}
|
||||
subtitle="consumed"
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
{/* Active Skills */}
|
||||
<div className="animate-fade-up" style={{ animationDelay: '100ms' }}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-white opacity-80">Active Skills</h2>
|
||||
<button
|
||||
onClick={() => setManagementModalOpen(true)}
|
||||
className="text-xs text-primary-400 hover:text-primary-300 transition-colors">
|
||||
Manage Skills
|
||||
</button>
|
||||
</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)}
|
||||
onSync={() => {
|
||||
skillManager.triggerSync(skill.id).catch(console.error);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Setup modal */}
|
||||
{setupModalOpen && activeSkillId && (
|
||||
<SkillSetupModal
|
||||
skillId={activeSkillId}
|
||||
skillName={activeSkillName}
|
||||
skillDescription={activeSkillDescription}
|
||||
hasSetup={activeSkillHasSetup}
|
||||
onClose={() => {
|
||||
setSetupModalOpen(false);
|
||||
setActiveSkillId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Skills Management Modal */}
|
||||
{managementModalOpen && (
|
||||
<ManagementModal
|
||||
skills={sortedSkillsList}
|
||||
onClose={() => setManagementModalOpen(false)}
|
||||
onOpenSetup={openSkillSetup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Management Modal (reused from SkillsGrid pattern) ─────────────────────
|
||||
|
||||
function ManagementModal({
|
||||
skills,
|
||||
onClose,
|
||||
onOpenSetup,
|
||||
}: {
|
||||
skills: SkillListEntry[];
|
||||
onClose: () => void;
|
||||
onOpenSetup: (skill: SkillListEntry) => void;
|
||||
}) {
|
||||
const skillsState = useAppSelector(state => state.skills.skills);
|
||||
const skillStates = useAppSelector(state => state.skills.skillStates);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 animate-fade-in"
|
||||
onClick={onClose}>
|
||||
<div
|
||||
className="bg-stone-900 rounded-2xl max-w-2xl w-full max-h-[80vh] shadow-large border border-stone-700/50 flex flex-col overflow-hidden animate-slide-up"
|
||||
onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 pb-4 border-b border-stone-700/50 flex-shrink-0 bg-stone-900">
|
||||
<h2 className="text-xl font-semibold text-white">Manage Skills</h2>
|
||||
<button onClick={onClose} className="text-stone-400 hover:text-white transition-colors">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="overflow-y-auto flex-1 p-6 pt-4">
|
||||
<div className="space-y-2">
|
||||
{skills.map(skill => {
|
||||
const skillState = skillsState[skill.id];
|
||||
const stateData = skillStates[skill.id];
|
||||
const connectionStatus: SkillConnectionStatus = deriveConnectionStatus(
|
||||
skillState?.status,
|
||||
skillState?.setupComplete,
|
||||
stateData
|
||||
);
|
||||
const statusDisplay = STATUS_DISPLAY[connectionStatus] || STATUS_DISPLAY.offline;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={skill.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-stone-800/30 border border-stone-700/30 hover:bg-stone-800/50 transition-colors">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="w-8 h-8 flex items-center justify-center text-white opacity-70 flex-shrink-0">
|
||||
{skill.icon || <DefaultIcon />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium text-white">{skill.name}</div>
|
||||
<span className={`text-xs ${statusDisplay.color}`}>
|
||||
{statusDisplay.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-stone-400">{skill.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<SkillActionButton
|
||||
skill={skill}
|
||||
connectionStatus={connectionStatus}
|
||||
onOpenModal={() => onOpenSetup(skill)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ApiResponse } from '../../types/api';
|
||||
import type {
|
||||
PurgeRequestBody,
|
||||
PurgeResultData,
|
||||
SendMessageData,
|
||||
ThreadCreateData,
|
||||
ThreadMessagesData,
|
||||
ThreadsListData,
|
||||
} from '../../types/thread';
|
||||
import { apiClient } from '../apiClient';
|
||||
|
||||
export const threadApi = {
|
||||
/** GET /telegram/threads — list all threads for the authenticated user */
|
||||
getThreads: async (): Promise<ThreadsListData> => {
|
||||
const response = await apiClient.get<ApiResponse<ThreadsListData>>('/telegram/threads');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** POST /telegram/threads — create a new thread */
|
||||
createThread: async (chatId?: number): Promise<ThreadCreateData> => {
|
||||
const response = await apiClient.post<ApiResponse<ThreadCreateData>>(
|
||||
'/telegram/threads',
|
||||
chatId != null ? { chatId } : undefined
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** GET /telegram/threads/:threadId/messages — get messages for a thread */
|
||||
getThreadMessages: async (threadId: string): Promise<ThreadMessagesData> => {
|
||||
const response = await apiClient.get<ApiResponse<ThreadMessagesData>>(
|
||||
`/telegram/threads/${encodeURIComponent(threadId)}/messages`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** POST /chat/autocomplete — send a user message to a thread and get the agent response */
|
||||
sendMessage: async (message: string, conversationId: string): Promise<SendMessageData> => {
|
||||
const response = await apiClient.post<ApiResponse<SendMessageData>>('/chat/autocomplete', {
|
||||
message,
|
||||
conversationId,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** POST /telegram/purge — purge messages and/or threads */
|
||||
purge: async (body: PurgeRequestBody): Promise<PurgeResultData> => {
|
||||
const response = await apiClient.post<ApiResponse<PurgeResultData>>('/telegram/purge', body);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { store } from '../store';
|
||||
import type { ApiError } from '../types/api';
|
||||
import { BACKEND_URL } from '../utils/config';
|
||||
|
||||
@@ -11,6 +10,19 @@ interface RequestOptions {
|
||||
requireAuth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy store accessor to break the circular dependency:
|
||||
* store/index → slices → api services → apiClient → store/index
|
||||
*
|
||||
* The store registers itself via `setStoreForApiClient` after creation,
|
||||
* so apiClient never imports store/index at module level.
|
||||
*/
|
||||
let _getToken: (() => string | null) | null = null;
|
||||
|
||||
export function setStoreForApiClient(getToken: () => string | null) {
|
||||
_getToken = getToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Client for making requests to the backend
|
||||
* Handles authentication, error handling, and response typing
|
||||
@@ -22,11 +34,8 @@ class ApiClient {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current JWT token from Redux store
|
||||
*/
|
||||
private getToken(): string | null {
|
||||
return store.getState().auth.token;
|
||||
return _getToken ? _getToken() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'redux-persist';
|
||||
import storage from 'redux-persist/lib/storage';
|
||||
|
||||
import { setStoreForApiClient } from '../services/apiClient';
|
||||
import { IS_DEV } from '../utils/config';
|
||||
import { storeSession } from '../utils/tauriCommands';
|
||||
import aiReducer from './aiSlice';
|
||||
@@ -20,6 +21,7 @@ import inviteReducer from './inviteSlice';
|
||||
import skillsReducer from './skillsSlice';
|
||||
import socketReducer from './socketSlice';
|
||||
import teamReducer from './teamSlice';
|
||||
import threadReducer from './threadSlice';
|
||||
import userReducer from './userSlice';
|
||||
|
||||
// Persist config for auth only
|
||||
@@ -78,6 +80,7 @@ export const store = configureStore({
|
||||
ai: persistedAiReducer,
|
||||
skills: persistedSkillsReducer,
|
||||
team: teamReducer,
|
||||
thread: threadReducer,
|
||||
invite: inviteReducer,
|
||||
},
|
||||
middleware: getDefaultMiddleware => {
|
||||
@@ -93,6 +96,9 @@ export const store = configureStore({
|
||||
},
|
||||
});
|
||||
|
||||
// Wire up the apiClient so it can read the token without a circular import
|
||||
setStoreForApiClient(() => store.getState().auth.token);
|
||||
|
||||
export const persistor = persistStore(store, null, () => {
|
||||
// Dev-only: auto-inject JWT token for testing (e.g. Android without login flow)
|
||||
const devToken = import.meta.env.VITE_DEV_JWT_TOKEN;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { threadApi } from '../services/api/threadApi';
|
||||
import type { Thread, ThreadMessage } from '../types/thread';
|
||||
|
||||
interface ThreadState {
|
||||
threads: Thread[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
selectedThreadId: string | null;
|
||||
messages: ThreadMessage[];
|
||||
isLoadingMessages: boolean;
|
||||
messagesError: string | null;
|
||||
createStatus: 'idle' | 'loading' | 'success' | 'error';
|
||||
purgeStatus: 'idle' | 'loading' | 'success' | 'error';
|
||||
sendStatus: 'idle' | 'loading' | 'success' | 'error';
|
||||
sendError: string | null;
|
||||
}
|
||||
|
||||
const initialState: ThreadState = {
|
||||
threads: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedThreadId: null,
|
||||
messages: [],
|
||||
isLoadingMessages: false,
|
||||
messagesError: null,
|
||||
createStatus: 'idle',
|
||||
purgeStatus: 'idle',
|
||||
sendStatus: 'idle',
|
||||
sendError: null,
|
||||
};
|
||||
|
||||
export const fetchThreads = createAsyncThunk(
|
||||
'thread/fetchThreads',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const data = await threadApi.getThreads();
|
||||
return data.threads;
|
||||
} catch (error) {
|
||||
const msg =
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? String(error.error)
|
||||
: 'Failed to fetch threads';
|
||||
return rejectWithValue(msg);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchThreadMessages = createAsyncThunk(
|
||||
'thread/fetchThreadMessages',
|
||||
async (threadId: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const data = await threadApi.getThreadMessages(threadId);
|
||||
return data.messages;
|
||||
} catch (error) {
|
||||
const msg =
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? String(error.error)
|
||||
: 'Failed to fetch messages';
|
||||
return rejectWithValue(msg);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const createThread = createAsyncThunk(
|
||||
'thread/createThread',
|
||||
async (chatId: number | undefined, { dispatch, rejectWithValue }) => {
|
||||
try {
|
||||
const data = await threadApi.createThread(chatId);
|
||||
dispatch(fetchThreads());
|
||||
return data;
|
||||
} catch (error) {
|
||||
const msg =
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? String(error.error)
|
||||
: 'Failed to create thread';
|
||||
return rejectWithValue(msg);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const purgeThreads = createAsyncThunk(
|
||||
'thread/purgeThreads',
|
||||
async (_, { dispatch, rejectWithValue }) => {
|
||||
try {
|
||||
const data = await threadApi.purge({
|
||||
messages: false,
|
||||
agentThreads: true,
|
||||
deleteEverything: true,
|
||||
});
|
||||
dispatch(fetchThreads());
|
||||
return data;
|
||||
} catch (error) {
|
||||
const msg =
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? String(error.error)
|
||||
: 'Failed to purge threads';
|
||||
return rejectWithValue(msg);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const sendMessage = createAsyncThunk(
|
||||
'thread/sendMessage',
|
||||
async (
|
||||
{ threadId, message }: { threadId: string; message: string },
|
||||
{ dispatch, rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const data = await threadApi.sendMessage(message, threadId);
|
||||
// Re-fetch messages to get the stored user message + agent response
|
||||
dispatch(fetchThreadMessages(threadId));
|
||||
// Re-fetch threads to update lastMessageAt / messageCount in the list
|
||||
dispatch(fetchThreads());
|
||||
return data;
|
||||
} catch (error) {
|
||||
const msg =
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? String(error.error)
|
||||
: 'Failed to send message';
|
||||
return rejectWithValue(msg);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const threadSlice = createSlice({
|
||||
name: 'thread',
|
||||
initialState,
|
||||
reducers: {
|
||||
setSelectedThread: (state, action: { payload: string }) => {
|
||||
state.selectedThreadId = action.payload;
|
||||
state.messages = [];
|
||||
state.messagesError = null;
|
||||
},
|
||||
clearSelectedThread: state => {
|
||||
state.selectedThreadId = null;
|
||||
state.messages = [];
|
||||
state.messagesError = null;
|
||||
},
|
||||
clearCreateStatus: state => {
|
||||
state.createStatus = 'idle';
|
||||
},
|
||||
clearPurgeStatus: state => {
|
||||
state.purgeStatus = 'idle';
|
||||
},
|
||||
addOptimisticMessage: (state, action: { payload: { content: string } }) => {
|
||||
state.messages.push({
|
||||
id: `optimistic-${Date.now()}`,
|
||||
content: action.payload.content,
|
||||
type: 'text',
|
||||
extraMetadata: {},
|
||||
sender: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
clearSendError: state => {
|
||||
state.sendError = null;
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
// fetchThreads
|
||||
.addCase(fetchThreads.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchThreads.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.threads = action.payload;
|
||||
})
|
||||
.addCase(fetchThreads.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// fetchThreadMessages
|
||||
.addCase(fetchThreadMessages.pending, state => {
|
||||
state.isLoadingMessages = true;
|
||||
state.messagesError = null;
|
||||
})
|
||||
.addCase(fetchThreadMessages.fulfilled, (state, action) => {
|
||||
state.isLoadingMessages = false;
|
||||
state.messages = action.payload;
|
||||
})
|
||||
.addCase(fetchThreadMessages.rejected, (state, action) => {
|
||||
state.isLoadingMessages = false;
|
||||
state.messagesError = action.payload as string;
|
||||
})
|
||||
// createThread
|
||||
.addCase(createThread.pending, state => {
|
||||
state.createStatus = 'loading';
|
||||
})
|
||||
.addCase(createThread.fulfilled, (state, action) => {
|
||||
state.createStatus = 'success';
|
||||
state.selectedThreadId = action.payload.id;
|
||||
state.messages = [];
|
||||
state.messagesError = null;
|
||||
})
|
||||
.addCase(createThread.rejected, state => {
|
||||
state.createStatus = 'error';
|
||||
})
|
||||
// purgeThreads
|
||||
.addCase(purgeThreads.pending, state => {
|
||||
state.purgeStatus = 'loading';
|
||||
})
|
||||
.addCase(purgeThreads.fulfilled, state => {
|
||||
state.purgeStatus = 'success';
|
||||
state.selectedThreadId = null;
|
||||
state.messages = [];
|
||||
})
|
||||
.addCase(purgeThreads.rejected, state => {
|
||||
state.purgeStatus = 'error';
|
||||
})
|
||||
// sendMessage
|
||||
.addCase(sendMessage.pending, state => {
|
||||
state.sendStatus = 'loading';
|
||||
state.sendError = null;
|
||||
})
|
||||
.addCase(sendMessage.fulfilled, state => {
|
||||
state.sendStatus = 'success';
|
||||
})
|
||||
.addCase(sendMessage.rejected, (state, action) => {
|
||||
state.sendStatus = 'error';
|
||||
state.sendError = action.payload as string;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setSelectedThread,
|
||||
clearSelectedThread,
|
||||
clearCreateStatus,
|
||||
clearPurgeStatus,
|
||||
addOptimisticMessage,
|
||||
clearSendError,
|
||||
} = threadSlice.actions;
|
||||
export default threadSlice.reducer;
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface Thread {
|
||||
id: string;
|
||||
title: string;
|
||||
chatId: number | null;
|
||||
isActive: boolean;
|
||||
messageCount: number;
|
||||
lastMessageAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ThreadMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
type: string;
|
||||
extraMetadata: Record<string, unknown>;
|
||||
sender: 'user' | 'agent';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ThreadsListData {
|
||||
threads: Thread[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ThreadMessagesData {
|
||||
messages: ThreadMessage[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ThreadCreateData {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface SendMessageData {
|
||||
suggestions: Array<{ text: string; confidence: number }>;
|
||||
}
|
||||
|
||||
export interface PurgeRequestBody {
|
||||
messages: boolean;
|
||||
agentThreads: boolean;
|
||||
deleteEverything: boolean;
|
||||
deleteFrom?: string;
|
||||
deleteTo?: string;
|
||||
}
|
||||
|
||||
export interface PurgeResultData {
|
||||
messagesDeleted: number;
|
||||
agentThreadsDeleted: number;
|
||||
agentMessagesDeleted: number;
|
||||
}
|
||||
Reference in New Issue
Block a user