Merge pull request #103 from vezuresdotxyz/develop

chore: merge develop into main (v0.42.0)
This commit is contained in:
Steven Enamakel
2026-02-11 17:57:59 +05:30
committed by GitHub
18 changed files with 1684 additions and 269 deletions
+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
View File
@@ -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 || {}),
});
};
+11
View File
@@ -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"
+16 -15
View File
@@ -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
View File
@@ -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">
+169
View File
@@ -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>
);
}
+117
View File
@@ -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,
};
}
+12
View File
@@ -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.
*/
+8
View File
@@ -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
View File
@@ -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>
);
+468
View File
@@ -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>
);
}
+50
View File
@@ -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;
},
};
+14 -5
View File
@@ -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;
}
/**
+6
View File
@@ -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;
+237
View File
@@ -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;
+50
View File
@@ -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;
}