Merge remote-tracking branch 'origin/main' into feat/telegram-channel

# Conflicts:
#	apps/desktop/electron/electron-env.d.ts
#	apps/desktop/electron/ipc/index.ts
#	apps/desktop/electron/preload.ts
#	apps/desktop/src/App.tsx
#	apps/desktop/src/pages/layout.tsx
#	src/agent/async-agent.ts
#	src/agent/runner.ts
#	src/hub/hub.ts
This commit is contained in:
Naiyuan Qing 2026-02-09 13:44:08 +08:00
commit 23905daaa1
85 changed files with 7368 additions and 470 deletions

View file

@ -5,6 +5,7 @@ import ChatPage from './pages/chat'
import ToolsPage from './pages/tools'
import SkillsPage from './pages/skills'
import ChannelsPage from './pages/channels'
import CronsPage from './pages/crons'
const router = createHashRouter([
{
@ -16,6 +17,7 @@ const router = createHashRouter([
{ path: 'tools', element: <ToolsPage /> },
{ path: 'skills', element: <SkillsPage /> },
{ path: 'channels', element: <ChannelsPage /> },
{ path: 'crons', element: <CronsPage /> },
],
},
])

View file

@ -0,0 +1,215 @@
import { useState } from 'react'
import { Switch } from '@multica/ui/components/ui/switch'
import { Button } from '@multica/ui/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import {
RotateClockwiseIcon,
Delete02Icon,
Loading03Icon,
Time04Icon,
CheckmarkCircle02Icon,
CancelCircleIcon,
AlertCircleIcon,
} from '@hugeicons/core-free-icons'
import type { CronJobInfo } from '../hooks/use-cron-jobs'
interface CronJobListProps {
jobs: CronJobInfo[]
loading: boolean
error: string | null
onToggleJob: (jobId: string) => Promise<void>
onRemoveJob: (jobId: string) => Promise<void>
onRefresh: () => Promise<void>
}
function StatusBadge({ status }: { status: CronJobInfo['lastStatus'] }) {
if (!status) {
return (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
no runs
</span>
)
}
const config = {
ok: { icon: CheckmarkCircle02Icon, className: 'text-emerald-600', label: 'ok' },
error: { icon: CancelCircleIcon, className: 'text-destructive', label: 'error' },
skipped: { icon: AlertCircleIcon, className: 'text-yellow-600', label: 'skipped' },
}[status]
return (
<span className={`flex items-center gap-1 text-xs ${config.className}`}>
<HugeiconsIcon icon={config.icon} className="size-3.5" />
{config.label}
</span>
)
}
function formatRelativeTime(isoString: string): string {
const date = new Date(isoString)
const now = Date.now()
const diffMs = date.getTime() - now
if (Math.abs(diffMs) < 60_000) return 'just now'
const absMs = Math.abs(diffMs)
const minutes = Math.floor(absMs / 60_000)
const hours = Math.floor(absMs / 3_600_000)
const days = Math.floor(absMs / 86_400_000)
const unit = days > 0 ? `${days}d` : hours > 0 ? `${hours}h` : `${minutes}m`
return diffMs > 0 ? `in ${unit}` : `${unit} ago`
}
export function CronJobList({
jobs,
loading,
error,
onToggleJob,
onRemoveJob,
onRefresh,
}: CronJobListProps) {
const [togglingJobs, setTogglingJobs] = useState<Set<string>>(new Set())
const [removingJobs, setRemovingJobs] = useState<Set<string>>(new Set())
const handleToggle = async (jobId: string) => {
setTogglingJobs((prev) => new Set(prev).add(jobId))
try {
await onToggleJob(jobId)
} finally {
setTogglingJobs((prev) => {
const next = new Set(prev)
next.delete(jobId)
return next
})
}
}
const handleRemove = async (jobId: string) => {
setRemovingJobs((prev) => new Set(prev).add(jobId))
try {
await onRemoveJob(jobId)
} finally {
setRemovingJobs((prev) => {
const next = new Set(prev)
next.delete(jobId)
return next
})
}
}
if (loading && jobs.length === 0) {
return (
<div className="flex items-center justify-center py-12">
<HugeiconsIcon icon={Loading03Icon} className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading cron jobs...</span>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div className="text-sm text-muted-foreground">
{jobs.filter((j) => j.enabled).length} of {jobs.length} jobs enabled
</div>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
className="gap-1.5"
disabled={loading}
>
<HugeiconsIcon
icon={loading ? Loading03Icon : RotateClockwiseIcon}
className={`size-4 ${loading ? 'animate-spin' : ''}`}
/>
Refresh
</Button>
</div>
{/* Error */}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{/* Empty state */}
{jobs.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<HugeiconsIcon icon={Time04Icon} className="size-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">No scheduled tasks</p>
<p className="text-xs text-muted-foreground/70 mt-1">
Use the cron tool in Chat to create one.
</p>
</div>
)}
{/* Job list */}
{jobs.length > 0 && (
<div className="border rounded-lg divide-y">
{jobs.map((job) => {
const isToggling = togglingJobs.has(job.id)
const isRemoving = removingJobs.has(job.id)
return (
<div
key={job.id}
className="flex items-center gap-4 px-4 py-3 hover:bg-muted/20 transition-colors"
>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{job.name}</span>
<StatusBadge status={job.lastStatus} />
</div>
<div className="flex items-center gap-3 mt-0.5 text-xs text-muted-foreground">
<span className="font-mono">{job.schedule}</span>
{job.nextRunAt && job.enabled && (
<span>next: {formatRelativeTime(job.nextRunAt)}</span>
)}
{job.lastRunAt && (
<span>last: {formatRelativeTime(job.lastRunAt)}</span>
)}
</div>
{job.lastError && (
<p className="text-xs text-destructive mt-0.5 truncate">{job.lastError}</p>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => handleRemove(job.id)}
disabled={isRemoving}
>
{isRemoving ? (
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin" />
) : (
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
)}
</Button>
{isToggling && (
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin text-muted-foreground" />
)}
<Switch
checked={job.enabled}
onCheckedChange={() => handleToggle(job.id)}
disabled={isToggling}
/>
</div>
</div>
)
})}
</div>
)}
</div>
)
}
export default CronJobList

View file

@ -11,6 +11,8 @@ import {
ArrowDown01Icon,
ArrowUp01Icon,
Loading03Icon,
Time04Icon,
UserMultipleIcon,
} from '@hugeicons/core-free-icons'
import type { ToolInfo, ToolGroup } from '../hooks/use-tools'
@ -20,6 +22,8 @@ const GROUP_ICONS: Record<string, typeof FolderOpenIcon> = {
runtime: CodeIcon,
web: GlobalIcon,
memory: AiBrainIcon,
subagent: UserMultipleIcon,
cron: Time04Icon,
other: CodeIcon,
}

View file

@ -0,0 +1,107 @@
import { useState, useEffect, useCallback } from 'react'
export interface CronJobInfo {
id: string
name: string
description?: string
enabled: boolean
schedule: string
sessionTarget: string
nextRunAt: string | null
lastStatus: 'ok' | 'error' | 'skipped' | null
lastRunAt: string | null
lastDurationMs: number | null
lastError: string | null
}
export interface UseCronJobsReturn {
jobs: CronJobInfo[]
loading: boolean
error: string | null
toggleJob: (jobId: string) => Promise<void>
removeJob: (jobId: string) => Promise<void>
refresh: () => Promise<void>
}
export function useCronJobs(): UseCronJobsReturn {
const [jobs, setJobs] = useState<CronJobInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchJobs = useCallback(async () => {
try {
setLoading(true)
setError(null)
const result = window.electronAPI
? await window.electronAPI.cron.list()
: await window.ipcRenderer.invoke('cron:list')
if (Array.isArray(result)) {
setJobs(result)
} else {
setError('Invalid response from cron:list')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch cron jobs')
setJobs([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchJobs()
}, [fetchJobs])
const toggleJob = useCallback(async (jobId: string) => {
try {
const result = window.electronAPI
? await window.electronAPI.cron.toggle(jobId)
: await window.ipcRenderer.invoke('cron:toggle', jobId)
const typed = result as { error?: string; id?: string; enabled?: boolean }
if (typed.error) {
setError(typed.error)
return
}
setJobs((prev) =>
prev.map((job) =>
job.id === jobId ? { ...job, enabled: typed.enabled ?? !job.enabled } : job
)
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to toggle job')
}
}, [])
const removeJob = useCallback(async (jobId: string) => {
try {
const result = window.electronAPI
? await window.electronAPI.cron.remove(jobId)
: await window.ipcRenderer.invoke('cron:remove', jobId)
const typed = result as { error?: string; ok?: boolean }
if (typed.error) {
setError(typed.error)
return
}
setJobs((prev) => prev.filter((job) => job.id !== jobId))
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove job')
}
}, [])
return {
jobs,
loading,
error,
toggleJob,
removeJob,
refresh: fetchJobs,
}
}
export default useCronJobs

View file

@ -0,0 +1,70 @@
import { useCallback, useEffect, useState } from "react";
export type HeartbeatEvent = {
ts: number;
status: "sent" | "ok-empty" | "ok-token" | "skipped" | "failed";
preview?: string;
durationMs?: number;
reason?: string;
};
export function useHeartbeat() {
const [enabled, setEnabled] = useState(true);
const [lastEvent, setLastEvent] = useState<HeartbeatEvent | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
setLoading(true);
setError(null);
const event = (await window.electronAPI.heartbeat.last()) as HeartbeatEvent | null;
setLastEvent(event);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
const timer = setInterval(() => {
void refresh();
}, 15000);
return () => clearInterval(timer);
}, [refresh]);
const toggleEnabled = useCallback(async () => {
const next = !enabled;
const result = await window.electronAPI.heartbeat.setEnabled(next);
if (result.ok) {
setEnabled(next);
} else {
setError(result.error ?? "Failed to update heartbeat setting");
}
}, [enabled]);
const wakeNow = useCallback(async () => {
setLoading(true);
try {
const result = await window.electronAPI.heartbeat.wake("manual");
if (!result.ok) {
setError(result.error ?? "Failed to run heartbeat");
}
await refresh();
} finally {
setLoading(false);
}
}, [refresh]);
return {
enabled,
lastEvent,
loading,
error,
refresh,
toggleEnabled,
wakeNow,
};
}

View file

@ -31,6 +31,8 @@ const TOOL_DESCRIPTIONS: Record<string, string> = {
memory_set: 'Store a memory value',
memory_delete: 'Delete a memory value',
memory_list: 'List all memory keys',
memory_search: 'Search memory files for keywords',
cron: 'Create and manage scheduled tasks',
}
// Group display names
@ -39,6 +41,8 @@ const GROUP_NAMES: Record<string, string> = {
runtime: 'Runtime',
web: 'Web',
memory: 'Memory',
subagent: 'Subagent',
cron: 'Cron',
other: 'Other',
}

View file

@ -0,0 +1,43 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@multica/ui/components/ui/card'
import { useCronJobs } from '../hooks/use-cron-jobs'
import { CronJobList } from '../components/cron-job-list'
export default function CronsPage() {
const {
jobs,
loading,
error,
toggleJob,
removeJob,
refresh,
} = useCronJobs()
return (
<div className="max-w-4xl mx-auto">
<Card>
<CardHeader>
<CardTitle>Cron Jobs</CardTitle>
<CardDescription>
View and manage scheduled tasks. Create new jobs by asking the Agent in Chat.
</CardDescription>
</CardHeader>
<CardContent>
<CronJobList
jobs={jobs}
loading={loading}
error={error}
onToggleJob={toggleJob}
onRemoveJob={removeJob}
onRefresh={refresh}
/>
</CardContent>
</Card>
</div>
)
}

View file

@ -1,9 +1,9 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import {
Comment01Icon,
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import {
Comment01Icon,
LinkSquare01Icon,
Loading03Icon,
AlertCircleIcon,
@ -15,17 +15,17 @@ import {
import { ConnectionQRCode } from '../components/qr-code'
import { DeviceList } from '../components/device-list'
import { AgentSettingsDialog } from '../components/agent-settings-dialog'
import { ApiKeyDialog } from '../components/api-key-dialog'
import { OAuthDialog } from '../components/oauth-dialog'
import { useHub } from '../hooks/use-hub'
import { useProvider } from '../hooks/use-provider'
export default function HomePage() {
const navigate = useNavigate()
const { hubInfo, agents, loading, error } = useHub()
const { providers, current, setProvider, refresh, loading: providerLoading } = useProvider()
const [settingsOpen, setSettingsOpen] = useState(false)
const [agentName, setAgentName] = useState<string | undefined>()
import { ApiKeyDialog } from '../components/api-key-dialog'
import { OAuthDialog } from '../components/oauth-dialog'
import { useHub } from '../hooks/use-hub'
import { useProvider } from '../hooks/use-provider'
export default function HomePage() {
const navigate = useNavigate()
const { hubInfo, agents, loading, error } = useHub()
const { providers, current, setProvider, refresh, loading: providerLoading } = useProvider()
const [settingsOpen, setSettingsOpen] = useState(false)
const [agentName, setAgentName] = useState<string | undefined>()
const [providerDropdownOpen, setProviderDropdownOpen] = useState(false)
const [switching, setSwitching] = useState(false)
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false)
@ -186,8 +186,8 @@ export default function HomePage() {
<p className="font-medium">{agentName || 'Unnamed Agent'}</p>
</div>
{/* Provider Selector */}
<div className="p-4 rounded-lg bg-muted/50 border border-border/50 relative" ref={dropdownRef}>
{/* Provider Selector */}
<div className="p-4 rounded-lg bg-muted/50 border border-border/50 relative" ref={dropdownRef}>
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-2">
LLM Provider
</p>
@ -270,10 +270,10 @@ export default function HomePage() {
</div>
</div>
)}
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4">
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Gateway

View file

@ -9,6 +9,7 @@ import {
PlugIcon,
Comment01Icon,
Share08Icon,
Time04Icon,
} from '@hugeicons/core-free-icons'
import { cn } from '@multica/ui/lib/utils'
import { DeviceConfirmDialog } from '../components/device-confirm-dialog'
@ -20,6 +21,7 @@ const tabs = [
{ path: '/tools', label: 'Tools', icon: CodeIcon },
{ path: '/skills', label: 'Skills', icon: PlugIcon },
{ path: '/channels', label: 'Channels', icon: Share08Icon },
{ path: '/crons', label: 'Cron', icon: Time04Icon },
]
export default function Layout() {