@agentprojectcontext/apx 1.35.0 → 1.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -3
- package/package.json +1 -1
- package/src/core/stores/conversations.js +27 -2
- package/src/host/daemon/api/agents.js +6 -0
- package/src/host/daemon/api/conversations.js +9 -2
- package/src/host/daemon/api/exec.js +2 -0
- package/src/host/daemon/api/web.js +20 -1
- package/src/host/daemon/desktop-ws.js +31 -0
- package/src/host/daemon/index.js +12 -2
- package/src/interfaces/cli/commands/agent.js +20 -0
- package/src/interfaces/cli/commands/chat.js +15 -6
- package/src/interfaces/cli/commands/identity.js +20 -1
- package/src/interfaces/cli/commands/update.js +2 -0
- package/src/interfaces/cli/index.js +14 -0
- package/src/interfaces/web/dist/assets/index-B6sYFQFa.css +1 -0
- package/src/interfaces/web/dist/assets/index-DsADpObh.js +633 -0
- package/src/interfaces/web/dist/assets/index-DsADpObh.js.map +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/src/App.tsx +23 -4
- package/src/interfaces/web/src/components/chat/ChatList.tsx +412 -0
- package/src/interfaces/web/src/components/settings/AppearancePanel.tsx +1 -1
- package/src/interfaces/web/src/components/settings/MemoryPanel.tsx +1 -1
- package/src/interfaces/web/src/components/settings/SkillsInspectorPanel.tsx +1 -1
- package/src/interfaces/web/src/hooks/useChat.ts +35 -2
- package/src/interfaces/web/src/i18n/en.ts +13 -2
- package/src/interfaces/web/src/i18n/es.ts +13 -2
- package/src/interfaces/web/src/lib/api/agents.ts +1 -1
- package/src/interfaces/web/src/screens/ProjectScreen.tsx +3 -5
- package/src/interfaces/web/src/screens/SettingsScreen.tsx +6 -4
- package/src/interfaces/web/src/screens/modules/VoiceScreen.tsx +1 -1
- package/src/interfaces/web/src/screens/project/ChatTab.tsx +120 -87
- package/src/interfaces/web/dist/assets/index-C0fm31dY.js +0 -618
- package/src/interfaces/web/dist/assets/index-C0fm31dY.js.map +0 -1
- package/src/interfaces/web/dist/assets/index-UcAqlBO6.css +0 -1
- package/src/interfaces/web/src/screens/project/ThreadsTab.tsx +0 -100
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
<link rel="apple-touch-icon" href="/favicon/dark/apple-touch-icon.png" media="(prefers-color-scheme: dark)" />
|
|
19
19
|
<link rel="manifest" href="/favicon/white/site.webmanifest" media="(prefers-color-scheme: light)" />
|
|
20
20
|
<link rel="manifest" href="/favicon/dark/site.webmanifest" media="(prefers-color-scheme: dark)" />
|
|
21
|
-
<script type="module" crossorigin src="/assets/index-
|
|
22
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
21
|
+
<script type="module" crossorigin src="/assets/index-DsADpObh.js"></script>
|
|
22
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B6sYFQFa.css">
|
|
23
23
|
</head>
|
|
24
24
|
<body class="bg-background text-foreground antialiased">
|
|
25
25
|
<div id="root"></div>
|
|
@@ -13,6 +13,7 @@ import { AddProjectDialog } from "./components/AddProjectDialog";
|
|
|
13
13
|
import { PairingScreen } from "./screens/PairingScreen";
|
|
14
14
|
import { RobyBubble } from "./components/RobyBubble";
|
|
15
15
|
import { ToastProvider } from "./components/Toast";
|
|
16
|
+
import { Button } from "./components/ui/button";
|
|
16
17
|
import { TooltipProvider } from "./components/ui/tooltip";
|
|
17
18
|
import { useTheme } from "./hooks/useTheme";
|
|
18
19
|
import { useProjects } from "./hooks/useProjects";
|
|
@@ -142,6 +143,7 @@ function TopBar({
|
|
|
142
143
|
{pageActions}
|
|
143
144
|
<button
|
|
144
145
|
type="button"
|
|
146
|
+
data-testid="theme-toggle"
|
|
145
147
|
onClick={onToggleTheme}
|
|
146
148
|
title={isDark ? t("topbar.light") : t("topbar.dark")}
|
|
147
149
|
className="shrink-0 rounded-md p-1.5 text-muted-fg hover:bg-accent hover:text-accent-fg"
|
|
@@ -179,7 +181,6 @@ function settingsLabel(key?: string) {
|
|
|
179
181
|
function projectLabel(key?: string) {
|
|
180
182
|
switch (key) {
|
|
181
183
|
case "chat": return t("project.nav.chat");
|
|
182
|
-
case "threads": return t("project.nav.threads");
|
|
183
184
|
case "telegram": return t("project.nav.telegram");
|
|
184
185
|
case "agents": return t("project.nav.agents");
|
|
185
186
|
case "routines": return t("project.nav.routines");
|
|
@@ -211,10 +212,28 @@ function Splash({ text, sub }: { text: string; sub?: string }) {
|
|
|
211
212
|
}
|
|
212
213
|
|
|
213
214
|
function NotFound() {
|
|
215
|
+
const navigate = useNavigate();
|
|
216
|
+
// Roby, but lost — a confused riff on the Splash mascot (asymmetric eyes,
|
|
217
|
+
// little "o" mouth, a floating "?"). Tinted with the APX brand green.
|
|
218
|
+
const robyLost =
|
|
219
|
+
" ?\n ▄███████▄\n █ ██ ██ █\n █ ◑ ◐ █\n █ o █\n ▀███████▀";
|
|
214
220
|
return (
|
|
215
|
-
<div className="p-8">
|
|
216
|
-
<
|
|
217
|
-
|
|
221
|
+
<div className="grid h-full place-items-center p-8" data-testid="screen-not-found">
|
|
222
|
+
<div className="flex flex-col items-center text-center">
|
|
223
|
+
<pre
|
|
224
|
+
aria-hidden
|
|
225
|
+
className="mb-6 select-none whitespace-pre font-mono text-xs leading-none text-emerald-500"
|
|
226
|
+
>
|
|
227
|
+
{robyLost}
|
|
228
|
+
</pre>
|
|
229
|
+
<div className="font-mono text-7xl font-semibold leading-none tracking-tight text-foreground">
|
|
230
|
+
{t("not_found.title")}
|
|
231
|
+
</div>
|
|
232
|
+
<p className="mt-4 max-w-sm text-sm text-muted-fg">{t("not_found.message")}</p>
|
|
233
|
+
<Button variant="outline" className="mt-6" onClick={() => navigate("/")}>
|
|
234
|
+
{t("not_found.home")}
|
|
235
|
+
</Button>
|
|
236
|
+
</div>
|
|
218
237
|
</div>
|
|
219
238
|
);
|
|
220
239
|
}
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState, type ElementType } from "react";
|
|
2
|
+
import useSWR from "swr";
|
|
3
|
+
import clsx from "clsx";
|
|
4
|
+
import {
|
|
5
|
+
Bot,
|
|
6
|
+
ChevronDown,
|
|
7
|
+
ChevronRight,
|
|
8
|
+
Clock,
|
|
9
|
+
FolderOpen,
|
|
10
|
+
MessageSquare,
|
|
11
|
+
Mic,
|
|
12
|
+
Monitor,
|
|
13
|
+
Plus,
|
|
14
|
+
Send,
|
|
15
|
+
Sparkles,
|
|
16
|
+
Timer,
|
|
17
|
+
User,
|
|
18
|
+
} from "lucide-react";
|
|
19
|
+
import { Conversations } from "../../lib/api";
|
|
20
|
+
import { Input, Loading } from "../ui";
|
|
21
|
+
import { UiSelect } from "../UiSelect";
|
|
22
|
+
import { t } from "../../i18n";
|
|
23
|
+
import type { AgentEntry, ConversationListEntry } from "../../types/daemon";
|
|
24
|
+
|
|
25
|
+
// Channel taxonomy — same channels the daemon writes ("web", "voice",
|
|
26
|
+
// "desktop", "telegram", …) folded into 8 sidebar groups. Each group has an
|
|
27
|
+
// icon + a fixed display order so the sidebar is stable across reloads.
|
|
28
|
+
export type ChannelGroupKey =
|
|
29
|
+
| "live"
|
|
30
|
+
| "telegram"
|
|
31
|
+
| "voice"
|
|
32
|
+
| "desktop"
|
|
33
|
+
| "web"
|
|
34
|
+
| "a2a"
|
|
35
|
+
| "schedule"
|
|
36
|
+
| "other";
|
|
37
|
+
|
|
38
|
+
interface GroupMeta {
|
|
39
|
+
label: string;
|
|
40
|
+
icon: ElementType;
|
|
41
|
+
order: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const GROUP_META: Record<ChannelGroupKey, GroupMeta> = {
|
|
45
|
+
live: { label: "Live", icon: Sparkles, order: 0 },
|
|
46
|
+
telegram: { label: "Telegram", icon: Send, order: 1 },
|
|
47
|
+
voice: { label: "Voice", icon: Mic, order: 2 },
|
|
48
|
+
desktop: { label: "Desktop", icon: Monitor, order: 3 },
|
|
49
|
+
web: { label: "Web", icon: MessageSquare, order: 4 },
|
|
50
|
+
a2a: { label: "Agent ↔ Agent", icon: Bot, order: 5 },
|
|
51
|
+
schedule: { label: "Schedule", icon: Timer, order: 6 },
|
|
52
|
+
other: { label: "Other", icon: FolderOpen, order: 7 },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function channelGroup(channel?: string): ChannelGroupKey {
|
|
56
|
+
if (!channel) return "web";
|
|
57
|
+
const c = channel.toLowerCase();
|
|
58
|
+
if (c === "telegram") return "telegram";
|
|
59
|
+
if (c === "voice" || c === "overlay") return "voice";
|
|
60
|
+
if (c === "desktop") return "desktop";
|
|
61
|
+
if (c === "web" || c === "sidebar" || c === "web-sidebar") return "web";
|
|
62
|
+
if (c === "a2a" || c.startsWith("agent")) return "a2a";
|
|
63
|
+
if (c === "schedule" || c === "cron" || c === "routine") return "schedule";
|
|
64
|
+
return "other";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Composite key identifying a sidebar selection: either a "live" agent session
|
|
68
|
+
// (no conversation file yet) or a persisted conversation tied to an agent.
|
|
69
|
+
export type ChatKey =
|
|
70
|
+
| { kind: "live"; agentSlug: string }
|
|
71
|
+
| { kind: "conv"; agentSlug: string; convId: string };
|
|
72
|
+
|
|
73
|
+
export function chatKeyToString(k: ChatKey): string {
|
|
74
|
+
return k.kind === "live" ? `live:${k.agentSlug}` : `conv:${k.agentSlug}:${k.convId}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface Props {
|
|
78
|
+
pid: string;
|
|
79
|
+
agents: AgentEntry[];
|
|
80
|
+
/** Virtual super-agent slug used by the dropdown (kept in sync with ChatTab). */
|
|
81
|
+
superAgentSlug: string;
|
|
82
|
+
superAgentLabel: string;
|
|
83
|
+
selected: ChatKey;
|
|
84
|
+
onSelect: (key: ChatKey) => void;
|
|
85
|
+
onNewChat: () => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Per-agent SWR fetcher. Lives in a child so adding/removing agents doesn't
|
|
89
|
+
// violate the rules of hooks in the parent.
|
|
90
|
+
function AgentConvFetcher({
|
|
91
|
+
pid,
|
|
92
|
+
slug,
|
|
93
|
+
onLoaded,
|
|
94
|
+
}: {
|
|
95
|
+
pid: string;
|
|
96
|
+
slug: string;
|
|
97
|
+
onLoaded: (slug: string, data: ConversationListEntry[] | undefined) => void;
|
|
98
|
+
}) {
|
|
99
|
+
const { data } = useSWR(
|
|
100
|
+
`/projects/${pid}/agents/${slug}/conversations`,
|
|
101
|
+
() => Conversations.list(pid, slug),
|
|
102
|
+
{ revalidateOnFocus: false },
|
|
103
|
+
);
|
|
104
|
+
// Bubble fetched data up to the parent on every change.
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
onLoaded(slug, data);
|
|
107
|
+
}, [slug, data]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function ChatList({
|
|
112
|
+
pid,
|
|
113
|
+
agents,
|
|
114
|
+
superAgentSlug,
|
|
115
|
+
superAgentLabel,
|
|
116
|
+
selected,
|
|
117
|
+
onSelect,
|
|
118
|
+
onNewChat,
|
|
119
|
+
}: Props) {
|
|
120
|
+
const [query, setQuery] = useState("");
|
|
121
|
+
const [agentFilter, setAgentFilter] = useState("");
|
|
122
|
+
const [collapsed, setCollapsed] = useState<Partial<Record<ChannelGroupKey, boolean>>>({});
|
|
123
|
+
const [byAgent, setByAgent] = useState<Record<string, ConversationListEntry[]>>({});
|
|
124
|
+
|
|
125
|
+
const handleLoaded = (slug: string, data: ConversationListEntry[] | undefined) => {
|
|
126
|
+
if (!data) return;
|
|
127
|
+
setByAgent((prev) => {
|
|
128
|
+
const cur = prev[slug];
|
|
129
|
+
if (cur && cur.length === data.length && cur === data) return prev;
|
|
130
|
+
return { ...prev, [slug]: data };
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const allConvs = useMemo<ConversationListEntry[]>(() => {
|
|
135
|
+
const out: ConversationListEntry[] = [];
|
|
136
|
+
for (const a of agents) {
|
|
137
|
+
for (const c of byAgent[a.slug] || []) {
|
|
138
|
+
out.push({ ...c, agent_slug: c.agent_slug || a.slug });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}, [agents, byAgent]);
|
|
143
|
+
|
|
144
|
+
const filteredConvs = useMemo(() => {
|
|
145
|
+
const q = query.trim().toLowerCase();
|
|
146
|
+
return allConvs.filter((c) => {
|
|
147
|
+
if (agentFilter && c.agent_slug !== agentFilter) return false;
|
|
148
|
+
if (q) {
|
|
149
|
+
const hay = `${c.title || ""} ${c.id} ${c.agent_slug}`.toLowerCase();
|
|
150
|
+
if (!hay.includes(q)) return false;
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
});
|
|
154
|
+
}, [allConvs, query, agentFilter]);
|
|
155
|
+
|
|
156
|
+
// Group: live entries (one per applicable agent) + stored conversations by channel.
|
|
157
|
+
const groups = useMemo(() => {
|
|
158
|
+
const byKey = new Map<ChannelGroupKey, ConversationListEntry[]>();
|
|
159
|
+
for (const c of filteredConvs) {
|
|
160
|
+
const key = channelGroup(c.channel);
|
|
161
|
+
const bucket = byKey.get(key);
|
|
162
|
+
if (bucket) bucket.push(c);
|
|
163
|
+
else byKey.set(key, [c]);
|
|
164
|
+
}
|
|
165
|
+
return Array.from(byKey.entries())
|
|
166
|
+
.map(([key, items]) => ({
|
|
167
|
+
key,
|
|
168
|
+
items: items.sort(
|
|
169
|
+
(a, b) => new Date(b.started_at || 0).getTime() - new Date(a.started_at || 0).getTime(),
|
|
170
|
+
),
|
|
171
|
+
}))
|
|
172
|
+
.sort((a, b) => GROUP_META[a.key].order - GROUP_META[b.key].order);
|
|
173
|
+
}, [filteredConvs]);
|
|
174
|
+
|
|
175
|
+
// "Live" agents shown at the top: super-agent + project agents matching the
|
|
176
|
+
// current agent filter (so "filter by foo" hides the others everywhere).
|
|
177
|
+
const liveAgents = useMemo(() => {
|
|
178
|
+
if (agentFilter === superAgentSlug) {
|
|
179
|
+
return [{ slug: superAgentSlug, label: superAgentLabel }];
|
|
180
|
+
}
|
|
181
|
+
const out: { slug: string; label: string }[] = [];
|
|
182
|
+
if (!agentFilter) out.push({ slug: superAgentSlug, label: superAgentLabel });
|
|
183
|
+
for (const a of agents) {
|
|
184
|
+
if (agentFilter && a.slug !== agentFilter) continue;
|
|
185
|
+
out.push({ slug: a.slug, label: a.slug });
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}, [agents, agentFilter, superAgentSlug, superAgentLabel]);
|
|
189
|
+
|
|
190
|
+
const agentOptions = useMemo(
|
|
191
|
+
() => [
|
|
192
|
+
{ value: "", label: t("project.chat.list.all_agents") },
|
|
193
|
+
{ value: superAgentSlug, label: superAgentLabel },
|
|
194
|
+
...agents.map((a) => ({ value: a.slug, label: a.slug })),
|
|
195
|
+
],
|
|
196
|
+
[agents, superAgentSlug, superAgentLabel],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const totalCount = allConvs.length + liveAgents.length;
|
|
200
|
+
const anyLoaded = Object.keys(byAgent).length > 0 || agents.length === 0;
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<aside className="flex h-full w-72 shrink-0 flex-col border-r border-border bg-card/30">
|
|
204
|
+
{/* Hidden per-agent fetchers — one SWR per agent, but with a stable
|
|
205
|
+
component per slug so rules-of-hooks are respected. */}
|
|
206
|
+
{agents.map((a) => (
|
|
207
|
+
<AgentConvFetcher key={a.slug} pid={pid} slug={a.slug} onLoaded={handleLoaded} />
|
|
208
|
+
))}
|
|
209
|
+
|
|
210
|
+
<header className="flex h-[57px] shrink-0 items-center justify-between gap-2 border-b border-border px-3">
|
|
211
|
+
<div className="min-w-0">
|
|
212
|
+
<p className="truncate text-sm font-semibold">{t("project.chat.list.title")}</p>
|
|
213
|
+
<p className="text-[10px] text-muted-fg">
|
|
214
|
+
{t("project.chat.list.count", { n: totalCount })}
|
|
215
|
+
</p>
|
|
216
|
+
</div>
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
onClick={onNewChat}
|
|
220
|
+
className="inline-flex items-center gap-1 rounded-md border border-border bg-accent/60 px-2 py-1 text-[11px] font-medium hover:bg-accent"
|
|
221
|
+
>
|
|
222
|
+
<Plus className="size-3" /> {t("project.chat.list.new")}
|
|
223
|
+
</button>
|
|
224
|
+
</header>
|
|
225
|
+
|
|
226
|
+
<div className="space-y-2 border-b border-border p-2">
|
|
227
|
+
<Input
|
|
228
|
+
value={query}
|
|
229
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
230
|
+
placeholder={t("project.chat.list.search")}
|
|
231
|
+
/>
|
|
232
|
+
<UiSelect value={agentFilter} onChange={setAgentFilter} options={agentOptions} />
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<div className="flex-1 space-y-3 overflow-y-auto p-2">
|
|
236
|
+
{!anyLoaded && (
|
|
237
|
+
<div className="px-2 py-1">
|
|
238
|
+
<Loading />
|
|
239
|
+
</div>
|
|
240
|
+
)}
|
|
241
|
+
|
|
242
|
+
{/* Live group — synthetic, always first. */}
|
|
243
|
+
{liveAgents.length > 0 && (
|
|
244
|
+
<ChannelGroup
|
|
245
|
+
keyName="live"
|
|
246
|
+
count={liveAgents.length}
|
|
247
|
+
collapsed={!!collapsed.live}
|
|
248
|
+
onToggle={() => setCollapsed((p) => ({ ...p, live: !p.live }))}
|
|
249
|
+
>
|
|
250
|
+
{liveAgents.map((a) => {
|
|
251
|
+
const isRoby = a.slug === superAgentSlug;
|
|
252
|
+
const active =
|
|
253
|
+
selected.kind === "live" && selected.agentSlug === a.slug;
|
|
254
|
+
return (
|
|
255
|
+
<ChatListItem
|
|
256
|
+
key={`live-${a.slug}`}
|
|
257
|
+
title={isRoby ? a.label : t("project.chat.list.live_with", { slug: a.slug })}
|
|
258
|
+
subtitle={t("project.chat.list.live_subtitle")}
|
|
259
|
+
badge={isRoby ? "super" : a.slug}
|
|
260
|
+
selected={active}
|
|
261
|
+
onClick={() => onSelect({ kind: "live", agentSlug: a.slug })}
|
|
262
|
+
/>
|
|
263
|
+
);
|
|
264
|
+
})}
|
|
265
|
+
</ChannelGroup>
|
|
266
|
+
)}
|
|
267
|
+
|
|
268
|
+
{/* Stored conversations, grouped by channel. */}
|
|
269
|
+
{groups.map((g) => (
|
|
270
|
+
<ChannelGroup
|
|
271
|
+
key={g.key}
|
|
272
|
+
keyName={g.key}
|
|
273
|
+
count={g.items.length}
|
|
274
|
+
collapsed={!!collapsed[g.key]}
|
|
275
|
+
onToggle={() => setCollapsed((p) => ({ ...p, [g.key]: !p[g.key] }))}
|
|
276
|
+
>
|
|
277
|
+
{g.items.map((c) => {
|
|
278
|
+
const active =
|
|
279
|
+
selected.kind === "conv" &&
|
|
280
|
+
selected.agentSlug === c.agent_slug &&
|
|
281
|
+
selected.convId === c.id;
|
|
282
|
+
return (
|
|
283
|
+
<ChatListItem
|
|
284
|
+
key={`${c.agent_slug}-${c.id}`}
|
|
285
|
+
title={c.title || c.id}
|
|
286
|
+
subtitle={[c.agent_slug, `${c.messages ?? 0} msg`]
|
|
287
|
+
.filter(Boolean)
|
|
288
|
+
.join(" · ")}
|
|
289
|
+
badge={c.agent_slug}
|
|
290
|
+
timeAgo={c.started_at}
|
|
291
|
+
selected={active}
|
|
292
|
+
onClick={() =>
|
|
293
|
+
onSelect({ kind: "conv", agentSlug: c.agent_slug, convId: c.id })
|
|
294
|
+
}
|
|
295
|
+
/>
|
|
296
|
+
);
|
|
297
|
+
})}
|
|
298
|
+
</ChannelGroup>
|
|
299
|
+
))}
|
|
300
|
+
|
|
301
|
+
{anyLoaded && allConvs.length === 0 && (
|
|
302
|
+
<p className="px-3 py-6 text-center text-xs text-muted-fg">
|
|
303
|
+
{t("project.chat.list.empty")}
|
|
304
|
+
</p>
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
</aside>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function ChannelGroup({
|
|
312
|
+
keyName,
|
|
313
|
+
count,
|
|
314
|
+
collapsed,
|
|
315
|
+
onToggle,
|
|
316
|
+
children,
|
|
317
|
+
}: {
|
|
318
|
+
keyName: ChannelGroupKey;
|
|
319
|
+
count: number;
|
|
320
|
+
collapsed: boolean;
|
|
321
|
+
onToggle: () => void;
|
|
322
|
+
children: React.ReactNode;
|
|
323
|
+
}) {
|
|
324
|
+
const meta = GROUP_META[keyName];
|
|
325
|
+
const Icon = meta.icon;
|
|
326
|
+
return (
|
|
327
|
+
<section className="space-y-1">
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
onClick={onToggle}
|
|
331
|
+
className="flex w-full items-center justify-between rounded-md px-2 py-1 text-muted-fg hover:bg-accent/30"
|
|
332
|
+
>
|
|
333
|
+
<span className="inline-flex items-center gap-1.5">
|
|
334
|
+
{collapsed ? (
|
|
335
|
+
<ChevronRight className="size-3" />
|
|
336
|
+
) : (
|
|
337
|
+
<ChevronDown className="size-3" />
|
|
338
|
+
)}
|
|
339
|
+
<Icon className="size-3" />
|
|
340
|
+
<span className="text-[10px] font-semibold uppercase tracking-wider">
|
|
341
|
+
{meta.label}
|
|
342
|
+
</span>
|
|
343
|
+
</span>
|
|
344
|
+
<span className="text-[10px]">{count}</span>
|
|
345
|
+
</button>
|
|
346
|
+
{!collapsed && <div className="space-y-0.5">{children}</div>}
|
|
347
|
+
</section>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function ChatListItem({
|
|
352
|
+
title,
|
|
353
|
+
subtitle,
|
|
354
|
+
badge,
|
|
355
|
+
timeAgo,
|
|
356
|
+
selected,
|
|
357
|
+
onClick,
|
|
358
|
+
}: {
|
|
359
|
+
title: string;
|
|
360
|
+
subtitle?: string;
|
|
361
|
+
badge?: string;
|
|
362
|
+
timeAgo?: string;
|
|
363
|
+
selected?: boolean;
|
|
364
|
+
onClick: () => void;
|
|
365
|
+
}) {
|
|
366
|
+
return (
|
|
367
|
+
<button
|
|
368
|
+
type="button"
|
|
369
|
+
onClick={onClick}
|
|
370
|
+
className={clsx(
|
|
371
|
+
"w-full rounded-md border px-2.5 py-2 text-left transition-colors",
|
|
372
|
+
selected
|
|
373
|
+
? "border-primary/50 bg-primary/10"
|
|
374
|
+
: "border-transparent hover:border-border hover:bg-accent/40",
|
|
375
|
+
)}
|
|
376
|
+
>
|
|
377
|
+
<div className="flex items-start justify-between gap-2">
|
|
378
|
+
<p className={clsx("truncate text-sm", selected ? "font-semibold" : "font-medium")}>
|
|
379
|
+
{title}
|
|
380
|
+
</p>
|
|
381
|
+
{timeAgo && (
|
|
382
|
+
<span className="inline-flex shrink-0 items-center gap-0.5 text-[10px] text-muted-fg">
|
|
383
|
+
<Clock className="size-2.5" />
|
|
384
|
+
{formatTimeAgo(timeAgo)}
|
|
385
|
+
</span>
|
|
386
|
+
)}
|
|
387
|
+
</div>
|
|
388
|
+
<div className="mt-0.5 flex items-center justify-between gap-2 text-[10px] text-muted-fg">
|
|
389
|
+
<span className="truncate">{subtitle}</span>
|
|
390
|
+
{badge && (
|
|
391
|
+
<span className="inline-flex shrink-0 items-center gap-1 rounded bg-accent/50 px-1.5 py-0.5">
|
|
392
|
+
<User className="size-2.5" />
|
|
393
|
+
{badge}
|
|
394
|
+
</span>
|
|
395
|
+
)}
|
|
396
|
+
</div>
|
|
397
|
+
</button>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function formatTimeAgo(iso?: string): string {
|
|
402
|
+
if (!iso) return "";
|
|
403
|
+
const ts = new Date(iso).getTime();
|
|
404
|
+
if (!Number.isFinite(ts)) return "";
|
|
405
|
+
const diff = Date.now() - ts;
|
|
406
|
+
const m = Math.floor(diff / 60000);
|
|
407
|
+
if (m < 1) return "now";
|
|
408
|
+
if (m < 60) return `${m}m`;
|
|
409
|
+
const h = Math.floor(m / 60);
|
|
410
|
+
if (h < 24) return `${h}h`;
|
|
411
|
+
return `${Math.floor(h / 24)}d`;
|
|
412
|
+
}
|
|
@@ -30,7 +30,7 @@ export function AppearancePanel() {
|
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
return (
|
|
33
|
-
<div className="
|
|
33
|
+
<div className="grid gap-6 xl:grid-cols-2 xl:items-start">
|
|
34
34
|
<Section title={t("settings.appearance")}>
|
|
35
35
|
<div className="flex items-center gap-2">
|
|
36
36
|
<Button variant={theme === "light" ? "primary" : "secondary"} onClick={() => set("light")}>{t("settings.light_mode")}</Button>
|
|
@@ -101,7 +101,7 @@ export function MemoryPanel() {
|
|
|
101
101
|
};
|
|
102
102
|
|
|
103
103
|
return (
|
|
104
|
-
<div className="
|
|
104
|
+
<div className="grid gap-6 xl:grid-cols-2 xl:items-start">
|
|
105
105
|
<Section
|
|
106
106
|
title={t("memory_panel.embeddings_title")}
|
|
107
107
|
description="Modelo que vectoriza el historial de todos los canales para la memoria relevante. Igual que TTS/STT: elegí proveedor y modelo. 'Automático' prueba local primero y cae al offline si no hay nada."
|
|
@@ -88,7 +88,7 @@ export function SkillsInspectorPanel() {
|
|
|
88
88
|
};
|
|
89
89
|
|
|
90
90
|
return (
|
|
91
|
-
<div className="
|
|
91
|
+
<div className="grid gap-6 xl:grid-cols-2 xl:items-start">
|
|
92
92
|
<Section
|
|
93
93
|
title="Skill Inspector (RAG por turno)"
|
|
94
94
|
description="Función experimental. Cuando está activa, el agente NO recibe la lista completa de skills en su prompt; en cada mensaje un RAG local decide qué skill(s) cargar — el cuerpo completo si hay match fuerte, una sugerencia si hay match medio, nada si no aplica. Se reevalúa cada turno: una skill que dejó de ser relevante desaparece del contexto."
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useRef, useState } from "react";
|
|
2
|
-
import { SuperAgent, Agents } from "../lib/api";
|
|
2
|
+
import { SuperAgent, Agents, Conversations } from "../lib/api";
|
|
3
3
|
import type { ChatStreamEvent, ChatUsage, ConversationMessage } from "../types/daemon";
|
|
4
4
|
|
|
5
5
|
export type ToolStatus = "running" | "done" | "error" | "deduped";
|
|
@@ -53,7 +53,14 @@ export interface UseChatResult {
|
|
|
53
53
|
send: (text: string, opts?: SendOptions) => Promise<void>;
|
|
54
54
|
stop: () => void;
|
|
55
55
|
clear: () => void;
|
|
56
|
+
/** Load a persisted conversation as history and bind subsequent sends to it.
|
|
57
|
+
* Only supported for project agents (super-agent conversations aren't
|
|
58
|
+
* persisted per-file). Pass `null` to drop the binding without clearing. */
|
|
59
|
+
load: (agentSlug: string, conversationId: string) => Promise<void>;
|
|
56
60
|
streaming: boolean;
|
|
61
|
+
/** Conversation id we're bound to, if any. Lets callers reflect "live vs
|
|
62
|
+
* loaded" state in the UI. */
|
|
63
|
+
conversationId: string | undefined;
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
/** Concatenate the text parts of a message (for clipboard). */
|
|
@@ -235,6 +242,7 @@ export function applyStreamEvent(turn: ChatMsg, ev: ChatStreamEvent): ChatMsg {
|
|
|
235
242
|
export function useChat(pid: string, onError?: (msg: string) => void): UseChatResult {
|
|
236
243
|
const [msgs, setMsgs] = useState<ChatMsg[]>([]);
|
|
237
244
|
const [streaming, setStreaming] = useState(false);
|
|
245
|
+
const [conversationId, setConversationId] = useState<string | undefined>(undefined);
|
|
238
246
|
const abortRef = useRef<AbortController | null>(null);
|
|
239
247
|
const convoRef = useRef<string | undefined>(undefined);
|
|
240
248
|
|
|
@@ -283,8 +291,10 @@ export function useChat(pid: string, onError?: (msg: string) => void): UseChatRe
|
|
|
283
291
|
prompt: trimmed,
|
|
284
292
|
conversation_id: convoRef.current,
|
|
285
293
|
model: opts.model || undefined,
|
|
294
|
+
channel: "web",
|
|
286
295
|
});
|
|
287
296
|
convoRef.current = out.conversation_id;
|
|
297
|
+
setConversationId(out.conversation_id);
|
|
288
298
|
patchLast((m) => ({
|
|
289
299
|
...m,
|
|
290
300
|
pending: false,
|
|
@@ -334,8 +344,31 @@ export function useChat(pid: string, onError?: (msg: string) => void): UseChatRe
|
|
|
334
344
|
const clear = useCallback(() => {
|
|
335
345
|
if (streaming) return;
|
|
336
346
|
convoRef.current = undefined;
|
|
347
|
+
setConversationId(undefined);
|
|
337
348
|
setMsgs([]);
|
|
338
349
|
}, [streaming]);
|
|
339
350
|
|
|
340
|
-
|
|
351
|
+
const load = useCallback(
|
|
352
|
+
async (agentSlug: string, conversationId: string) => {
|
|
353
|
+
if (streaming) return;
|
|
354
|
+
try {
|
|
355
|
+
const detail = await Conversations.get(pid, agentSlug, conversationId);
|
|
356
|
+
const loaded: ChatMsg[] = detail.messages
|
|
357
|
+
.filter((m) => m.role === "user" || m.role === "assistant")
|
|
358
|
+
.map((m) => ({
|
|
359
|
+
role: m.role as "user" | "assistant",
|
|
360
|
+
parts: [{ kind: "text", text: m.content }],
|
|
361
|
+
ts: m.ts || new Date().toISOString(),
|
|
362
|
+
}));
|
|
363
|
+
convoRef.current = conversationId;
|
|
364
|
+
setConversationId(conversationId);
|
|
365
|
+
setMsgs(loaded);
|
|
366
|
+
} catch (e) {
|
|
367
|
+
onError?.((e as Error)?.message || "could not load conversation");
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
[pid, streaming, onError],
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
return { msgs, send, stop, clear, load, streaming, conversationId };
|
|
341
374
|
}
|
|
@@ -275,7 +275,6 @@ export const en = {
|
|
|
275
275
|
tasks: "Tasks",
|
|
276
276
|
mcps: "MCPs",
|
|
277
277
|
vars: "Variables",
|
|
278
|
-
threads: "Chats",
|
|
279
278
|
logs: "Logs",
|
|
280
279
|
memories: "Memories",
|
|
281
280
|
},
|
|
@@ -301,6 +300,7 @@ export const en = {
|
|
|
301
300
|
subtitle: "Direct conversations with project agents. The super-agent does not intervene.",
|
|
302
301
|
superagent_title: "Chat with {persona}",
|
|
303
302
|
superagent_subtitle: "Chat with {persona} — the APX super-agent. Can use tools (projects, tasks, mcps, agents).",
|
|
303
|
+
loaded_subtitle: "Loaded conversation with {slug}. Sending will append to this thread.",
|
|
304
304
|
empty: "Send a message to start the conversation.",
|
|
305
305
|
placeholder: "Type something and press enter to send (shift+enter = new line)",
|
|
306
306
|
send: "Send",
|
|
@@ -316,6 +316,16 @@ export const en = {
|
|
|
316
316
|
model_label: "model",
|
|
317
317
|
model_hint: "e.g. openai:gpt-5, groq:llama-3.3-70b-versatile",
|
|
318
318
|
master_label: "Master agent",
|
|
319
|
+
list: {
|
|
320
|
+
title: "Chats",
|
|
321
|
+
new: "New",
|
|
322
|
+
search: "Search chats…",
|
|
323
|
+
all_agents: "All agents",
|
|
324
|
+
empty: "No conversations yet. Start one from the right.",
|
|
325
|
+
count: "{n} total",
|
|
326
|
+
live_with: "Live · {slug}",
|
|
327
|
+
live_subtitle: "In-memory session",
|
|
328
|
+
},
|
|
319
329
|
},
|
|
320
330
|
|
|
321
331
|
tasks: {
|
|
@@ -807,7 +817,8 @@ export const en = {
|
|
|
807
817
|
|
|
808
818
|
not_found: {
|
|
809
819
|
title: "404",
|
|
810
|
-
message: "
|
|
820
|
+
message: "Roby got lost: this page doesn't exist or has moved.",
|
|
821
|
+
home: "Back to home",
|
|
811
822
|
},
|
|
812
823
|
|
|
813
824
|
ask_panel: {
|
|
@@ -276,7 +276,6 @@ export const es = {
|
|
|
276
276
|
tasks: "Tasks",
|
|
277
277
|
mcps: "MCPs",
|
|
278
278
|
vars: "Variables",
|
|
279
|
-
threads: "Chats",
|
|
280
279
|
logs: "Logs",
|
|
281
280
|
memories: "Memorias",
|
|
282
281
|
},
|
|
@@ -302,6 +301,7 @@ export const es = {
|
|
|
302
301
|
subtitle: "Chat directo con el agente del proyecto.",
|
|
303
302
|
superagent_title: "Chat con {persona}",
|
|
304
303
|
superagent_subtitle: "Chat con {persona} — el super-agente APX. Puede usar tools (proyectos, tasks, mcps, agentes).",
|
|
304
|
+
loaded_subtitle: "Conversación cargada con {slug}. Lo que mandes se agrega a este chat.",
|
|
305
305
|
empty: "Mandá un mensaje para arrancar la conversación.",
|
|
306
306
|
placeholder: "Escribí algo y enter para enviar (shift+enter = nueva línea)",
|
|
307
307
|
send: "Enviar",
|
|
@@ -317,6 +317,16 @@ export const es = {
|
|
|
317
317
|
model_label: "modelo",
|
|
318
318
|
model_hint: "ej. openai:gpt-5, groq:llama-3.3-70b-versatile",
|
|
319
319
|
master_label: "Agente master",
|
|
320
|
+
list: {
|
|
321
|
+
title: "Chats",
|
|
322
|
+
new: "Nuevo",
|
|
323
|
+
search: "Buscar chats…",
|
|
324
|
+
all_agents: "Todos los agentes",
|
|
325
|
+
empty: "No hay conversaciones todavía. Arrancá una desde la derecha.",
|
|
326
|
+
count: "{n} en total",
|
|
327
|
+
live_with: "Live · {slug}",
|
|
328
|
+
live_subtitle: "Sesión en memoria",
|
|
329
|
+
},
|
|
320
330
|
},
|
|
321
331
|
|
|
322
332
|
tasks: {
|
|
@@ -805,7 +815,8 @@ export const es = {
|
|
|
805
815
|
|
|
806
816
|
not_found: {
|
|
807
817
|
title: "404",
|
|
808
|
-
message: "
|
|
818
|
+
message: "Roby se perdió: esta página no existe o se movió.",
|
|
819
|
+
home: "Volver al inicio",
|
|
809
820
|
},
|
|
810
821
|
|
|
811
822
|
ask_panel: {
|
|
@@ -10,7 +10,7 @@ export const Agents = {
|
|
|
10
10
|
http.patch<AgentEntry>(`/projects/${pid}/agents/${encodeURIComponent(slug)}`, body),
|
|
11
11
|
remove: (pid: string, slug: string) =>
|
|
12
12
|
http.del<{ ok: boolean }>(`/projects/${pid}/agents/${encodeURIComponent(slug)}`),
|
|
13
|
-
chat: (pid: string, slug: string, body: { prompt: string; conversation_id?: string; model?: string }) =>
|
|
13
|
+
chat: (pid: string, slug: string, body: { prompt: string; conversation_id?: string; model?: string; channel?: string }) =>
|
|
14
14
|
http.post<{ conversation_id: string; text: string; usage?: unknown; engine: string }>(
|
|
15
15
|
`/projects/${pid}/agents/${encodeURIComponent(slug)}/chat`,
|
|
16
16
|
body,
|