@nextxai/cli 0.2.0 → 0.3.1
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/CHANGELOG.md +13 -1
- package/dist/ai/client.js +30 -20
- package/dist/auth/session.js +3 -3
- package/dist/commands/ai.js +62 -13
- package/dist/commands/ci-policy.js +98 -0
- package/dist/commands/ci.js +28 -4
- package/dist/commands/keys.js +2 -1
- package/dist/commands/mcp.js +3 -3
- package/dist/commands/tools.js +4 -24
- package/dist/commands/webhooks.js +17 -15
- package/dist/core/brand.js +2 -3
- package/dist/core/http.js +11 -3
- package/dist/mcp/client.js +1 -1
- package/dist/repl/App.js +0 -68
- package/dist/repl/slash.js +1 -4
- package/package.json +1 -1
- package/dist/ai/nextfarm.js +0 -387
- package/dist/commands/ai-nextfarm.js +0 -109
- package/dist/core/case.js +0 -23
package/dist/core/http.js
CHANGED
|
@@ -7,8 +7,16 @@ import { context } from "./context.js";
|
|
|
7
7
|
import { CliError, Exit } from "./errors.js";
|
|
8
8
|
import { t, currentLang } from "./i18n.js";
|
|
9
9
|
import { VERSION } from "./version.js";
|
|
10
|
-
import { snakeOnWire, snakeKeys, camelKeys } from "./case.js";
|
|
11
10
|
import { currentAuth, refreshSession } from "../auth/session.js";
|
|
11
|
+
/** Key case on the wire: some services speak snake_case; the CLI keeps camelCase inside. */
|
|
12
|
+
function toWire(url, body) {
|
|
13
|
+
void url;
|
|
14
|
+
return body;
|
|
15
|
+
}
|
|
16
|
+
function fromWire(url, body) {
|
|
17
|
+
void url;
|
|
18
|
+
return body;
|
|
19
|
+
}
|
|
12
20
|
setGlobalDispatcher(new EnvHttpProxyAgent());
|
|
13
21
|
export const userAgent = () => `${brand().bin}-cli/${VERSION} (${platform()}; ${arch()}; node ${process.versions.node})`;
|
|
14
22
|
export function apiPath(path) {
|
|
@@ -51,7 +59,7 @@ async function send(method, url, opts, auth) {
|
|
|
51
59
|
if (opts.body instanceof FormData || typeof opts.body === "string")
|
|
52
60
|
body = opts.body;
|
|
53
61
|
else {
|
|
54
|
-
body = JSON.stringify(
|
|
62
|
+
body = JSON.stringify(opts.verbatim ? opts.body : toWire(url, opts.body));
|
|
55
63
|
headers["Content-Type"] ??= "application/json";
|
|
56
64
|
}
|
|
57
65
|
}
|
|
@@ -161,7 +169,7 @@ export async function request(method, path, opts = {}) {
|
|
|
161
169
|
if (opts.raw)
|
|
162
170
|
return { status: res.status, data: res, headers: res.headers, requestId };
|
|
163
171
|
const body = await readBody(res);
|
|
164
|
-
const data =
|
|
172
|
+
const data = opts.verbatim ? body : fromWire(res.url || path, body);
|
|
165
173
|
if (!res.ok && !opts.allow?.includes(res.status))
|
|
166
174
|
throw errorFor(res.status, data, res);
|
|
167
175
|
return { status: res.status, data: data, headers: res.headers, requestId };
|
package/dist/mcp/client.js
CHANGED
|
@@ -17,7 +17,7 @@ export class McpClient {
|
|
|
17
17
|
nextId = 1;
|
|
18
18
|
token;
|
|
19
19
|
ready = false;
|
|
20
|
-
/** `url`: MCP endpoint other than the hosted OAuth server (
|
|
20
|
+
/** `url`: MCP endpoint other than the hosted OAuth server (API keys that go through the gateway's /api/v1/mcp). */
|
|
21
21
|
constructor(interactive, apiKey, url) {
|
|
22
22
|
this.interactive = interactive;
|
|
23
23
|
this.apiKey = apiKey;
|
package/dist/repl/App.js
CHANGED
|
@@ -22,7 +22,6 @@ import { Editor, Menu, Select, edit } from "./components.js";
|
|
|
22
22
|
import { LocalChat } from "../ai/local.js";
|
|
23
23
|
import { listModels, resolveModel } from "../ai/models.js";
|
|
24
24
|
import { SHORTCUTS, SLASH, matchSlash, slashCommands } from "./slash.js";
|
|
25
|
-
import * as nf from "../ai/nextfarm.js";
|
|
26
25
|
const MODES = ["propose", "auto", "read"];
|
|
27
26
|
const modeLabel = (m) => m === "propose" ? c.cyan(tr("⏵⏵ đề xuất", "⏵⏵ propose")) : m === "auto" ? c.yellow(tr("⏵⏵ tự ghi việc thường", "⏵⏵ auto-write normal")) : c.dim(tr("⏸ chỉ đọc", "⏸ read only"));
|
|
28
27
|
let seq = 0;
|
|
@@ -333,71 +332,6 @@ export function App(props) {
|
|
|
333
332
|
say(`${c.green("✓")} ${tr(`Đã ghi vào ${relative(process.cwd(), target) || notesFileName()} — gửi kèm mọi câu hỏi trong thư mục này.`, `Saved to ${relative(process.cwd(), target) || notesFileName()} — sent with every question in this folder.`)}`);
|
|
334
333
|
}
|
|
335
334
|
// ── slash commands ────────────────────────────────────────────────────────────────────────────
|
|
336
|
-
/** NextFarm versions of the slash commands whose server contract differs. True = handled. */
|
|
337
|
-
async function farmSlash(cmd, arg) {
|
|
338
|
-
switch (cmd) {
|
|
339
|
-
case "undo": {
|
|
340
|
-
const target = nf.undoTarget(arg || undefined);
|
|
341
|
-
const ok = await pick(tr(`Hoàn tác "${target.label}"?`, `Undo "${target.label}"?`), [{ label: tr("Không", "No"), value: "n" }, { label: tr("Hoàn tác", "Undo"), value: "y" }]);
|
|
342
|
-
if (ok === "y")
|
|
343
|
-
say(`${c.green("✓")} ${await nf.undo(target)}`);
|
|
344
|
-
return true;
|
|
345
|
-
}
|
|
346
|
-
case "why": {
|
|
347
|
-
const id = arg || lastOutcome?.runId;
|
|
348
|
-
if (!id)
|
|
349
|
-
throw new Error(tr("Chưa có câu trả lời nào trong phiên này.", "No answer in this session yet."));
|
|
350
|
-
say((await nf.runTrace(id)).join("\n"));
|
|
351
|
-
return true;
|
|
352
|
-
}
|
|
353
|
-
case "memory": {
|
|
354
|
-
const m = await nf.memories();
|
|
355
|
-
const [sub, id] = arg.split(/\s+/);
|
|
356
|
-
if (sub === "forget" && id) {
|
|
357
|
-
const hit = m.items.find((x) => x.id.startsWith(id));
|
|
358
|
-
if (!hit)
|
|
359
|
-
throw new Error(tr("Không thấy điều nhớ này.", "Memory not found."));
|
|
360
|
-
await nf.forgetMemory(hit.id);
|
|
361
|
-
say(`${c.green("✓")} ${tr("Đã quên.", "Forgotten.")}`);
|
|
362
|
-
return true;
|
|
363
|
-
}
|
|
364
|
-
say(m.items.length ? table(["ID", tr("Điều nhớ", "Memory"), tr("Dùng", "Uses")], m.items.map((x) => [x.id.slice(0, 8), x.display, x.uses])) + (m.canDelete ? c.dim("\n /memory forget <id>") : "") : tr(" Trợ lý chưa nhớ gì về trang trại.", " Nothing remembered about the farm yet."));
|
|
365
|
-
return true;
|
|
366
|
-
}
|
|
367
|
-
case "schedule": {
|
|
368
|
-
const m = /^"([^"]+)"\s+(daily|weekly)\s+(?:([1-7])@)?(\d{1,2}:\d{2})$/.exec(arg);
|
|
369
|
-
if (m) {
|
|
370
|
-
const [, q, freq, day, time] = m;
|
|
371
|
-
const s = await nf.createSchedule(q, freq, time.padStart(5, "0"), day ? Number(day) : undefined);
|
|
372
|
-
say(`${c.green("✓")} ${tr(`Đã lên lịch · ${s.when}`, `Scheduled · ${s.when}`)}\n${c.dim(tr(` Mở mục Trợ lý AI trong app ${b.mobileApp} một lần để cho phép chạy nền.`, ` Open the AI assistant in the ${b.mobileApp} app once to allow background runs.`))}`);
|
|
373
|
-
}
|
|
374
|
-
else {
|
|
375
|
-
const list = await nf.schedules();
|
|
376
|
-
say((list.length ? table(["ID", tr("Tên", "Title"), tr("Lịch", "When"), tr("Lần tới", "Next")], list.map((s) => [s.id.slice(0, 8), s.title, s.when, s.nextRunAt.slice(0, 16).replace("T", " ")])) + "\n" : "") + c.dim(tr(' /schedule "nhật ký hôm qua" daily 07:00 · weekly 1@07:00 (1 = thứ Hai)', ' /schedule "yesterday\'s log" daily 07:00 · weekly 1@07:00 (1 = Monday)')));
|
|
377
|
-
}
|
|
378
|
-
return true;
|
|
379
|
-
}
|
|
380
|
-
case "sau-benh": {
|
|
381
|
-
if (!arg)
|
|
382
|
-
throw new Error(tr("Cần ảnh: /sau-benh <ảnh.jpg>", "Pass a photo: /sau-benh <photo.jpg>"));
|
|
383
|
-
const [path, ...note] = arg.split(/\s+/);
|
|
384
|
-
await addAttachment(path);
|
|
385
|
-
await send(note.length ? note.join(" ") : tr("Ảnh này cây bị sâu bệnh gì? Mức độ, cách xử lý và lô nào cần kiểm tra thêm.", "What pest or disease is in this photo? Severity, treatment and which plots to check."));
|
|
386
|
-
return true;
|
|
387
|
-
}
|
|
388
|
-
case "thoi-tiet":
|
|
389
|
-
await send(arg ? tr(`Thời tiết 3 ngày tới ở ${arg}, có ảnh hưởng gì tới việc phun, tưới, thu hoạch?`, `Weather for the next 3 days at ${arg} — any impact on spraying, irrigation, harvest?`)
|
|
390
|
-
: tr("Thời tiết 3 ngày tới ở trang trại, có ảnh hưởng gì tới việc phun, tưới, thu hoạch?", "Weather for the next 3 days at the farm — any impact on spraying, irrigation, harvest?"));
|
|
391
|
-
return true;
|
|
392
|
-
case "ban-tin": {
|
|
393
|
-
const brief = await nf.latestBrief();
|
|
394
|
-
say(brief ? `${c.bold(brief.title)} ${c.dim(brief.createdAt.slice(0, 16).replace("T", " "))}\n${markdown(brief.text)}` : tr(" Chưa có bản tin sáng nào. Bật trong app: Trợ lý AI › Bản tin sáng.", " No morning brief yet. Turn it on in the app: AI assistant › Morning brief."));
|
|
395
|
-
return true;
|
|
396
|
-
}
|
|
397
|
-
default:
|
|
398
|
-
return false;
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
335
|
async function runSlash(line) {
|
|
402
336
|
const [cmd0, ...rest] = line.slice(1).split(/\s+/);
|
|
403
337
|
const cmd = (cmd0 ?? "").toLowerCase();
|
|
@@ -408,8 +342,6 @@ export function App(props) {
|
|
|
408
342
|
say(`${c.yellow("⚠")} ${tr(`Không có lệnh /${cmd}.`, `No command /${cmd}.`)} ${c.dim("/help")}`);
|
|
409
343
|
return;
|
|
410
344
|
}
|
|
411
|
-
if (b.platform === "nextfarm" && (await farmSlash(cmd, arg)))
|
|
412
|
-
return;
|
|
413
345
|
switch (cmd) {
|
|
414
346
|
case "help":
|
|
415
347
|
case "?": {
|
package/dist/repl/slash.js
CHANGED
|
@@ -20,12 +20,9 @@ export const SLASH = [
|
|
|
20
20
|
{ name: "memory", description: () => tr("AI đang nhớ gì về công ty — sửa, khoá, quên", "What the AI remembers — edit, lock, forget") },
|
|
21
21
|
{ name: "schedule", args: '"câu" daily 08:00', description: () => tr("Lên lịch một câu hỏi chạy hằng ngày / tuần / tháng", "Schedule a question daily / weekly / monthly") },
|
|
22
22
|
{ name: "library", description: () => tr("Ảnh, tài liệu, tệp AI đã tạo", "Images, documents and files the AI produced"), platforms: ["nextx"] },
|
|
23
|
-
{ name: "sau-benh", args: "<ảnh>", platforms: ["nextfarm"], description: () => tr("Chẩn đoán sâu bệnh từ ảnh lá, quả, thân", "Diagnose pests and diseases from a photo") },
|
|
24
|
-
{ name: "thoi-tiet", args: "[lô]", platforms: ["nextfarm"], description: () => tr("Thời tiết mấy ngày tới ở trang trại / lô", "Weather for the farm or a plot in the coming days") },
|
|
25
|
-
{ name: "ban-tin", platforms: ["nextfarm"], description: () => tr("Bản tin sáng mới nhất của trang trại", "The farm's latest morning brief") },
|
|
26
23
|
{ name: "export", args: "md|json [tệp]", description: () => tr("Xuất hội thoại ra Markdown / JSON", "Export the conversation to Markdown / JSON") },
|
|
27
24
|
{ name: "tools", description: () => tr("Công cụ AI bạn đang được dùng (theo quyền thật)", "AI tools you can use (by your real permissions)") },
|
|
28
|
-
{ name: "usage", description: () => brand().platform === "
|
|
25
|
+
{ name: "usage", description: () => brand().platform === "nextx" ? tr("Lượt AI tháng này", "AI turns this month") : tr("Câu hỏi AI còn lại tuần này", "AI questions left this week") },
|
|
29
26
|
{ name: "feedback", args: "up|down", description: () => tr("Báo câu trả lời vừa rồi tốt / chưa tốt", "Rate the last answer") },
|
|
30
27
|
{ name: "support", args: "<vấn đề>", description: () => tr("Chuyển cho nhân viên hỗ trợ (mở ticket)", "Hand over to a support agent (ticket)"), platforms: ["nextx"] },
|
|
31
28
|
{ name: "status", description: () => tr("Tài khoản, workspace, môi trường, phiên bản", "Account, workspace, environment, version") },
|
package/package.json
CHANGED
package/dist/ai/nextfarm.js
DELETED
|
@@ -1,387 +0,0 @@
|
|
|
1
|
-
// NextFarm assistant contract. Same exported shapes as client.ts, different server:
|
|
2
|
-
// POST /ai/agent-runs → GET /ai/agent-runs/{id}/events (SSE, resumable with Last-Event-ID)
|
|
3
|
-
// POST /ai/action-proposals/{id}/decision|undo (Idempotency-Key required)
|
|
4
|
-
// REST bodies are snake_case both ways; SSE event payloads are camelCase.
|
|
5
|
-
import { randomUUID } from "node:crypto";
|
|
6
|
-
import { readFileSync, statSync } from "node:fs";
|
|
7
|
-
import { basename, extname } from "node:path";
|
|
8
|
-
import { FormData } from "undici";
|
|
9
|
-
import { get, post, patch, del, requestRaw, errorFor } from "../core/http.js";
|
|
10
|
-
import { CliError, Exit } from "../core/errors.js";
|
|
11
|
-
import { currentLang, tr } from "../core/i18n.js";
|
|
12
|
-
/** First present key, snake or camel. */
|
|
13
|
-
function pick(o, ...keys) {
|
|
14
|
-
if (!o || typeof o !== "object")
|
|
15
|
-
return undefined;
|
|
16
|
-
for (const k of keys)
|
|
17
|
-
if (o[k] !== undefined && o[k] !== null)
|
|
18
|
-
return o[k];
|
|
19
|
-
return undefined;
|
|
20
|
-
}
|
|
21
|
-
// ── Conversations ───────────────────────────────────────────────────────────────────────────────────
|
|
22
|
-
/** A new conversation is created by the first run; "" means "start one". */
|
|
23
|
-
export const openConversation = async (_forceNew) => ({ sessionId: "", greeting: "", suggestedQuestions: [], status: "active" });
|
|
24
|
-
export async function listConversations(limit = 30) {
|
|
25
|
-
const r = await get("/ai/conversations", { query: { page: 1, page_size: Math.min(limit, 50) } });
|
|
26
|
-
return (pick(r, "items") ?? []).map((x) => ({
|
|
27
|
-
sessionId: String(pick(x, "conversation_id", "conversationId")),
|
|
28
|
-
title: pick(x, "title") ?? null,
|
|
29
|
-
lastPreview: pick(x, "title") ?? null,
|
|
30
|
-
isPinned: Boolean(pick(x, "pinned")),
|
|
31
|
-
status: pick(x, "status"),
|
|
32
|
-
updatedAt: String(pick(x, "last_message_at", "lastMessageAt", "updated_at", "updatedAt") ?? ""),
|
|
33
|
-
}));
|
|
34
|
-
}
|
|
35
|
-
export const renameConversation = (id, title) => patch(`/ai/conversations/${id}`, { title: title ?? "" });
|
|
36
|
-
export const pinConversation = (id, pinned) => patch(`/ai/conversations/${id}`, { pinned });
|
|
37
|
-
export const deleteConversation = (id) => del(`/ai/conversations/${id}`);
|
|
38
|
-
export async function history(sessionId) {
|
|
39
|
-
if (!sessionId)
|
|
40
|
-
return [];
|
|
41
|
-
const r = await get(`/ai/conversations/${sessionId}`);
|
|
42
|
-
return (pick(r, "messages") ?? []).map((m) => ({
|
|
43
|
-
id: String(pick(m, "message_id", "messageId")),
|
|
44
|
-
role: pick(m, "role") === "user" ? "User" : "Assistant",
|
|
45
|
-
text: pick(m, "text") ?? "",
|
|
46
|
-
createdAt: String(pick(m, "created_at", "createdAt") ?? ""),
|
|
47
|
-
}));
|
|
48
|
-
}
|
|
49
|
-
/** NextFarm rates a whole run, so `messageId` here is the run id (see `done.messageId` below). */
|
|
50
|
-
export const feedback = (runId, helpful) => post(`/ai/agent-runs/${runId}/feedback`, { rating: helpful ? "up" : "down" });
|
|
51
|
-
export const escalate = async (_sessionId, _topic) => {
|
|
52
|
-
throw new CliError(tr("NextFarm chưa có chuyển cho nhân viên hỗ trợ từ terminal.", "Handing over to a support agent is not available for NextFarm yet."), Exit.general);
|
|
53
|
-
};
|
|
54
|
-
export async function usage() {
|
|
55
|
-
const q = await get("/ai/assistant/quota");
|
|
56
|
-
const limit = Number(pick(q, "limit") ?? 0);
|
|
57
|
-
return {
|
|
58
|
-
planName: tr("Tuần này", "This week"),
|
|
59
|
-
used: Number(pick(q, "used") ?? 0),
|
|
60
|
-
limit,
|
|
61
|
-
remaining: Number(pick(q, "remaining") ?? 0),
|
|
62
|
-
resetsAt: nextMondayVietnam(),
|
|
63
|
-
enforced: limit > 0,
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
/** The weekly quota resets at 00:00 Monday, Vietnam time (UTC+7). */
|
|
67
|
-
export function nextMondayVietnam(now = new Date()) {
|
|
68
|
-
const vn = new Date(now.getTime() + 7 * 3600_000);
|
|
69
|
-
const days = ((8 - vn.getUTCDay()) % 7) || 7;
|
|
70
|
-
const d = new Date(Date.UTC(vn.getUTCFullYear(), vn.getUTCMonth(), vn.getUTCDate() + days));
|
|
71
|
-
return `${d.toISOString().slice(0, 10)}T00:00:00+07:00`;
|
|
72
|
-
}
|
|
73
|
-
// ── Attachments ─────────────────────────────────────────────────────────────────────────────────────
|
|
74
|
-
const IMAGE = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
|
|
75
|
-
const DOCUMENT = {
|
|
76
|
-
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
77
|
-
".csv": "text/csv",
|
|
78
|
-
".pdf": "application/pdf",
|
|
79
|
-
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
80
|
-
};
|
|
81
|
-
const MAX_BYTES = 10 * 1024 * 1024;
|
|
82
|
-
/** Images go to /ai/attachments, documents to /ai/files; both return an id the run references. */
|
|
83
|
-
export async function attachBinary(path, kind) {
|
|
84
|
-
const name = basename(path);
|
|
85
|
-
const ext = extname(path).toLowerCase();
|
|
86
|
-
const size = statSync(path).size;
|
|
87
|
-
if (size > MAX_BYTES)
|
|
88
|
-
throw new CliError(tr("Tệp quá lớn (tối đa 10 MB).", "File too large (max 10 MB)."), Exit.usage);
|
|
89
|
-
const type = kind === "image" ? IMAGE[ext] : DOCUMENT[ext];
|
|
90
|
-
if (!type)
|
|
91
|
-
throw new CliError(tr(`Chưa hỗ trợ ${ext}.`, `Unsupported ${ext}.`), Exit.usage);
|
|
92
|
-
const form = new FormData();
|
|
93
|
-
form.append("file", new Blob([readFileSync(path)], { type }), name);
|
|
94
|
-
const r = await post(kind === "image" ? "/ai/attachments" : "/ai/files", form);
|
|
95
|
-
const id = String(pick(r, "attachment_id", "attachmentId", "id"));
|
|
96
|
-
return kind === "image"
|
|
97
|
-
? { path, name, kind, imageKey: id, summary: `${Math.round(size / 1024)} KB` }
|
|
98
|
-
: { path, name, kind, fileKey: id, summary: `${Math.round(size / 1024)} KB` };
|
|
99
|
-
}
|
|
100
|
-
export const isDocument = (path) => extname(path).toLowerCase() in DOCUMENT;
|
|
101
|
-
/** Last proposal written from this process — `/undo` without an id targets it. */
|
|
102
|
-
export let lastWritten;
|
|
103
|
-
export async function ask(input) {
|
|
104
|
-
const attachmentIds = [...new Set(input.attachments?.flatMap((a) => [a.imageKey, a.fileKey]).filter(Boolean) ?? [])];
|
|
105
|
-
const created = await post("/ai/agent-runs", {
|
|
106
|
-
message: input.text,
|
|
107
|
-
route_key: "assistant.orchestrate",
|
|
108
|
-
app_id: "farm",
|
|
109
|
-
locale: currentLang(),
|
|
110
|
-
conversation_id: input.sessionId || null,
|
|
111
|
-
client_request_id: randomUUID(),
|
|
112
|
-
attachment_ids: attachmentIds,
|
|
113
|
-
}, { signal: input.signal });
|
|
114
|
-
const runId = String(pick(created, "run_id", "runId", "id"));
|
|
115
|
-
const outcome = {
|
|
116
|
-
text: "", notices: [], activities: [], ended: "error", runId,
|
|
117
|
-
sessionId: String(pick(created, "conversation_id", "conversationId") ?? input.sessionId ?? ""),
|
|
118
|
-
};
|
|
119
|
-
const proposals = [];
|
|
120
|
-
// The run lives on the server; a dropped stream reconnects from the last event instead of asking again.
|
|
121
|
-
let lastEventId;
|
|
122
|
-
for (let attempt = 0;; attempt++) {
|
|
123
|
-
if (attempt > 0) {
|
|
124
|
-
if (attempt > 5)
|
|
125
|
-
throw new CliError(tr("Mất kết nối giữa chừng.", "Connection lost mid-answer."), Exit.network);
|
|
126
|
-
input.onRetry?.(attempt);
|
|
127
|
-
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
|
128
|
-
}
|
|
129
|
-
try {
|
|
130
|
-
const res = await requestRaw("GET", `/ai/agent-runs/${runId}/events`, {
|
|
131
|
-
signal: input.signal,
|
|
132
|
-
headers: { Accept: "text/event-stream", ...(lastEventId ? { "Last-Event-ID": lastEventId } : {}) },
|
|
133
|
-
});
|
|
134
|
-
if (!res.ok)
|
|
135
|
-
throw errorFor(res.status, await res.json().catch(() => undefined), res);
|
|
136
|
-
if (!res.body)
|
|
137
|
-
throw new CliError(tr("Máy chủ trả luồng rỗng.", "Empty stream from server."), Exit.general);
|
|
138
|
-
for await (const ev of events(res.body)) {
|
|
139
|
-
lastEventId = ev.id ?? lastEventId;
|
|
140
|
-
const chunk = toChunk(ev, outcome, proposals);
|
|
141
|
-
if (chunk)
|
|
142
|
-
input.onChunk(chunk);
|
|
143
|
-
if (outcome.done) {
|
|
144
|
-
outcome.proposedAction = proposals[0];
|
|
145
|
-
if (proposals.length > 1)
|
|
146
|
-
outcome.notices.push({ code: "proposals", message: tr(`Còn ${proposals.length - 1} đề xuất khác — xem trong app.`, `${proposals.length - 1} more proposals — see the app.`) });
|
|
147
|
-
return outcome;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
throw new CliError(tr("Mất kết nối giữa chừng.", "Connection lost mid-answer."), Exit.network);
|
|
151
|
-
}
|
|
152
|
-
catch (e) {
|
|
153
|
-
if (input.signal?.aborted) {
|
|
154
|
-
await post(`/ai/agent-runs/${runId}/cancel`, {}).catch(() => undefined);
|
|
155
|
-
throw e;
|
|
156
|
-
}
|
|
157
|
-
if (!(e instanceof CliError) || e.exitCode !== Exit.network)
|
|
158
|
-
throw e;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
function toChunk(ev, out, proposals) {
|
|
163
|
-
const p = ev.payload ?? {};
|
|
164
|
-
switch (ev.type) {
|
|
165
|
-
case "progress.updated": {
|
|
166
|
-
const activity = { kind: "tool", label: String(p.label ?? ""), toolName: p.toolName ?? null };
|
|
167
|
-
if (p.completed)
|
|
168
|
-
return undefined;
|
|
169
|
-
out.activities.push(activity);
|
|
170
|
-
return { type: "activity", activity, runId: out.runId };
|
|
171
|
-
}
|
|
172
|
-
case "assistant.message_delta": {
|
|
173
|
-
// Leading blank deltas would print empty lines before the answer.
|
|
174
|
-
const delta = out.text ? String(p.delta ?? "") : String(p.delta ?? "").trimStart();
|
|
175
|
-
if (!delta)
|
|
176
|
-
return undefined;
|
|
177
|
-
out.text += delta;
|
|
178
|
-
return { type: "token", text: delta };
|
|
179
|
-
}
|
|
180
|
-
case "assistant.message_completed": {
|
|
181
|
-
const text = String(pick(p.message, "text") ?? "");
|
|
182
|
-
// Some answers arrive whole, without deltas.
|
|
183
|
-
if (!out.text && text) {
|
|
184
|
-
out.text = text;
|
|
185
|
-
return { type: "token", text };
|
|
186
|
-
}
|
|
187
|
-
return undefined;
|
|
188
|
-
}
|
|
189
|
-
case "action.approval_required":
|
|
190
|
-
proposals.push(toProposal(String(p.proposalId), String(p.actionType), p.proposal));
|
|
191
|
-
return undefined;
|
|
192
|
-
case "ui.action_requested": {
|
|
193
|
-
// Clarifying question: { action: "ask", label, value: '{"options":[…]}' }. Options become numbered follow-ups (type 1, 2, 3).
|
|
194
|
-
if (p.action !== "ask" || typeof p.label !== "string")
|
|
195
|
-
return undefined;
|
|
196
|
-
let options = [];
|
|
197
|
-
try {
|
|
198
|
-
options = (JSON.parse(String(p.value ?? "{}")).options ?? []).filter((x) => typeof x === "string");
|
|
199
|
-
}
|
|
200
|
-
catch {
|
|
201
|
-
// question without options
|
|
202
|
-
}
|
|
203
|
-
out.followUps = options;
|
|
204
|
-
const text = `${out.text ? "\n\n" : ""}${p.label.trim()}${options.map((o, i) => `\n ${i + 1}. ${o}`).join("")}`;
|
|
205
|
-
out.text += text;
|
|
206
|
-
return { type: "token", text };
|
|
207
|
-
}
|
|
208
|
-
case "run.completed":
|
|
209
|
-
case "run.awaiting_user":
|
|
210
|
-
out.ended = "done";
|
|
211
|
-
out.done = { type: "done", messageId: out.runId, runId: out.runId, followUps: out.followUps ?? [] };
|
|
212
|
-
return out.done;
|
|
213
|
-
case "run.cancelled":
|
|
214
|
-
case "run.failed": {
|
|
215
|
-
const message = String(p.errorMessage ?? (ev.type === "run.cancelled" ? tr("Đã dừng.", "Stopped.") : tr("Trợ lý gặp lỗi, thử lại.", "The assistant failed, try again.")));
|
|
216
|
-
out.ended = "error";
|
|
217
|
-
out.done = { type: "error", code: String(p.errorCode ?? ev.type), message, text: message, runId: out.runId };
|
|
218
|
-
if (!out.text)
|
|
219
|
-
out.text = message;
|
|
220
|
-
return out.done;
|
|
221
|
-
}
|
|
222
|
-
default:
|
|
223
|
-
return undefined;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
async function* events(stream) {
|
|
227
|
-
const decoder = new TextDecoder();
|
|
228
|
-
let buf = "";
|
|
229
|
-
for await (const part of stream) {
|
|
230
|
-
buf += decoder.decode(part, { stream: true });
|
|
231
|
-
let idx;
|
|
232
|
-
while ((idx = buf.indexOf("\n\n")) >= 0) {
|
|
233
|
-
const frame = buf.slice(0, idx);
|
|
234
|
-
buf = buf.slice(idx + 2);
|
|
235
|
-
const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trimStart()).join("\n");
|
|
236
|
-
if (!data)
|
|
237
|
-
continue;
|
|
238
|
-
try {
|
|
239
|
-
yield JSON.parse(data);
|
|
240
|
-
}
|
|
241
|
-
catch {
|
|
242
|
-
// heartbeat or partial frame
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
// ── Proposals ───────────────────────────────────────────────────────────────────────────────────────
|
|
248
|
-
/** Line kinds that move money or touch devices: never confirmed from the terminal (the server enforces it too). */
|
|
249
|
-
const FINANCE_KINDS = new Set(["cash", "settlement", "purchase_slip"]);
|
|
250
|
-
function kindsOf(v, acc = []) {
|
|
251
|
-
if (Array.isArray(v))
|
|
252
|
-
for (const x of v)
|
|
253
|
-
kindsOf(x, acc);
|
|
254
|
-
else if (v && typeof v === "object")
|
|
255
|
-
for (const [k, x] of Object.entries(v)) {
|
|
256
|
-
if (k === "kind" && typeof x === "string")
|
|
257
|
-
acc.push(x);
|
|
258
|
-
else
|
|
259
|
-
kindsOf(x, acc);
|
|
260
|
-
}
|
|
261
|
-
return acc;
|
|
262
|
-
}
|
|
263
|
-
function entriesOf(v) {
|
|
264
|
-
if (!v || typeof v !== "object")
|
|
265
|
-
return [];
|
|
266
|
-
if (Array.isArray(v.entries))
|
|
267
|
-
return v.entries;
|
|
268
|
-
for (const x of Object.values(v)) {
|
|
269
|
-
const found = entriesOf(x);
|
|
270
|
-
if (found.length)
|
|
271
|
-
return found;
|
|
272
|
-
}
|
|
273
|
-
return [];
|
|
274
|
-
}
|
|
275
|
-
export function riskOfNextFarm(actionType, proposal) {
|
|
276
|
-
if (/^(automation|iot|drone)\./.test(actionType))
|
|
277
|
-
return "device";
|
|
278
|
-
const kinds = kindsOf(proposal);
|
|
279
|
-
if (kinds.some((k) => k.startsWith("iot_")))
|
|
280
|
-
return "device";
|
|
281
|
-
if (kinds.some((k) => FINANCE_KINDS.has(k)))
|
|
282
|
-
return "finance";
|
|
283
|
-
if (entriesOf(proposal).length > 20 || kinds.some((k) => /close|return|delete|remove/.test(k)))
|
|
284
|
-
return "large";
|
|
285
|
-
return "normal";
|
|
286
|
-
}
|
|
287
|
-
function toProposal(proposalId, actionType, proposal) {
|
|
288
|
-
const entries = entriesOf(proposal);
|
|
289
|
-
const summary = pick(proposal, "summary", "title") ?? "";
|
|
290
|
-
const lines = entries.length
|
|
291
|
-
? entries.map((e) => `+ ${pick(e, "label") ?? pick(pick(e, "display"), "title") ?? pick(e, "kind") ?? ""}`)
|
|
292
|
-
: summary ? [] : [JSON.stringify(proposal).slice(0, 300)];
|
|
293
|
-
return {
|
|
294
|
-
kind: actionType,
|
|
295
|
-
label: summary || (entries.length ? tr(`Ghi ${entries.length} mục`, `Write ${entries.length} entries`) : actionType),
|
|
296
|
-
confirmPrompt: lines.join("\n"),
|
|
297
|
-
payloadJson: JSON.stringify(proposal ?? {}),
|
|
298
|
-
writes: true,
|
|
299
|
-
nonce: proposalId,
|
|
300
|
-
recipientCount: entries.length > 1 ? entries.length : null,
|
|
301
|
-
risk: riskOfNextFarm(actionType, proposal),
|
|
302
|
-
};
|
|
303
|
-
}
|
|
304
|
-
const EXECUTED = new Set(["succeeded", "partial"]);
|
|
305
|
-
export async function confirmAction(_sessionId, a) {
|
|
306
|
-
const r = await post(`/ai/action-proposals/${a.nonce}/decision`, { approved: true }, { idempotencyKey: true });
|
|
307
|
-
const execution = String(pick(r, "execution") ?? "");
|
|
308
|
-
const lines = pick(r, "lines") ?? [];
|
|
309
|
-
const ok = lines.filter((l) => pick(l, "status") === "succeeded").length;
|
|
310
|
-
const failed = lines.filter((l) => ["failed", "outcome_unknown"].includes(String(pick(l, "status")))).length;
|
|
311
|
-
const success = EXECUTED.has(execution);
|
|
312
|
-
const codes = [...new Set(lines.filter((l) => pick(l, "code")).map((l) => String(pick(l, "code"))))];
|
|
313
|
-
if (success)
|
|
314
|
-
lastWritten = { proposalId: a.nonce, label: a.label };
|
|
315
|
-
const message = execution === "deterministic_command_required"
|
|
316
|
-
? tr("Đã ghi nhận đồng ý. Việc này hoàn tất trong app NextFarm.", "Approval recorded. Finish this in the NextFarm app.")
|
|
317
|
-
: success
|
|
318
|
-
? tr(`Đã ghi ${ok || lines.length} mục${failed ? `, ${failed} mục lỗi — xem trong app` : ""}.`, `Wrote ${ok || lines.length} entries${failed ? `, ${failed} failed — check the app` : ""}.`)
|
|
319
|
-
: tr(`Chưa ghi được (${[execution || "không rõ", ...codes].join(" · ")}).`, `Not written (${[execution || "unknown", ...codes].join(" · ")}).`);
|
|
320
|
-
return { success, message, messageId: a.nonce, kind: a.kind, writes: true };
|
|
321
|
-
}
|
|
322
|
-
export async function rejectAction(proposalId) {
|
|
323
|
-
await post(`/ai/action-proposals/${proposalId}/decision`, { approved: false }, { idempotencyKey: true });
|
|
324
|
-
}
|
|
325
|
-
/** What `/undo` would revert: the given proposal, or the last one written from this process. */
|
|
326
|
-
export function undoTarget(proposalId) {
|
|
327
|
-
const target = proposalId ? { proposalId, label: proposalId.slice(0, 8) } : lastWritten;
|
|
328
|
-
if (!target)
|
|
329
|
-
throw new CliError(tr("Chưa có lần ghi nào trong phiên này để hoàn tác.", "Nothing written in this session to undo."), Exit.notFound);
|
|
330
|
-
return target;
|
|
331
|
-
}
|
|
332
|
-
export async function undo(target) {
|
|
333
|
-
const r = await post(`/ai/action-proposals/${target.proposalId}/undo`, {}, { idempotencyKey: true });
|
|
334
|
-
const undone = (pick(r, "lines") ?? []).filter((l) => pick(l, "undone")).length;
|
|
335
|
-
return tr(`Đã hoàn tác${undone ? ` ${undone} mục` : ""}.`, `Undone${undone ? ` ${undone} entries` : ""}.`);
|
|
336
|
-
}
|
|
337
|
-
// ── Run trace, memory, schedules, brief ─────────────────────────────────────────────────────────────
|
|
338
|
-
export async function runTrace(runId) {
|
|
339
|
-
const r = await get(`/ai/agent-runs/${runId}`);
|
|
340
|
-
const status = String(pick(r, "status") ?? "");
|
|
341
|
-
const steps = (pick(r, "activities", "progress") ?? []).map((s, i) => ` ${i + 1}. ${pick(s, "label") ?? ""}${pick(s, "tool_name", "toolName") ? ` · ${pick(s, "tool_name", "toolName")}` : ""}`);
|
|
342
|
-
return [` ${tr("Lượt", "Run")} ${runId.slice(0, 8)} · ${status}`, ...(steps.length ? steps : [tr(" (không có bước công cụ)", " (no tool steps)")])];
|
|
343
|
-
}
|
|
344
|
-
export async function memories() {
|
|
345
|
-
const r = await get("/ai/memories");
|
|
346
|
-
return {
|
|
347
|
-
canDelete: Boolean(pick(r, "can_delete", "canDelete")),
|
|
348
|
-
items: (pick(r, "items") ?? []).map((m) => ({
|
|
349
|
-
id: String(pick(m, "id")),
|
|
350
|
-
display: `${pick(m, "key") ?? ""}: ${pick(m, "value") ?? ""}`,
|
|
351
|
-
kind: String(pick(m, "kind") ?? ""),
|
|
352
|
-
uses: Number(pick(m, "use_count", "useCount") ?? 0),
|
|
353
|
-
})),
|
|
354
|
-
};
|
|
355
|
-
}
|
|
356
|
-
export const forgetMemory = (id) => del(`/ai/memories/${id}`);
|
|
357
|
-
export async function schedules() {
|
|
358
|
-
const r = await get("/ai/scheduled-prompts");
|
|
359
|
-
const list = Array.isArray(r) ? r : (pick(r, "items") ?? []);
|
|
360
|
-
return list.map((s) => ({
|
|
361
|
-
id: String(pick(s, "id")),
|
|
362
|
-
title: String(pick(s, "title", "prompt") ?? ""),
|
|
363
|
-
when: `${(pick(s, "days_of_week", "daysOfWeek") ?? []).length ? `${tr("thứ", "days")} ${(pick(s, "days_of_week", "daysOfWeek") ?? []).join(",")}` : tr("hằng ngày", "daily")} ${pick(s, "time_of_day", "timeOfDay") ?? ""}`,
|
|
364
|
-
nextRunAt: String(pick(s, "next_run_at", "nextRunAt") ?? ""),
|
|
365
|
-
enabled: pick(s, "enabled") !== false,
|
|
366
|
-
}));
|
|
367
|
-
}
|
|
368
|
-
/** daily → every day; weekly N → ISO weekday N (1 = Monday). Running in the background needs the app's one-time consent. */
|
|
369
|
-
export async function createSchedule(question, frequency, timeOfDay, day) {
|
|
370
|
-
const r = await post("/ai/scheduled-prompts", {
|
|
371
|
-
title: question.slice(0, 60), question, time_of_day: timeOfDay, days_of_week: frequency === "weekly" ? [day ?? 1] : [], channels: ["app"], enabled: true,
|
|
372
|
-
});
|
|
373
|
-
return (await schedules()).find((x) => x.id === String(pick(r, "id"))) ?? { id: String(pick(r, "id")), title: question, when: timeOfDay, nextRunAt: "", enabled: true };
|
|
374
|
-
}
|
|
375
|
-
export const runSchedule = (id) => post(`/ai/scheduled-prompts/${id}/run`, {});
|
|
376
|
-
export const deleteSchedule = (id) => del(`/ai/scheduled-prompts/${id}`);
|
|
377
|
-
/** Latest morning brief, or undefined when none was sent yet. */
|
|
378
|
-
export async function latestBrief() {
|
|
379
|
-
const r = await get("/ai/briefs/latest");
|
|
380
|
-
if (!r)
|
|
381
|
-
return undefined;
|
|
382
|
-
return {
|
|
383
|
-
title: String(pick(r, "title", "subject") ?? tr("Bản tin sáng", "Morning brief")),
|
|
384
|
-
text: String(pick(r, "text", "body", "content", "summary") ?? ""),
|
|
385
|
-
createdAt: String(pick(r, "created_at", "createdAt") ?? ""),
|
|
386
|
-
};
|
|
387
|
-
}
|