@agentprojectcontext/apx 1.73.0 → 1.74.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/channels/telegram/ask-callbacks.js +4 -0
- package/src/core/channels/telegram/dispatch.js +13 -3
- package/src/core/channels/telegram/reply.js +19 -7
- package/src/core/net/ipv4-first.js +32 -0
- package/src/core/stores/messages.js +19 -2
- package/src/host/daemon/api/super-agent.js +35 -5
- package/src/host/daemon/api/voice.js +20 -1
- package/src/host/daemon/index.js +2 -0
- package/src/host/daemon/plugins/desktop/index.js +13 -2
- package/src/interfaces/cli/commands/exec.js +81 -3
- package/src/interfaces/web/dist/assets/index-BxYXJEtf.js +819 -0
- package/src/interfaces/web/dist/assets/index-BxYXJEtf.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-COrRuBp1.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +327 -324
- package/src/interfaces/web/src/components/chat/ContextBar.tsx +83 -28
- package/src/interfaces/web/src/components/chat/MessageBubble.tsx +23 -1
- package/src/interfaces/web/src/hooks/useChat.ts +37 -5
- package/src/interfaces/web/src/i18n/en.ts +3 -0
- package/src/interfaces/web/src/i18n/es.ts +3 -0
- package/src/interfaces/web/src/lib/api/agents.ts +2 -2
- package/src/interfaces/web/src/screens/base/LogsTab.tsx +19 -0
- package/src/interfaces/web/src/screens/project/AgentBrainGraph.tsx +10 -1
- package/src/interfaces/web/src/types/daemon.ts +11 -0
- package/src/interfaces/web/dist/assets/index-CUeIhw7z.css +0 -1
- package/src/interfaces/web/dist/assets/index-D2b7Sqvg.js +0 -819
- package/src/interfaces/web/dist/assets/index-D2b7Sqvg.js.map +0 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useMemo, useState } from "react";
|
|
2
|
-
import { ChevronDown, FilePen, Gauge, Wrench } from "lucide-react";
|
|
2
|
+
import { Bot, ChevronDown, FilePen, Gauge, Wrench } from "lucide-react";
|
|
3
3
|
import { cn } from "../../lib/cn";
|
|
4
4
|
import { FILE_TOOLS } from "./ToolCall";
|
|
5
|
+
import { t } from "../../i18n";
|
|
5
6
|
import type { ChatMsg, ToolPart } from "../../hooks/useChat";
|
|
6
7
|
|
|
7
8
|
interface ChangedFile {
|
|
@@ -9,6 +10,18 @@ interface ChangedFile {
|
|
|
9
10
|
tool: string;
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
/** One (agent, model) pair that contributed to this conversation, with what it
|
|
14
|
+
* spent. A turn where the router fell back mid-conversation produces two rows
|
|
15
|
+
* for the same agent — that's the point: you see WHICH model cost what. */
|
|
16
|
+
interface ActorUsage {
|
|
17
|
+
key: string;
|
|
18
|
+
agent?: string;
|
|
19
|
+
model?: string;
|
|
20
|
+
inTok: number;
|
|
21
|
+
outTok: number;
|
|
22
|
+
turns: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
12
25
|
// Pull the file path out of a write_file/edit_file invocation's args.
|
|
13
26
|
function filePathOf(args?: Record<string, unknown>): string | undefined {
|
|
14
27
|
if (!args) return undefined;
|
|
@@ -18,26 +31,37 @@ function filePathOf(args?: Record<string, unknown>): string | undefined {
|
|
|
18
31
|
|
|
19
32
|
/**
|
|
20
33
|
* Compact, opencode-style strip summarising the conversation: token usage,
|
|
21
|
-
* tool count,
|
|
22
|
-
*
|
|
34
|
+
* tool count, an expandable list of files the agent wrote or edited, and the
|
|
35
|
+
* per-actor breakdown (which agent answered on which model, and what each
|
|
36
|
+
* spent). Renders nothing until the agent has actually done something.
|
|
23
37
|
*/
|
|
24
38
|
export function ContextBar({ msgs }: { msgs: ChatMsg[] }) {
|
|
25
39
|
const [open, setOpen] = useState(false);
|
|
26
40
|
|
|
27
|
-
const { inTok, outTok, toolCount, changed,
|
|
41
|
+
const { inTok, outTok, toolCount, changed, actors } = useMemo(() => {
|
|
28
42
|
let inTok = 0;
|
|
29
43
|
let outTok = 0;
|
|
30
44
|
let toolCount = 0;
|
|
31
|
-
let model: string | undefined;
|
|
32
45
|
const seen = new Set<string>();
|
|
33
46
|
const changed: ChangedFile[] = [];
|
|
47
|
+
const byActor = new Map<string, ActorUsage>();
|
|
34
48
|
for (const m of msgs) {
|
|
35
49
|
if (m.role !== "assistant") continue;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
50
|
+
const mIn = m.usage?.input_tokens || 0;
|
|
51
|
+
const mOut = m.usage?.output_tokens || 0;
|
|
52
|
+
inTok += mIn;
|
|
53
|
+
outTok += mOut;
|
|
54
|
+
if (m.agent || m.model) {
|
|
55
|
+
const key = `${m.agent || ""}::${m.model || ""}`;
|
|
56
|
+
const prev = byActor.get(key);
|
|
57
|
+
if (prev) {
|
|
58
|
+
prev.inTok += mIn;
|
|
59
|
+
prev.outTok += mOut;
|
|
60
|
+
prev.turns += 1;
|
|
61
|
+
} else {
|
|
62
|
+
byActor.set(key, { key, agent: m.agent, model: m.model, inTok: mIn, outTok: mOut, turns: 1 });
|
|
63
|
+
}
|
|
39
64
|
}
|
|
40
|
-
if (m.model) model = m.model;
|
|
41
65
|
for (const part of m.parts) {
|
|
42
66
|
if (part.kind !== "tool") continue;
|
|
43
67
|
toolCount += 1;
|
|
@@ -50,20 +74,21 @@ export function ContextBar({ msgs }: { msgs: ChatMsg[] }) {
|
|
|
50
74
|
}
|
|
51
75
|
}
|
|
52
76
|
}
|
|
53
|
-
return { inTok, outTok, toolCount, changed,
|
|
77
|
+
return { inTok, outTok, toolCount, changed, actors: [...byActor.values()] };
|
|
54
78
|
}, [msgs]);
|
|
55
79
|
|
|
56
80
|
const totalTok = inTok + outTok;
|
|
57
|
-
|
|
81
|
+
const expandable = changed.length > 0 || actors.length > 1;
|
|
82
|
+
if (totalTok === 0 && toolCount === 0 && actors.length === 0) return null;
|
|
58
83
|
|
|
59
84
|
return (
|
|
60
85
|
<div className="shrink-0 border-t border-border bg-card/40 text-[11px]">
|
|
61
86
|
<button
|
|
62
87
|
type="button"
|
|
63
|
-
onClick={() =>
|
|
88
|
+
onClick={() => expandable && setOpen((v) => !v)}
|
|
64
89
|
className={cn(
|
|
65
90
|
"flex w-full items-center gap-3 px-4 py-1.5 text-muted-foreground",
|
|
66
|
-
|
|
91
|
+
expandable && "hover:text-foreground",
|
|
67
92
|
)}
|
|
68
93
|
>
|
|
69
94
|
<span className="flex items-center gap-1">
|
|
@@ -79,27 +104,57 @@ export function ContextBar({ msgs }: { msgs: ChatMsg[] }) {
|
|
|
79
104
|
)}
|
|
80
105
|
{changed.length > 0 && (
|
|
81
106
|
<span className="flex items-center gap-1 text-violet-400">
|
|
82
|
-
<FilePen size={12} /> {changed.length}
|
|
107
|
+
<FilePen size={12} /> {changed.length} {t("chat_ui.ctx_files")}
|
|
83
108
|
</span>
|
|
84
109
|
)}
|
|
85
|
-
{
|
|
86
|
-
|
|
110
|
+
{/* One actor → show it inline. Several → say how many and let the user
|
|
111
|
+
expand for the split. */}
|
|
112
|
+
{actors.length === 1 && (
|
|
113
|
+
<span className="ml-auto truncate font-mono text-muted-foreground/70">
|
|
114
|
+
{[actors[0].agent, actors[0].model].filter(Boolean).join(" · ")}
|
|
115
|
+
</span>
|
|
116
|
+
)}
|
|
117
|
+
{actors.length > 1 && (
|
|
118
|
+
<span className="ml-auto flex items-center gap-1 text-sky-400">
|
|
119
|
+
<Bot size={12} /> {t("chat_ui.ctx_actors", { n: actors.length })}
|
|
120
|
+
</span>
|
|
121
|
+
)}
|
|
122
|
+
{expandable && (
|
|
87
123
|
<ChevronDown className={cn("size-3 shrink-0 transition-transform", open && "rotate-180")} />
|
|
88
124
|
)}
|
|
89
125
|
</button>
|
|
90
126
|
|
|
91
|
-
{open &&
|
|
92
|
-
<
|
|
93
|
-
{
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
127
|
+
{open && (
|
|
128
|
+
<div className="max-h-52 space-y-2 overflow-y-auto border-t border-border/60 px-4 py-2">
|
|
129
|
+
{actors.length > 1 && (
|
|
130
|
+
<ul className="space-y-0.5">
|
|
131
|
+
{actors.map((a) => (
|
|
132
|
+
<li key={a.key} className="flex items-center gap-2 text-[11px]">
|
|
133
|
+
<Bot size={11} className="shrink-0 text-sky-400" />
|
|
134
|
+
<span className="shrink-0 font-medium text-emerald-300">{a.agent || "—"}</span>
|
|
135
|
+
<span className="truncate font-mono text-muted-foreground/70">{a.model || "—"}</span>
|
|
136
|
+
<span className="ml-auto shrink-0 font-mono text-[10px] text-muted-foreground/60">
|
|
137
|
+
{fmt(a.inTok + a.outTok)} tok ({fmt(a.inTok)}↑ / {fmt(a.outTok)}↓) ·{" "}
|
|
138
|
+
{t("chat_ui.ctx_turns", { n: a.turns })}
|
|
139
|
+
</span>
|
|
140
|
+
</li>
|
|
141
|
+
))}
|
|
142
|
+
</ul>
|
|
143
|
+
)}
|
|
144
|
+
{changed.length > 0 && (
|
|
145
|
+
<ul className="space-y-0.5">
|
|
146
|
+
{changed.map((f) => (
|
|
147
|
+
<li key={f.path} className="flex items-center gap-2 font-mono text-[11px]">
|
|
148
|
+
<FilePen size={11} className="shrink-0 text-violet-400" />
|
|
149
|
+
<span className="truncate">{f.path}</span>
|
|
150
|
+
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground/60">
|
|
151
|
+
{f.tool === "write_file" ? "write" : "edit"}
|
|
152
|
+
</span>
|
|
153
|
+
</li>
|
|
154
|
+
))}
|
|
155
|
+
</ul>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
103
158
|
)}
|
|
104
159
|
</div>
|
|
105
160
|
);
|
|
@@ -105,9 +105,31 @@ export function MessageBubble({ msg, isLast, isAskAnswer, onCopy }: Props) {
|
|
|
105
105
|
</div>
|
|
106
106
|
)}
|
|
107
107
|
|
|
108
|
+
{/* Attribution: who answered and on which engine. Always visible (not
|
|
109
|
+
hover-gated) — in a thread where several agents/models take turns,
|
|
110
|
+
this is the only way to tell them apart at a glance. */}
|
|
111
|
+
{!mine && (msg.agent || msg.model) && (
|
|
112
|
+
<div className="flex flex-wrap items-center gap-1 text-[10px]">
|
|
113
|
+
{msg.agent && (
|
|
114
|
+
<span className="rounded bg-emerald-500/15 px-1 py-0.5 font-medium text-emerald-300">
|
|
115
|
+
{msg.agent}
|
|
116
|
+
</span>
|
|
117
|
+
)}
|
|
118
|
+
{msg.model && (
|
|
119
|
+
<span className="rounded border border-border px-1 py-0.5 font-mono text-muted-foreground">
|
|
120
|
+
{msg.model}
|
|
121
|
+
</span>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
|
|
108
126
|
<div className="flex items-center gap-2 text-[10px] text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100">
|
|
109
127
|
<span>{formatTs(msg.ts)}</span>
|
|
110
|
-
{!mine && msg.
|
|
128
|
+
{!mine && msg.usage && (msg.usage.input_tokens || msg.usage.output_tokens) ? (
|
|
129
|
+
<span className="font-mono">
|
|
130
|
+
· {(msg.usage.input_tokens || 0) + (msg.usage.output_tokens || 0)} tok
|
|
131
|
+
</span>
|
|
132
|
+
) : null}
|
|
111
133
|
{!mine && hasTools && (
|
|
112
134
|
<span>· {t("shared_ui.tools_count", { n: msg.parts.filter((p) => p.kind === "tool").length })}</span>
|
|
113
135
|
)}
|
|
@@ -29,6 +29,10 @@ export interface ChatMsg {
|
|
|
29
29
|
pending?: boolean;
|
|
30
30
|
/** Model that produced an assistant turn (after routing). */
|
|
31
31
|
model?: string;
|
|
32
|
+
/** Who answered: display name of the agent/persona (Roby, a project agent…). */
|
|
33
|
+
agent?: string;
|
|
34
|
+
/** Stable id of that actor (super_agent | agent slug). Turns are split on it. */
|
|
35
|
+
agentId?: string;
|
|
32
36
|
/** Token accounting from the `final` event. */
|
|
33
37
|
usage?: ChatUsage;
|
|
34
38
|
/** Operational notes (engine fallbacks, retries, suppressions). */
|
|
@@ -135,19 +139,31 @@ function isErrorResult(result: unknown): boolean {
|
|
|
135
139
|
* single assistant bubble with interleaved text + tool parts — mirroring how a
|
|
136
140
|
* live streamed turn is shaped, so tool executions render the same on reload as
|
|
137
141
|
* they did in real time. Persisted rows carry no live status, so it's derived
|
|
138
|
-
* from the stored result (error → "error", else "done").
|
|
142
|
+
* from the stored result (error → "error", else "done").
|
|
143
|
+
*
|
|
144
|
+
* A change of ACTOR also breaks the bubble: if Roby answers and then a project
|
|
145
|
+
* agent does, they're two turns, not one — otherwise the footer would credit
|
|
146
|
+
* the whole block to whichever one happened to be last. Token usage is summed
|
|
147
|
+
* across the rows of a turn (streamed channels write several agent rows and
|
|
148
|
+
* only the final one carries `usage`). */
|
|
139
149
|
function threadToChatMsgs(messages: ConversationMessage[]): ChatMsg[] {
|
|
140
150
|
const out: ChatMsg[] = [];
|
|
141
151
|
let turn: ChatMsg | null = null;
|
|
152
|
+
let turnActor: string | undefined;
|
|
142
153
|
let toolSeq = 0;
|
|
143
154
|
for (const m of messages) {
|
|
144
155
|
const ts = m.ts || new Date().toISOString();
|
|
145
156
|
if (m.role === "user") {
|
|
146
157
|
turn = null;
|
|
158
|
+
turnActor = undefined;
|
|
147
159
|
out.push({ role: "user", parts: userPart(m.content), ts });
|
|
148
160
|
} else if (m.role === "assistant" || m.role === "tool") {
|
|
149
|
-
|
|
161
|
+
// Tool rows inherit the current actor (they're logged by whoever is
|
|
162
|
+
// running); only assistant rows can start a new one.
|
|
163
|
+
const actor = m.role === "assistant" ? m.agent : turnActor;
|
|
164
|
+
if (!turn || (m.role === "assistant" && actor !== turnActor)) {
|
|
150
165
|
turn = { role: "assistant", parts: [], ts };
|
|
166
|
+
turnActor = actor;
|
|
151
167
|
out.push(turn);
|
|
152
168
|
}
|
|
153
169
|
if (m.role === "tool") {
|
|
@@ -159,8 +175,17 @@ function threadToChatMsgs(messages: ConversationMessage[]): ChatMsg[] {
|
|
|
159
175
|
result: m.result,
|
|
160
176
|
status: isErrorResult(m.result) ? "error" : "done",
|
|
161
177
|
});
|
|
162
|
-
} else
|
|
163
|
-
turn.
|
|
178
|
+
} else {
|
|
179
|
+
if (m.agent) turn.agentId = m.agent;
|
|
180
|
+
if (m.agent_name) turn.agent = m.agent_name;
|
|
181
|
+
if (m.model) turn.model = m.model;
|
|
182
|
+
if (m.usage) {
|
|
183
|
+
turn.usage = {
|
|
184
|
+
input_tokens: (turn.usage?.input_tokens || 0) + (m.usage.input_tokens || 0),
|
|
185
|
+
output_tokens: (turn.usage?.output_tokens || 0) + (m.usage.output_tokens || 0),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (m.content) turn.parts.push({ kind: "text", text: m.content });
|
|
164
189
|
}
|
|
165
190
|
}
|
|
166
191
|
// system/compact rows are context-only; not rendered in the thread viewer.
|
|
@@ -256,7 +281,11 @@ export function applyStreamEvent(turn: ChatMsg, ev: ChatStreamEvent): ChatMsg {
|
|
|
256
281
|
...turn,
|
|
257
282
|
pending: false,
|
|
258
283
|
usage: ev.result?.usage ?? turn.usage,
|
|
259
|
-
|
|
284
|
+
// `model` is the engine (from model_start/model_routed, or the final
|
|
285
|
+
// event); `name` is the persona that answered — they are not the same
|
|
286
|
+
// thing and must not fall back to each other.
|
|
287
|
+
model: turn.model ?? ev.result?.model,
|
|
288
|
+
agent: turn.agent ?? ev.result?.name,
|
|
260
289
|
parts:
|
|
261
290
|
ev.result?.text && !turn.parts.some((p) => p.kind === "text")
|
|
262
291
|
? [...turn.parts, { kind: "text", text: ev.result.text }]
|
|
@@ -347,6 +376,9 @@ export function useChat(pid: string, onError?: (msg: string) => void): UseChatRe
|
|
|
347
376
|
...m,
|
|
348
377
|
pending: false,
|
|
349
378
|
model: out.engine,
|
|
379
|
+
agent: opts.agentSlug,
|
|
380
|
+
agentId: opts.agentSlug,
|
|
381
|
+
usage: out.usage,
|
|
350
382
|
parts: [{ kind: "text", text: out.text }],
|
|
351
383
|
}));
|
|
352
384
|
} catch (e) {
|
|
@@ -1211,6 +1211,9 @@ export const en = {
|
|
|
1211
1211
|
send: "Send",
|
|
1212
1212
|
pick_model: "Pick model (or Auto)",
|
|
1213
1213
|
insert_variable: "Insert variable",
|
|
1214
|
+
ctx_files: "files",
|
|
1215
|
+
ctx_actors: "{n} agents/models",
|
|
1216
|
+
ctx_turns: "{n} turns",
|
|
1214
1217
|
},
|
|
1215
1218
|
|
|
1216
1219
|
sidebar_ui: {
|
|
@@ -1209,6 +1209,9 @@ export const es = {
|
|
|
1209
1209
|
send: "Enviar",
|
|
1210
1210
|
pick_model: "Elegir modelo (o Auto)",
|
|
1211
1211
|
insert_variable: "Insertar variable",
|
|
1212
|
+
ctx_files: "archivos",
|
|
1213
|
+
ctx_actors: "{n} agentes/modelos",
|
|
1214
|
+
ctx_turns: "{n} turnos",
|
|
1212
1215
|
},
|
|
1213
1216
|
|
|
1214
1217
|
sidebar_ui: {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { http } from "../http";
|
|
2
|
-
import type { AgentDetail, AgentEntry } from "../../types/daemon";
|
|
2
|
+
import type { AgentDetail, AgentEntry, ChatUsage } from "../../types/daemon";
|
|
3
3
|
|
|
4
4
|
export const Agents = {
|
|
5
5
|
list: (pid: string, opts?: { stats?: boolean }) =>
|
|
@@ -12,7 +12,7 @@ export const Agents = {
|
|
|
12
12
|
remove: (pid: string, slug: string) =>
|
|
13
13
|
http.del<{ ok: boolean }>(`/projects/${pid}/agents/${encodeURIComponent(slug)}`),
|
|
14
14
|
chat: (pid: string, slug: string, body: { prompt: string; conversation_id?: string; model?: string; channel?: string }) =>
|
|
15
|
-
http.post<{ conversation_id: string; text: string; usage?:
|
|
15
|
+
http.post<{ conversation_id: string; text: string; usage?: ChatUsage; engine: string }>(
|
|
16
16
|
`/projects/${pid}/agents/${encodeURIComponent(slug)}/chat`,
|
|
17
17
|
body,
|
|
18
18
|
),
|
|
@@ -23,10 +23,27 @@ function actorLabel(m: MessageEntry): string {
|
|
|
23
23
|
return m.agent_slug || m.actor_id || m.author || m.actor_kind || "—";
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** Model that produced this record — persisted in meta by every channel that
|
|
27
|
+
* runs an agent turn. Absent on user/system rows and on pre-1.74 history. */
|
|
28
|
+
function modelOf(m: MessageEntry): string | null {
|
|
29
|
+
const v = m.meta?.model;
|
|
30
|
+
return typeof v === "string" && v ? v : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Total tokens for this record, when the channel recorded usage. */
|
|
34
|
+
function tokensOf(m: MessageEntry): number | null {
|
|
35
|
+
const u = m.meta?.usage as { input_tokens?: number; output_tokens?: number } | undefined;
|
|
36
|
+
if (!u || typeof u !== "object") return null;
|
|
37
|
+
const total = (u.input_tokens || 0) + (u.output_tokens || 0);
|
|
38
|
+
return total > 0 ? total : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
function LogRow({ m }: { m: MessageEntry }) {
|
|
27
42
|
const [expanded, setExpanded] = useState(false);
|
|
28
43
|
const long = (m.body?.length || 0) > CLAMP;
|
|
29
44
|
const shown = !long || expanded ? m.body : `${m.body.slice(0, CLAMP)}…`;
|
|
45
|
+
const model = modelOf(m);
|
|
46
|
+
const tokens = tokensOf(m);
|
|
30
47
|
return (
|
|
31
48
|
<li className="flex items-start gap-3 rounded-md border border-border bg-muted/30 px-3 py-2">
|
|
32
49
|
<span className="mt-0.5 shrink-0">
|
|
@@ -40,6 +57,8 @@ function LogRow({ m }: { m: MessageEntry }) {
|
|
|
40
57
|
<Badge tone="info">{m.channel}</Badge>
|
|
41
58
|
{m.type && <Badge>{m.type}</Badge>}
|
|
42
59
|
<span className="font-medium text-foreground">{actorLabel(m)}</span>
|
|
60
|
+
{model && <span className="font-mono text-[11px] text-sky-400/90">{model}</span>}
|
|
61
|
+
{tokens !== null && <span className="font-mono text-[11px]">{tokens} tok</span>}
|
|
43
62
|
</div>
|
|
44
63
|
{m.body && (
|
|
45
64
|
<p className="mt-1 whitespace-pre-wrap break-words text-xs">{shown}</p>
|
|
@@ -78,6 +78,9 @@ export function BrainGraph({
|
|
|
78
78
|
const linksRef = useRef<SimLink[]>([]);
|
|
79
79
|
const dragRef = useRef<SimNode | null>(null);
|
|
80
80
|
const panRef = useRef<{ x: number; y: number } | null>(null);
|
|
81
|
+
// Press bookkeeping so a drag is never mistaken for a click (which navigates).
|
|
82
|
+
const downRef = useRef<{ x: number; y: number } | null>(null);
|
|
83
|
+
const movedRef = useRef(false);
|
|
81
84
|
const viewRef = useRef({ tx: 0, ty: 0, k: 1 });
|
|
82
85
|
const fitRef = useRef<() => void>(() => {});
|
|
83
86
|
const [, setVersion] = useState(0);
|
|
@@ -194,6 +197,8 @@ export function BrainGraph({
|
|
|
194
197
|
if (roleOf(n) === "core") return;
|
|
195
198
|
e.stopPropagation();
|
|
196
199
|
dragRef.current = n;
|
|
200
|
+
downRef.current = { x: e.clientX, y: e.clientY };
|
|
201
|
+
movedRef.current = false;
|
|
197
202
|
(e.target as Element).setPointerCapture?.(e.pointerId);
|
|
198
203
|
simRef.current?.alphaTarget(0.3).restart();
|
|
199
204
|
};
|
|
@@ -203,6 +208,8 @@ export function BrainGraph({
|
|
|
203
208
|
};
|
|
204
209
|
const onMove = (e: React.PointerEvent) => {
|
|
205
210
|
if (dragRef.current) {
|
|
211
|
+
const d = downRef.current;
|
|
212
|
+
if (d && !movedRef.current && Math.hypot(e.clientX - d.x, e.clientY - d.y) > 4) movedRef.current = true;
|
|
206
213
|
const w = worldFromClient(e.clientX, e.clientY);
|
|
207
214
|
dragRef.current.fx = w.x; dragRef.current.fy = w.y;
|
|
208
215
|
return;
|
|
@@ -222,6 +229,8 @@ export function BrainGraph({
|
|
|
222
229
|
panRef.current = null;
|
|
223
230
|
simRef.current?.alphaTarget(0);
|
|
224
231
|
};
|
|
232
|
+
// Native click fires reliably on pointerup; skip it when the press was a drag.
|
|
233
|
+
const onNodeClickGuarded = (n: SimNode) => () => { if (!movedRef.current) pick(n); };
|
|
225
234
|
|
|
226
235
|
const simNodes = nodesRef.current;
|
|
227
236
|
const links = linksRef.current;
|
|
@@ -342,7 +351,7 @@ export function BrainGraph({
|
|
|
342
351
|
const showLabel = isHub || isSel || !hideLeafLabels;
|
|
343
352
|
return (
|
|
344
353
|
<g key={n.id} transform={`translate(${n.x},${n.y})`} className="cursor-grab active:cursor-grabbing"
|
|
345
|
-
onPointerDown={onNodeDown(n)} onClick={(
|
|
354
|
+
onPointerDown={onNodeDown(n)} onClick={onNodeClickGuarded(n)}>
|
|
346
355
|
<circle r={r} fill={color} filter="url(#brain-glow)" opacity={0.3}>
|
|
347
356
|
<animate attributeName="r" values={`${r};${r + 6};${r}`} dur={`${beat}s`} begin={begin} repeatCount="indefinite" />
|
|
348
357
|
<animate attributeName="opacity" values="0.32;0.08;0.32" dur={`${beat}s`} begin={begin} repeatCount="indefinite" />
|
|
@@ -221,6 +221,13 @@ export interface ConversationMessage {
|
|
|
221
221
|
tool?: string;
|
|
222
222
|
args?: Record<string, unknown>;
|
|
223
223
|
result?: unknown;
|
|
224
|
+
/** Attribution on role:"assistant" rows from the global ledger: who answered
|
|
225
|
+
* (stable id + display name + kind), on which model, and what it cost. */
|
|
226
|
+
agent?: string;
|
|
227
|
+
agent_name?: string;
|
|
228
|
+
actor_kind?: string;
|
|
229
|
+
model?: string;
|
|
230
|
+
usage?: ChatUsage;
|
|
224
231
|
}
|
|
225
232
|
|
|
226
233
|
export interface ConversationDetail {
|
|
@@ -408,7 +415,11 @@ export interface ChatStreamEvent {
|
|
|
408
415
|
result?: {
|
|
409
416
|
text?: string;
|
|
410
417
|
usage?: ChatUsage;
|
|
418
|
+
/** Agent persona that answered (identity.json name / agent slug). */
|
|
411
419
|
name?: string;
|
|
420
|
+
/** Engine that actually produced the reply — may differ from the configured
|
|
421
|
+
* one when routing fell back mid-turn. */
|
|
422
|
+
model?: string;
|
|
412
423
|
trace?: ToolTrace[];
|
|
413
424
|
};
|
|
414
425
|
}
|