@agentprojectcontext/apx 1.48.1 → 1.49.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/voice/stt-hardware.js +13 -4
- package/src/host/daemon/api/sessions.js +11 -2
- package/src/host/daemon/api/transcribe.js +5 -1
- package/src/interfaces/cli/commands/sessions.js +25 -15
- package/src/interfaces/web/dist/assets/index-C53eJujd.css +1 -0
- package/src/interfaces/web/dist/assets/index-lu6lfr1N.js +656 -0
- package/src/interfaces/web/dist/assets/{index-BDJfFzQk.js.map → index-lu6lfr1N.js.map} +1 -1
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/src/components/RobyBubble.tsx +15 -0
- package/src/interfaces/web/src/components/voice/VoiceSttCard.tsx +123 -17
- package/src/interfaces/web/src/i18n/en.ts +23 -0
- package/src/interfaces/web/src/i18n/es.ts +23 -0
- package/src/interfaces/web/src/lib/api/sessions.ts +10 -5
- package/src/interfaces/web/src/lib/api/voice.ts +37 -1
- package/src/interfaces/web/src/screens/base/SessionsTab.tsx +115 -25
- package/src/interfaces/web/dist/assets/index-BDJfFzQk.js +0 -656
- package/src/interfaces/web/dist/assets/index-CilEtMjV.css +0 -1
|
@@ -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-lu6lfr1N.js"></script>
|
|
22
|
+
<link rel="stylesheet" crossorigin href="/assets/index-C53eJujd.css">
|
|
23
23
|
</head>
|
|
24
24
|
<body class="bg-background text-foreground antialiased">
|
|
25
25
|
<div id="root"></div>
|
|
@@ -79,6 +79,21 @@ export function RobyBubble({
|
|
|
79
79
|
// Abort any in-flight stream on unmount.
|
|
80
80
|
useEffect(() => () => abortRef.current?.abort(), []);
|
|
81
81
|
|
|
82
|
+
// Open the quick chat with a pre-filled prompt when another screen asks for
|
|
83
|
+
// it (e.g. the Sessions tab's "ask {persona} to continue this session"). The
|
|
84
|
+
// dispatcher passes the text; we open and load it without auto-sending so the
|
|
85
|
+
// user can add their own instructions first.
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const onPreload = (e: Event) => {
|
|
88
|
+
const text = (e as CustomEvent<{ prompt?: string }>).detail?.prompt;
|
|
89
|
+
if (typeof text !== "string") return;
|
|
90
|
+
onOpenChange(true);
|
|
91
|
+
setDraft(text);
|
|
92
|
+
};
|
|
93
|
+
window.addEventListener("apx:roby-prompt", onPreload);
|
|
94
|
+
return () => window.removeEventListener("apx:roby-prompt", onPreload);
|
|
95
|
+
}, [onOpenChange]);
|
|
96
|
+
|
|
82
97
|
const stop = () => {
|
|
83
98
|
abortRef.current?.abort();
|
|
84
99
|
setBusy(false);
|
|
@@ -1,9 +1,36 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
1
2
|
import { Field, Input } from "../ui";
|
|
2
3
|
import { UiSelect } from "../UiSelect";
|
|
3
|
-
import { WHISPER_MODELS, type TranscriptionConfig } from "../../lib/api/voice";
|
|
4
|
+
import { Voice, WHISPER_MODELS, type TranscriptionConfig, type SttHardwareResponse, type SttModelEntry } from "../../lib/api/voice";
|
|
4
5
|
import { isSecretMarker, secretSuffix } from "../../lib/secrets";
|
|
5
6
|
import { t } from "../../i18n";
|
|
6
7
|
|
|
8
|
+
// Acceleration badge — each compute backend gets its own colour so the user can
|
|
9
|
+
// tell at a glance what the local engine runs on (Metal on Apple Silicon, CUDA
|
|
10
|
+
// on NVIDIA, Vulkan/ROCm on AMD, plain CPU otherwise).
|
|
11
|
+
const ACCEL: Record<string, { label: string; cls: string }> = {
|
|
12
|
+
metal: { label: "Metal", cls: "text-emerald-400 border-emerald-500/40 bg-emerald-500/10" },
|
|
13
|
+
cuda: { label: "CUDA", cls: "text-lime-400 border-lime-500/40 bg-lime-500/10" },
|
|
14
|
+
rocm: { label: "Vulkan / ROCm", cls: "text-orange-400 border-orange-500/40 bg-orange-500/10" },
|
|
15
|
+
none: { label: "CPU", cls: "text-muted-fg border-border bg-muted" },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function AccelBadge({ gpu }: { gpu: string }) {
|
|
19
|
+
const a = ACCEL[gpu] ?? ACCEL.none;
|
|
20
|
+
return (
|
|
21
|
+
<span className={`inline-flex items-center rounded-md border px-1.5 py-0.5 text-[11px] font-medium ${a.cls}`}>
|
|
22
|
+
{a.label}
|
|
23
|
+
</span>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Human label for the recommended backend (engine + where it runs).
|
|
28
|
+
function backendLabel(rec: SttHardwareResponse["recommended"]): string {
|
|
29
|
+
if (rec.backend === "mlx") return "Metal · mlx-whisper";
|
|
30
|
+
if (rec.backend === "faster") return (rec.device === "cuda" ? "CUDA" : "CPU") + " · faster-whisper";
|
|
31
|
+
return rec.backend;
|
|
32
|
+
}
|
|
33
|
+
|
|
7
34
|
// STT (speech-to-text) configuration. Persisted under config.transcription.
|
|
8
35
|
// The actual capture happens in the desktop window / Telegram / CLI; here the
|
|
9
36
|
// owner picks the engine and configures it:
|
|
@@ -36,6 +63,13 @@ const langOptions = () => [
|
|
|
36
63
|
];
|
|
37
64
|
|
|
38
65
|
export function VoiceSttCard({ config, onPatch, busy }: Props) {
|
|
66
|
+
const [hw, setHw] = useState<SttHardwareResponse | null>(null);
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
let alive = true;
|
|
69
|
+
Voice.sttHardware().then((r) => { if (alive) setHw(r); }).catch(() => {});
|
|
70
|
+
return () => { alive = false; };
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
39
73
|
const provider = config.provider || "auto";
|
|
40
74
|
const local = config.local || {};
|
|
41
75
|
const openai = config.openai || {};
|
|
@@ -63,8 +97,63 @@ export function VoiceSttCard({ config, onPatch, busy }: Props) {
|
|
|
63
97
|
? t("voice_ui.api_key_set", { suffix: secretSuffix(marker) ?? "" })
|
|
64
98
|
: t("voice_ui.api_key_label");
|
|
65
99
|
|
|
100
|
+
// ── Local engine: acceleration backend + model (hardware-adaptive) ─────────
|
|
101
|
+
const localBackend = local.backend || "auto";
|
|
102
|
+
const accel = hw?.hardware.gpu || "none";
|
|
103
|
+
// What "auto" actually resolves to on this machine (mlx on Metal, faster else).
|
|
104
|
+
const effectiveBackend = localBackend === "auto" ? (hw?.recommended.backend || "faster") : localBackend;
|
|
105
|
+
const isMlx = effectiveBackend === "mlx";
|
|
106
|
+
// The accel a chosen backend runs on — drives the badge next to the selector.
|
|
107
|
+
const selectedAccel = isMlx ? "metal" : (effectiveBackend === "faster" && accel === "cuda" ? "cuda" : "none");
|
|
108
|
+
|
|
109
|
+
const backendOptions = () => {
|
|
110
|
+
const opts = [{ value: "auto", label: t("voice_ui.stt_backend_auto") }];
|
|
111
|
+
if (accel === "metal") opts.push({ value: "mlx", label: "Metal — mlx-whisper" });
|
|
112
|
+
opts.push({ value: "faster", label: accel === "cuda" ? "CUDA — faster-whisper" : "CPU — faster-whisper" });
|
|
113
|
+
return opts;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Model list for the effective backend, with on-disk status in the label.
|
|
117
|
+
const [models, setModels] = useState<SttModelEntry[]>([]);
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
let alive = true;
|
|
120
|
+
Voice.sttModels(effectiveBackend).then((r) => { if (alive) setModels(r.models); }).catch(() => { if (alive) setModels([]); });
|
|
121
|
+
return () => { alive = false; };
|
|
122
|
+
}, [effectiveBackend]);
|
|
123
|
+
|
|
124
|
+
const fmtModel = (m: SttModelEntry) => `${m.id} · ${m.downloaded ? "✓ " + m.size : m.size}`;
|
|
125
|
+
const modelOptions = () =>
|
|
126
|
+
models.length
|
|
127
|
+
? models.map((m) => ({ value: isMlx ? m.repo : m.id, label: fmtModel(m) }))
|
|
128
|
+
: WHISPER_MODELS.map((m) => ({ value: m, label: m }));
|
|
129
|
+
const modelValue = isMlx ? (local.mlx_model || hw?.recommended.model || "") : model;
|
|
130
|
+
const modelPatchKey = isMlx ? "transcription.local.mlx_model" : "transcription.local.model";
|
|
131
|
+
const selectedModel = models.find((m) => (isMlx ? m.repo : m.id) === modelValue);
|
|
132
|
+
const needsDownload = !!selectedModel && !selectedModel.downloaded;
|
|
133
|
+
|
|
66
134
|
return (
|
|
67
135
|
<div className="space-y-3">
|
|
136
|
+
{hw && (
|
|
137
|
+
<div className="rounded-lg border border-border bg-muted px-3 py-2 text-sm">
|
|
138
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
139
|
+
<span className="text-muted-fg">{t("voice_ui.stt_hw_label")}:</span>
|
|
140
|
+
<AccelBadge gpu={hw.hardware.gpu} />
|
|
141
|
+
<span className="font-medium text-fg">{hw.hardware.gpuName || hw.hardware.platform}</span>
|
|
142
|
+
{hw.hardware.mem_gb ? (
|
|
143
|
+
<span className="text-muted-fg">
|
|
144
|
+
· {hw.hardware.mem_gb} GB{hw.hardware.unified_memory ? " unified" : ""}
|
|
145
|
+
</span>
|
|
146
|
+
) : null}
|
|
147
|
+
</div>
|
|
148
|
+
<div className="mt-1 text-xs text-muted-fg">
|
|
149
|
+
{t("voice_ui.stt_hw_recommended")}:{" "}
|
|
150
|
+
<span className="text-fg">{hw.recommended.model}</span>
|
|
151
|
+
{" "}({backendLabel(hw.recommended)})
|
|
152
|
+
{hw.recommended.limited ? ` — ${t("voice_ui.stt_hw_limited")}` : ""}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
|
|
68
157
|
<Field label={t("voice_ui.stt_engine_label")} hint={t("voice_ui.stt_engine_hint")}>
|
|
69
158
|
<UiSelect
|
|
70
159
|
value={provider}
|
|
@@ -76,23 +165,40 @@ export function VoiceSttCard({ config, onPatch, busy }: Props) {
|
|
|
76
165
|
</Field>
|
|
77
166
|
|
|
78
167
|
{showLocal && (
|
|
79
|
-
<div className="
|
|
80
|
-
<Field label={t("voice_ui.
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
onChange={(v) => onPatch({ "transcription.local.language": v })}
|
|
92
|
-
options={langOptions()}
|
|
93
|
-
disabled={busy}
|
|
94
|
-
/>
|
|
168
|
+
<div className="space-y-3">
|
|
169
|
+
<Field label={t("voice_ui.stt_backend_label")} hint={t("voice_ui.stt_backend_hint")}>
|
|
170
|
+
<div className="flex items-center gap-2">
|
|
171
|
+
<UiSelect
|
|
172
|
+
value={localBackend}
|
|
173
|
+
onChange={(v) => onPatch({ "transcription.local.backend": v })}
|
|
174
|
+
options={backendOptions()}
|
|
175
|
+
disabled={busy}
|
|
176
|
+
className="max-w-xs"
|
|
177
|
+
/>
|
|
178
|
+
<AccelBadge gpu={selectedAccel} />
|
|
179
|
+
</div>
|
|
95
180
|
</Field>
|
|
181
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
182
|
+
<Field
|
|
183
|
+
label={t("voice_ui.stt_model_label")}
|
|
184
|
+
hint={needsDownload ? t("voice_ui.stt_model_needs_download", { size: selectedModel!.size }) : t("voice_ui.stt_model_hint")}
|
|
185
|
+
>
|
|
186
|
+
<UiSelect
|
|
187
|
+
value={modelValue}
|
|
188
|
+
onChange={(v) => onPatch({ [modelPatchKey]: v })}
|
|
189
|
+
options={modelOptions()}
|
|
190
|
+
disabled={busy}
|
|
191
|
+
/>
|
|
192
|
+
</Field>
|
|
193
|
+
<Field label={t("voice_ui.stt_language_label")} hint={t("voice_ui.stt_language_hint")}>
|
|
194
|
+
<UiSelect
|
|
195
|
+
value={language}
|
|
196
|
+
onChange={(v) => onPatch({ "transcription.local.language": v })}
|
|
197
|
+
options={langOptions()}
|
|
198
|
+
disabled={busy}
|
|
199
|
+
/>
|
|
200
|
+
</Field>
|
|
201
|
+
</div>
|
|
96
202
|
</div>
|
|
97
203
|
)}
|
|
98
204
|
|
|
@@ -718,6 +718,22 @@ export const en = {
|
|
|
718
718
|
sessions_all: "All engines",
|
|
719
719
|
sessions_empty: "No sessions.",
|
|
720
720
|
sessions_error: "Could not read sessions: {msg}",
|
|
721
|
+
sessions_search_ph: "Search sessions…",
|
|
722
|
+
sessions_deep: "Deep",
|
|
723
|
+
sessions_deep_tip: "Also search inside transcripts (slower)",
|
|
724
|
+
sessions_clear: "Clear filters",
|
|
725
|
+
sessions_refresh: "Refresh list",
|
|
726
|
+
sessions_no_match: "No sessions match “{q}”.",
|
|
727
|
+
sessions_act_cmd: "Copy apx command",
|
|
728
|
+
sessions_act_ask: "Ask {name} to continue",
|
|
729
|
+
sessions_act_folder:"Open folder",
|
|
730
|
+
sessions_act_path: "Copy path",
|
|
731
|
+
sessions_cmd_copied:"Command copied — paste it in your terminal",
|
|
732
|
+
sessions_path_copied:"Path copied",
|
|
733
|
+
sessions_copy_failed:"Could not copy",
|
|
734
|
+
sessions_no_folder: "This session has no folder",
|
|
735
|
+
sessions_no_path: "This session has no path",
|
|
736
|
+
sessions_folder_failed:"Could not open folder: {msg}",
|
|
721
737
|
defaults_title: "Agent defaults",
|
|
722
738
|
defaults_desc: "Global vault templates. Bundled ones come with APX and are always present; ones you create or edit go in ~/.apx/agents and override. Import them into a project from Agents › Import.",
|
|
723
739
|
defaults_show_removed: "Show removed",
|
|
@@ -1171,6 +1187,13 @@ export const en = {
|
|
|
1171
1187
|
stt_custom_model_label: "Model",
|
|
1172
1188
|
stt_custom_model_hint: "e.g. mlx-community/whisper-large-v3-turbo or large-v3.",
|
|
1173
1189
|
stt_custom_key_hint: "Optional — most local servers need no key.",
|
|
1190
|
+
stt_hw_label: "Detected hardware",
|
|
1191
|
+
stt_hw_recommended: "Recommended",
|
|
1192
|
+
stt_hw_limited: "limited GPU acceleration, using CPU",
|
|
1193
|
+
stt_backend_label: "Acceleration / Engine",
|
|
1194
|
+
stt_backend_hint: "Auto adapts to your hardware. Metal runs on the GPU (mlx); CPU uses faster-whisper.",
|
|
1195
|
+
stt_backend_auto: "Automatic (recommended)",
|
|
1196
|
+
stt_model_needs_download: "Not downloaded (~{size}). The model must be downloaded to use this engine.",
|
|
1174
1197
|
lang_auto: "Auto-detect",
|
|
1175
1198
|
lang_es: "Spanish",
|
|
1176
1199
|
lang_en: "English",
|
|
@@ -716,6 +716,22 @@ export const es = {
|
|
|
716
716
|
sessions_all: "Todos los engines",
|
|
717
717
|
sessions_empty: "Sin sesiones.",
|
|
718
718
|
sessions_error: "No pude leer las sesiones: {msg}",
|
|
719
|
+
sessions_search_ph: "Buscar sesiones…",
|
|
720
|
+
sessions_deep: "Profundo",
|
|
721
|
+
sessions_deep_tip: "También busca dentro de los transcripts (más lento)",
|
|
722
|
+
sessions_clear: "Limpiar filtros",
|
|
723
|
+
sessions_refresh: "Refrescar lista",
|
|
724
|
+
sessions_no_match: "Ninguna sesión coincide con «{q}».",
|
|
725
|
+
sessions_act_cmd: "Copiar comando apx",
|
|
726
|
+
sessions_act_ask: "Pedir a {name} que continúe",
|
|
727
|
+
sessions_act_folder:"Abrir carpeta",
|
|
728
|
+
sessions_act_path: "Copiar ruta",
|
|
729
|
+
sessions_cmd_copied:"Comando copiado — pegalo en tu terminal",
|
|
730
|
+
sessions_path_copied:"Ruta copiada",
|
|
731
|
+
sessions_copy_failed:"No se pudo copiar",
|
|
732
|
+
sessions_no_folder: "Esta sesión no tiene carpeta",
|
|
733
|
+
sessions_no_path: "Esta sesión no tiene ruta",
|
|
734
|
+
sessions_folder_failed:"No se pudo abrir la carpeta: {msg}",
|
|
719
735
|
defaults_title: "Agent defaults",
|
|
720
736
|
defaults_desc: "Plantillas globales del vault. Las bundled vienen con APX y siempre están; las que crees o edites quedan en ~/.apx/agents y se superponen. Importalas a un proyecto desde Agents › Importar.",
|
|
721
737
|
defaults_show_removed: "Mostrar removidos",
|
|
@@ -1169,6 +1185,13 @@ export const es = {
|
|
|
1169
1185
|
stt_custom_model_label: "Modelo",
|
|
1170
1186
|
stt_custom_model_hint: "Ej: mlx-community/whisper-large-v3-turbo o large-v3.",
|
|
1171
1187
|
stt_custom_key_hint: "Opcional — la mayoría de los servers locales no requieren key.",
|
|
1188
|
+
stt_hw_label: "Hardware detectado",
|
|
1189
|
+
stt_hw_recommended: "Recomendado",
|
|
1190
|
+
stt_hw_limited: "aceleración GPU limitada, se usa CPU",
|
|
1191
|
+
stt_backend_label: "Aceleración / Motor",
|
|
1192
|
+
stt_backend_hint: "Auto elige según tu hardware. Metal corre en la GPU (mlx); CPU usa faster-whisper.",
|
|
1193
|
+
stt_backend_auto: "Automático (recomendado)",
|
|
1194
|
+
stt_model_needs_download: "Falta descargar (~{size}). Hay que bajar el modelo para usar este motor.",
|
|
1172
1195
|
lang_auto: "Detección automática",
|
|
1173
1196
|
lang_es: "Español",
|
|
1174
1197
|
lang_en: "Inglés",
|
|
@@ -7,6 +7,8 @@ export interface SessionRow {
|
|
|
7
7
|
mtime: number;
|
|
8
8
|
cwd: string;
|
|
9
9
|
path: string | null;
|
|
10
|
+
// Present only on search results: where the query matched.
|
|
11
|
+
match?: "title" | "content";
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export const Sessions = {
|
|
@@ -15,10 +17,13 @@ export const Sessions = {
|
|
|
15
17
|
http
|
|
16
18
|
.get<unknown>(`/sessions${engine ? `?engine=${encodeURIComponent(engine)}` : ""}`)
|
|
17
19
|
.then((b) => ({ sessions: unwrapPage<SessionRow>(b).items })),
|
|
18
|
-
// Server-paginated page
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
// Server-paginated page. Optional `q` runs the same search core as
|
|
21
|
+
// `apx session find` (title; + transcript content when `deep`).
|
|
22
|
+
page: ({ engine, q, deep, limit, offset }: { engine?: string; q?: string; deep?: boolean; limit: number; offset: number }) => {
|
|
23
|
+
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
|
24
|
+
if (engine) params.set("engine", engine);
|
|
25
|
+
if (q?.trim()) params.set("q", q.trim());
|
|
26
|
+
if (deep) params.set("deep", "1");
|
|
27
|
+
return http.get<unknown>(`/sessions?${params.toString()}`).then((b) => unwrapPage<SessionRow>(b));
|
|
23
28
|
},
|
|
24
29
|
};
|
|
@@ -92,7 +92,9 @@ export interface VoiceTtsConfig {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
export interface TranscriptionLocalConfig {
|
|
95
|
-
|
|
95
|
+
backend?: string; // auto | faster | mlx (auto adapts to the hardware)
|
|
96
|
+
model?: string; // faster-whisper model id (tiny | base | small | …)
|
|
97
|
+
mlx_model?: string; // mlx repo (e.g. mlx-community/whisper-large-v3-turbo)
|
|
96
98
|
device?: string; // cpu | cuda
|
|
97
99
|
compute_type?: string; // int8 | int8_float16 | float16 | float32
|
|
98
100
|
language?: string; // ISO code or "auto"
|
|
@@ -117,6 +119,34 @@ export interface TranscriptionConfig {
|
|
|
117
119
|
custom?: TranscriptionCustomConfig;
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
/** Detected machine + recommended local backend (GET /transcribe/hardware). */
|
|
123
|
+
export interface SttHardware {
|
|
124
|
+
platform: string;
|
|
125
|
+
arch: string;
|
|
126
|
+
appleSilicon: boolean;
|
|
127
|
+
gpu: "metal" | "cuda" | "rocm" | "none";
|
|
128
|
+
gpuName?: string;
|
|
129
|
+
mem_gb?: number;
|
|
130
|
+
unified_memory?: boolean;
|
|
131
|
+
}
|
|
132
|
+
export interface SttHardwareResponse {
|
|
133
|
+
hardware: SttHardware;
|
|
134
|
+
recommended: { backend: string; device?: string; model: string; reason?: string; tier?: string; limited?: boolean };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** One model row from GET /transcribe/models. */
|
|
138
|
+
export interface SttModelEntry {
|
|
139
|
+
id: string;
|
|
140
|
+
repo: string;
|
|
141
|
+
downloaded: boolean;
|
|
142
|
+
size: string; // "1.6 GB" when present, "~1.6 GB" when not yet downloaded
|
|
143
|
+
size_bytes: number;
|
|
144
|
+
}
|
|
145
|
+
export interface SttModelsResponse {
|
|
146
|
+
backend: string;
|
|
147
|
+
models: SttModelEntry[];
|
|
148
|
+
}
|
|
149
|
+
|
|
120
150
|
/** One STT engine entry as reported by GET /transcribe/providers. */
|
|
121
151
|
export interface SttProviderEntry {
|
|
122
152
|
id: string; // "local" | "openai" | "custom"
|
|
@@ -169,6 +199,12 @@ export const Voice = {
|
|
|
169
199
|
/** List TTS engines + availability + the configured default provider. */
|
|
170
200
|
providers: () => http.get<TtsProvidersResponse>("/tts/providers"),
|
|
171
201
|
|
|
202
|
+
/** Detected hardware + the recommended local STT backend (Metal/CUDA/CPU). */
|
|
203
|
+
sttHardware: () => http.get<SttHardwareResponse>("/transcribe/hardware"),
|
|
204
|
+
|
|
205
|
+
/** Model catalog + on-disk status for a local backend ("faster" | "mlx"). */
|
|
206
|
+
sttModels: (backend: string) => http.get<SttModelsResponse>(`/transcribe/models?backend=${backend}`),
|
|
207
|
+
|
|
172
208
|
/**
|
|
173
209
|
* Synthesize speech. Returns the audio file path (server-side); the web
|
|
174
210
|
* fetches it via fetchTtsAudioUrl() to play it in the browser. `no_play`
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
2
|
-
import { RefreshCw } from "lucide-react";
|
|
3
|
-
import { Sessions } from "../../lib/api";
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { RefreshCw, Search, X, Terminal, Bot, FolderOpen, Copy } from "lucide-react";
|
|
3
|
+
import { Sessions, Deck, type SessionRow } from "../../lib/api";
|
|
4
4
|
import { Section } from "../../components/Section";
|
|
5
5
|
import { PagedList, usePagedQuery } from "../../components/Pager";
|
|
6
|
-
import { Badge, Button, Empty, Loading } from "../../components/ui";
|
|
6
|
+
import { Badge, Button, Empty, Input, Loading, Tip } from "../../components/ui";
|
|
7
7
|
import { UiSelect } from "../../components/UiSelect";
|
|
8
|
+
import { useToast } from "../../components/Toast";
|
|
9
|
+
import { usePersonaName } from "../../hooks/usePersonaName";
|
|
8
10
|
import { t } from "../../i18n";
|
|
9
11
|
|
|
10
12
|
const ENGINE_TONE: Record<string, "success" | "info" | "warning" | "muted"> = {
|
|
@@ -12,49 +14,137 @@ const ENGINE_TONE: Record<string, "success" | "info" | "warning" | "muted"> = {
|
|
|
12
14
|
};
|
|
13
15
|
|
|
14
16
|
export function SessionsTab() {
|
|
17
|
+
const toast = useToast();
|
|
18
|
+
const persona = usePersonaName();
|
|
15
19
|
const [engine, setEngine] = useState("");
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
const [input, setInput] = useState("");
|
|
21
|
+
const [query, setQuery] = useState("");
|
|
22
|
+
const [deep, setDeep] = useState(false);
|
|
23
|
+
|
|
24
|
+
// Debounce the raw input so we don't hit the search core on every keystroke.
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const id = setTimeout(() => setQuery(input.trim()), 350);
|
|
27
|
+
return () => clearTimeout(id);
|
|
28
|
+
}, [input]);
|
|
29
|
+
|
|
30
|
+
const paged = usePagedQuery<SessionRow>({
|
|
31
|
+
key: `/sessions?engine=${engine}&q=${query}&deep=${deep ? 1 : 0}`,
|
|
32
|
+
fetchPage: (limit, offset) =>
|
|
33
|
+
Sessions.page({ engine: engine || undefined, q: query || undefined, deep, limit, offset }),
|
|
34
|
+
resetKey: `${engine}|${query}|${deep ? 1 : 0}`,
|
|
20
35
|
});
|
|
21
36
|
|
|
37
|
+
const clear = () => { setInput(""); setQuery(""); setEngine(""); setDeep(false); };
|
|
38
|
+
|
|
39
|
+
// ── per-row actions (all reuse existing system functions) ──────────────────
|
|
40
|
+
const copyCmd = async (s: SessionRow) => {
|
|
41
|
+
// Same command a user would run in the terminal to resume the session.
|
|
42
|
+
try {
|
|
43
|
+
await navigator.clipboard.writeText(`apx session resume ${s.id} --continue`);
|
|
44
|
+
toast.success(t("base.sessions_cmd_copied"));
|
|
45
|
+
} catch { toast.error(t("base.sessions_copy_failed")); }
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const askPersona = (s: SessionRow) => {
|
|
49
|
+
// English on purpose: the super-agent's language is unknown, and the user
|
|
50
|
+
// appends their own instructions after the colon.
|
|
51
|
+
const prompt =
|
|
52
|
+
`Continue this session: ${s.id} ` +
|
|
53
|
+
`(engine: ${s.engine}${s.title ? `, title: "${s.title}"` : ""}${s.cwd ? `, folder: ${s.cwd}` : ""}). ` +
|
|
54
|
+
`With these instructions: `;
|
|
55
|
+
window.dispatchEvent(new CustomEvent("apx:roby-prompt", { detail: { prompt } }));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const openFolder = async (s: SessionRow) => {
|
|
59
|
+
if (!s.cwd) { toast.error(t("base.sessions_no_folder")); return; }
|
|
60
|
+
try { await Deck.exec({ kind: "open_path", target: s.cwd }); }
|
|
61
|
+
catch (e) { toast.error(t("base.sessions_folder_failed", { msg: (e as Error).message })); }
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const copyPath = async (s: SessionRow) => {
|
|
65
|
+
const p = s.path || s.cwd;
|
|
66
|
+
if (!p) { toast.error(t("base.sessions_no_path")); return; }
|
|
67
|
+
try { await navigator.clipboard.writeText(p); toast.success(t("base.sessions_path_copied")); }
|
|
68
|
+
catch { toast.error(t("base.sessions_copy_failed")); }
|
|
69
|
+
};
|
|
70
|
+
|
|
22
71
|
return (
|
|
23
72
|
<Section
|
|
24
73
|
fullHeight
|
|
25
74
|
title={t("base.sessions_title")}
|
|
26
75
|
description={t("base.sessions_desc")}
|
|
27
76
|
action={
|
|
28
|
-
<
|
|
29
|
-
<div className="w-40">
|
|
30
|
-
<UiSelect
|
|
31
|
-
value={engine}
|
|
32
|
-
onChange={setEngine}
|
|
33
|
-
options={[
|
|
34
|
-
{ value: "", label: t("base.sessions_all") },
|
|
35
|
-
{ value: "apx", label: "apx" },
|
|
36
|
-
{ value: "claude", label: "claude" },
|
|
37
|
-
{ value: "codex", label: "codex" },
|
|
38
|
-
]}
|
|
39
|
-
/>
|
|
40
|
-
</div>
|
|
77
|
+
<Tip content={t("base.sessions_refresh")}>
|
|
41
78
|
<Button size="sm" variant="secondary" onClick={() => paged.mutate()}><RefreshCw size={13} /></Button>
|
|
42
|
-
</
|
|
79
|
+
</Tip>
|
|
43
80
|
}
|
|
44
81
|
>
|
|
82
|
+
{/* Toolbar: search + deep toggle + engine selector + clear. */}
|
|
83
|
+
<div className="mb-3 flex items-center gap-2">
|
|
84
|
+
<div className="relative flex-1">
|
|
85
|
+
<Search size={14} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-fg" />
|
|
86
|
+
<Input
|
|
87
|
+
className="pl-8"
|
|
88
|
+
placeholder={t("base.sessions_search_ph")}
|
|
89
|
+
value={input}
|
|
90
|
+
onChange={(e) => setInput(e.target.value)}
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
<Tip content={t("base.sessions_deep_tip")}>
|
|
94
|
+
<Button size="sm" variant={deep ? "primary" : "secondary"} onClick={() => setDeep((d) => !d)}>
|
|
95
|
+
{t("base.sessions_deep")}
|
|
96
|
+
</Button>
|
|
97
|
+
</Tip>
|
|
98
|
+
<div className="w-36">
|
|
99
|
+
<UiSelect
|
|
100
|
+
value={engine}
|
|
101
|
+
onChange={setEngine}
|
|
102
|
+
options={[
|
|
103
|
+
{ value: "", label: t("base.sessions_all") },
|
|
104
|
+
{ value: "apx", label: "apx" },
|
|
105
|
+
{ value: "claude", label: "claude" },
|
|
106
|
+
{ value: "codex", label: "codex" },
|
|
107
|
+
]}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
<Tip content={t("base.sessions_clear")}>
|
|
111
|
+
<Button size="sm" variant="ghost" onClick={clear}><X size={14} /></Button>
|
|
112
|
+
</Tip>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
45
115
|
{paged.isLoading && <Loading />}
|
|
46
116
|
{paged.error && <Empty>{t("base.sessions_error", { msg: (paged.error as Error).message })}</Empty>}
|
|
47
|
-
{!paged.isLoading && !paged.error && paged.total === 0 &&
|
|
117
|
+
{!paged.isLoading && !paged.error && paged.total === 0 && (
|
|
118
|
+
<Empty>{query ? t("base.sessions_no_match", { q: query }) : t("base.sessions_empty")}</Empty>
|
|
119
|
+
)}
|
|
120
|
+
|
|
48
121
|
<PagedList paged={paged} fullHeight>
|
|
49
122
|
<ul className="space-y-1 text-sm">
|
|
50
123
|
{paged.items.map((s, i) => (
|
|
51
|
-
<li key={`${s.engine}-${s.id}-${i}`} className="flex items-center gap-3 rounded-md border border-border bg-muted/30 px-3 py-2">
|
|
124
|
+
<li key={`${s.engine}-${s.id}-${i}`} className="group flex items-center gap-3 rounded-md border border-border bg-muted/30 px-3 py-2">
|
|
52
125
|
<Badge tone={ENGINE_TONE[s.engine] || "muted"}>{s.engine}</Badge>
|
|
53
126
|
<div className="min-w-0 flex-1">
|
|
54
127
|
<div className="truncate">{s.title || s.id}</div>
|
|
55
|
-
<div className="
|
|
128
|
+
<div className="flex items-center gap-2 font-mono text-[10px] text-muted-fg">
|
|
129
|
+
<span className="shrink-0">{s.id}</span>
|
|
130
|
+
{s.cwd && <span className="truncate">· {s.cwd}</span>}
|
|
131
|
+
</div>
|
|
56
132
|
</div>
|
|
57
133
|
{s.mtime > 0 && <span className="shrink-0 text-[11px] text-muted-fg">{new Date(s.mtime).toLocaleString()}</span>}
|
|
134
|
+
<div className="flex shrink-0 items-center gap-0.5 opacity-60 transition-opacity group-hover:opacity-100">
|
|
135
|
+
<Tip content={t("base.sessions_act_cmd")}>
|
|
136
|
+
<Button size="sm" variant="ghost" aria-label={t("base.sessions_act_cmd")} onClick={() => copyCmd(s)}><Terminal size={13} /></Button>
|
|
137
|
+
</Tip>
|
|
138
|
+
<Tip content={t("base.sessions_act_ask", { name: persona })}>
|
|
139
|
+
<Button size="sm" variant="ghost" aria-label={t("base.sessions_act_ask", { name: persona })} onClick={() => askPersona(s)}><Bot size={13} /></Button>
|
|
140
|
+
</Tip>
|
|
141
|
+
<Tip content={t("base.sessions_act_folder")}>
|
|
142
|
+
<Button size="sm" variant="ghost" aria-label={t("base.sessions_act_folder")} onClick={() => openFolder(s)}><FolderOpen size={13} /></Button>
|
|
143
|
+
</Tip>
|
|
144
|
+
<Tip content={t("base.sessions_act_path")}>
|
|
145
|
+
<Button size="sm" variant="ghost" aria-label={t("base.sessions_act_path")} onClick={() => copyPath(s)}><Copy size={13} /></Button>
|
|
146
|
+
</Tip>
|
|
147
|
+
</div>
|
|
58
148
|
</li>
|
|
59
149
|
))}
|
|
60
150
|
</ul>
|