@agentprojectcontext/apx 1.35.0 → 1.36.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/package.json +1 -1
- package/src/core/stores/conversations.js +27 -2
- package/src/host/daemon/api/exec.js +2 -0
- package/src/interfaces/web/dist/assets/index-Cm0KyPoZ.css +1 -0
- package/src/interfaces/web/dist/assets/index-DJKA763h.js +628 -0
- package/src/interfaces/web/dist/assets/index-DJKA763h.js.map +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/src/App.tsx +0 -1
- 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 +11 -1
- package/src/interfaces/web/src/i18n/es.ts +11 -1
- 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-DJKA763h.js"></script>
|
|
22
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Cm0KyPoZ.css">
|
|
23
23
|
</head>
|
|
24
24
|
<body class="bg-background text-foreground antialiased">
|
|
25
25
|
<div id="root"></div>
|
|
@@ -179,7 +179,6 @@ function settingsLabel(key?: string) {
|
|
|
179
179
|
function projectLabel(key?: string) {
|
|
180
180
|
switch (key) {
|
|
181
181
|
case "chat": return t("project.nav.chat");
|
|
182
|
-
case "threads": return t("project.nav.threads");
|
|
183
182
|
case "telegram": return t("project.nav.telegram");
|
|
184
183
|
case "agents": return t("project.nav.agents");
|
|
185
184
|
case "routines": return t("project.nav.routines");
|
|
@@ -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: {
|
|
@@ -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: {
|
|
@@ -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,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
|
-
import { useParams, Routes, Route, useLocation, useNavigate } from "react-router-dom";
|
|
2
|
+
import { useParams, Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom";
|
|
3
3
|
import {
|
|
4
4
|
Bot, Heart, Zap, Puzzle, FolderKanban, Settings,
|
|
5
5
|
MessagesSquare, Send, KeyRound,
|
|
@@ -23,7 +23,6 @@ import { RoutinesTab } from "./project/RoutinesTab";
|
|
|
23
23
|
import { TasksTab } from "./project/TasksTab";
|
|
24
24
|
import { McpsTab } from "./project/McpsTab";
|
|
25
25
|
import { VarsTab } from "./project/VarsTab";
|
|
26
|
-
import { ThreadsTab } from "./project/ThreadsTab";
|
|
27
26
|
import { ChatTab } from "./project/ChatTab";
|
|
28
27
|
import { TelegramTab } from "./project/TelegramTab";
|
|
29
28
|
import { MemoriesTab } from "./project/MemoriesTab";
|
|
@@ -31,7 +30,7 @@ import { AgentDetailScreen } from "./project/AgentDetailScreen";
|
|
|
31
30
|
|
|
32
31
|
type NavKey =
|
|
33
32
|
| "" | "chat" | "config" | "telegram"
|
|
34
|
-
| "agents" | "routines" | "tasks" | "mcps" | "vars" | "
|
|
33
|
+
| "agents" | "routines" | "tasks" | "mcps" | "vars" | "logs" | "memories";
|
|
35
34
|
|
|
36
35
|
export function ProjectScreen() {
|
|
37
36
|
const navigate = useNavigate();
|
|
@@ -83,7 +82,6 @@ export function ProjectScreen() {
|
|
|
83
82
|
{ key: "", label: t("project.nav.overview"), icon: FolderKanban },
|
|
84
83
|
{ key: "telegram", label: t("project.nav.telegram"), icon: Send },
|
|
85
84
|
{ key: "chat", label: t("project.nav.chat"), icon: MessagesSquare },
|
|
86
|
-
{ key: "threads", label: t("project.nav.threads"), icon: MessagesSquare },
|
|
87
85
|
{ key: "agents", label: t("project.nav.agents"), icon: Bot },
|
|
88
86
|
{ key: "memories", label: t("project.nav.memories"), icon: Brain },
|
|
89
87
|
],
|
|
@@ -146,7 +144,7 @@ export function ProjectScreen() {
|
|
|
146
144
|
<Route path="tasks" element={isBase ? <GlobalTasksTab /> : <TasksTab pid={pid} />} />
|
|
147
145
|
<Route path="mcps" element={<McpsTab pid={pid} />} />
|
|
148
146
|
<Route path="vars" element={<VarsTab pid={pid} />} />
|
|
149
|
-
<Route path="threads" element={<
|
|
147
|
+
<Route path="threads" element={<Navigate to={`/p/${pid}/chat`} replace />} />
|
|
150
148
|
<Route path="chat" element={<ChatTab pid={pid} />} />
|
|
151
149
|
<Route path="*" element={<Overview pid={pid} />} />
|
|
152
150
|
</Routes>
|
|
@@ -52,9 +52,11 @@ const SECTIONS: TabSection[] = [
|
|
|
52
52
|
},
|
|
53
53
|
];
|
|
54
54
|
|
|
55
|
-
// Tabs whose content
|
|
56
|
-
//
|
|
57
|
-
|
|
55
|
+
// Tabs whose content lays out multiple top-level sections in a two-column grid
|
|
56
|
+
// on xl (and so wants full available width). Single-section panels (identity,
|
|
57
|
+
// super agent, devices, advanced) keep a cosier reading width so wide displays
|
|
58
|
+
// don't blow form fields up to absurd widths.
|
|
59
|
+
const WIDE_TABS = new Set<TabKey>(["engines", "telegram", "memory", "skills", "appearance"]);
|
|
58
60
|
|
|
59
61
|
const PANELS: Record<TabKey, () => ReactElement> = {
|
|
60
62
|
identity: () => <IdentityPanel />,
|
|
@@ -84,7 +86,7 @@ export function SettingsScreen() {
|
|
|
84
86
|
onChange={(k) => navigate(k === "identity" ? "/settings" : `/settings/${pathFromTab(k as TabKey)}`)}
|
|
85
87
|
collapsed={collapsed}
|
|
86
88
|
onToggleCollapse={toggle}
|
|
87
|
-
contentClassName={`
|
|
89
|
+
contentClassName={`w-full ${WIDE_TABS.has(active) ? "" : "mx-auto max-w-3xl"} space-y-6 p-6 pt-3`}
|
|
88
90
|
testId={`settings-tab-${active}`}
|
|
89
91
|
>
|
|
90
92
|
<Panel />
|