zigmini 1.0.0-beta.3 → 1.1.0-beta.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/README.ko.md +27 -2
- package/README.md +27 -2
- package/assets/agent/gateway-request-rules.md +1 -1
- package/assets/agent/gateway-rules.md +6 -1
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/assets/document-reader/document-reader +0 -0
- package/assets/document-reader/host-selector +0 -0
- package/assets/document-reader/manifest.json +27 -0
- package/docs/manual.ko.md +114 -10
- package/docs/manual.md +117 -11
- package/host/document-reader/DocumentRead.swift +370 -0
- package/host/document-reader/HostSelector.swift +192 -0
- package/host/document-reader/Protocol.swift +647 -0
- package/host/document-reader/main.swift +22 -0
- package/host/omo-bridge/agent-aside-tool.ts +119 -0
- package/host/omo-bridge/agent-aside.ts +522 -0
- package/host/omo-bridge/agent-collaboration-files.ts +282 -0
- package/host/omo-bridge/agent-collaboration.ts +385 -0
- package/host/omo-bridge/agent-control.ts +400 -28
- package/host/omo-bridge/agent-execution.sb +42 -0
- package/host/omo-bridge/agent-execution.ts +158 -0
- package/host/omo-bridge/agent-tool-adapters.ts +388 -0
- package/host/omo-bridge/agent-tool-permissions.ts +96 -0
- package/host/omo-bridge/aside-tabs.ts +41 -0
- package/host/omo-bridge/attachment-read.ts +100 -0
- package/host/omo-bridge/bridge.ts +10 -0
- package/host/omo-bridge/managed-call.ts +46 -0
- package/host/omo-bridge/task-control.ts +39 -5
- package/host/omo-check/patches/attachments-293/README.md +138 -0
- package/host/omo-check/patches/attachments-293/document-content.js +234 -0
- package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
- package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +28 -6
- package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
- package/package.json +5 -3
- package/src/agent/api.ts +345 -20
- package/src/agent/artifacts.ts +196 -0
- package/src/agent/aside-target-routes.ts +369 -0
- package/src/agent/aside-targets.ts +215 -0
- package/src/agent/collaboration-admission.ts +47 -0
- package/src/agent/collaboration-read.ts +117 -0
- package/src/agent/collaboration-session.ts +196 -0
- package/src/agent/collaboration-types.ts +258 -0
- package/src/agent/collaboration.ts +399 -0
- package/src/agent/definition.ts +65 -3
- package/src/agent/fields.ts +9 -3
- package/src/agent/file-core.ts +325 -0
- package/src/agent/files.ts +87 -8
- package/src/agent/list.ts +3 -3
- package/src/agent/managed-call.ts +144 -0
- package/src/agent/tool-catalog.ts +184 -0
- package/src/agent/tool-delivery.ts +359 -0
- package/src/agent/tool-permission-types.ts +81 -0
- package/src/agent/tool-permissions.ts +1366 -0
- package/src/attachments/api.ts +161 -0
- package/src/attachments/document-reader.ts +325 -0
- package/src/attachments/host-selector.ts +410 -0
- package/src/attachments/managed-binding.ts +83 -0
- package/src/attachments/prepare.ts +214 -0
- package/src/attachments/selector-port.ts +28 -0
- package/src/attachments/service.ts +729 -0
- package/src/attachments/store.ts +698 -0
- package/src/attachments/types.ts +123 -0
- package/src/cli/doctor.ts +30 -10
- package/src/decisions.ts +109 -10
- package/src/engine/apply-patch.ts +21 -0
- package/src/engine/compat.json +2 -1
- package/src/engine/install.ts +45 -4
- package/src/engine/sandbox-cell.ts +2 -0
- package/src/gc.ts +33 -225
- package/src/housekeeping/api.ts +163 -0
- package/src/housekeeping/audit.ts +61 -0
- package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
- package/src/housekeeping/collectors/agent-drafts.ts +43 -0
- package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
- package/src/housekeeping/collectors/agent-trash.ts +28 -0
- package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
- package/src/housekeeping/collectors/db-backup.ts +25 -0
- package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
- package/src/housekeeping/collectors/docker-image.ts +135 -0
- package/src/housekeeping/collectors/omo-version.ts +32 -0
- package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
- package/src/housekeeping/collectors/reconcile-work.ts +55 -0
- package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
- package/src/housekeeping/collectors/shared.ts +149 -0
- package/src/housekeeping/collectors/temp-dir.ts +28 -0
- package/src/housekeeping/collectors/update-file.ts +25 -0
- package/src/housekeeping/collectors/worktree.ts +193 -0
- package/src/housekeeping/engine.ts +125 -0
- package/src/housekeeping/reconcile/work.ts +130 -0
- package/src/housekeeping/registry.ts +63 -0
- package/src/housekeeping/trash.ts +59 -0
- package/src/housekeeping/types.ts +31 -0
- package/src/hub.ts +1095 -41
- package/src/main.ts +228 -30
- package/src/message-references.ts +14 -5
- package/src/profile-prompt.ts +13 -1
- package/src/protocol.ts +41 -6
- package/src/security-allow.ts +79 -2
- package/src/security-check.ts +1 -1
- package/src/store.ts +1946 -23
- package/src/test-crash.ts +20 -0
- package/src/work-watch.ts +55 -7
- package/src/work.ts +192 -3
- package/web/DESIGN.md +33 -0
- package/web/agents.d.ts +189 -12
- package/web/agents.js +898 -39
- package/web/app.js +2634 -210
- package/web/attachments.d.ts +91 -0
- package/web/attachments.js +422 -0
- package/web/collaboration.d.ts +87 -0
- package/web/collaboration.js +290 -0
- package/web/decisions.d.ts +39 -2
- package/web/decisions.js +354 -2
- package/web/gc.d.ts +26 -5
- package/web/gc.js +113 -13
- package/web/i18n/agents.d.ts +4 -0
- package/web/i18n/agents.js +200 -8
- package/web/i18n/attachments.js +78 -0
- package/web/i18n/decisions.d.ts +4 -0
- package/web/i18n/decisions.js +68 -0
- package/web/i18n/gc.d.ts +4 -0
- package/web/i18n/gc.js +184 -2
- package/web/i18n/main.js +80 -8
- package/web/i18n/tour.d.ts +4 -0
- package/web/i18n/tour.js +37 -0
- package/web/index.html +20 -3
- package/web/profile.d.ts +2 -2
- package/web/profile.js +11 -6
- package/web/style.css +342 -21
- package/web/tooltip.d.ts +2 -0
- package/web/tooltip.js +12 -0
- package/web/tour.d.ts +57 -0
- package/web/tour.js +450 -0
package/web/app.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ClapDetector } from "./clap.js";
|
|
2
|
+
import { hostFileRequest, mountHostFiles, replaceAttachmentQuery, sentDocumentBlock, validDocument, visibleOwnerText } from "./attachments.js";
|
|
2
3
|
import { renderDiagramMarkdown } from "./diagram.js";
|
|
3
4
|
import { setupMemo } from "./memo.js";
|
|
4
5
|
import { setupAutoCopy } from "./autocopy.js";
|
|
@@ -12,6 +13,9 @@ import {
|
|
|
12
13
|
isReviewRequired, isSubmitting, isUnresolved, noteConflict, originalActionId, originalChoices, presentation, pruneDetails, questionTitle, reference,
|
|
13
14
|
sortedDecisions, speechTextForTurn, dismissMode, DISMISS_TEXT,
|
|
14
15
|
STATUS_LABELS, submitKey, bindingLabel, waitReason, sourceContext,
|
|
16
|
+
AGENT_TOOL_CLIENT_HEADER, AGENT_TOOL_CLIENT_PATH, candidatesPath, clientExpired, composeToolGrantResponse, designateRequest, featureHeaders, interpretClientIssue,
|
|
17
|
+
interpretDesignate, isToolGrant, operationLabel, ownerAnswerRequired, ownerHeaders, scopeText, targetCandidates, toolErrorText, toolGrantOutcomeText, toolGrantView, toolName,
|
|
18
|
+
artifactReviewOutcome, artifactReviewRequest, artifactReviewView, isArtifactReview,
|
|
15
19
|
} from "./decisions.js";
|
|
16
20
|
import { chatTurnEndLine, describeEvent } from "./devlog.js";
|
|
17
21
|
import { applyActivityEvent, applyActivitySnapshot, beginGeneration, createActivity, endGeneration, isBusy, isUnknown } from "./activity.js";
|
|
@@ -45,16 +49,22 @@ import { createEnrollment, NO_DEVICES_LINE, REVOKE_ARMED_LABEL, REVOKE_LABEL } f
|
|
|
45
49
|
import { canUpdate, checkFailedLine, noticeLine, parseNotice, progressLine, questionGate, resultLine, UPDATE_CHECK_PATH, UPDATE_CONSENT_PATH, UPDATE_PATH, UPDATE_STATE_PATH, UPDATE_STATUS_PATH, updateStep, updateView } from "./update.js";
|
|
46
50
|
import { createAppDownload } from "./app-download.js";
|
|
47
51
|
import { setupNotificationSettings } from "./notifications.js";
|
|
48
|
-
import { cleanRequest, cleanResultLine, formatBytes,
|
|
52
|
+
import { AUDIT_LINES, auditView, cleanRequest, cleanResultLine, formatBytes, gcView, HOUSEKEEPING_AUDIT_PATH, HOUSEKEEPING_CLEAN_PATH, HOUSEKEEPING_PATH, selectAllState, selectAllTarget } from "./gc.js";
|
|
49
53
|
import {
|
|
50
|
-
applyAgentApplied, applyAgentUpdate, clearField, cloneRequest, DEFAULTS_PATH, DELETE_HINT, deleteEnabled, deleteRequest,
|
|
51
|
-
|
|
52
|
-
|
|
54
|
+
advanceUpload, applyAgentApplied, freshListAnswer, reconcileAgents, applyAgentUpdate, APPLY_WAIT_MS, applyLine, applyNowShown, applyNowTarget, applyWatch, capabilities, clearField, cloneRequest, composeSource, createDraftSaver, createPreviewRequester, DEFAULTS_PATH, DRAFT_REOPEN_WAIT_MS, DELETE_HINT, deletableFile, deleteEnabled, deleteRequest, discardDraftRequest, downloadable, downloadPath, draftLoadRequest,
|
|
55
|
+
draftFileList, draftRequest, draftRows, DRAFTS_PATH, editorRows, editorState, editorUpdateAction, fileDeleteRequest, fileRestoreRequest, fileTrashLabel, fileTrashPath, fileTrashRows, modifiedLabel, nextUpload, queuedUploadRows, retryUpload, turnEndRefreshes, UPLOAD_MAX_BYTES, uploadRows, uploadsAfterCreate, uploadStateLine,
|
|
56
|
+
EMPTY_AGENTS_LINE, EMPTY_TRASH_LINE, errorLine, fileAreas, fileRows, filesPath, headerView, lastDailyLine, mainConversations, newDraftId, PREVIEW_DELAY_MS, previewRequest, previewState, shownConversation, AGENTS_PATH,
|
|
57
|
+
restoreOutcome, restoreRequest, roleGuideItems, rotateRequest, saveErrorLine, saveOutcome, saveRequest, setBody, setField, setUnknownRaw, sidebarItems, skillOptions, sourceUploadPath, speakerView, STALE_TITLE, staleNotice, TRASH_PATH, trashRows,
|
|
58
|
+
unchangedSource, uploadedLine, validateEditor, widgetValue,
|
|
59
|
+
accessSummary, applyRevoke, classifyEdit, editLine, editRequest, interpretPermissionResponse, parseScopeText, policyEventStale, revokeAllRequest, revokeLine, revokeRequest,
|
|
60
|
+
scopeDraft, toolGrantRows, toolPermissionErrorLine, toolPermissionsRequest,
|
|
53
61
|
} from "./agents.js";
|
|
62
|
+
import { applyCollaborationEvent, artifactPath, cancelOutcome, cancelRequest, cardView, downloadOutcome, emptyCards, fileDeleteText, fileOriginLabel, isCollaborationFallback, listingOutcome, listingRequest, mergeRequestListing, targetOptions } from "./collaboration.js";
|
|
54
63
|
import { manualFailureText, manualLinkLang, manualUrl, otherLang } from "./manual.js";
|
|
55
64
|
import { canRestoreHeld, completeDetail, createAllowedValues, createSecurityBaseline, createSecurityInbox, repoAllowed, repoScopeBlock } from "./security-inbox.js";
|
|
56
65
|
import { getLanguage, isUiLanguage, onLanguageChange, registerStrings, setLanguage, t, UI_LANGUAGES } from "./i18n.js";
|
|
57
66
|
import mainStrings from "./i18n/main.js";
|
|
67
|
+
import { autoStartWanted, installTour } from "./tour.js";
|
|
58
68
|
|
|
59
69
|
installTooltips(document, window);
|
|
60
70
|
registerStrings(mainStrings);
|
|
@@ -140,6 +150,11 @@ const state = {
|
|
|
140
150
|
sessionTransitions: new Set(),
|
|
141
151
|
ws: null,
|
|
142
152
|
};
|
|
153
|
+
// The agents the gateway lists (GET /api/agents, agent.updated); the sidebar group, header, profile and bubble speaker read it.
|
|
154
|
+
// `writes` stamps every write to the list (writeAgents); a list answer lands only at the stamp it was sent at (T21W-B2).
|
|
155
|
+
const agentState = { list: [], loaded: false, writes: 0 };
|
|
156
|
+
// Listeners of "지금 적용" requests that wait for their rotation and the new session's agent.applied (agent-ux todo 21).
|
|
157
|
+
const applyWaits = new Set();
|
|
143
158
|
// Work activity the gateway reports per conversation (snapshot + activity.updated). It drives the avatar only;
|
|
144
159
|
// state.running stays the sole input of #interrupt, and history pages never write here.
|
|
145
160
|
const activity = createActivity();
|
|
@@ -182,9 +197,20 @@ function bubbleIconUrl() {
|
|
|
182
197
|
return agentIconUrl(agentIcon);
|
|
183
198
|
}
|
|
184
199
|
|
|
185
|
-
/**
|
|
200
|
+
/** Who speaks in the open conversation (bug 284): the agent's name and text icon, or main's name and icon picture. */
|
|
201
|
+
function speaker() {
|
|
202
|
+
return speakerView(currentConversation(), agentState.list, { name: agentName, iconUrl: bubbleIconUrl() });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A bot row of the open conversation, named and pictured after its speaker. */
|
|
206
|
+
function botRow(extra) {
|
|
207
|
+
const view = speaker();
|
|
208
|
+
return messageRow({ who: view.name, icon: view.icon, bot: true, ...extra });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The reference labels: the speaker's references carry the speaker's current name. */
|
|
186
212
|
function roleLabels() {
|
|
187
|
-
return { ...ROLE_LABELS, assistant:
|
|
213
|
+
return { ...ROLE_LABELS, assistant: speaker().name };
|
|
188
214
|
}
|
|
189
215
|
|
|
190
216
|
function receiveProfile(data) {
|
|
@@ -229,11 +255,13 @@ function applyNickname() {
|
|
|
229
255
|
|
|
230
256
|
function applyAgentIcon() {
|
|
231
257
|
const url = agentIconUrl(agentIcon);
|
|
232
|
-
for (const img of document.querySelectorAll("#
|
|
258
|
+
for (const img of document.querySelectorAll("#head-avatar, #status-back img")) {
|
|
233
259
|
img.src = url;
|
|
234
260
|
img.classList.toggle("custom-icon", Boolean(agentIcon));
|
|
235
261
|
}
|
|
236
|
-
|
|
262
|
+
// The rows drawn belong to the open conversation: main's follow main's icon, an agent's keep its own text icon (bug 284).
|
|
263
|
+
if (!speaker().agent) iconAgentRows(document.querySelectorAll(".msg:not(.mine)"), bubbleIconUrl());
|
|
264
|
+
renderAgentProfile();
|
|
237
265
|
hubEffects?.setIcon(url);
|
|
238
266
|
}
|
|
239
267
|
|
|
@@ -249,16 +277,38 @@ async function changeAgentIcon(method, body) {
|
|
|
249
277
|
}
|
|
250
278
|
|
|
251
279
|
function applyAgentName() {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
280
|
+
// Main's bubbles and reference labels already drawn follow the name, like the user's rows in applyNickname; an
|
|
281
|
+
// agent conversation's rows keep the agent's own name (bug 284).
|
|
282
|
+
if (!speaker().agent) {
|
|
283
|
+
nameAgentRows(document.querySelectorAll(".msg:not(.mine)"), agentName);
|
|
284
|
+
for (const label of document.querySelectorAll('[data-role="assistant"]')) label.textContent = agentName;
|
|
285
|
+
}
|
|
286
|
+
renderAgentProfile();
|
|
258
287
|
statusParts.agentName = agentName;
|
|
259
288
|
renderWorkStatusAll();
|
|
260
289
|
}
|
|
261
290
|
|
|
291
|
+
/**
|
|
292
|
+
* The sidebar profile (bug 284): main's name, icon picture and "Default agent" in main; the agent's name, text icon
|
|
293
|
+
* and "Agent" in an agent conversation, where a double-click opens that agent's settings instead of editing main.
|
|
294
|
+
*/
|
|
295
|
+
function renderAgentProfile() {
|
|
296
|
+
const view = speaker();
|
|
297
|
+
const profile = document.querySelector("#agent-profile");
|
|
298
|
+
const nameEl = profile.querySelector(".agent-name");
|
|
299
|
+
if (!nameEl.querySelector("input")) nameEl.textContent = view.name;
|
|
300
|
+
nameEl.title = view.name;
|
|
301
|
+
const avatar = profile.querySelector(".agent-avatar");
|
|
302
|
+
renderAgentAvatar(avatar, view.iconUrl, view.icon);
|
|
303
|
+
avatar.querySelector("img")?.classList.toggle("custom-icon", Boolean(agentIcon));
|
|
304
|
+
const sub = profile.querySelector(".me-sub");
|
|
305
|
+
sub.dataset.i18n = view.agent ? "profile.agentConversation" : "profile.agent";
|
|
306
|
+
sub.textContent = t(sub.dataset.i18n);
|
|
307
|
+
profile.dataset.i18nAttr = `title:${view.agent ? "profile.editAgent.tip" : "profile.editAgentName.tip"}`;
|
|
308
|
+
avatar.dataset.i18nAttr = `title:${view.agent ? "profile.editAgent.tip" : "profile.editAgentIcon.tip"}`;
|
|
309
|
+
for (const el of [profile, avatar]) el.title = t(el.dataset.i18nAttr.slice("title:".length));
|
|
310
|
+
}
|
|
311
|
+
|
|
262
312
|
document.querySelector("#owner-profile").addEventListener("dblclick", () => {
|
|
263
313
|
editNickname(document.querySelector("#owner-profile .me-name"), nickname, (value) => {
|
|
264
314
|
nickname = writeNickname(localStorage, value);
|
|
@@ -270,6 +320,11 @@ document.querySelector("#owner-profile").addEventListener("dblclick", () => {
|
|
|
270
320
|
|
|
271
321
|
document.querySelector("#agent-profile .agent-avatar").addEventListener("dblclick", (event) => {
|
|
272
322
|
event.stopPropagation();
|
|
323
|
+
const view = speaker();
|
|
324
|
+
if (view.agent) {
|
|
325
|
+
openAgents("editor", view.agentId);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
273
328
|
editAgentIcon(document.querySelector(".sidebar-foot"), agentIconUrl(agentIcon), {
|
|
274
329
|
onSave: (png) => changeAgentIcon("PUT", png),
|
|
275
330
|
onReset: () => changeAgentIcon("DELETE"),
|
|
@@ -277,6 +332,11 @@ document.querySelector("#agent-profile .agent-avatar").addEventListener("dblclic
|
|
|
277
332
|
});
|
|
278
333
|
|
|
279
334
|
document.querySelector("#agent-profile").addEventListener("dblclick", () => {
|
|
335
|
+
const view = speaker();
|
|
336
|
+
if (view.agent) {
|
|
337
|
+
openAgents("editor", view.agentId);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
280
340
|
editNickname(document.querySelector("#agent-profile .agent-name"), agentName, (value) => {
|
|
281
341
|
agentName = writeAgentName(localStorage, value);
|
|
282
342
|
queueMicrotask(applyAgentName);
|
|
@@ -438,6 +498,7 @@ function renderSessionTransition() {
|
|
|
438
498
|
for (const id of ["input", "send", "attach", "mic", "rotate-session"]) {
|
|
439
499
|
$(id).disabled = active;
|
|
440
500
|
}
|
|
501
|
+
documents.render();
|
|
441
502
|
}
|
|
442
503
|
|
|
443
504
|
const els = {
|
|
@@ -576,12 +637,13 @@ nav.next.ondblclick = () => {
|
|
|
576
637
|
els.messages.scrollTo({ top: els.messages.scrollHeight, behavior: "smooth" });
|
|
577
638
|
};
|
|
578
639
|
|
|
579
|
-
|
|
640
|
+
/** `icon`: a custom agent's text icon for its bot rows (bug 284); main's rows take the icon picture. */
|
|
641
|
+
function messageRow({ who, bot, ts, tag, icon = null }) {
|
|
580
642
|
const row = document.createElement("div");
|
|
581
643
|
row.className = bot ? "msg" : "msg mine";
|
|
582
644
|
const avatar = document.createElement("div");
|
|
583
645
|
avatar.className = `avatar ${bot ? "bot-avatar" : "me-avatar"}`;
|
|
584
|
-
if (bot) renderAgentAvatar(avatar, bubbleIconUrl());
|
|
646
|
+
if (bot) renderAgentAvatar(avatar, bubbleIconUrl(), icon);
|
|
585
647
|
else avatar.textContent = nicknameInitial(nickname);
|
|
586
648
|
const body = document.createElement("div");
|
|
587
649
|
body.className = "msg-body";
|
|
@@ -654,7 +716,7 @@ function ensureLiveItem(itemId, ts, fromThinking = false) {
|
|
|
654
716
|
liveOut = true;
|
|
655
717
|
let body;
|
|
656
718
|
try {
|
|
657
|
-
body =
|
|
719
|
+
body = botRow({ ts });
|
|
658
720
|
} finally {
|
|
659
721
|
liveOut = false;
|
|
660
722
|
}
|
|
@@ -861,19 +923,21 @@ function renderCardBody(card, decision) {
|
|
|
861
923
|
card.dataset.decisionRevision = String(decision.revision);
|
|
862
924
|
card.classList.toggle("resolved", p.terminal || !isUnresolved(decision));
|
|
863
925
|
card.setAttribute("aria-busy", String(busy));
|
|
864
|
-
const
|
|
865
|
-
|
|
926
|
+
const tool = isToolGrant(decision);
|
|
927
|
+
const nodes = [make("div", "approval-title", tool ? toolGrantView(decision).title : payload.title || request?.title || t(decision.permission ? "approval.permissionRequest" : "approval.question"))];
|
|
928
|
+
if (!tool && payload.detail) nodes.push(make("div", "approval-detail", payload.detail));
|
|
866
929
|
// A question cancelled by deleting it from the inbox stores { cancelled: true }, which is not an answer the user gave.
|
|
867
|
-
if (decision.answer && decision.reason !== "user_cancelled") nodes.push(make("div", "inbox-note", t("decision.myAnswer", { answer: describeAnswer(decision.answer) })));
|
|
868
|
-
if (
|
|
930
|
+
if (decision.answer && !tool && decision.reason !== "user_cancelled") nodes.push(make("div", "inbox-note", t("decision.myAnswer", { answer: describeAnswer(decision.answer) })));
|
|
931
|
+
if (tool) nodes.push(...renderToolGrantFields(decision, p, locked));
|
|
932
|
+
else if (p.form) nodes.push(...renderAnswerFields(decision, form, locked, () => syncDecisionSurfaces(decisionById(inbox.state, decision.id) ?? decision, card)));
|
|
869
933
|
else {
|
|
870
934
|
const saved = renderSavedChoices(decision);
|
|
871
935
|
if (saved) nodes.push(saved);
|
|
872
936
|
}
|
|
873
937
|
const lost = renderReference(decision);
|
|
874
938
|
if (lost) nodes.push(lost);
|
|
875
|
-
const kind = decision.permission ? t("approval.permission") : decision.method === "question" ? t("approval.question") : decision.method;
|
|
876
|
-
const binding = p.kind === "live" ? "" : ` · ${bindingLabel(decision)}`;
|
|
939
|
+
const kind = tool ? toolGrantView(decision).title : decision.permission ? t("approval.permission") : decision.method === "question" ? t("approval.question") : decision.method;
|
|
940
|
+
const binding = p.kind === "live" || tool ? "" : ` · ${bindingLabel(decision)}`;
|
|
877
941
|
nodes.push(make("div", "approval-meta", `${deadlineLabel(decision.deadlineAt)} · ${kind}${binding}`));
|
|
878
942
|
const status = cardStatus(decision, p, busy, review);
|
|
879
943
|
if (status) {
|
|
@@ -900,7 +964,7 @@ function renderApproval(req, resolved, ts) {
|
|
|
900
964
|
const explicitId = typeof req.decisionId === "string" ? req.decisionId : null;
|
|
901
965
|
if (explicitId) card.dataset.decisionId = explicitId;
|
|
902
966
|
cardRequests.set(card, req);
|
|
903
|
-
const body =
|
|
967
|
+
const body = botRow({ ts: req.createdAt ? new Date(req.createdAt).toISOString() : ts, tag: req.permission ? "approval.permissionRequest" : "approval.question" });
|
|
904
968
|
body.appendChild(card);
|
|
905
969
|
const decision = decisionForCard(inbox.state, req);
|
|
906
970
|
if (decision) {
|
|
@@ -1031,6 +1095,249 @@ function markResolved(card, res) {
|
|
|
1031
1095
|
card.appendChild(note);
|
|
1032
1096
|
}
|
|
1033
1097
|
|
|
1098
|
+
// ---- 에이전트 협업 카드 (agent-collaboration-292 task 11, C-10). web/collaboration.js decides; this block draws one card per request
|
|
1099
|
+
// in the open conversation's transcript (at the event that first named it), keeps it current from later events and the
|
|
1100
|
+
// owner listing, and sends the owner actions (cancel, recheck, download) whose refusals are shown as they are.
|
|
1101
|
+
const collab = { conv: null, cards: emptyCards(), lines: new Map(), listing: 0 };
|
|
1102
|
+
|
|
1103
|
+
function resetCollaboration(conv) {
|
|
1104
|
+
collab.conv = conv;
|
|
1105
|
+
collab.cards = emptyCards();
|
|
1106
|
+
collab.lines = new Map();
|
|
1107
|
+
collab.listing += 1;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function collaborationCardEl(requestId) {
|
|
1111
|
+
const selector = `.collaboration-card[data-request-id="${CSS.escape(requestId)}"]`;
|
|
1112
|
+
return sink?.querySelector(selector) ?? els.messages.querySelector(selector);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function collaborationContext() {
|
|
1116
|
+
return { agents: agentState.list, agentId: currentConversation()?.agentId ?? null, mainName: agentName };
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function renderCollaborationEvent(e, fromHistory) {
|
|
1120
|
+
const outcome = applyCollaborationEvent(collab.cards, e);
|
|
1121
|
+
collab.cards = outcome.cards;
|
|
1122
|
+
if (outcome.notice) {
|
|
1123
|
+
systemLine(outcome.notice, e.type === "agent.collaboration.blocked");
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
if (!outcome.requestId || !outcome.change) return;
|
|
1127
|
+
const card = collab.cards.get(outcome.requestId);
|
|
1128
|
+
if (!card || card.seq === null) return;
|
|
1129
|
+
const existing = collaborationCardEl(outcome.requestId);
|
|
1130
|
+
if (existing) fillCollaborationCard(existing, card);
|
|
1131
|
+
else if (outcome.change === "created") out(buildCollaborationCard(card));
|
|
1132
|
+
if (!fromHistory && !existing && card.state === null && collab.conv === state.current) void loadCollaborationListing(collab.conv);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function buildCollaborationCard(card) {
|
|
1136
|
+
const el = make("div", "collaboration-card");
|
|
1137
|
+
el.dataset.requestId = card.requestId;
|
|
1138
|
+
el.setAttribute("role", "group");
|
|
1139
|
+
fillCollaborationCard(el, card);
|
|
1140
|
+
return el;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function collaborationFileList(requestId, files, withSize) {
|
|
1144
|
+
const list = make("ul", "collab-files");
|
|
1145
|
+
for (const file of files) {
|
|
1146
|
+
const li = make("li", "collab-file");
|
|
1147
|
+
li.dataset.artifactId = file.artifactId;
|
|
1148
|
+
li.dataset.name = file.name;
|
|
1149
|
+
if (file.sha256) li.dataset.sha256 = file.sha256;
|
|
1150
|
+
const name = make("span", "collab-file-name", file.name);
|
|
1151
|
+
name.title = file.artifactId;
|
|
1152
|
+
li.append(name);
|
|
1153
|
+
if (withSize && file.size) li.append(make("span", "collab-file-size", file.size));
|
|
1154
|
+
const download = peButton(t("collab.download"), null, null, "text-btn");
|
|
1155
|
+
download.dataset.action = "collaboration-download";
|
|
1156
|
+
download.setAttribute("aria-label", t("collab.download.label", { name: file.name }));
|
|
1157
|
+
li.append(download);
|
|
1158
|
+
list.append(li);
|
|
1159
|
+
}
|
|
1160
|
+
return list;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function fillCollaborationCard(el, card) {
|
|
1164
|
+
const view = cardView(card, collaborationContext());
|
|
1165
|
+
el.dataset.state = view.state ?? "unconfirmed";
|
|
1166
|
+
el.classList.toggle("outgoing", view.outgoing);
|
|
1167
|
+
el.setAttribute("aria-label", `${view.title} · ${view.stateLabel}`);
|
|
1168
|
+
const head = make("div", "collab-head");
|
|
1169
|
+
head.append(make("span", "collab-title", view.title));
|
|
1170
|
+
if (view.kindLabel) head.append(make("span", "collab-kind", view.kindLabel));
|
|
1171
|
+
const stateEl = make("span", `collab-state ${view.tone}`, view.stateLabel);
|
|
1172
|
+
stateEl.dataset.state = view.state ?? "unconfirmed";
|
|
1173
|
+
head.append(stateEl);
|
|
1174
|
+
if (view.ts) head.append(make("span", "collab-time", timeLabel(view.ts)));
|
|
1175
|
+
const parties = make("div", "collab-parties");
|
|
1176
|
+
const from = make("span", "collab-party", `${t("collab.from")} `);
|
|
1177
|
+
from.append(make("b", "", view.from));
|
|
1178
|
+
const to = make("span", "collab-party", `${t("collab.to")} `);
|
|
1179
|
+
to.append(make("b", "", view.to));
|
|
1180
|
+
parties.append(from, make("span", "collab-arrow", "→"), to);
|
|
1181
|
+
const children = [head, parties];
|
|
1182
|
+
if (view.text) children.push(make("div", "collab-text", view.text));
|
|
1183
|
+
if (view.inputs.length) {
|
|
1184
|
+
const section = make("div", "collab-section");
|
|
1185
|
+
section.append(make("div", "collab-section-label", t("collab.inputs")), collaborationFileList(card.requestId, view.inputs, false));
|
|
1186
|
+
children.push(section);
|
|
1187
|
+
}
|
|
1188
|
+
if (view.result) {
|
|
1189
|
+
const section = make("div", "collab-section collab-result");
|
|
1190
|
+
section.append(make("div", "collab-section-label", t("collab.result")));
|
|
1191
|
+
section.append(view.result.text ? make("div", "collab-text", view.result.text) : make("div", "collab-note", t("collab.result.none")));
|
|
1192
|
+
if (view.result.files.length) section.append(make("div", "collab-section-label", t("collab.files")), collaborationFileList(card.requestId, view.result.files, true));
|
|
1193
|
+
children.push(section);
|
|
1194
|
+
}
|
|
1195
|
+
if (view.replied) children.push(make("div", "collab-note", t("collab.replied")));
|
|
1196
|
+
for (const note of view.notes) children.push(make("div", `collab-note${note.error ? " error" : ""}`, note.text));
|
|
1197
|
+
const line = make("div", "collab-line");
|
|
1198
|
+
line.setAttribute("role", "status");
|
|
1199
|
+
const last = collab.lines.get(card.requestId);
|
|
1200
|
+
if (last) {
|
|
1201
|
+
line.textContent = last.text;
|
|
1202
|
+
line.classList.toggle("error", last.error);
|
|
1203
|
+
}
|
|
1204
|
+
children.push(line);
|
|
1205
|
+
const actions = make("div", "collab-actions");
|
|
1206
|
+
if (view.cancellable) {
|
|
1207
|
+
const cancel = peButton(t("collab.cancel"), null, null, "text-btn danger");
|
|
1208
|
+
cancel.dataset.action = "collaboration-cancel";
|
|
1209
|
+
cancel.setAttribute("aria-label", t("collab.cancel.label", { id: card.requestId.slice(0, 8) }));
|
|
1210
|
+
cancel.disabled = cancelRequest(card) === null;
|
|
1211
|
+
actions.append(cancel);
|
|
1212
|
+
}
|
|
1213
|
+
const recheck = peButton(t("collab.recheck"), null, null, "text-btn");
|
|
1214
|
+
recheck.dataset.action = "collaboration-recheck";
|
|
1215
|
+
actions.append(recheck);
|
|
1216
|
+
children.push(actions);
|
|
1217
|
+
el.replaceChildren(...children);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function refreshCollaborationCards() {
|
|
1221
|
+
for (const [requestId, card] of collab.cards) {
|
|
1222
|
+
const el = collaborationCardEl(requestId);
|
|
1223
|
+
if (el) fillCollaborationCard(el, card);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function setCollaborationLine(requestId, text, error) {
|
|
1228
|
+
collab.lines.set(requestId, { text, error });
|
|
1229
|
+
const el = collaborationCardEl(requestId);
|
|
1230
|
+
const card = collab.cards.get(requestId);
|
|
1231
|
+
if (el && card) fillCollaborationCard(el, card);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// Owner actions (cancel, download) carry the 294 caller helper proof: the HttpOnly client cookie plus a page nonce
|
|
1235
|
+
// issued by POST /api/agent-tool-client. A stale nonce is renewed once.
|
|
1236
|
+
let collaborationNonce = null;
|
|
1237
|
+
async function collaborationOwnerHeaders(fresh) {
|
|
1238
|
+
if (fresh || !collaborationNonce) {
|
|
1239
|
+
collaborationNonce = null;
|
|
1240
|
+
try {
|
|
1241
|
+
const issued = await fetch("/api/agent-tool-client", { method: "POST", headers: { "x-zigmini-agent-tool-client": "1" } });
|
|
1242
|
+
const body = await issued.json().catch(() => null);
|
|
1243
|
+
if (issued.ok && typeof body?.pageNonce === "string") collaborationNonce = body.pageNonce;
|
|
1244
|
+
} catch {}
|
|
1245
|
+
}
|
|
1246
|
+
return collaborationNonce ? { "x-zigmini-agent-tool-client": "1", "x-zigmini-page-nonce": collaborationNonce } : { "x-zigmini-agent-tool-client": "1" };
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
async function collaborationFetch(request, retried = false) {
|
|
1250
|
+
const owner = request.method === "POST";
|
|
1251
|
+
let res;
|
|
1252
|
+
try {
|
|
1253
|
+
const headers = owner ? { ...(request.headers ?? {}), ...(await collaborationOwnerHeaders(retried)) } : request.headers;
|
|
1254
|
+
res = await fetch(request.path, { method: request.method, headers, body: request.body });
|
|
1255
|
+
} catch {
|
|
1256
|
+
return { status: 0, body: null, res: null };
|
|
1257
|
+
}
|
|
1258
|
+
const body = await res.clone().json().catch(() => null);
|
|
1259
|
+
if (owner && !retried && res.status === 403 && (body?.error === "page_nonce_invalid" || body?.error === "agent_tool_client_required")) return collaborationFetch(request, true);
|
|
1260
|
+
return { status: res.status, body, res };
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
async function loadCollaborationListing(conv, requestId = null) {
|
|
1264
|
+
if (!conv) return;
|
|
1265
|
+
const stamp = ++collab.listing;
|
|
1266
|
+
const { status, body } = await collaborationFetch(listingRequest(conv));
|
|
1267
|
+
if (collab.conv !== conv || collab.listing !== stamp) return;
|
|
1268
|
+
const outcome = listingOutcome(status, body);
|
|
1269
|
+
if (outcome.ok) {
|
|
1270
|
+
const merged = mergeRequestListing(collab.cards, body);
|
|
1271
|
+
collab.cards = merged.cards;
|
|
1272
|
+
for (const id of merged.changed) {
|
|
1273
|
+
const el = collaborationCardEl(id);
|
|
1274
|
+
const card = collab.cards.get(id);
|
|
1275
|
+
if (el && card) fillCollaborationCard(el, card);
|
|
1276
|
+
}
|
|
1277
|
+
} else console.warn("collaboration listing:", status, body);
|
|
1278
|
+
if (requestId) setCollaborationLine(requestId, outcome.line, !outcome.ok);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
async function cancelCollaboration(requestId, button) {
|
|
1282
|
+
const request = cancelRequest(collab.cards.get(requestId));
|
|
1283
|
+
if (!request) return;
|
|
1284
|
+
button.disabled = true;
|
|
1285
|
+
const conv = collab.conv;
|
|
1286
|
+
const { status, body } = await collaborationFetch(request);
|
|
1287
|
+
if (collab.conv !== conv) return;
|
|
1288
|
+
const outcome = cancelOutcome(status, body);
|
|
1289
|
+
const prior = collab.cards.get(requestId);
|
|
1290
|
+
if (outcome.ok && outcome.state && prior) collab.cards = new Map(collab.cards).set(requestId, { ...prior, state: outcome.state, revision: outcome.revision ?? prior.revision });
|
|
1291
|
+
setCollaborationLine(requestId, outcome.line, !outcome.ok);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function saveBlob(blob, name) {
|
|
1295
|
+
const url = URL.createObjectURL(blob);
|
|
1296
|
+
const link = document.createElement("a");
|
|
1297
|
+
link.href = url;
|
|
1298
|
+
link.download = name;
|
|
1299
|
+
document.body.append(link);
|
|
1300
|
+
link.click();
|
|
1301
|
+
link.remove();
|
|
1302
|
+
URL.revokeObjectURL(url);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
async function sha256Hex(bytes) {
|
|
1306
|
+
if (!globalThis.crypto?.subtle) return null;
|
|
1307
|
+
const hash = await crypto.subtle.digest("SHA-256", bytes);
|
|
1308
|
+
return [...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
async function downloadCollaborationFile(requestId, item, button) {
|
|
1312
|
+
const artifactId = item?.dataset.artifactId;
|
|
1313
|
+
if (!artifactId) return;
|
|
1314
|
+
const name = item.dataset.name || artifactId;
|
|
1315
|
+
button.disabled = true;
|
|
1316
|
+
const conv = collab.conv;
|
|
1317
|
+
const { status, body, res } = await collaborationFetch({ method: "POST", path: artifactPath(artifactId) });
|
|
1318
|
+
let digests = { expected: item.dataset.sha256 ?? null, header: null, computed: null };
|
|
1319
|
+
let blob = null;
|
|
1320
|
+
if (status === 200 && res) {
|
|
1321
|
+
blob = await res.blob();
|
|
1322
|
+
digests = { ...digests, header: res.headers.get("x-zgw-artifact-sha256"), computed: await sha256Hex(new Uint8Array(await blob.arrayBuffer())) };
|
|
1323
|
+
}
|
|
1324
|
+
const outcome = downloadOutcome(name, status, body, digests);
|
|
1325
|
+
if (outcome.ok && blob) saveBlob(blob, name);
|
|
1326
|
+
if (collab.conv === conv) setCollaborationLine(requestId, outcome.line, !outcome.ok);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
els.messages.addEventListener("click", (ev) => {
|
|
1330
|
+
const button = ev.target instanceof Element ? ev.target.closest("[data-action^='collaboration-']") : null;
|
|
1331
|
+
if (!(button instanceof HTMLButtonElement) || button.disabled) return;
|
|
1332
|
+
const requestId = button.closest(".collaboration-card")?.dataset.requestId;
|
|
1333
|
+
if (!requestId) return;
|
|
1334
|
+
switch (button.dataset.action) {
|
|
1335
|
+
case "collaboration-cancel": void cancelCollaboration(requestId, button); break;
|
|
1336
|
+
case "collaboration-recheck": void loadCollaborationListing(collab.conv, requestId); break;
|
|
1337
|
+
case "collaboration-download": void downloadCollaborationFile(requestId, button.closest("[data-artifact-id]"), button); break;
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1034
1341
|
function renderEvent(e, fromHistory = false) {
|
|
1035
1342
|
renderSeq = typeof e.seq === "number" ? e.seq : null;
|
|
1036
1343
|
switch (e.type) {
|
|
@@ -1041,8 +1348,13 @@ function renderEvent(e, fromHistory = false) {
|
|
|
1041
1348
|
const quoted = sentReferences(e.data);
|
|
1042
1349
|
if (quoted.length) body.appendChild(sentReferenceBlock(quoted, roleLabels()));
|
|
1043
1350
|
if (Array.isArray(e.data.images) && e.data.images.length) body.appendChild(imageStrip(e.data.images));
|
|
1044
|
-
|
|
1045
|
-
|
|
1351
|
+
if (Array.isArray(e.data.attachments)) body.appendChild(sentDocumentBlock(e.data.attachments));
|
|
1352
|
+
textBlock(body, visibleOwnerText(e.data));
|
|
1353
|
+
const documents = (Array.isArray(e.data.attachments) ? e.data.attachments : []).filter(validDocument);
|
|
1354
|
+
if (documents.length) documentSources.set(`${e.conv}:${e.seq}`, documents);
|
|
1355
|
+
const source = referenceSource(e) ?? (documents.length ? {
|
|
1356
|
+
seq: e.seq, role: "user", text: documents.map(d => d.name).join(", "), images: [],
|
|
1357
|
+
} : null);
|
|
1046
1358
|
if (source) markReferenceable(body.parentElement, source);
|
|
1047
1359
|
setLastSent(nextLastSent(lastSent.rec, e));
|
|
1048
1360
|
rememberSent(e);
|
|
@@ -1051,7 +1363,7 @@ function renderEvent(e, fromHistory = false) {
|
|
|
1051
1363
|
}
|
|
1052
1364
|
case "message.precomment": {
|
|
1053
1365
|
// Main's one-line notice before longer work: its own lighter, smaller message, never merged into the answer.
|
|
1054
|
-
const body =
|
|
1366
|
+
const body = botRow({ ts: e.ts, tag: "chat.tag.notice" });
|
|
1055
1367
|
body.parentElement.classList.add("precomment");
|
|
1056
1368
|
textBlock(body, e.data.text ?? "");
|
|
1057
1369
|
break;
|
|
@@ -1084,7 +1396,7 @@ function renderEvent(e, fromHistory = false) {
|
|
|
1084
1396
|
const row = body.parentElement;
|
|
1085
1397
|
if (row.parentNode) placeRow(row.parentNode, row, renderSeq ?? hist.newest);
|
|
1086
1398
|
} else {
|
|
1087
|
-
body =
|
|
1399
|
+
body = botRow({ ts: e.ts });
|
|
1088
1400
|
textBlock(body, e.data.text);
|
|
1089
1401
|
}
|
|
1090
1402
|
const source = referenceSource(e);
|
|
@@ -1130,10 +1442,19 @@ function renderEvent(e, fromHistory = false) {
|
|
|
1130
1442
|
case "conversation.thread":
|
|
1131
1443
|
els.thread.textContent = `thread ${e.data.threadId}`;
|
|
1132
1444
|
break;
|
|
1445
|
+
case "message.collaboration":
|
|
1446
|
+
case "agent.collaboration.status":
|
|
1447
|
+
case "agent.collaboration.blocked":
|
|
1448
|
+
case "agent.collaboration.waiting_owner":
|
|
1449
|
+
case "agent.collaboration.delivery_unknown":
|
|
1450
|
+
renderCollaborationEvent(e, fromHistory);
|
|
1451
|
+
break;
|
|
1133
1452
|
case "error":
|
|
1134
1453
|
systemLine(e.data?.kind === "agent-definition" ? t("agent.definitionError") : t("chat.error", { message: e.data.message }), true);
|
|
1135
1454
|
break;
|
|
1136
1455
|
case "notice":
|
|
1456
|
+
// The C-4 collaboration fallback repeats a message this page already draws as its card.
|
|
1457
|
+
if (isCollaborationFallback(e)) break;
|
|
1137
1458
|
systemLine(t("chat.notice", { message: e.data.message }), e.data.level === "error");
|
|
1138
1459
|
break;
|
|
1139
1460
|
}
|
|
@@ -1207,6 +1528,7 @@ function rememberSent(e) {
|
|
|
1207
1528
|
|
|
1208
1529
|
function clearMessages(conv) {
|
|
1209
1530
|
els.messages.innerHTML = "";
|
|
1531
|
+
resetCollaboration(conv);
|
|
1210
1532
|
turnStats.reset(conv);
|
|
1211
1533
|
setLastSent(null);
|
|
1212
1534
|
recall.sent.length = 0;
|
|
@@ -1223,19 +1545,25 @@ function clearMessages(conv) {
|
|
|
1223
1545
|
function selectConversation(requested, resume = false) {
|
|
1224
1546
|
// Improvement 262: a hidden conversation (old "+" conversation, a decision or link that names one) opens main.
|
|
1225
1547
|
const id = shownConversation(state.conversations, requested);
|
|
1548
|
+
const changing = id !== state.current;
|
|
1226
1549
|
const canResume = resume && id === state.current && hist.conv === id && hist.newest > 0;
|
|
1227
1550
|
if (id !== state.current) {
|
|
1551
|
+
saveDocumentDraftView();
|
|
1228
1552
|
clearPendingReportSpeech(state.current);
|
|
1229
1553
|
clearReferences(refs.state);
|
|
1230
1554
|
renderReferences();
|
|
1231
1555
|
resetSlashCommands();
|
|
1232
1556
|
}
|
|
1233
1557
|
state.current = id;
|
|
1558
|
+
if (changing) restoreDocumentDraftView();
|
|
1559
|
+
documents.close();
|
|
1560
|
+
documents.render();
|
|
1234
1561
|
const conv = state.conversations.find((c) => c.id === id);
|
|
1235
1562
|
els.title.textContent = conv ? conv.title : id;
|
|
1236
1563
|
els.thread.textContent = conv?.threadId ? `thread ${conv.threadId}` : "";
|
|
1237
1564
|
renderAgentHeader();
|
|
1238
1565
|
renderConversations();
|
|
1566
|
+
renderAgentProfile();
|
|
1239
1567
|
renderInterrupt();
|
|
1240
1568
|
renderActivity();
|
|
1241
1569
|
renderSessionTransition();
|
|
@@ -1300,6 +1628,7 @@ function applyHistoryPage(page) {
|
|
|
1300
1628
|
applyHistory(page.events);
|
|
1301
1629
|
fillViewport();
|
|
1302
1630
|
}
|
|
1631
|
+
if (page.mode !== "before" && collab.cards.size > 0) void loadCollaborationListing(state.current);
|
|
1303
1632
|
if (page.mode !== "before" && !lastSent.rec && hist.hasMore && hist.oldest !== null) seekLastSent(state.current, hist.oldest);
|
|
1304
1633
|
const referencedIds = new Set();
|
|
1305
1634
|
for (const card of els.messages.querySelectorAll(".approval")) {
|
|
@@ -1358,10 +1687,15 @@ function recountPending(approvals) {
|
|
|
1358
1687
|
}
|
|
1359
1688
|
|
|
1360
1689
|
function onServerEvent(e, generation, statusGeneration) {
|
|
1690
|
+
if (e.type === "message.user") finishDocumentSubmission(e, true);
|
|
1361
1691
|
if (e.type !== "security-check.held") devLog("in", e);
|
|
1692
|
+
// "지금 적용" requests waiting for their rotation see every event first (agent-ux todo 21).
|
|
1693
|
+
for (const wait of applyWaits) wait(e);
|
|
1694
|
+
if (reconcileAgents(e, agentState.list)) void loadAgents();
|
|
1362
1695
|
if (e.seq && e.conv === state.current && hist.conv === e.conv && hist.seen.has(e.seq)) return;
|
|
1363
1696
|
switch (e.type) {
|
|
1364
1697
|
case "state.snapshot":
|
|
1698
|
+
documents.snapshot(e.data);
|
|
1365
1699
|
state.conversations = e.data.conversations;
|
|
1366
1700
|
recountPending(e.data.approvals);
|
|
1367
1701
|
state.sessionTransitions = new Set(e.data.sessionTransitions ?? []);
|
|
@@ -1373,10 +1707,14 @@ function onServerEvent(e, generation, statusGeneration) {
|
|
|
1373
1707
|
void loadAgents();
|
|
1374
1708
|
selectConversation(state.current, true);
|
|
1375
1709
|
return;
|
|
1710
|
+
case "attachment.updated":
|
|
1711
|
+
documents.event(e);
|
|
1712
|
+
return;
|
|
1376
1713
|
case "decision.updated":
|
|
1377
1714
|
onDecisionUpdated(e.data);
|
|
1378
1715
|
return;
|
|
1379
1716
|
case "error":
|
|
1717
|
+
if (e.data?.submissionId) finishDocumentSubmission(e, false);
|
|
1380
1718
|
if (typeof e.data?.actionId === "string") onActionError(e.data);
|
|
1381
1719
|
break;
|
|
1382
1720
|
case "history":
|
|
@@ -1420,10 +1758,11 @@ function onServerEvent(e, generation, statusGeneration) {
|
|
|
1420
1758
|
receiveAgentUpdate(e.data);
|
|
1421
1759
|
return;
|
|
1422
1760
|
case "agent.applied":
|
|
1423
|
-
|
|
1424
|
-
renderAgentHeader();
|
|
1425
|
-
renderAgentGroup();
|
|
1761
|
+
receiveAgentApplied(e.conv, e.data);
|
|
1426
1762
|
break;
|
|
1763
|
+
case "agent.tool_permissions.updated":
|
|
1764
|
+
receiveToolPermissionsEvent(e.data);
|
|
1765
|
+
return;
|
|
1427
1766
|
case "action.request":
|
|
1428
1767
|
state.pendingByConv.set(e.conv, (state.pendingByConv.get(e.conv) ?? 0) + 1);
|
|
1429
1768
|
renderConversations();
|
|
@@ -1520,6 +1859,8 @@ function onServerEvent(e, generation, statusGeneration) {
|
|
|
1520
1859
|
state.running.delete(e.conv);
|
|
1521
1860
|
renderInterrupt();
|
|
1522
1861
|
renderAgentGroup();
|
|
1862
|
+
// An open editor of this conversation's agent reads its files again: the outputs the answer wrote (agent-ux todo 17).
|
|
1863
|
+
if (turnEndRefreshes(agentView.editor, e.conv)) void refreshKnowledgeAfterTurn(agentView.editor.id);
|
|
1523
1864
|
// A report-only turn names its published message (report_event_seq); user turns keep the last answer.
|
|
1524
1865
|
const speech = e.conv === state.current ? speechTextForTurn(e.data, tts.lastText.get(e.conv), (seq) => tts.finals.get(`${e.conv}/${seq}`)) : null;
|
|
1525
1866
|
if (tts.enabled && speech) speakSummary(speech);
|
|
@@ -1817,7 +2158,7 @@ function connect() {
|
|
|
1817
2158
|
void allowedValues.connect();
|
|
1818
2159
|
void securityBaseline.connect();
|
|
1819
2160
|
renderInboxOnline();
|
|
1820
|
-
send({ type: "hello", data: { client: "web-dev", version: "0.1.0" } });
|
|
2161
|
+
send({ type: "hello", data: { client: "web-dev", version: "0.1.0", capabilities: { hostFileAttachments: 1 } } });
|
|
1821
2162
|
sendWebPresence();
|
|
1822
2163
|
resendPendingReportSpeech();
|
|
1823
2164
|
void readUiLanguage();
|
|
@@ -1839,6 +2180,7 @@ function connect() {
|
|
|
1839
2180
|
endGeneration(activity, generation);
|
|
1840
2181
|
if (endStatusGeneration(workStatus, statusGeneration)) renderWorkStatusAll();
|
|
1841
2182
|
connState = "waiting";
|
|
2183
|
+
documents.disconnect();
|
|
1842
2184
|
renderConn();
|
|
1843
2185
|
$("head-avatar").classList.remove("live");
|
|
1844
2186
|
renderActivity();
|
|
@@ -1889,6 +2231,7 @@ els.messages.addEventListener("click", (ev) => {
|
|
|
1889
2231
|
|
|
1890
2232
|
function sendText() {
|
|
1891
2233
|
if (state.sessionTransitions.has(state.current)) return;
|
|
2234
|
+
if (attach.interceptSend?.()) return;
|
|
1892
2235
|
cancelAutoSend();
|
|
1893
2236
|
const text = els.input.value.trim();
|
|
1894
2237
|
const plan = planSend(text, refs.state, parseOptionCommand, attach.items.length);
|
|
@@ -1911,6 +2254,7 @@ function sendText() {
|
|
|
1911
2254
|
}
|
|
1912
2255
|
// Not taken by the socket (offline): the draft and the tags stay for a retry.
|
|
1913
2256
|
if (!send({ type: "input.text", conv: state.current, data: plan.data })) return;
|
|
2257
|
+
attach.onSent?.(state.current);
|
|
1914
2258
|
clearReferences(refs.state);
|
|
1915
2259
|
renderReferences();
|
|
1916
2260
|
els.input.value = "";
|
|
@@ -1923,6 +2267,136 @@ const MAX_ATTACH = 4;
|
|
|
1923
2267
|
const MAX_SIDE = 2048;
|
|
1924
2268
|
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
1925
2269
|
const attach = { items: [], busy: false, tray: $("attach-tray"), input: $("attach-input"), button: $("attach") };
|
|
2270
|
+
const documentSources = new Map();
|
|
2271
|
+
const documentDraftViews = new Map();
|
|
2272
|
+
const documents = mountHostFiles({
|
|
2273
|
+
input: els.input, menu: $("host-file-menu"), tray: $("document-tray"), note: $("document-note"), button: $("host-files"),
|
|
2274
|
+
}, {
|
|
2275
|
+
conv: () => state.current,
|
|
2276
|
+
conversations: () => state.conversations.map(c => c.id),
|
|
2277
|
+
request: hostFileRequest,
|
|
2278
|
+
locked: () => attach.busy || state.sessionTransitions.has(state.current),
|
|
2279
|
+
closeSlash: () => closeSlashMenu(),
|
|
2280
|
+
changed: () => {
|
|
2281
|
+
const draft = documents.current();
|
|
2282
|
+
attach.documentLocked = !!draft.submission;
|
|
2283
|
+
attach.documentCount = draft.items.length;
|
|
2284
|
+
els.input.readOnly = !!draft.submission;
|
|
2285
|
+
$("send").disabled = state.sessionTransitions.has(state.current)
|
|
2286
|
+
|| (!!draft.items.length && !documents.ready()) || !!draft.submission;
|
|
2287
|
+
$("attach").disabled = !!draft.submission || state.sessionTransitions.has(state.current);
|
|
2288
|
+
},
|
|
2289
|
+
selected: (draft, span) => {
|
|
2290
|
+
if (draft.conv === state.current && documents.current() === draft) {
|
|
2291
|
+
els.input.value = replaceAttachmentQuery(els.input.value, span);
|
|
2292
|
+
els.input.dispatchEvent(new Event("input"));
|
|
2293
|
+
} else {
|
|
2294
|
+
const view = documentDraftViews.get(draft.conv);
|
|
2295
|
+
if (view?.draftId === draft.id) view.text = replaceAttachmentQuery(view.text, span);
|
|
2296
|
+
}
|
|
2297
|
+
},
|
|
2298
|
+
retry: draft => void transmitDocumentSubmission(draft),
|
|
2299
|
+
});
|
|
2300
|
+
attach.interceptSend = () => {
|
|
2301
|
+
if (documents.composing) return true;
|
|
2302
|
+
if (!documents.current().items.length && !documents.current().submission && !referencedDocuments().length) return false;
|
|
2303
|
+
void sendDocumentComposition();
|
|
2304
|
+
return true;
|
|
2305
|
+
};
|
|
2306
|
+
attach.onSent = conv => { documentDraftViews.delete(conv); documents.drafts.reset(conv); };
|
|
2307
|
+
function saveDocumentDraftView() {
|
|
2308
|
+
documentDraftViews.set(state.current, {
|
|
2309
|
+
draftId: documents.current().id, text: els.input.value,
|
|
2310
|
+
references: { ...refs.state, items: [...refs.state.items] }, images: attach.items,
|
|
2311
|
+
});
|
|
2312
|
+
}
|
|
2313
|
+
function restoreDocumentDraftView() {
|
|
2314
|
+
const view = documentDraftViews.get(state.current);
|
|
2315
|
+
els.input.value = view?.text ?? "";
|
|
2316
|
+
refs.state = view?.references ?? createReferences();
|
|
2317
|
+
attach.items = view?.images ?? [];
|
|
2318
|
+
renderReferences();
|
|
2319
|
+
renderTray();
|
|
2320
|
+
}
|
|
2321
|
+
function referencedDocuments() {
|
|
2322
|
+
return refs.state.items.flatMap(r => documentSources.get(`${state.current}:${r.seq}`) ?? []);
|
|
2323
|
+
}
|
|
2324
|
+
async function sendDocumentComposition() {
|
|
2325
|
+
const draft = documents.current();
|
|
2326
|
+
if (draft.submission || attach.busy) return;
|
|
2327
|
+
if (!documents.ready()) { documents.refuseSend("pending"); return; }
|
|
2328
|
+
const ids = draft.items.map(i => i.id);
|
|
2329
|
+
if (new Set([...ids, ...referencedDocuments().map(d => d.id)]).size > 4) {
|
|
2330
|
+
documents.refuseSend("limit"); return;
|
|
2331
|
+
}
|
|
2332
|
+
// Preserve the existing image+reference preflight and upload semantics.
|
|
2333
|
+
const plan = planSend(els.input.value, refs.state, () => null, attach.items.length);
|
|
2334
|
+
if (plan.kind === "blocked") { refs.state.notice = plan.notice; renderReferences(); return; }
|
|
2335
|
+
draft.submission = {
|
|
2336
|
+
id: crypto.randomUUID(), state: "uploading", text: els.input.value,
|
|
2337
|
+
references: referenceSeqs(refs.state), attachments: ids, images: [...attach.items], uploaded: [],
|
|
2338
|
+
};
|
|
2339
|
+
const referenceState = refs.state;
|
|
2340
|
+
referenceState.locked = true;
|
|
2341
|
+
documents.close();
|
|
2342
|
+
documents.render();
|
|
2343
|
+
try {
|
|
2344
|
+
for (const item of draft.submission.images) draft.submission.uploaded.push(await uploadImage(item.file));
|
|
2345
|
+
await transmitDocumentSubmission(draft);
|
|
2346
|
+
} catch (e) {
|
|
2347
|
+
if (!(e instanceof Error)) throw e;
|
|
2348
|
+
draft.notice = e.message;
|
|
2349
|
+
draft.submission = null;
|
|
2350
|
+
} finally {
|
|
2351
|
+
if (!draft.submission) referenceState.locked = false;
|
|
2352
|
+
documents.render();
|
|
2353
|
+
renderTray();
|
|
2354
|
+
renderReferences();
|
|
2355
|
+
els.input.readOnly = !!documents.current().submission;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
async function transmitDocumentSubmission(draft) {
|
|
2359
|
+
const s = draft.submission;
|
|
2360
|
+
if (!s || (s.state !== "uploading" && s.state !== "uncertain")) return;
|
|
2361
|
+
if (!documents.ready(draft)) return;
|
|
2362
|
+
s.state = "awaiting";
|
|
2363
|
+
const data = inputData(s.text, { images: s.uploaded, references: s.references });
|
|
2364
|
+
if (s.attachments.length) data.attachments = [...s.attachments];
|
|
2365
|
+
data.submissionId = s.id;
|
|
2366
|
+
if (!send({ type: "input.text", conv: draft.conv, data })) s.state = "uncertain";
|
|
2367
|
+
draft.notice = t(s.state === "uncertain" ? "documents.uncertain" : "documents.sending");
|
|
2368
|
+
documents.render();
|
|
2369
|
+
}
|
|
2370
|
+
function finishDocumentSubmission(event, accepted) {
|
|
2371
|
+
const draft = documents.drafts.get(event.conv);
|
|
2372
|
+
const s = draft.submission;
|
|
2373
|
+
if (!s || event.data?.submissionId !== s.id) return;
|
|
2374
|
+
if (accepted && s.attachments.length) {
|
|
2375
|
+
const received = (Array.isArray(event.data.attachments) ? event.data.attachments : []).filter(validDocument).map(d => d.id);
|
|
2376
|
+
if (JSON.stringify(received) !== JSON.stringify(s.attachments)) return;
|
|
2377
|
+
}
|
|
2378
|
+
if (!accepted) {
|
|
2379
|
+
draft.notice = event.data.message ?? event.data.error ?? "send_failed";
|
|
2380
|
+
draft.submission = null;
|
|
2381
|
+
const view = documentDraftViews.get(draft.conv);
|
|
2382
|
+
if (view) view.references.locked = false;
|
|
2383
|
+
} else {
|
|
2384
|
+
for (const image of s.images) URL.revokeObjectURL(image.url);
|
|
2385
|
+
documentDraftViews.delete(draft.conv);
|
|
2386
|
+
documents.drafts.reset(draft.conv);
|
|
2387
|
+
if (state.current === draft.conv) {
|
|
2388
|
+
attach.items = [];
|
|
2389
|
+
clearReferences(refs.state);
|
|
2390
|
+
els.input.value = "";
|
|
2391
|
+
els.input.style.height = "";
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
if (state.current === draft.conv) {
|
|
2395
|
+
refs.state.locked = false;
|
|
2396
|
+
els.input.readOnly = false;
|
|
2397
|
+
renderTray(); renderReferences(); documents.render();
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
1926
2400
|
|
|
1927
2401
|
function imageStrip(names) {
|
|
1928
2402
|
const strip = document.createElement("div");
|
|
@@ -1974,7 +2448,7 @@ function renderTray() {
|
|
|
1974
2448
|
}
|
|
1975
2449
|
|
|
1976
2450
|
function addAttachments(files) {
|
|
1977
|
-
if (attach.busy) {
|
|
2451
|
+
if (attach.busy || attach.documentLocked) {
|
|
1978
2452
|
systemLine(t("attach.busy"));
|
|
1979
2453
|
return;
|
|
1980
2454
|
}
|
|
@@ -2025,6 +2499,8 @@ async function sendWithImages(text) {
|
|
|
2025
2499
|
// The conversation and the tags as they are now go out with the images; the tags are locked until the upload ends.
|
|
2026
2500
|
const conv = state.current;
|
|
2027
2501
|
const draft = els.input.value;
|
|
2502
|
+
const items = [...attach.items];
|
|
2503
|
+
const referenceState = refs.state;
|
|
2028
2504
|
const references = referenceSeqs(refs.state);
|
|
2029
2505
|
attach.busy = true;
|
|
2030
2506
|
refs.state.locked = true;
|
|
@@ -2032,12 +2508,13 @@ async function sendWithImages(text) {
|
|
|
2032
2508
|
renderReferences();
|
|
2033
2509
|
try {
|
|
2034
2510
|
const names = [];
|
|
2035
|
-
for (const item of
|
|
2511
|
+
for (const item of items) names.push(await uploadImage(item.file));
|
|
2036
2512
|
if (state.sessionTransitions.has(conv)) return;
|
|
2037
2513
|
if (!send({ type: "input.text", conv, data: inputData(text, { images: names, references }) })) return;
|
|
2038
|
-
for (const item of
|
|
2039
|
-
attach.items = [];
|
|
2040
|
-
clearReferences(
|
|
2514
|
+
for (const item of items) URL.revokeObjectURL(item.url);
|
|
2515
|
+
if (state.current === conv) attach.items = [];
|
|
2516
|
+
clearReferences(referenceState);
|
|
2517
|
+
attach.onSent?.(conv);
|
|
2041
2518
|
if (state.current === conv && els.input.value === draft) {
|
|
2042
2519
|
els.input.value = "";
|
|
2043
2520
|
els.input.style.height = "";
|
|
@@ -2047,7 +2524,7 @@ async function sendWithImages(text) {
|
|
|
2047
2524
|
systemLine(t("attach.uploadFailed", { error: error.message }), true);
|
|
2048
2525
|
} finally {
|
|
2049
2526
|
attach.busy = false;
|
|
2050
|
-
|
|
2527
|
+
referenceState.locked = false;
|
|
2051
2528
|
renderTray();
|
|
2052
2529
|
renderReferences();
|
|
2053
2530
|
}
|
|
@@ -2163,6 +2640,7 @@ async function applyOptionCommand(command) {
|
|
|
2163
2640
|
|
|
2164
2641
|
els.composer.addEventListener("submit", (ev) => {
|
|
2165
2642
|
ev.preventDefault();
|
|
2643
|
+
if ($("host-file-menu").contains(document.activeElement)) return;
|
|
2166
2644
|
sendText();
|
|
2167
2645
|
});
|
|
2168
2646
|
const slash = { el: $("slash-menu"), omo: null, loading: null, failed: false, items: [], index: 0 };
|
|
@@ -2248,7 +2726,7 @@ function pickSlashCommand(i) {
|
|
|
2248
2726
|
}
|
|
2249
2727
|
|
|
2250
2728
|
function slashMenuKey(ev) {
|
|
2251
|
-
if (slash.el.hidden || ev.isComposing) return false;
|
|
2729
|
+
if (slash.el.hidden || ev.isComposing || ev.keyCode === 229 || documents.composing) return false;
|
|
2252
2730
|
if (ev.key === "Escape") {
|
|
2253
2731
|
closeSlashMenu();
|
|
2254
2732
|
return true;
|
|
@@ -2268,6 +2746,8 @@ function slashMenuKey(ev) {
|
|
|
2268
2746
|
}
|
|
2269
2747
|
|
|
2270
2748
|
els.input.addEventListener("keydown", (ev) => {
|
|
2749
|
+
if (ev.isComposing || ev.keyCode === 229 || documents.composing) return;
|
|
2750
|
+
if (documents.key(ev)) { ev.preventDefault(); return; }
|
|
2271
2751
|
if (slashMenuKey(ev)) {
|
|
2272
2752
|
ev.preventDefault();
|
|
2273
2753
|
return;
|
|
@@ -2298,15 +2778,26 @@ els.input.addEventListener("input", (ev) => {
|
|
|
2298
2778
|
els.input.style.height = "auto";
|
|
2299
2779
|
els.input.style.height = `${Math.min(els.input.scrollHeight, 200)}px`;
|
|
2300
2780
|
slash.index = 0;
|
|
2301
|
-
|
|
2781
|
+
if (!documents.composing && !ev.isComposing) {
|
|
2782
|
+
documents.input();
|
|
2783
|
+
if (!documents.visible) renderSlashMenu();
|
|
2784
|
+
}
|
|
2302
2785
|
});
|
|
2786
|
+
els.input.addEventListener("compositionstart", () => { closeSlashMenu(); documents.composition(true); });
|
|
2787
|
+
els.input.addEventListener("compositionend", () => { documents.composition(false); if (!documents.visible) renderSlashMenu(); });
|
|
2788
|
+
els.input.addEventListener("click", () => documents.input());
|
|
2789
|
+
els.input.addEventListener("blur", (ev) => { if (!$("host-file-menu").contains(ev.relatedTarget)) documents.close(); });
|
|
2303
2790
|
els.input.addEventListener("blur", closeSlashMenu);
|
|
2304
2791
|
slash.el.addEventListener("pointerdown", (ev) => ev.preventDefault());
|
|
2305
2792
|
$("interrupt").onclick = () => send({ type: "input.interrupt", conv: state.current, data: {} });
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2793
|
+
// Bug 283: while the open agent's definition changed, the question says that the new session applies it, and the
|
|
2794
|
+
// request opens that session at once (agent-ux todo 21); main and an unchanged agent keep the plain rotation.
|
|
2795
|
+
function rotateCurrentSession() {
|
|
2796
|
+
const view = headerView(currentConversation(), agentState.list);
|
|
2797
|
+
if (!confirm(t(staleNotice(view).confirmKey))) return;
|
|
2798
|
+
send(rotateRequest(state.current, view));
|
|
2799
|
+
}
|
|
2800
|
+
$("rotate-session").onclick = rotateCurrentSession;
|
|
2310
2801
|
function toggleDevPanel() {
|
|
2311
2802
|
const open = els.app.classList.toggle("dev-hidden") === false;
|
|
2312
2803
|
$("toggle-dev").setAttribute("aria-expanded", String(open));
|
|
@@ -2896,7 +3387,7 @@ renderFormal();
|
|
|
2896
3387
|
|
|
2897
3388
|
/** A held text comes back into an empty composer of its conversation, so the user can fix it and send again. */
|
|
2898
3389
|
function restoreDraft(conv, text) {
|
|
2899
|
-
if (typeof text !== "string" || state.current !== conv || els.input.value !== "" || attach.items.length || refs.state.items.length) return;
|
|
3390
|
+
if (typeof text !== "string" || state.current !== conv || els.input.value !== "" || attach.items.length || refs.state.items.length || attach.documentCount) return;
|
|
2900
3391
|
els.input.value = text;
|
|
2901
3392
|
els.input.focus();
|
|
2902
3393
|
}
|
|
@@ -3205,6 +3696,9 @@ const inbox = {
|
|
|
3205
3696
|
/** Items whose delete confirmation is open, and those whose delete request is in flight. */
|
|
3206
3697
|
dismissing: new Set(),
|
|
3207
3698
|
dismissBusy: new Set(),
|
|
3699
|
+
/** Held file reads this screen allowed or denied (the latest copy, kept listed with the outcome until deleted), and the ids with a review call in flight. */
|
|
3700
|
+
reviewed: new Map(),
|
|
3701
|
+
reviewBusy: new Set(),
|
|
3208
3702
|
el: $("inbox"),
|
|
3209
3703
|
list: $("inbox-list"),
|
|
3210
3704
|
toggle: $("toggle-inbox"),
|
|
@@ -3247,12 +3741,57 @@ function describeAnswer(a) {
|
|
|
3247
3741
|
|
|
3248
3742
|
async function fetchDecisions(cursor) {
|
|
3249
3743
|
const query = `limit=${INBOX_PAGE}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
|
|
3250
|
-
const res = await fetch(`/api/decisions?${query}`, { cache: "no-store", signal: AbortSignal.timeout(30_000) });
|
|
3744
|
+
const res = await fetch(`/api/decisions?${query}`, { cache: "no-store", headers: featureHeaders(), signal: AbortSignal.timeout(30_000) });
|
|
3251
3745
|
const body = await res.json().catch(() => null);
|
|
3252
3746
|
if (!res.ok) throw new Error(body?.error ?? String(res.status));
|
|
3253
3747
|
return body;
|
|
3254
3748
|
}
|
|
3255
3749
|
|
|
3750
|
+
// The confirmation session of this screen for agent tool permissions (294 C3): one page nonce from POST
|
|
3751
|
+
// /api/agent-tool-client beside the HttpOnly cookie the gateway sets. A gateway restart or an expired nonce drops the
|
|
3752
|
+
// token so the next call takes a new one; the refused POST itself is never replayed.
|
|
3753
|
+
let agentToolClient = null;
|
|
3754
|
+
let agentToolClientPromise = null;
|
|
3755
|
+
let agentToolClientRefused = false;
|
|
3756
|
+
|
|
3757
|
+
async function ensureAgentToolClient() {
|
|
3758
|
+
if (agentToolClient) return agentToolClient;
|
|
3759
|
+
agentToolClientPromise ??= (async () => {
|
|
3760
|
+
try {
|
|
3761
|
+
const res = await fetch(AGENT_TOOL_CLIENT_PATH, { method: "POST", headers: { [AGENT_TOOL_CLIENT_HEADER]: "1" }, cache: "no-store", signal: AbortSignal.timeout(30_000) });
|
|
3762
|
+
const result = interpretClientIssue(res.status, await res.json().catch(() => null));
|
|
3763
|
+
agentToolClient = result.kind === "ready" ? result.client : null;
|
|
3764
|
+
agentToolClientRefused = result.kind === "refused";
|
|
3765
|
+
} catch {
|
|
3766
|
+
agentToolClient = null;
|
|
3767
|
+
agentToolClientRefused = false;
|
|
3768
|
+
} finally {
|
|
3769
|
+
agentToolClientPromise = null;
|
|
3770
|
+
}
|
|
3771
|
+
return agentToolClient;
|
|
3772
|
+
})();
|
|
3773
|
+
return agentToolClientPromise;
|
|
3774
|
+
}
|
|
3775
|
+
|
|
3776
|
+
/** An owner call (list, revoke, edit, tab picker) with this screen's session; `client: false` means no session could be had. */
|
|
3777
|
+
async function agentToolFetch(path, init = {}) {
|
|
3778
|
+
const client = await ensureAgentToolClient();
|
|
3779
|
+
if (!client) return { status: 0, body: null, client: false };
|
|
3780
|
+
let res;
|
|
3781
|
+
try {
|
|
3782
|
+
res = await fetch(path, { ...init, cache: "no-store", headers: { ...(init.headers ?? {}), ...ownerHeaders(client) }, signal: AbortSignal.timeout(30_000) });
|
|
3783
|
+
} catch {
|
|
3784
|
+
return { status: 0, body: null, client: true };
|
|
3785
|
+
}
|
|
3786
|
+
const body = await res.json().catch(() => null);
|
|
3787
|
+
if (res.status === 403 && clientExpired(body?.error)) agentToolClient = null;
|
|
3788
|
+
return { status: res.status, body, client: true };
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
function agentToolClientLine(prefix) {
|
|
3792
|
+
return t(`${prefix}.${agentToolClientRefused ? "clientUnavailable" : "clientOffline"}`);
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3256
3795
|
/** `refresh`: load the current copy even though one is cached (a mounted card the reset page no longer lists). */
|
|
3257
3796
|
/** The mounted cards a detail load reports to: every card of the decision id, so a second card never waits on the first. */
|
|
3258
3797
|
function detailCards(id, card) {
|
|
@@ -3265,7 +3804,7 @@ async function loadDecisionDetail(id, card, { refresh = false } = {}) {
|
|
|
3265
3804
|
if ((!refresh && decisionById(inbox.state, id)) || !inbox.online || !beginDetailLoad(inbox.state, id, generation)) return;
|
|
3266
3805
|
const retry = () => void loadDecisionDetail(id, card, { refresh });
|
|
3267
3806
|
try {
|
|
3268
|
-
const res = await fetch(`/api/decisions/${encodeURIComponent(id)}`, { cache: "no-store", signal: AbortSignal.timeout(30_000) });
|
|
3807
|
+
const res = await fetch(`/api/decisions/${encodeURIComponent(id)}`, { cache: "no-store", headers: featureHeaders(), signal: AbortSignal.timeout(30_000) });
|
|
3269
3808
|
const body = await res.json().catch(() => null);
|
|
3270
3809
|
if (!res.ok) {
|
|
3271
3810
|
if (!failDetailLoad(inbox.state, id, generation)) return;
|
|
@@ -3390,7 +3929,9 @@ function renderInboxOnline() {
|
|
|
3390
3929
|
|
|
3391
3930
|
/** Keyed nodes: only the changed item is rebuilt, so typing in another item survives updates. */
|
|
3392
3931
|
function renderInboxList() {
|
|
3393
|
-
|
|
3932
|
+
// A held file read this screen reviewed stays listed with its outcome after the gateway resolved it, until 삭제 hides it.
|
|
3933
|
+
const kept = new Map([...inbox.reviewed].filter(([id]) => !inbox.state.items.has(id)));
|
|
3934
|
+
const ordered = kept.size === 0 ? sortedDecisions(inbox.state) : sortedDecisions({ items: new Map([...kept, ...inbox.state.items]) });
|
|
3394
3935
|
const keep = new Set(ordered.map((d) => d.id));
|
|
3395
3936
|
for (const [id, node] of inbox.nodes) {
|
|
3396
3937
|
if (keep.has(id)) continue;
|
|
@@ -3589,7 +4130,8 @@ function renderDecisionActions(decision, p, { locked, busy, review, surface }) {
|
|
|
3589
4130
|
};
|
|
3590
4131
|
if (review) actions.append(button(t("decision.reviewLatest"), "ok", () => reviewLatest(decision), !busy));
|
|
3591
4132
|
else {
|
|
3592
|
-
|
|
4133
|
+
// A tool grant answers through its own two buttons (allow this scope / do not allow), never a generic send.
|
|
4134
|
+
if (p.mode && !isToolGrant(decision)) {
|
|
3593
4135
|
// The retry label appears once an earlier attempt is uncertain, never while that attempt is still in flight.
|
|
3594
4136
|
const label = !busy && inbox.state.drafts.has(decision.id) ? t("decision.resend") : t(`decision.primary.${p.kind}`);
|
|
3595
4137
|
actions.append(button(label, "ok", () => void submitDecisionForm(decision, p.mode)));
|
|
@@ -3640,32 +4182,261 @@ function renderSourceContext(payload) {
|
|
|
3640
4182
|
return box;
|
|
3641
4183
|
}
|
|
3642
4184
|
|
|
4185
|
+
function agentDisplayName(id) {
|
|
4186
|
+
return agentState.list.find((agent) => agent.id === id)?.name ?? id;
|
|
4187
|
+
}
|
|
4188
|
+
|
|
4189
|
+
/** decision id -> { status, candidates, note }: the open tabs the gateway listed for a card's target picker (C7, this screen only). */
|
|
4190
|
+
const toolTargets = new Map();
|
|
4191
|
+
|
|
4192
|
+
/**
|
|
4193
|
+
* The tool grant card (294 C8): the design rows (agent, request source, tool, target, operation, duration), the two
|
|
4194
|
+
* notes, the tab picker while a borrowed tab is still unchosen, and the two direct answers. Allow stays off until the
|
|
4195
|
+
* tab is chosen; deny never needs one.
|
|
4196
|
+
*/
|
|
4197
|
+
function renderToolGrantFields(decision, p, locked) {
|
|
4198
|
+
const view = toolGrantView(decision, { agentName: agentDisplayName });
|
|
4199
|
+
const dl = make("dl", "inbox-tool");
|
|
4200
|
+
for (const row of view.rows) dl.append(make("dt", null, row.label), make("dd", null, row.value));
|
|
4201
|
+
const nodes = [dl, make("p", "inbox-tool-note", view.note), make("p", "inbox-tool-restriction", view.restriction)];
|
|
4202
|
+
const outcome = toolGrantOutcomeText(decision);
|
|
4203
|
+
if (outcome) nodes.push(make("div", "inbox-note", outcome));
|
|
4204
|
+
if (!p.form) return nodes;
|
|
4205
|
+
if (view.targetNeeded) nodes.push(renderToolTargetPicker(decision, locked));
|
|
4206
|
+
const choices = make("div", "approval-options inbox-tool-choices");
|
|
4207
|
+
for (const choice of view.choices) {
|
|
4208
|
+
const button = make("button", `btn ${choice.cls}`, choice.label);
|
|
4209
|
+
button.type = "button";
|
|
4210
|
+
button.dataset.choice = choice.choice;
|
|
4211
|
+
button.disabled = locked || (choice.choice === "allow" && view.targetNeeded);
|
|
4212
|
+
button.onclick = () => {
|
|
4213
|
+
const composed = composeToolGrantResponse(decision, { choice: choice.choice });
|
|
4214
|
+
if (!composed.ok) {
|
|
4215
|
+
noteFor(decision.id, t(composed.reason === "target_required" ? "toolGrant.pickTabFirst" : "decision.incomplete"), true);
|
|
4216
|
+
syncDecisionSurfaces(decisionById(inbox.state, decision.id) ?? decision);
|
|
4217
|
+
return;
|
|
4218
|
+
}
|
|
4219
|
+
void submitDecision(decision.id, "answer", composed.response);
|
|
4220
|
+
};
|
|
4221
|
+
choices.append(button);
|
|
4222
|
+
}
|
|
4223
|
+
nodes.push(choices);
|
|
4224
|
+
return nodes;
|
|
4225
|
+
}
|
|
4226
|
+
|
|
4227
|
+
function renderToolTargetPicker(decision, locked) {
|
|
4228
|
+
const targets = toolTargets.get(decision.id) ?? { status: "idle", candidates: [], note: null };
|
|
4229
|
+
const box = make("div", "inbox-tool-targets");
|
|
4230
|
+
box.setAttribute("role", "group");
|
|
4231
|
+
box.setAttribute("aria-label", t("toolGrant.targets"));
|
|
4232
|
+
const head = make("div", "inbox-tool-targets-head");
|
|
4233
|
+
const load = make("button", "text-btn", t(targets.status === "ready" ? "toolGrant.reloadTabs" : "toolGrant.loadTabs"));
|
|
4234
|
+
load.type = "button";
|
|
4235
|
+
load.disabled = locked || targets.status === "loading" || targets.status === "designating";
|
|
4236
|
+
load.onclick = () => void loadToolTargets(decision);
|
|
4237
|
+
head.append(make("span", "inbox-tool-targets-label", t("toolGrant.pickTab")), load);
|
|
4238
|
+
const status = make("div", `inbox-note${targets.note?.error ? " error" : ""}`, targets.status === "loading" ? t("common.checking") : targets.note?.text ?? "");
|
|
4239
|
+
status.setAttribute("role", "status");
|
|
4240
|
+
box.append(head, status);
|
|
4241
|
+
if (targets.status !== "ready" && targets.status !== "designating") return box;
|
|
4242
|
+
if (targets.candidates.length === 0) box.append(make("p", "inbox-tool-targets-empty", t("toolGrant.tabsEmpty")));
|
|
4243
|
+
const list = make("div", "approval-options inbox-tool-target-list");
|
|
4244
|
+
for (const candidate of targets.candidates) {
|
|
4245
|
+
const button = make("button", "btn neutral inbox-tool-target", candidate.title);
|
|
4246
|
+
button.type = "button";
|
|
4247
|
+
button.dataset.candidate = candidate.id;
|
|
4248
|
+
button.disabled = locked || targets.status === "designating";
|
|
4249
|
+
if (candidate.suggested) button.append(make("small", "inbox-tool-target-suggested", t("toolGrant.suggested")));
|
|
4250
|
+
button.append(make("small", "inbox-tool-target-url", candidate.url));
|
|
4251
|
+
button.onclick = () => void designateToolTarget(decision, candidate);
|
|
4252
|
+
list.append(button);
|
|
4253
|
+
}
|
|
4254
|
+
box.append(list);
|
|
4255
|
+
return box;
|
|
4256
|
+
}
|
|
4257
|
+
|
|
4258
|
+
async function loadToolTargets(decision) {
|
|
4259
|
+
const current = toolTargets.get(decision.id);
|
|
4260
|
+
if (current?.status === "loading" || current?.status === "designating") return;
|
|
4261
|
+
toolTargets.set(decision.id, { status: "loading", candidates: current?.candidates ?? [], note: null });
|
|
4262
|
+
syncDecisionSurfaces(decision);
|
|
4263
|
+
const { status, body, client } = await agentToolFetch(candidatesPath(decision), { method: "GET" });
|
|
4264
|
+
let next;
|
|
4265
|
+
if (!client) next = { status: "failed", candidates: [], note: { text: agentToolClientLine("toolGrant"), error: true } };
|
|
4266
|
+
else if (status === 200) next = { status: "ready", candidates: targetCandidates(body), note: null };
|
|
4267
|
+
else if (status === 404) next = { status: "unsupported", candidates: [], note: { text: t("toolGrant.tabsUnsupported"), error: true } };
|
|
4268
|
+
else if (status === 0) next = { status: "failed", candidates: [], note: { text: t("toolGrant.tabsFailed"), error: true } };
|
|
4269
|
+
else next = { status: "failed", candidates: [], note: { text: toolErrorText(body?.error, status), error: true } };
|
|
4270
|
+
toolTargets.set(decision.id, next);
|
|
4271
|
+
syncDecisionSurfaces(decisionById(inbox.state, decision.id) ?? decision);
|
|
4272
|
+
}
|
|
4273
|
+
|
|
4274
|
+
/** Binds the chosen tab; the gateway answers with the re-proposed card, whose digest the allow button then confirms (C7). */
|
|
4275
|
+
async function designateToolTarget(decision, candidate) {
|
|
4276
|
+
const current = toolTargets.get(decision.id);
|
|
4277
|
+
if (!current || current.status !== "ready") return;
|
|
4278
|
+
toolTargets.set(decision.id, { ...current, status: "designating", note: null });
|
|
4279
|
+
syncDecisionSurfaces(decision);
|
|
4280
|
+
const request = designateRequest(decision, candidate.id);
|
|
4281
|
+
const { status, body, client } = await agentToolFetch(request.path, { method: request.method, headers: { "content-type": "application/json" }, body: JSON.stringify(request.body) });
|
|
4282
|
+
const result = client ? interpretDesignate(status, body) : { kind: "client", error: null };
|
|
4283
|
+
switch (result.kind) {
|
|
4284
|
+
case "accepted":
|
|
4285
|
+
toolTargets.delete(decision.id);
|
|
4286
|
+
applyDecision(inbox.state, result.decision);
|
|
4287
|
+
noteFor(decision.id, t("toolGrant.tabChosen"));
|
|
4288
|
+
break;
|
|
4289
|
+
case "conflict":
|
|
4290
|
+
if (result.decision) applyDecision(inbox.state, result.decision);
|
|
4291
|
+
toolTargets.set(decision.id, { ...current, status: "ready", note: { text: t("toolGrant.tabConflict"), error: true } });
|
|
4292
|
+
break;
|
|
4293
|
+
case "unsupported":
|
|
4294
|
+
toolTargets.set(decision.id, { status: "unsupported", candidates: [], note: { text: t("toolGrant.tabsUnsupported"), error: true } });
|
|
4295
|
+
break;
|
|
4296
|
+
case "client":
|
|
4297
|
+
toolTargets.set(decision.id, { ...current, status: "ready", note: { text: agentToolClientLine("toolGrant"), error: true } });
|
|
4298
|
+
break;
|
|
4299
|
+
default:
|
|
4300
|
+
toolTargets.set(decision.id, { ...current, status: "ready", note: { text: toolErrorText(result.error, status), error: true } });
|
|
4301
|
+
}
|
|
4302
|
+
syncDecisionSurfaces(decisionById(inbox.state, decision.id) ?? decision);
|
|
4303
|
+
renderInboxList();
|
|
4304
|
+
}
|
|
4305
|
+
|
|
4306
|
+
/**
|
|
4307
|
+
* The owner card of a held collaboration file read (purpose collaboration_artifact_review): who reads, which file, which
|
|
4308
|
+
* part, what the security check found (kinds and counts only) or could not check, and the two direct answers through
|
|
4309
|
+
* the owner hold route. The outcome line stays on the card after the gateway resolves the hold.
|
|
4310
|
+
*/
|
|
4311
|
+
function renderArtifactReviewItem(decision) {
|
|
4312
|
+
const view = artifactReviewView(decision, { agentName: agentDisplayName, mainName: agentName });
|
|
4313
|
+
const busy = inbox.reviewBusy.has(decision.id);
|
|
4314
|
+
const li = make("li", "inbox-item kind-artifact-review inbox-item-tool");
|
|
4315
|
+
li.dataset.id = decision.id;
|
|
4316
|
+
li.dataset.revision = String(decision.revision);
|
|
4317
|
+
li.dataset.holdId = view.holdId;
|
|
4318
|
+
li.dataset.reviewState = view.state;
|
|
4319
|
+
li.setAttribute("aria-busy", String(busy));
|
|
4320
|
+
const head = make("div", "inbox-item-head");
|
|
4321
|
+
head.append(
|
|
4322
|
+
make("span", "inbox-work", decision.ownerConv === "main" ? t("inbox.mainConversation") : conversationTitle(decision.ownerConv)),
|
|
4323
|
+
make("span", `inbox-chip chip-${decision.status}`, view.chip ?? STATUS_LABELS[decision.status] ?? decision.status),
|
|
4324
|
+
);
|
|
4325
|
+
const question = make("div", "inbox-question", view.title);
|
|
4326
|
+
question.id = `inbox-q-${decision.id}`;
|
|
4327
|
+
li.setAttribute("aria-labelledby", question.id);
|
|
4328
|
+
li.append(head, question);
|
|
4329
|
+
const dl = make("dl", "inbox-tool");
|
|
4330
|
+
for (const row of view.rows) {
|
|
4331
|
+
const dd = make("dd", null, row.value);
|
|
4332
|
+
dd.dataset.row = row.key;
|
|
4333
|
+
dl.append(make("dt", null, row.label), dd);
|
|
4334
|
+
}
|
|
4335
|
+
li.append(dl);
|
|
4336
|
+
if (view.warning) {
|
|
4337
|
+
const warning = make("p", "inbox-tool-note inbox-review-unscanned", view.warning);
|
|
4338
|
+
warning.setAttribute("role", "note");
|
|
4339
|
+
li.append(warning);
|
|
4340
|
+
}
|
|
4341
|
+
if (view.question) li.append(make("p", "inbox-tool-restriction", view.question));
|
|
4342
|
+
if (view.outcome) li.append(make("div", "inbox-note inbox-review-outcome", view.outcome));
|
|
4343
|
+
const meta = make("dl", "inbox-meta");
|
|
4344
|
+
meta.append(make("dt", null, t("inbox.meta.created")), make("dd", null, dateTimeLabel(decision.createdAt)));
|
|
4345
|
+
li.append(meta);
|
|
4346
|
+
// The receipt's line stands in until the gateway's own copy carries the outcome; a refusal stays until the next click.
|
|
4347
|
+
const note = inbox.notes.get(decision.id);
|
|
4348
|
+
const shown = note && (note.error || !view.outcome) ? note : null;
|
|
4349
|
+
const noteEl = make("div", `inbox-note inbox-review-line${shown?.error ? " error" : ""}`, shown?.text ?? "");
|
|
4350
|
+
noteEl.setAttribute("role", "status");
|
|
4351
|
+
li.append(noteEl);
|
|
4352
|
+
if (view.pending) {
|
|
4353
|
+
const choices = make("div", "approval-options inbox-tool-choices");
|
|
4354
|
+
for (const choice of view.choices) {
|
|
4355
|
+
const button = make("button", `btn ${choice.cls}`, choice.label);
|
|
4356
|
+
button.type = "button";
|
|
4357
|
+
button.dataset.choice = choice.choice;
|
|
4358
|
+
button.disabled = busy || !inbox.online;
|
|
4359
|
+
button.onclick = () => void reviewArtifactHold(decision, choice.choice);
|
|
4360
|
+
choices.append(button);
|
|
4361
|
+
}
|
|
4362
|
+
li.append(choices);
|
|
4363
|
+
} else if (!isUnresolved(decision) && inbox.reviewed.has(decision.id)) {
|
|
4364
|
+
const actions = make("div", "inbox-actions");
|
|
4365
|
+
const hide = make("button", "text-btn inbox-review-hide", t("common.delete"));
|
|
4366
|
+
hide.type = "button";
|
|
4367
|
+
hide.onclick = () => {
|
|
4368
|
+
inbox.reviewed.delete(decision.id);
|
|
4369
|
+
inbox.notes.delete(decision.id);
|
|
4370
|
+
renderInboxList();
|
|
4371
|
+
};
|
|
4372
|
+
actions.append(hide);
|
|
4373
|
+
li.append(actions);
|
|
4374
|
+
}
|
|
4375
|
+
const previous = inbox.nodes.get(decision.id);
|
|
4376
|
+
if (previous?.isConnected) previous.replaceWith(li);
|
|
4377
|
+
inbox.nodes.set(decision.id, li);
|
|
4378
|
+
return li;
|
|
4379
|
+
}
|
|
4380
|
+
|
|
4381
|
+
function conversationTitle(convId) {
|
|
4382
|
+
return state.conversations.find((row) => row.id === convId)?.title ?? convId;
|
|
4383
|
+
}
|
|
4384
|
+
|
|
4385
|
+
/**
|
|
4386
|
+
* One click = one owner call (POST /api/agent-collaboration/holds/:holdId) with the HttpOnly client cookie and the page
|
|
4387
|
+
* nonce (collaborationFetch renews a stale nonce once), the revision on screen and a fresh submissionId. The answer is
|
|
4388
|
+
* shown as it is: the receipt's outcome, or the refusal; a conflict reloads the latest decision so the next click uses it.
|
|
4389
|
+
*/
|
|
4390
|
+
async function reviewArtifactHold(decision, choice) {
|
|
4391
|
+
const request = artifactReviewRequest(decision, choice);
|
|
4392
|
+
if (!request || inbox.reviewBusy.has(decision.id) || !inbox.online) return;
|
|
4393
|
+
inbox.reviewBusy.add(decision.id);
|
|
4394
|
+
inbox.reviewed.set(decision.id, decision);
|
|
4395
|
+
noteFor(decision.id, null);
|
|
4396
|
+
renderInboxItem(decision);
|
|
4397
|
+
const { status, body } = await collaborationFetch(request);
|
|
4398
|
+
inbox.reviewBusy.delete(decision.id);
|
|
4399
|
+
const outcome = artifactReviewOutcome(status, body);
|
|
4400
|
+
devLog("in", { type: "artifact-review.answer", data: { id: decision.id, choice, status, result: outcome.kind, error: body?.error ?? null } });
|
|
4401
|
+
if (!outcome.ok) inbox.reviewed.delete(decision.id);
|
|
4402
|
+
noteFor(decision.id, outcome.line, !outcome.ok);
|
|
4403
|
+
if (outcome.kind === "conflict" || outcome.kind === "gone" || outcome.kind === "network") void loadDecisionDetail(decision.id, null, { refresh: true });
|
|
4404
|
+
const current = decisionById(inbox.state, decision.id) ?? inbox.reviewed.get(decision.id) ?? decision;
|
|
4405
|
+
if (outcome.ok) inbox.reviewed.set(decision.id, current);
|
|
4406
|
+
renderInboxItem(current);
|
|
4407
|
+
renderInboxList();
|
|
4408
|
+
}
|
|
4409
|
+
|
|
3643
4410
|
function renderInboxItem(decision) {
|
|
3644
4411
|
if (decision.security) return renderSecurityInboxItem(decision);
|
|
4412
|
+
if (isArtifactReview(decision)) return renderArtifactReviewItem(decision);
|
|
3645
4413
|
const p = presentation(decision);
|
|
3646
4414
|
const payload = decision.payload && typeof decision.payload === "object" ? decision.payload : {};
|
|
4415
|
+
const tool = isToolGrant(decision);
|
|
3647
4416
|
const form = formFor(decision);
|
|
3648
4417
|
const busy = isSubmitting(inbox.state, decision);
|
|
3649
4418
|
const review = isReviewRequired(inbox.state, decision.id);
|
|
3650
4419
|
const locked = busy || !inbox.online || review;
|
|
3651
|
-
const li = make("li", `inbox-item kind-${p.kind}`);
|
|
4420
|
+
const li = make("li", `inbox-item kind-${p.kind}${tool ? " inbox-item-tool" : ""}`);
|
|
3652
4421
|
li.dataset.id = decision.id;
|
|
3653
4422
|
li.dataset.revision = String(decision.revision);
|
|
3654
4423
|
li.setAttribute("aria-busy", String(busy));
|
|
3655
4424
|
const head = make("div", "inbox-item-head");
|
|
3656
4425
|
head.append(
|
|
3657
|
-
|
|
4426
|
+
// F3 D3: an agent conversation's card is headed by the agent's name, as the review card is, never by its conversation id.
|
|
4427
|
+
make("span", "inbox-work", decision.work ? decision.work.title : decision.ownerConv === "main" ? t("inbox.mainConversation") : conversationTitle(decision.ownerConv)),
|
|
3658
4428
|
make("span", `inbox-chip chip-${decision.status}`, STATUS_LABELS[decision.status] ?? decision.status),
|
|
3659
4429
|
);
|
|
3660
|
-
const question = make("div", "inbox-question", payload.title || t(decision.permission ? "approval.permissionRequest" : "approval.question"));
|
|
4430
|
+
const question = make("div", "inbox-question", tool ? toolGrantView(decision).title : payload.title || t(decision.permission ? "approval.permissionRequest" : "approval.question"));
|
|
3661
4431
|
question.id = `inbox-q-${decision.id}`;
|
|
3662
4432
|
li.setAttribute("aria-labelledby", question.id);
|
|
3663
4433
|
li.append(head, question);
|
|
3664
|
-
if (payload.detail) li.append(make("div", "approval-detail", payload.detail));
|
|
4434
|
+
if (!tool && payload.detail) li.append(make("div", "approval-detail", payload.detail));
|
|
3665
4435
|
const context = renderSourceContext(payload);
|
|
3666
4436
|
if (context) li.append(context);
|
|
3667
|
-
if (decision.answer) li.append(make("div", "inbox-note", t("decision.myAnswer", { answer: describeAnswer(decision.answer) })));
|
|
3668
|
-
if (
|
|
4437
|
+
if (decision.answer && !tool) li.append(make("div", "inbox-note", t("decision.myAnswer", { answer: describeAnswer(decision.answer) })));
|
|
4438
|
+
if (tool) li.append(...renderToolGrantFields(decision, p, locked));
|
|
4439
|
+
else if (p.form) li.append(...renderAnswerFields(decision, form, locked, () => syncDecisionSurfaces(decisionById(inbox.state, decision.id) ?? decision, li)));
|
|
3669
4440
|
else {
|
|
3670
4441
|
// No answer is taken now, but the question and its choices still read here, not only on the original card.
|
|
3671
4442
|
const saved = renderSavedChoices(decision);
|
|
@@ -3680,8 +4451,10 @@ function renderInboxItem(decision) {
|
|
|
3680
4451
|
};
|
|
3681
4452
|
row(t("inbox.meta.created"), dateTimeLabel(decision.createdAt));
|
|
3682
4453
|
row(t("inbox.meta.deadline"), decision.deadlineAt === null ? (decision.bindingState === "live" ? t("inbox.meta.none") : "") : `${dateTimeLabel(decision.deadlineAt)}${decision.bindingState === "expired" ? t("inbox.meta.expired") : ""}`);
|
|
3683
|
-
|
|
3684
|
-
|
|
4454
|
+
if (!tool) {
|
|
4455
|
+
row(t("inbox.meta.state"), `${bindingLabel(decision)}${decision.required ? t("inbox.meta.required") : ""}`);
|
|
4456
|
+
row(t("inbox.meta.waitReason"), waitReason(decision));
|
|
4457
|
+
}
|
|
3685
4458
|
li.append(meta);
|
|
3686
4459
|
const note = inbox.notes.get(decision.id);
|
|
3687
4460
|
const noteEl = make("div", `inbox-note${note?.error ? " error" : ""}`, note?.text ?? "");
|
|
@@ -3821,17 +4594,28 @@ async function submitDecision(id, mode, response) {
|
|
|
3821
4594
|
if (!beginSubmit(inbox.state, id, "http")) return;
|
|
3822
4595
|
noteFor(id, null);
|
|
3823
4596
|
syncDecisionSurfaces(decision);
|
|
4597
|
+
// A tool card answer (294 C3: grant, action, migration) carries this screen's confirmation session; without one nothing is sent.
|
|
4598
|
+
const tool = ownerAnswerRequired(decision);
|
|
4599
|
+
const client = tool ? await ensureAgentToolClient() : null;
|
|
3824
4600
|
let result;
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
4601
|
+
if (tool && !client) result = { kind: "client", error: null };
|
|
4602
|
+
else {
|
|
4603
|
+
try {
|
|
4604
|
+
const res = await fetch(`/api/decisions/${encodeURIComponent(id)}/answer`, {
|
|
4605
|
+
method: "POST",
|
|
4606
|
+
headers: { "content-type": "application/json", ...(client ? ownerHeaders(client) : {}) },
|
|
4607
|
+
body: JSON.stringify(body),
|
|
4608
|
+
signal: AbortSignal.timeout(30_000),
|
|
4609
|
+
});
|
|
4610
|
+
const answer = await res.json().catch(() => null);
|
|
4611
|
+
result = interpretAnswerResponse(res.status, answer);
|
|
4612
|
+
if (tool && res.status === 403 && clientExpired(answer?.error)) {
|
|
4613
|
+
agentToolClient = null;
|
|
4614
|
+
result = { kind: "expired", error: answer.error };
|
|
4615
|
+
}
|
|
4616
|
+
} catch (err) {
|
|
4617
|
+
result = { kind: "offline", error: err instanceof Error ? err.message : String(err) };
|
|
4618
|
+
}
|
|
3835
4619
|
}
|
|
3836
4620
|
endSubmit(inbox.state, id);
|
|
3837
4621
|
devLog("in", { type: "decision.answer", data: { id, mode: body.mode, submissionId: body.submissionId, result: result.kind, error: result.error ?? null } });
|
|
@@ -3840,6 +4624,7 @@ async function submitDecision(id, mode, response) {
|
|
|
3840
4624
|
|
|
3841
4625
|
function settleSubmit(decision, result) {
|
|
3842
4626
|
const id = decision.id;
|
|
4627
|
+
const tool = ownerAnswerRequired(decision);
|
|
3843
4628
|
switch (result.kind) {
|
|
3844
4629
|
case "accepted":
|
|
3845
4630
|
inbox.forms.delete(id);
|
|
@@ -3850,7 +4635,7 @@ function settleSubmit(decision, result) {
|
|
|
3850
4635
|
case "conflict":
|
|
3851
4636
|
inbox.forms.delete(id);
|
|
3852
4637
|
noteConflict(inbox.state, decision, result.decision);
|
|
3853
|
-
noteFor(id, t("decision.conflict"), true);
|
|
4638
|
+
noteFor(id, tool ? toolErrorText(result.error, 409) : t("decision.conflict"), true);
|
|
3854
4639
|
break;
|
|
3855
4640
|
case "blocked":
|
|
3856
4641
|
// Improvement 165: the answer is held; the card decides. The typed answer stays in the form for a fix.
|
|
@@ -3861,7 +4646,16 @@ function settleSubmit(decision, result) {
|
|
|
3861
4646
|
case "rejected":
|
|
3862
4647
|
inbox.state.drafts.delete(id);
|
|
3863
4648
|
if (result.decision) applyDecision(inbox.state, result.decision);
|
|
3864
|
-
noteFor(id, t("decision.rejected", { error: result.error ?? "invalid" }), true);
|
|
4649
|
+
noteFor(id, tool ? toolErrorText(result.error, 400) : t("decision.rejected", { error: result.error ?? "invalid" }), true);
|
|
4650
|
+
break;
|
|
4651
|
+
case "client":
|
|
4652
|
+
// No confirmation session: the gateway refused this origin, or it could not be reached. Nothing was sent.
|
|
4653
|
+
inbox.state.drafts.delete(id);
|
|
4654
|
+
noteFor(id, agentToolClientLine("toolGrant"), true);
|
|
4655
|
+
break;
|
|
4656
|
+
case "expired":
|
|
4657
|
+
inbox.state.drafts.delete(id);
|
|
4658
|
+
noteFor(id, toolErrorText(result.error, 403), true);
|
|
3865
4659
|
break;
|
|
3866
4660
|
case "gone": {
|
|
3867
4661
|
forgetDecision(inbox.state, id);
|
|
@@ -3872,7 +4666,7 @@ function settleSubmit(decision, result) {
|
|
|
3872
4666
|
break;
|
|
3873
4667
|
}
|
|
3874
4668
|
case "unavailable":
|
|
3875
|
-
noteFor(id, t("decision.unavailable", { status: result.status, error: result.error ? ` ${result.error}` : "" }), true);
|
|
4669
|
+
noteFor(id, tool ? toolErrorText(result.error, result.status) : t("decision.unavailable", { status: result.status, error: result.error ? ` ${result.error}` : "" }), true);
|
|
3876
4670
|
break;
|
|
3877
4671
|
default:
|
|
3878
4672
|
noteFor(id, t("decision.offline.kept"), true);
|
|
@@ -3891,6 +4685,7 @@ function onDecisionUpdated(data) {
|
|
|
3891
4685
|
securityInbox.merge(decision.security);
|
|
3892
4686
|
if (decision.security.status === "pending") void securityInbox.detail(decision.id);
|
|
3893
4687
|
}
|
|
4688
|
+
if (inbox.reviewed.has(decision.id) && decision.revision >= (inbox.reviewed.get(decision.id)?.revision ?? 0)) inbox.reviewed.set(decision.id, decision);
|
|
3894
4689
|
renderInboxBadge();
|
|
3895
4690
|
if (!result.changed) return;
|
|
3896
4691
|
if (endSocketSubmit(inbox.state, decision)) setCardBusy(originalActionId(decision), false);
|
|
@@ -4290,11 +5085,16 @@ const appDownload = createAppDownload({
|
|
|
4290
5085
|
});
|
|
4291
5086
|
apkEls.renew.onclick = () => void appDownload.renew();
|
|
4292
5087
|
|
|
4293
|
-
// 정리 (improvement 66):
|
|
4294
|
-
|
|
5088
|
+
// 정리 (improvement 66, housekeeping todo 13): two roles in one list. Nothing is cleaned until the user ticks 회수 rows and
|
|
5089
|
+
// presses the button; 상태 정리 rows wait for main and kept items are information only.
|
|
5090
|
+
const gcEls = {
|
|
5091
|
+
summary: $("gc-summary"), list: $("gc-list"), message: $("gc-message"), clean: $("gc-clean"), all: $("gc-all"), allLabel: $("gc-all-label"),
|
|
5092
|
+
audit: $("gc-audit"), auditList: $("gc-audit-list"),
|
|
5093
|
+
};
|
|
4295
5094
|
|
|
5095
|
+
// Only an enabled box belongs to a row that can be ticked: a kept item has a disabled box and a 상태 정리 row has none.
|
|
4296
5096
|
function gcBoxes() {
|
|
4297
|
-
return [...gcEls.list.querySelectorAll("input[type=checkbox]")];
|
|
5097
|
+
return [...gcEls.list.querySelectorAll("input[type=checkbox]:enabled")];
|
|
4298
5098
|
}
|
|
4299
5099
|
|
|
4300
5100
|
// Improvement 142: the 전체 선택 box and the clean button follow the ticked rows.
|
|
@@ -4315,47 +5115,92 @@ gcEls.all.onchange = () => {
|
|
|
4315
5115
|
};
|
|
4316
5116
|
|
|
4317
5117
|
function gcTicked() {
|
|
4318
|
-
return [...gcEls.list.querySelectorAll("input[type=checkbox]:checked")].map((box) => box.value);
|
|
5118
|
+
return [...gcEls.list.querySelectorAll("input[type=checkbox]:enabled:checked")].map((box) => box.value);
|
|
4319
5119
|
}
|
|
4320
5120
|
|
|
4321
5121
|
function gcRow(row) {
|
|
4322
5122
|
const li = document.createElement("li");
|
|
4323
|
-
const
|
|
4324
|
-
|
|
5123
|
+
const line = document.createElement(row.role === "reconcile" ? "div" : "label");
|
|
5124
|
+
const kept = row.role === "gc" && !row.selectable;
|
|
5125
|
+
line.className = kept ? "gc-row readonly" : "gc-row";
|
|
5126
|
+
if (row.role === "reconcile") {
|
|
5127
|
+
const mark = document.createElement("span");
|
|
5128
|
+
mark.className = "gc-mark";
|
|
5129
|
+
line.append(mark);
|
|
5130
|
+
} else {
|
|
5131
|
+
const box = document.createElement("input");
|
|
5132
|
+
box.type = "checkbox";
|
|
5133
|
+
box.value = row.id;
|
|
5134
|
+
box.disabled = !row.selectable;
|
|
5135
|
+
box.onchange = gcSync;
|
|
5136
|
+
line.append(box);
|
|
5137
|
+
}
|
|
4325
5138
|
const main = document.createElement("span");
|
|
4326
5139
|
main.className = "gc-main";
|
|
4327
|
-
const
|
|
4328
|
-
|
|
4329
|
-
box.value = row.id;
|
|
4330
|
-
box.onchange = gcSync;
|
|
5140
|
+
const head = document.createElement("span");
|
|
5141
|
+
head.className = "gc-line";
|
|
4331
5142
|
const name = document.createElement("span");
|
|
4332
5143
|
name.className = "gc-name";
|
|
4333
5144
|
name.textContent = row.label;
|
|
4334
5145
|
name.title = row.title;
|
|
5146
|
+
head.append(name);
|
|
5147
|
+
if (row.badge !== null) {
|
|
5148
|
+
const badge = document.createElement("span");
|
|
5149
|
+
badge.className = "gc-badge";
|
|
5150
|
+
badge.textContent = row.badge;
|
|
5151
|
+
head.append(badge);
|
|
5152
|
+
}
|
|
4335
5153
|
const sub = document.createElement("span");
|
|
4336
5154
|
sub.className = "gc-sub";
|
|
4337
5155
|
sub.textContent = row.sub;
|
|
4338
|
-
main.append(
|
|
4339
|
-
|
|
4340
|
-
|
|
5156
|
+
main.append(head, sub);
|
|
5157
|
+
if (row.note !== null) {
|
|
5158
|
+
const note = document.createElement("span");
|
|
5159
|
+
note.className = "gc-note";
|
|
5160
|
+
note.textContent = row.note;
|
|
5161
|
+
main.append(note);
|
|
5162
|
+
}
|
|
5163
|
+
line.append(main);
|
|
5164
|
+
li.append(line);
|
|
5165
|
+
return li;
|
|
5166
|
+
}
|
|
5167
|
+
|
|
5168
|
+
function gcGroup(group) {
|
|
5169
|
+
const li = document.createElement("li");
|
|
5170
|
+
li.className = "gc-group";
|
|
5171
|
+
li.hidden = group.hidden;
|
|
5172
|
+
const head = document.createElement("div");
|
|
5173
|
+
head.className = "gc-group-head";
|
|
5174
|
+
const title = document.createElement("span");
|
|
5175
|
+
title.textContent = group.title;
|
|
5176
|
+
const count = document.createElement("span");
|
|
5177
|
+
count.className = "gc-count";
|
|
5178
|
+
count.textContent = String(group.count);
|
|
5179
|
+
head.append(title, count);
|
|
5180
|
+
const rows = document.createElement("ul");
|
|
5181
|
+
rows.className = "gc-rows";
|
|
5182
|
+
rows.setAttribute("aria-label", group.title);
|
|
5183
|
+
rows.append(...group.rows.map(gcRow));
|
|
5184
|
+
li.append(head, rows);
|
|
4341
5185
|
return li;
|
|
4342
5186
|
}
|
|
4343
5187
|
|
|
4344
|
-
/** The last GET /api/
|
|
5188
|
+
/** The last GET /api/housekeeping answer (null when the gateway did not answer), drawn again in a new language. */
|
|
4345
5189
|
function renderGc(body) {
|
|
4346
5190
|
gcEls.body = body;
|
|
4347
5191
|
const view = gcView(body);
|
|
4348
5192
|
gcEls.summary.textContent = view.summary;
|
|
4349
5193
|
gcEls.summary.classList.toggle("error", view.error);
|
|
4350
|
-
gcEls.list.replaceChildren(...view.
|
|
5194
|
+
gcEls.list.replaceChildren(...view.groups.map(gcGroup));
|
|
4351
5195
|
gcSync();
|
|
5196
|
+
renderGcAudit();
|
|
4352
5197
|
}
|
|
4353
5198
|
|
|
4354
5199
|
async function loadGc() {
|
|
4355
5200
|
gcEls.clean.disabled = true;
|
|
4356
5201
|
let body = null;
|
|
4357
5202
|
try {
|
|
4358
|
-
const res = await fetch(
|
|
5203
|
+
const res = await fetch(HOUSEKEEPING_PATH, { cache: "no-store" });
|
|
4359
5204
|
if (res.ok) body = await res.json();
|
|
4360
5205
|
} catch {
|
|
4361
5206
|
body = null;
|
|
@@ -4363,12 +5208,53 @@ async function loadGc() {
|
|
|
4363
5208
|
renderGc(body);
|
|
4364
5209
|
}
|
|
4365
5210
|
|
|
5211
|
+
// 기록 보기: the last 20 audit lines, newest first. The button text follows the open state in the current language.
|
|
5212
|
+
function renderGcAudit() {
|
|
5213
|
+
const open = !gcEls.auditList.hidden;
|
|
5214
|
+
gcEls.audit.textContent = t(open ? "settings.gc.auditHide" : "settings.gc.audit");
|
|
5215
|
+
gcEls.audit.setAttribute("aria-expanded", String(open));
|
|
5216
|
+
if (!open) return;
|
|
5217
|
+
const view = auditView(gcEls.auditBody);
|
|
5218
|
+
const items = view.rows.map((row) => {
|
|
5219
|
+
const li = document.createElement("li");
|
|
5220
|
+
li.textContent = `${dateTimeLabel(row.at)} · ${row.text}`;
|
|
5221
|
+
li.title = row.title;
|
|
5222
|
+
li.classList.toggle("error", row.error);
|
|
5223
|
+
return li;
|
|
5224
|
+
});
|
|
5225
|
+
if (view.line !== "") {
|
|
5226
|
+
const li = document.createElement("li");
|
|
5227
|
+
li.className = view.error ? "gc-audit-line error" : "gc-audit-line";
|
|
5228
|
+
li.textContent = view.line;
|
|
5229
|
+
items.push(li);
|
|
5230
|
+
}
|
|
5231
|
+
gcEls.auditList.replaceChildren(...items);
|
|
5232
|
+
}
|
|
5233
|
+
|
|
5234
|
+
async function loadGcAudit() {
|
|
5235
|
+
let body = null;
|
|
5236
|
+
try {
|
|
5237
|
+
const res = await fetch(`${HOUSEKEEPING_AUDIT_PATH}?limit=${AUDIT_LINES}`, { cache: "no-store" });
|
|
5238
|
+
if (res.ok) body = await res.json();
|
|
5239
|
+
} catch {
|
|
5240
|
+
body = null;
|
|
5241
|
+
}
|
|
5242
|
+
gcEls.auditBody = body;
|
|
5243
|
+
renderGcAudit();
|
|
5244
|
+
}
|
|
5245
|
+
|
|
5246
|
+
gcEls.audit.onclick = async () => {
|
|
5247
|
+
gcEls.auditList.hidden = !gcEls.auditList.hidden;
|
|
5248
|
+
if (gcEls.auditList.hidden) renderGcAudit();
|
|
5249
|
+
else await loadGcAudit();
|
|
5250
|
+
};
|
|
5251
|
+
|
|
4366
5252
|
gcEls.clean.onclick = async () => {
|
|
4367
5253
|
const ids = gcTicked();
|
|
4368
5254
|
if (ids.length === 0) return;
|
|
4369
5255
|
gcEls.clean.disabled = true;
|
|
4370
5256
|
try {
|
|
4371
|
-
const res = await fetch(
|
|
5257
|
+
const res = await fetch(HOUSEKEEPING_CLEAN_PATH, cleanRequest(ids));
|
|
4372
5258
|
gcEls.message.textContent = res.ok ? cleanResultLine(await res.json()) : t("settings.gc.failedHttp", { status: res.status });
|
|
4373
5259
|
gcEls.message.classList.toggle("error", !res.ok);
|
|
4374
5260
|
} catch (err) {
|
|
@@ -4376,6 +5262,7 @@ gcEls.clean.onclick = async () => {
|
|
|
4376
5262
|
gcEls.message.classList.add("error");
|
|
4377
5263
|
}
|
|
4378
5264
|
await loadGc();
|
|
5265
|
+
if (!gcEls.auditList.hidden) await loadGcAudit();
|
|
4379
5266
|
};
|
|
4380
5267
|
|
|
4381
5268
|
function openSettings() {
|
|
@@ -4389,6 +5276,7 @@ function openSettings() {
|
|
|
4389
5276
|
void loadAccess();
|
|
4390
5277
|
void appDownload.start();
|
|
4391
5278
|
gcEls.message.textContent = "";
|
|
5279
|
+
gcEls.auditList.hidden = true;
|
|
4392
5280
|
void loadGc();
|
|
4393
5281
|
settings.title.focus();
|
|
4394
5282
|
}
|
|
@@ -4523,53 +5411,279 @@ manualEls.el.addEventListener("keydown", (ev) => {
|
|
|
4523
5411
|
|
|
4524
5412
|
// 에이전트 (agent-sessions todo 10, design D5): web/agents.js decides; this block draws the sidebar group above 대화, the
|
|
4525
5413
|
// header's name and stale badge, and the 에이전트 dialog (목록, editor, 공통 기본값, 보관함) over /api/agents.
|
|
4526
|
-
|
|
5414
|
+
// agentState itself sits beside `state` near the top: the profile and bubble speaker read it from the first paint.
|
|
4527
5415
|
const agentEls = {
|
|
4528
5416
|
list: $("agent-list"), manage: $("agent-manage"),
|
|
4529
|
-
head: $("agent-head"), headIcon: $("agent-head-icon"), headName: $("agent-head-name"), stale: $("agent-stale"),
|
|
4530
|
-
toggle: $("toggle-agents"), el: $("agents"), close: $("agents-close"), title: $("agents-title"), status: $("agents-status"), body: $("agents-body"),
|
|
5417
|
+
head: $("agent-head"), headIcon: $("agent-head-icon"), headName: $("agent-head-name"), stale: $("agent-stale"), staleText: $("agent-stale-text"), rotate: $("rotate-session"),
|
|
5418
|
+
toggle: $("toggle-agents"), el: $("agents"), close: $("agents-close"), title: $("agents-title"), status: $("agents-status"), applyNow: $("agents-apply-now"), body: $("agents-body"),
|
|
4531
5419
|
nav: { list: $("agents-nav-list"), defaults: $("agents-nav-defaults"), trash: $("agents-nav-trash") },
|
|
4532
5420
|
};
|
|
4533
|
-
|
|
5421
|
+
// `preview` is the can/cannot box's state (todo 12: the gateway's evaluation of the editor's text, its request sequence and
|
|
5422
|
+
// whether an answer is pending); `uploads` holds every agent's upload queue by id (the files picked for its source/ folder,
|
|
5423
|
+
// agent-ux todo 14) and `uploading` the ids whose runner is active: a queue keeps going out when another editor opens and a
|
|
5424
|
+
// second editor's queue starts at once (review fix B1); `lastDaily` is the open agent's newest daily note date from its detail
|
|
5425
|
+
// (todo 15); `fileTrash` its deleted files, `fileConfirm` the path whose in-row delete confirmation is open and `fileTrashOpen`
|
|
5426
|
+
// the disclosure state (todo 16). The files picked on a create screen live on that screen's editor state (`pendingUploads`,
|
|
5427
|
+
// todo 18 fix): the create request and the draft autosave take them from the state they were given, never from another screen.
|
|
5428
|
+
// While the editor's own save is on the wire, its agent.updated is that save, not a change made elsewhere.
|
|
5429
|
+
let editorSaving = false;
|
|
5430
|
+
const agentView = { open: false, view: "list", editor: null, files: [], returnFocus: null, load: 0, skills: { status: "idle", commands: null }, drafts: [], preview: { status: "loading", fields: null, seq: 0, pending: false }, uploads: new Map(), uploading: new Set(), lastDaily: null, fileTrash: [], fileConfirm: null, fileTrashOpen: false, applyNow: null };
|
|
5431
|
+
// The create screen's autosave (todo 11, 18): durable per draft id - one PUT per quiet period with the latest state of each draft, one
|
|
5432
|
+
// request per draft on the wire, and a state that became due is never dropped by closing or by another screen.
|
|
5433
|
+
const draftAutosave = createDraftSaver(saveDraft);
|
|
5434
|
+
// The capability preview (todo 12, 18): disposable and owned by the open editor - the gateway evaluates the editor's current text, one
|
|
5435
|
+
// request on the wire with the latest state after it; closing, opening another editor or a save answer discards what waits.
|
|
5436
|
+
const capabilityPreview = createPreviewRequester(requestPreview, { delayMs: PREVIEW_DELAY_MS });
|
|
4534
5437
|
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
5438
|
+
/**
|
|
5439
|
+
* Every editor change lands here: the state is replaced, a new agent's draft autosave is touched (todo 11), and the box asks
|
|
5440
|
+
* the gateway again (todo 12). The edit moves the preview sequence on (todo 12 fix 4): an answer still on the wire belongs to
|
|
5441
|
+
* older text and is dropped when it lands, and the box stays busy until the answer for this text arrives.
|
|
5442
|
+
*/
|
|
5443
|
+
function commitEditor(next) {
|
|
5444
|
+
agentView.editor = next;
|
|
5445
|
+
if (next.kind === "new" && next.draftId) draftAutosave.touch(next);
|
|
5446
|
+
if (next.kind === "defaults") return;
|
|
5447
|
+
agentView.preview = { ...agentView.preview, seq: agentView.preview.seq + 1, pending: true };
|
|
5448
|
+
document.getElementById("pe-capabilities")?.setAttribute("aria-busy", "true");
|
|
5449
|
+
capabilityPreview.touch(next);
|
|
4540
5450
|
}
|
|
4541
5451
|
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
5452
|
+
/**
|
|
5453
|
+
* The can/cannot box at the top of an agent's editor (agent-ux todo 12): two columns from the gateway's own evaluation of
|
|
5454
|
+
* the editor's current text (POST /api/agents/preview; web/agents.js capabilities), each line with its detail as the
|
|
5455
|
+
* tooltip. Until the first answer it says it is checking; text the gateway refuses shows a line instead of a guess, and so
|
|
5456
|
+
* does a request that brought no answer (the gateway could not be reached); while a newer answer is pending the last valid
|
|
5457
|
+
* one stays, marked busy.
|
|
5458
|
+
*/
|
|
5459
|
+
function renderCapabilities() {
|
|
5460
|
+
const preview = agentView.preview;
|
|
5461
|
+
const box = document.createElement("div");
|
|
5462
|
+
box.className = "pe-capabilities";
|
|
5463
|
+
box.id = "pe-capabilities";
|
|
5464
|
+
box.setAttribute("role", "group");
|
|
5465
|
+
box.setAttribute("aria-label", t("agents.cap.title"));
|
|
5466
|
+
box.setAttribute("aria-busy", String(preview.pending || preview.status === "loading"));
|
|
5467
|
+
box.dataset.state = preview.status;
|
|
5468
|
+
if (preview.status !== "ready") {
|
|
5469
|
+
const line = document.createElement("p");
|
|
5470
|
+
line.className = "pe-cap-line";
|
|
5471
|
+
line.id = "pe-cap-line";
|
|
5472
|
+
line.textContent = t(preview.status === "loading" ? "agents.cap.loading" : preview.status === "failed" ? "agents.cap.failed" : "agents.cap.unavailable");
|
|
5473
|
+
box.append(line);
|
|
5474
|
+
return box;
|
|
5475
|
+
}
|
|
5476
|
+
const result = capabilities(preview.fields);
|
|
5477
|
+
for (const [kind, items] of [["can", result.can], ["cannot", result.cannot]]) {
|
|
5478
|
+
const column = document.createElement("section");
|
|
5479
|
+
column.className = `pe-cap ${kind}`;
|
|
5480
|
+
const heading = document.createElement("h4");
|
|
5481
|
+
heading.className = "pe-cap-title";
|
|
5482
|
+
heading.textContent = t(`agents.cap.${kind}`);
|
|
5483
|
+
const list = document.createElement("ul");
|
|
5484
|
+
for (const item of items) {
|
|
5485
|
+
const li = document.createElement("li");
|
|
5486
|
+
li.dataset.cap = item.key;
|
|
5487
|
+
li.textContent = item.text;
|
|
5488
|
+
// The page tooltip (web/tooltip.js, bug 90): shown on hover and on focus, so the line is focusable.
|
|
5489
|
+
li.tabIndex = 0;
|
|
5490
|
+
li.setAttribute("data-tip-keep", "");
|
|
5491
|
+
li.setAttribute("data-tip", item.tip);
|
|
5492
|
+
list.append(li);
|
|
5493
|
+
}
|
|
5494
|
+
column.append(heading, list);
|
|
5495
|
+
box.append(column);
|
|
4553
5496
|
}
|
|
5497
|
+
return box;
|
|
4554
5498
|
}
|
|
4555
5499
|
|
|
4556
|
-
function
|
|
4557
|
-
|
|
5500
|
+
function refreshCapabilities() {
|
|
5501
|
+
document.getElementById("pe-capabilities")?.replaceWith(renderCapabilities());
|
|
4558
5502
|
}
|
|
4559
5503
|
|
|
4560
|
-
|
|
4561
|
-
|
|
5504
|
+
/**
|
|
5505
|
+
* POST the editor's text for the box. While the request is on the wire the box keeps its last valid answer, marked busy
|
|
5506
|
+
* (marked here too, since the controller sends a queued state right after an answer cleared the mark). The answer is
|
|
5507
|
+
* applied only while it is the newest request of the editor that owns the preview, for its current text: a later request
|
|
5508
|
+
* or an edit moves the sequence on, and closing, another editor or a save answer discards the preview and moves its owner
|
|
5509
|
+
* token on (todo 18 fix 2) - the older answer is dropped, the box staying busy for the request that follows. A request
|
|
5510
|
+
* that brought no answer leaves the box `failed` (todo 12 fix 4), never an older answer marked ready.
|
|
5511
|
+
*/
|
|
5512
|
+
async function requestPreview(state, token) {
|
|
5513
|
+
const seq = ++agentView.preview.seq;
|
|
5514
|
+
agentView.preview = { ...agentView.preview, pending: true };
|
|
5515
|
+
document.getElementById("pe-capabilities")?.setAttribute("aria-busy", "true");
|
|
5516
|
+
let answer = null;
|
|
5517
|
+
try {
|
|
5518
|
+
answer = await agentApi(previewRequest(state));
|
|
5519
|
+
} catch {
|
|
5520
|
+
answer = null;
|
|
5521
|
+
}
|
|
5522
|
+
if (!capabilityPreview.owns(token) || seq !== agentView.preview.seq) return;
|
|
5523
|
+
agentView.preview = { ...previewState(answer), seq, pending: false };
|
|
5524
|
+
refreshCapabilities();
|
|
4562
5525
|
}
|
|
4563
5526
|
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
return el;
|
|
5527
|
+
/** The box's state for a newly opened editor: the gateway's fields when a detail or save answer already holds them, else checking until the first request answers. */
|
|
5528
|
+
function resetPreview(fields = null) {
|
|
5529
|
+
const seq = agentView.preview.seq + 1;
|
|
5530
|
+
agentView.preview = fields ? { status: "ready", fields, seq, pending: false } : { status: "loading", fields: null, seq, pending: true };
|
|
4569
5531
|
}
|
|
4570
5532
|
|
|
4571
|
-
|
|
4572
|
-
|
|
5533
|
+
/**
|
|
5534
|
+
* PUT the draft (one request per draft on the wire, see createDraftSaver). The status line under the create form says
|
|
5535
|
+
* when it was saved, or why not (editing goes on); it is written only while that draft is the open editor's, so an answer
|
|
5536
|
+
* for an earlier draft never shows as the newest save. A 409 draft_closed means the gateway archived the draft meanwhile
|
|
5537
|
+
* (it was created or discarded, maybe from another tab): the line says so, the text stays, and that id is not saved again.
|
|
5538
|
+
*/
|
|
5539
|
+
async function saveDraft(state) {
|
|
5540
|
+
const current = () => agentView.editor?.draftId === state.draftId;
|
|
5541
|
+
if (current()) setDraftStatus(t("agents.draft.saving"), "");
|
|
5542
|
+
try {
|
|
5543
|
+
// The draft lists the queued files of the state being saved by name and size (todo 18): a deferred state still carries its own
|
|
5544
|
+
// files once another screen is open; a reopened draft keeps the names it had until new files are picked.
|
|
5545
|
+
const files = state.pendingUploads?.length ? draftFileList(state.pendingUploads) : state.draftFiles ?? [];
|
|
5546
|
+
await agentApi(draftRequest(state, files));
|
|
5547
|
+
if (current()) setDraftStatus(t("agents.draft.saved", { time: new Date().toLocaleTimeString(uiLocale(), { hour: "numeric", minute: "2-digit" }) }), "ok");
|
|
5548
|
+
} catch (error) {
|
|
5549
|
+
if (!current()) return false;
|
|
5550
|
+
if (error.code === "draft_closed") {
|
|
5551
|
+
agentView.editor = { ...agentView.editor, draftId: null };
|
|
5552
|
+
setDraftStatus(error.message, "warn");
|
|
5553
|
+
return false;
|
|
5554
|
+
}
|
|
5555
|
+
setDraftStatus(t("agents.draft.failed", { reason: error.message }), "warn");
|
|
5556
|
+
return false;
|
|
5557
|
+
}
|
|
5558
|
+
}
|
|
5559
|
+
|
|
5560
|
+
function setDraftStatus(text, kind) {
|
|
5561
|
+
const line = document.getElementById("pe-draft-status");
|
|
5562
|
+
if (!line) return;
|
|
5563
|
+
line.textContent = text;
|
|
5564
|
+
line.className = `pe-draft-status${kind ? ` ${kind}` : ""}`;
|
|
5565
|
+
}
|
|
5566
|
+
|
|
5567
|
+
/** GET /api/agents/drafts for the list screen's reopen button (todo 11); a failure leaves the cached rows. */
|
|
5568
|
+
async function refreshDrafts(load) {
|
|
5569
|
+
try {
|
|
5570
|
+
const rows = draftRows(await agentApi({ method: "GET", path: DRAFTS_PATH }));
|
|
5571
|
+
if (load !== agentView.load) return;
|
|
5572
|
+
agentView.drafts = rows;
|
|
5573
|
+
if (agentView.view === "list") renderAgentList();
|
|
5574
|
+
} catch (error) {
|
|
5575
|
+
console.warn("agent drafts:", error);
|
|
5576
|
+
}
|
|
5577
|
+
}
|
|
5578
|
+
|
|
5579
|
+
/**
|
|
5580
|
+
* Reopens a draft from the list (todo 11). What this page still owes that draft lands first - a state due behind its wire
|
|
5581
|
+
* included - so the GET answers the latest persisted state and the reopened editor never saves an older file list over a
|
|
5582
|
+
* newer one (todo 18 fix 3); the list says it is waiting. The wait is bounded (todo 18 fix 4): a save that never answers
|
|
5583
|
+
* must not block the list, so after DRAFT_REOPEN_WAIT_MS the draft opens from the newest state this page holds, with a
|
|
5584
|
+
* note, and the autosave goes on behind it. A failed save also recovers the local state, with retry guidance instead of a stale GET.
|
|
5585
|
+
* A reopen whose navigation token is no longer current (the user opened another
|
|
5586
|
+
* editor, the list or closed the dialog meanwhile) does nothing.
|
|
5587
|
+
*/
|
|
5588
|
+
async function openDraft(id) {
|
|
5589
|
+
const load = agentView.load;
|
|
5590
|
+
try {
|
|
5591
|
+
showAgentStatus(t("agents.draft.waiting"));
|
|
5592
|
+
let bound = null;
|
|
5593
|
+
const saved = await Promise.race([draftAutosave.settle(id), new Promise((resolve) => { bound = setTimeout(() => resolve(null), DRAFT_REOPEN_WAIT_MS); })]);
|
|
5594
|
+
if (bound !== null) clearTimeout(bound);
|
|
5595
|
+
if (load !== agentView.load) return;
|
|
5596
|
+
const local = saved === false || saved === null ? draftAutosave.latest(id) : null;
|
|
5597
|
+
const draft = local ? { id, source: composeSource(local), files: local.pendingUploads?.length ? draftFileList(local.pendingUploads) : local.draftFiles ?? [] } : await agentApi(draftLoadRequest(id));
|
|
5598
|
+
if (load !== agentView.load) return;
|
|
5599
|
+
await showAgentView("editor", null, draft);
|
|
5600
|
+
if (agentView.load !== load + 1) return;
|
|
5601
|
+
showAgentStatus(local && saved === null ? t("agents.draft.unsettled") : "", local && saved === null ? "warn" : "");
|
|
5602
|
+
if (local && saved === false) setDraftStatus(t("agents.draft.recovered"), "warn");
|
|
5603
|
+
} catch (error) {
|
|
5604
|
+
if (load === agentView.load) showAgentStatus(error.message, "error");
|
|
5605
|
+
}
|
|
5606
|
+
}
|
|
5607
|
+
|
|
5608
|
+
async function discardDraft(id) {
|
|
5609
|
+
try {
|
|
5610
|
+
await agentApi(discardDraftRequest(id));
|
|
5611
|
+
agentView.drafts = agentView.drafts.filter((draft) => draft.id !== id);
|
|
5612
|
+
showAgentStatus(t("agents.draft.discarded"), "ok");
|
|
5613
|
+
renderAgentList();
|
|
5614
|
+
} catch (error) {
|
|
5615
|
+
showAgentStatus(error.message, "error");
|
|
5616
|
+
}
|
|
5617
|
+
}
|
|
5618
|
+
|
|
5619
|
+
/**
|
|
5620
|
+
* One agent API request. A refusal throws the Korean/English line with the gateway's `code`, `field` and `status` on
|
|
5621
|
+
* the error (saveErrorLine turns them into save-specific sentences, todo 13); a request that never got an answer
|
|
5622
|
+
* throws with `network: true`.
|
|
5623
|
+
*/
|
|
5624
|
+
async function agentApi(request) {
|
|
5625
|
+
let res;
|
|
5626
|
+
try {
|
|
5627
|
+
res = await fetch(request.path, { method: request.method, headers: request.headers, body: request.body });
|
|
5628
|
+
} catch (error) {
|
|
5629
|
+
throw Object.assign(new Error(error instanceof Error ? error.message : String(error)), { code: null, field: null, status: 0, network: true });
|
|
5630
|
+
}
|
|
5631
|
+
const body = await res.json().catch(() => null);
|
|
5632
|
+
if (!res.ok) throw Object.assign(new Error(errorLine(body, res.status)), { code: typeof body?.error === "string" ? body.error : null, field: typeof body?.field === "string" ? body.field : null, status: res.status, network: false });
|
|
5633
|
+
return body;
|
|
5634
|
+
}
|
|
5635
|
+
|
|
5636
|
+
/** The only writer of the agent list: every write moves its stamp on (T21W-B2). */
|
|
5637
|
+
function writeAgents(list) {
|
|
5638
|
+
agentState.writes += 1;
|
|
5639
|
+
agentState.list = list;
|
|
5640
|
+
}
|
|
5641
|
+
|
|
5642
|
+
/**
|
|
5643
|
+
* Reads the gateway's agent list. The answer lands only when nothing was written to the list after the request went
|
|
5644
|
+
* out (freshListAnswer); an overtaken answer is dropped and asked once more, and that answer follows the same rule.
|
|
5645
|
+
*/
|
|
5646
|
+
async function loadAgents(again = true) {
|
|
5647
|
+
const sent = agentState.writes;
|
|
5648
|
+
try {
|
|
5649
|
+
const list = await agentApi({ method: "GET", path: AGENTS_PATH });
|
|
5650
|
+
if (!Array.isArray(list)) return;
|
|
5651
|
+
if (!freshListAnswer(sent, agentState.writes)) {
|
|
5652
|
+
if (again) await loadAgents(false);
|
|
5653
|
+
return;
|
|
5654
|
+
}
|
|
5655
|
+
writeAgents(list);
|
|
5656
|
+
agentState.loaded = true;
|
|
5657
|
+
renderAgentGroup();
|
|
5658
|
+
renderAgentHeader();
|
|
5659
|
+
renderApplyNow();
|
|
5660
|
+
// The list may arrive after the transcript was painted (first load) or changed while this page was offline
|
|
5661
|
+
// (reconnect): the open conversation's rows, reference labels and profile follow it (bug 284).
|
|
5662
|
+
repaintSpeaker();
|
|
5663
|
+
if (agentView.open && agentView.view === "list") renderAgentList();
|
|
5664
|
+
refreshTargetRows();
|
|
5665
|
+
} catch (error) {
|
|
5666
|
+
console.warn("agents load:", error);
|
|
5667
|
+
}
|
|
5668
|
+
}
|
|
5669
|
+
|
|
5670
|
+
function agentSidebarView() {
|
|
5671
|
+
return { current: state.current, running: state.running, busy: (id) => isBusy(activity, id), pending: state.pendingByConv };
|
|
5672
|
+
}
|
|
5673
|
+
|
|
5674
|
+
function currentConversation() {
|
|
5675
|
+
return state.conversations.find((c) => c.id === state.current) ?? null;
|
|
5676
|
+
}
|
|
5677
|
+
|
|
5678
|
+
function peSpan(className, text) {
|
|
5679
|
+
const el = document.createElement("span");
|
|
5680
|
+
el.className = className;
|
|
5681
|
+
el.textContent = text;
|
|
5682
|
+
return el;
|
|
5683
|
+
}
|
|
5684
|
+
|
|
5685
|
+
function peButton(text, onClick, id, className = "text-btn", act) {
|
|
5686
|
+
const button = document.createElement("button");
|
|
4573
5687
|
button.type = "button";
|
|
4574
5688
|
button.className = className;
|
|
4575
5689
|
button.textContent = text;
|
|
@@ -4618,33 +5732,131 @@ function renderAgentGroup() {
|
|
|
4618
5732
|
}
|
|
4619
5733
|
}
|
|
4620
5734
|
|
|
5735
|
+
/** The header's agent block and the new-session button: while the definition changed, the status says so and the button is the way to apply it (bug 283). */
|
|
4621
5736
|
function renderAgentHeader() {
|
|
4622
5737
|
const view = headerView(currentConversation(), agentState.list);
|
|
5738
|
+
const notice = staleNotice(view);
|
|
5739
|
+
agentEls.rotate.classList.toggle("attention", notice.attention);
|
|
5740
|
+
agentEls.rotate.textContent = t(notice.labelKey);
|
|
5741
|
+
agentEls.rotate.setAttribute("aria-label", t(notice.labelKey));
|
|
4623
5742
|
agentEls.head.hidden = !view.agent;
|
|
4624
5743
|
if (!view.agent) return;
|
|
4625
5744
|
agentEls.headIcon.textContent = view.icon;
|
|
4626
5745
|
agentEls.headName.textContent = view.name;
|
|
4627
5746
|
agentEls.headName.title = view.name;
|
|
4628
|
-
agentEls.stale.hidden = !
|
|
5747
|
+
agentEls.stale.hidden = !notice.shown;
|
|
4629
5748
|
agentEls.stale.title = STALE_TITLE;
|
|
5749
|
+
if (notice.shown) agentEls.staleText.textContent = t(notice.textKey);
|
|
5750
|
+
}
|
|
5751
|
+
|
|
5752
|
+
/** agent.applied of a conversation: the list follows the new session's sha (a write, so an older list answer cannot undo it). */
|
|
5753
|
+
function receiveAgentApplied(conv, data) {
|
|
5754
|
+
writeAgents(applyAgentApplied(agentState.list, conv, data));
|
|
5755
|
+
renderAgentHeader();
|
|
5756
|
+
renderAgentGroup();
|
|
5757
|
+
renderApplyNow();
|
|
4630
5758
|
}
|
|
4631
5759
|
|
|
4632
5760
|
function receiveAgentUpdate(data) {
|
|
4633
5761
|
const result = applyAgentUpdate(agentState.list, state.conversations, data);
|
|
4634
|
-
|
|
5762
|
+
writeAgents(result.agents);
|
|
4635
5763
|
state.conversations = result.conversations;
|
|
4636
5764
|
if (result.removedConv && result.removedConv === state.current) selectConversation("main");
|
|
4637
5765
|
else if (result.restoredConv) selectConversation(result.restoredConv);
|
|
4638
5766
|
else {
|
|
4639
5767
|
renderConversations();
|
|
4640
5768
|
renderAgentHeader();
|
|
5769
|
+
repaintSpeaker(data?.id);
|
|
4641
5770
|
}
|
|
4642
5771
|
if (agentView.open && agentView.view === "list") renderAgentList();
|
|
5772
|
+
refreshTargetRows();
|
|
5773
|
+
// The open editor's own save answers through saveAgentEditor; only a change made elsewhere is followed here.
|
|
5774
|
+
switch (agentView.open && !editorSaving ? editorUpdateAction(agentView.editor, data) : null) {
|
|
5775
|
+
case "reload": void reloadOpenEditor(data.id); break;
|
|
5776
|
+
case "changed": showAgentStatus(t("agents.changedElsewhere"), "error"); break;
|
|
5777
|
+
}
|
|
4643
5778
|
}
|
|
4644
5779
|
|
|
4645
|
-
|
|
5780
|
+
/** An untouched editor of an agent whose saved definition changed elsewhere shows the gateway's current definition. */
|
|
5781
|
+
async function reloadOpenEditor(id) {
|
|
5782
|
+
const load = agentView.load;
|
|
5783
|
+
try {
|
|
5784
|
+
const detail = await agentApi({ method: "GET", path: `${AGENTS_PATH}/${encodeURIComponent(id)}` });
|
|
5785
|
+
const editor = agentView.editor;
|
|
5786
|
+
if (load !== agentView.load || editorSaving || editor?.kind !== "agent" || editor.id !== id || editor.dirty === true) return;
|
|
5787
|
+
agentView.editor = editorState(detail, "agent");
|
|
5788
|
+
capabilityPreview.discard();
|
|
5789
|
+
resetPreview(detail.fields);
|
|
5790
|
+
renderAgentEditor();
|
|
5791
|
+
showAgentStatus(t("agents.changedElsewhere.reloaded"), "ok");
|
|
5792
|
+
} catch (error) {
|
|
5793
|
+
if (load === agentView.load) showAgentStatus(t("agents.changedElsewhere"), "error");
|
|
5794
|
+
}
|
|
5795
|
+
}
|
|
5796
|
+
|
|
5797
|
+
/**
|
|
5798
|
+
* After agent metadata changed (bug 284): the open agent conversation's rows and reference labels follow its name and
|
|
5799
|
+
* icon, and the sidebar profile is redrawn. [agentId] limits the row repaint to one agent's update (a realtime
|
|
5800
|
+
* `agent.updated`); without it (a list refresh) the open agent conversation is repainted whatever changed.
|
|
5801
|
+
*/
|
|
5802
|
+
function repaintSpeaker(agentId = null) {
|
|
5803
|
+
const view = speaker();
|
|
5804
|
+
if (view.agent && (agentId === null || view.agentId === agentId)) {
|
|
5805
|
+
nameAgentRows(document.querySelectorAll(".msg:not(.mine)"), view.name);
|
|
5806
|
+
iconAgentRows(document.querySelectorAll(".msg:not(.mine)"), null, view.icon);
|
|
5807
|
+
for (const label of document.querySelectorAll('[data-role="assistant"]')) label.textContent = view.name;
|
|
5808
|
+
}
|
|
5809
|
+
refreshCollaborationCards();
|
|
5810
|
+
renderAgentProfile();
|
|
5811
|
+
}
|
|
5812
|
+
|
|
5813
|
+
/** The dialog's status line; [apply] is the "지금 적용" target a changed save offers beside it (todo 21), else no button. */
|
|
5814
|
+
function showAgentStatus(text, kind = "", apply = null) {
|
|
4646
5815
|
agentEls.status.textContent = text;
|
|
4647
5816
|
agentEls.status.className = `agents-status${kind ? ` ${kind}` : ""}`;
|
|
5817
|
+
agentView.applyNow = apply;
|
|
5818
|
+
renderApplyNow();
|
|
5819
|
+
}
|
|
5820
|
+
|
|
5821
|
+
function renderApplyNow() {
|
|
5822
|
+
agentEls.applyNow.hidden = !applyNowShown(agentView.applyNow, agentState.list);
|
|
5823
|
+
}
|
|
5824
|
+
|
|
5825
|
+
/**
|
|
5826
|
+
* Sends the rotation that opens [target]'s next session at once, subscribed to its conversation's events before the
|
|
5827
|
+
* request goes out; resolves with applyWatch's outcome, or "unconfirmed" when nothing decides it within APPLY_WAIT_MS.
|
|
5828
|
+
*/
|
|
5829
|
+
function watchApply(target) {
|
|
5830
|
+
return new Promise((resolve) => {
|
|
5831
|
+
const follow = applyWatch(target);
|
|
5832
|
+
let timer = null;
|
|
5833
|
+
const finish = (result) => {
|
|
5834
|
+
applyWaits.delete(listen);
|
|
5835
|
+
clearTimeout(timer);
|
|
5836
|
+
resolve(result);
|
|
5837
|
+
};
|
|
5838
|
+
const listen = (event) => {
|
|
5839
|
+
const result = follow(event);
|
|
5840
|
+
if (result !== null) finish(result);
|
|
5841
|
+
};
|
|
5842
|
+
timer = setTimeout(() => finish("unconfirmed"), APPLY_WAIT_MS);
|
|
5843
|
+
applyWaits.add(listen);
|
|
5844
|
+
if (!send({ type: "conversation.rotate", conv: target.convId, open: true })) finish("unconfirmed");
|
|
5845
|
+
});
|
|
5846
|
+
}
|
|
5847
|
+
|
|
5848
|
+
/** "지금 적용": the same question as the header button, then the rotation; the outcome is said in the status line. */
|
|
5849
|
+
async function applyAgentNow() {
|
|
5850
|
+
const target = agentView.applyNow;
|
|
5851
|
+
if (!target || agentEls.applyNow.disabled) return;
|
|
5852
|
+
if (!confirm(t("head.newSession.confirm.apply"))) return;
|
|
5853
|
+
agentEls.applyNow.disabled = true;
|
|
5854
|
+
const result = await watchApply(target);
|
|
5855
|
+
agentEls.applyNow.disabled = false;
|
|
5856
|
+
// Whatever the outcome (a timeout included), the header and the button follow the gateway's agent state (T21W-B1).
|
|
5857
|
+
void loadAgents();
|
|
5858
|
+
// Only a turn that held the rotation leaves the button to press again once the answer ends.
|
|
5859
|
+
showAgentStatus(applyLine(result), result === "applied" ? "ok" : "error", result === "turn_running" ? target : null);
|
|
4648
5860
|
}
|
|
4649
5861
|
|
|
4650
5862
|
function openAgents(view = "list", target = null) {
|
|
@@ -4661,9 +5873,13 @@ function openAgents(view = "list", target = null) {
|
|
|
4661
5873
|
|
|
4662
5874
|
function closeAgents() {
|
|
4663
5875
|
if (!agentView.open) return;
|
|
5876
|
+
// A change made less than the quiet period ago is saved now; the request outlives the dialog. The preview is discarded.
|
|
5877
|
+
draftAutosave.flush();
|
|
5878
|
+
capabilityPreview.discard();
|
|
4664
5879
|
agentView.open = false;
|
|
4665
5880
|
agentView.load += 1;
|
|
4666
5881
|
agentView.editor = null;
|
|
5882
|
+
agentView.skills = { status: "idle", commands: null };
|
|
4667
5883
|
agentEls.el.hidden = true;
|
|
4668
5884
|
agentEls.body.replaceChildren();
|
|
4669
5885
|
for (const button of [agentEls.toggle, agentEls.manage]) button.setAttribute("aria-expanded", "false");
|
|
@@ -4672,13 +5888,16 @@ function closeAgents() {
|
|
|
4672
5888
|
(back instanceof HTMLElement && back !== document.body && document.contains(back) ? back : agentEls.toggle).focus();
|
|
4673
5889
|
}
|
|
4674
5890
|
|
|
4675
|
-
async function showAgentView(view, target = null) {
|
|
5891
|
+
async function showAgentView(view, target = null, draft = null) {
|
|
5892
|
+
draftAutosave.flush();
|
|
5893
|
+
capabilityPreview.discard();
|
|
4676
5894
|
agentView.view = view;
|
|
4677
5895
|
for (const [name, button] of Object.entries(agentEls.nav)) button.setAttribute("aria-current", String(name === view));
|
|
4678
5896
|
const load = ++agentView.load;
|
|
4679
5897
|
agentEls.body.replaceChildren();
|
|
4680
5898
|
if (view === "list") {
|
|
4681
5899
|
renderAgentList();
|
|
5900
|
+
void refreshDrafts(load);
|
|
4682
5901
|
return;
|
|
4683
5902
|
}
|
|
4684
5903
|
if (view === "trash") {
|
|
@@ -4694,14 +5913,31 @@ async function showAgentView(view, target = null) {
|
|
|
4694
5913
|
// The field registry rides on the defaults answer; a new agent has no detail to take it from yet.
|
|
4695
5914
|
const defaults = await agentApi({ method: "GET", path: DEFAULTS_PATH });
|
|
4696
5915
|
if (load !== agentView.load) return;
|
|
4697
|
-
|
|
5916
|
+
// A reopened draft brings its text and id (todo 11); a fresh create screen makes a new id. defaults.md rides along as the
|
|
5917
|
+
// gateway parsed it (`fields`), the shared layer under the agent's own values that the can/cannot box and the rows read (todo 12 fix 2).
|
|
5918
|
+
agentView.editor = { ...editorState({ registry: defaults.registry, source: draft?.source, draftId: draft?.id ?? newDraftId(), defaultsFields: defaults.fields }, "new"), draftFiles: Array.isArray(draft?.files) ? draft.files : [], pendingUploads: [] };
|
|
4698
5919
|
agentView.files = [];
|
|
5920
|
+
agentView.lastDaily = null;
|
|
5921
|
+
agentView.fileTrash = [];
|
|
5922
|
+
agentView.fileConfirm = null;
|
|
5923
|
+
// The box asks the gateway at once for the fresh or reopened text (todo 12).
|
|
5924
|
+
resetPreview();
|
|
5925
|
+
capabilityPreview.touch(agentView.editor);
|
|
5926
|
+
capabilityPreview.flush();
|
|
4699
5927
|
} else {
|
|
4700
5928
|
const detail = await agentApi({ method: "GET", path: `${AGENTS_PATH}/${encodeURIComponent(target)}` });
|
|
4701
|
-
const files = await agentApi({ method: "GET", path: filesPath(target) }).catch(() => []);
|
|
5929
|
+
const [files, trash] = await Promise.all([agentApi({ method: "GET", path: filesPath(target) }).catch(() => []), agentApi({ method: "GET", path: fileTrashPath(target) }).catch(() => [])]);
|
|
4702
5930
|
if (load !== agentView.load) return;
|
|
5931
|
+
// The detail carries the parsed shared layer (`defaultsFields`) beside the merged fields, which are the gateway's evaluation of this untouched text (todo 12).
|
|
4703
5932
|
agentView.editor = editorState(detail, "agent");
|
|
4704
5933
|
agentView.files = fileRows(files);
|
|
5934
|
+
// The agent's own queue stays (a run started in an earlier visit goes on, failed rows keep their 다시 시도); only the rows
|
|
5935
|
+
// done by now are dropped - their files are in the list.
|
|
5936
|
+
agentView.uploads.set(target, uploadQueue(target).filter((row) => row.status !== "done"));
|
|
5937
|
+
agentView.lastDaily = typeof detail.lastDaily === "string" ? detail.lastDaily : null;
|
|
5938
|
+
agentView.fileTrash = fileTrashRows(trash);
|
|
5939
|
+
agentView.fileConfirm = null;
|
|
5940
|
+
resetPreview(detail.fields);
|
|
4705
5941
|
}
|
|
4706
5942
|
} catch (error) {
|
|
4707
5943
|
if (load === agentView.load) showAgentStatus(error.message, "error");
|
|
@@ -4716,6 +5952,19 @@ function renderAgentList() {
|
|
|
4716
5952
|
const actions = document.createElement("div");
|
|
4717
5953
|
actions.className = "pe-actions";
|
|
4718
5954
|
actions.append(peButton(t("side.newAgent"), () => openAgents("editor", null), "pe-new", "text-btn primary"));
|
|
5955
|
+
// Drafts of the create screen (todo 11): a button beside + 에이전트 opens their list.
|
|
5956
|
+
const drafts = agentView.drafts;
|
|
5957
|
+
const draftList = renderDraftList(drafts);
|
|
5958
|
+
draftList.hidden = true;
|
|
5959
|
+
if (drafts.length > 0) {
|
|
5960
|
+
const open = peButton(t("agents.draft.open", { count: drafts.length }), () => {
|
|
5961
|
+
draftList.hidden = !draftList.hidden;
|
|
5962
|
+
open.setAttribute("aria-expanded", String(!draftList.hidden));
|
|
5963
|
+
}, "pe-drafts-open");
|
|
5964
|
+
open.setAttribute("aria-expanded", "false");
|
|
5965
|
+
open.setAttribute("aria-controls", "pe-draft-list");
|
|
5966
|
+
actions.append(open);
|
|
5967
|
+
}
|
|
4719
5968
|
const list = document.createElement("ul");
|
|
4720
5969
|
list.className = "pe-list";
|
|
4721
5970
|
list.id = "pe-agent-list";
|
|
@@ -4752,10 +6001,53 @@ function renderAgentList() {
|
|
|
4752
6001
|
li.append(peSpan("agent-icon", item.icon), main, rowActions);
|
|
4753
6002
|
list.append(li);
|
|
4754
6003
|
}
|
|
4755
|
-
section.append(actions, list);
|
|
6004
|
+
section.append(actions, draftList, list);
|
|
4756
6005
|
agentEls.body.replaceChildren(section);
|
|
4757
6006
|
}
|
|
4758
6007
|
|
|
6008
|
+
/** The create screen's drafts (todo 11): name or 이름 없음, when it was saved, reopen, and discard with an in-row confirmation. */
|
|
6009
|
+
function renderDraftList(drafts) {
|
|
6010
|
+
const list = document.createElement("ul");
|
|
6011
|
+
list.className = "pe-list pe-draft-list";
|
|
6012
|
+
list.id = "pe-draft-list";
|
|
6013
|
+
for (const draft of drafts) {
|
|
6014
|
+
const li = document.createElement("li");
|
|
6015
|
+
li.className = "pe-list-row";
|
|
6016
|
+
li.dataset.draft = draft.id;
|
|
6017
|
+
const main = document.createElement("div");
|
|
6018
|
+
main.className = "pe-list-main";
|
|
6019
|
+
const name = document.createElement("div");
|
|
6020
|
+
name.className = "pe-list-name";
|
|
6021
|
+
name.textContent = draft.name ?? t("agents.draft.unnamed");
|
|
6022
|
+
const sub = document.createElement("div");
|
|
6023
|
+
sub.className = "pe-list-sub";
|
|
6024
|
+
sub.textContent = draft.updatedAt ? t("agents.draft.savedAt", { time: new Date(draft.updatedAt).toLocaleString(uiLocale()) }) : "";
|
|
6025
|
+
main.append(name, sub);
|
|
6026
|
+
const confirmBox = document.createElement("div");
|
|
6027
|
+
confirmBox.className = "inbox-dismiss mode-cancel pe-draft-discard";
|
|
6028
|
+
confirmBox.hidden = true;
|
|
6029
|
+
const text = document.createElement("p");
|
|
6030
|
+
text.className = "inbox-dismiss-text";
|
|
6031
|
+
text.textContent = t("agents.draft.discard.confirm");
|
|
6032
|
+
const buttons = document.createElement("div");
|
|
6033
|
+
buttons.className = "pe-actions";
|
|
6034
|
+
buttons.append(
|
|
6035
|
+
peButton(t("agents.draft.discard"), () => void discardDraft(draft.id), undefined, "text-btn danger", "discard-confirm"),
|
|
6036
|
+
peButton(t("common.cancel"), () => { confirmBox.hidden = true; }, undefined, "text-btn", "discard-cancel"),
|
|
6037
|
+
);
|
|
6038
|
+
confirmBox.append(text, buttons);
|
|
6039
|
+
const rowActions = document.createElement("div");
|
|
6040
|
+
rowActions.className = "pe-list-actions";
|
|
6041
|
+
rowActions.append(
|
|
6042
|
+
peButton(t("agents.draft.load"), () => void openDraft(draft.id), undefined, "text-btn primary", "load"),
|
|
6043
|
+
peButton(t("agents.draft.discard"), () => { confirmBox.hidden = !confirmBox.hidden; }, undefined, "text-btn", "discard"),
|
|
6044
|
+
);
|
|
6045
|
+
li.append(peSpan("agent-icon", "✎"), main, rowActions, confirmBox);
|
|
6046
|
+
list.append(li);
|
|
6047
|
+
}
|
|
6048
|
+
return list;
|
|
6049
|
+
}
|
|
6050
|
+
|
|
4759
6051
|
function renderEditorRow(row) {
|
|
4760
6052
|
const wrap = document.createElement("div");
|
|
4761
6053
|
wrap.className = "pe-row";
|
|
@@ -4788,18 +6080,21 @@ function renderEditorRow(row) {
|
|
|
4788
6080
|
}
|
|
4789
6081
|
error.textContent = "";
|
|
4790
6082
|
input.removeAttribute("aria-invalid");
|
|
4791
|
-
|
|
6083
|
+
commitEditor(setField(agentView.editor, row.name, result.value));
|
|
4792
6084
|
refreshEditorRow(wrap, row.name);
|
|
4793
6085
|
};
|
|
4794
6086
|
if (row.widget === "toggle") {
|
|
4795
|
-
|
|
6087
|
+
// The settings switch (role=switch, aria-checked), not a checkbox; the label's `for` names it.
|
|
6088
|
+
const toggle = document.createElement("div");
|
|
4796
6089
|
toggle.className = "pe-toggle";
|
|
4797
|
-
input = document.createElement("
|
|
4798
|
-
input.type = "
|
|
6090
|
+
input = document.createElement("button");
|
|
6091
|
+
input.type = "button";
|
|
6092
|
+
input.className = "switch";
|
|
4799
6093
|
input.id = id;
|
|
4800
|
-
input.
|
|
6094
|
+
input.setAttribute("role", "switch");
|
|
6095
|
+
renderSwitch(input, row.value === true);
|
|
4801
6096
|
toggle.append(input, peSpan("", t(row.value === true ? "common.onShort" : "common.offShort")));
|
|
4802
|
-
input.addEventListener("
|
|
6097
|
+
input.addEventListener("click", () => apply(input.getAttribute("aria-checked") !== "true"));
|
|
4803
6098
|
line.append(toggle);
|
|
4804
6099
|
} else if (row.widget === "select") {
|
|
4805
6100
|
input = document.createElement("select");
|
|
@@ -4810,13 +6105,27 @@ function renderEditorRow(row) {
|
|
|
4810
6105
|
input.value = typeof row.value === "string" ? row.value : "";
|
|
4811
6106
|
input.addEventListener("change", () => apply(input.value));
|
|
4812
6107
|
line.append(input);
|
|
4813
|
-
} else if (row.widget === "
|
|
6108
|
+
} else if (row.widget === "skills") {
|
|
6109
|
+
// The check list is a group named by the row label, not a labelable control (todo 10).
|
|
6110
|
+
label.removeAttribute("for");
|
|
6111
|
+
label.id = `${id}-label`;
|
|
6112
|
+
input = renderSkillsControl(row, apply, id);
|
|
6113
|
+
input.setAttribute("aria-labelledby", label.id);
|
|
6114
|
+
line.append(input);
|
|
6115
|
+
} else if (row.widget === "targets") {
|
|
6116
|
+
label.removeAttribute("for");
|
|
6117
|
+
label.id = `${id}-label`;
|
|
6118
|
+
input = renderTargetsControl(row, apply, id);
|
|
6119
|
+
input.setAttribute("aria-labelledby", label.id);
|
|
6120
|
+
line.append(input);
|
|
6121
|
+
} else if (row.widget === "textarea") {
|
|
4814
6122
|
input = document.createElement("textarea");
|
|
4815
6123
|
input.className = "pe-textarea";
|
|
4816
6124
|
input.id = id;
|
|
4817
6125
|
input.rows = 2;
|
|
4818
6126
|
input.value = Array.isArray(row.value) ? row.value.join(", ") : typeof row.value === "string" ? row.value : "";
|
|
4819
|
-
|
|
6127
|
+
// Text is committed as it is typed so the draft autosave (todo 11) sees it; a number waits for change (its validation).
|
|
6128
|
+
input.addEventListener("input", () => apply(input.value));
|
|
4820
6129
|
line.append(input);
|
|
4821
6130
|
} else {
|
|
4822
6131
|
input = document.createElement("input");
|
|
@@ -4826,7 +6135,7 @@ function renderEditorRow(row) {
|
|
|
4826
6135
|
if (row.min !== undefined) input.min = String(row.min);
|
|
4827
6136
|
if (row.max !== undefined) input.max = String(row.max);
|
|
4828
6137
|
input.value = row.value === null || row.value === undefined ? "" : String(row.value);
|
|
4829
|
-
input.addEventListener("change", () => apply(input.value));
|
|
6138
|
+
input.addEventListener(row.widget === "number" ? "change" : "input", () => apply(input.value));
|
|
4830
6139
|
line.append(input);
|
|
4831
6140
|
}
|
|
4832
6141
|
if (row.required) input.required = true;
|
|
@@ -4844,9 +6153,218 @@ function renderEditorRow(row) {
|
|
|
4844
6153
|
return wrap;
|
|
4845
6154
|
}
|
|
4846
6155
|
|
|
6156
|
+
/** The installed skill commands for the check list (todo 10), fetched once per open dialog; a failure keeps the saved values. */
|
|
6157
|
+
function loadSkillCommands() {
|
|
6158
|
+
if (agentView.skills.status === "loading") return;
|
|
6159
|
+
agentView.skills = { status: "loading", commands: null };
|
|
6160
|
+
agentApi({ method: "GET", path: "/api/commands?conv=main" })
|
|
6161
|
+
.then((list) => { agentView.skills = { status: "ready", commands: Array.isArray(list) ? list : [] }; })
|
|
6162
|
+
.catch(() => { agentView.skills = { status: "failed", commands: null }; })
|
|
6163
|
+
.finally(() => refreshSkillRows());
|
|
6164
|
+
}
|
|
6165
|
+
|
|
6166
|
+
/** The open editor's skills row drawn again from the loaded (or failed) list; the editor state is untouched. */
|
|
6167
|
+
function refreshSkillRows() {
|
|
6168
|
+
if (!agentView.open || !agentView.editor) return;
|
|
6169
|
+
for (const wrap of document.querySelectorAll('#pe-form .pe-row[data-field="skills"]')) {
|
|
6170
|
+
const row = editorRows(agentView.editor).find((r) => r.kind === "field" && r.name === "skills");
|
|
6171
|
+
if (row) wrap.replaceWith(renderEditorRow(row));
|
|
6172
|
+
}
|
|
6173
|
+
}
|
|
6174
|
+
|
|
6175
|
+
/**
|
|
6176
|
+
* The directional targets check list (agent-collaboration-292 C-10): every other agent the gateway lists, one checkbox
|
|
6177
|
+
* each, never this agent itself; a saved id the list no longer carries (a deleted agent) stays checked with a 없음 tag so
|
|
6178
|
+
* the owner can uncheck it. The gateway refuses new requests to it either way.
|
|
6179
|
+
*/
|
|
6180
|
+
function renderTargetsControl(row, apply, id) {
|
|
6181
|
+
const box = document.createElement("div");
|
|
6182
|
+
box.className = "pe-skills pe-targets";
|
|
6183
|
+
box.id = id;
|
|
6184
|
+
box.setAttribute("role", "group");
|
|
6185
|
+
const selfId = agentView.editor?.kind === "agent" ? agentView.editor.id : null;
|
|
6186
|
+
const { loaded, options } = targetOptions(agentState.loaded ? agentState.list : null, selfId, row.value);
|
|
6187
|
+
if (!loaded) {
|
|
6188
|
+
const line = document.createElement("p");
|
|
6189
|
+
line.className = "pe-skills-status";
|
|
6190
|
+
line.setAttribute("role", "status");
|
|
6191
|
+
line.textContent = t("agents.targets.loading");
|
|
6192
|
+
box.append(line);
|
|
6193
|
+
}
|
|
6194
|
+
const list = document.createElement("div");
|
|
6195
|
+
list.className = "pe-skill-list";
|
|
6196
|
+
if (loaded && options.length === 0) {
|
|
6197
|
+
const empty = document.createElement("p");
|
|
6198
|
+
empty.className = "pe-skills-empty";
|
|
6199
|
+
empty.textContent = t("agents.targets.none");
|
|
6200
|
+
list.append(empty);
|
|
6201
|
+
}
|
|
6202
|
+
const checked = new Set(options.filter((option) => option.checked).map((option) => option.id));
|
|
6203
|
+
for (const option of options) {
|
|
6204
|
+
const item = document.createElement("label");
|
|
6205
|
+
item.className = `pe-skill pe-target${option.missing ? " missing" : ""}`;
|
|
6206
|
+
item.dataset.agentId = option.id;
|
|
6207
|
+
const input = document.createElement("input");
|
|
6208
|
+
input.type = "checkbox";
|
|
6209
|
+
input.value = option.id;
|
|
6210
|
+
input.checked = option.checked;
|
|
6211
|
+
input.disabled = !loaded;
|
|
6212
|
+
input.addEventListener("change", () => {
|
|
6213
|
+
if (input.checked) checked.add(option.id);
|
|
6214
|
+
else checked.delete(option.id);
|
|
6215
|
+
apply(options.filter((candidate) => checked.has(candidate.id)).map((candidate) => candidate.id));
|
|
6216
|
+
});
|
|
6217
|
+
item.append(input, peSpan("pe-target-name", option.name));
|
|
6218
|
+
if (option.missing) {
|
|
6219
|
+
const tag = peSpan("pe-skill-missing", t("agents.targets.missing"));
|
|
6220
|
+
// Not a kept-tip spot (web/tooltip.js, bug 161): the explanation becomes the tag's aria-description.
|
|
6221
|
+
tag.title = t("agents.targets.missing.tip");
|
|
6222
|
+
item.append(tag);
|
|
6223
|
+
} else item.append(peSpan("pe-skill-desc", option.id));
|
|
6224
|
+
list.append(item);
|
|
6225
|
+
}
|
|
6226
|
+
box.append(list);
|
|
6227
|
+
return box;
|
|
6228
|
+
}
|
|
6229
|
+
|
|
6230
|
+
/** The open editor's targets row drawn again after the agent list changed (a load, a rename, a delete); the editor state is untouched. */
|
|
6231
|
+
function refreshTargetRows() {
|
|
6232
|
+
if (!agentView.open || !agentView.editor) return;
|
|
6233
|
+
for (const wrap of document.querySelectorAll('#pe-form .pe-row[data-field="collaboration.targets"]')) {
|
|
6234
|
+
const row = editorRows(agentView.editor).find((r) => r.kind === "field" && r.name === "collaboration.targets");
|
|
6235
|
+
if (row) wrap.replaceWith(renderEditorRow(row));
|
|
6236
|
+
}
|
|
6237
|
+
}
|
|
6238
|
+
|
|
6239
|
+
/**
|
|
6240
|
+
* The allowed-skills check list (agent-ux todo 10): the installed skills from GET /api/commands?conv=main, one checkbox
|
|
6241
|
+
* each (its description as the tooltip); a saved name that is not installed stays checked at the top with a tag; a
|
|
6242
|
+
* search box appears over ten rows. While the list loads or could not be loaded, the saved names are shown read-only
|
|
6243
|
+
* with a retry button, and saving keeps the value either way.
|
|
6244
|
+
*/
|
|
6245
|
+
function renderSkillsControl(row, apply, id) {
|
|
6246
|
+
const box = document.createElement("div");
|
|
6247
|
+
box.className = "pe-skills";
|
|
6248
|
+
box.id = id;
|
|
6249
|
+
box.setAttribute("role", "group");
|
|
6250
|
+
if (agentView.skills.status === "idle") loadSkillCommands();
|
|
6251
|
+
const { status } = agentView.skills;
|
|
6252
|
+
const { loaded, options } = skillOptions(agentView.skills.commands, row.value);
|
|
6253
|
+
if (!loaded) {
|
|
6254
|
+
const line = document.createElement("p");
|
|
6255
|
+
line.className = "pe-skills-status";
|
|
6256
|
+
line.setAttribute("role", "status");
|
|
6257
|
+
line.append(peSpan("", t(status === "failed" ? "agents.skills.loadFailed" : "agents.skills.loading")));
|
|
6258
|
+
if (status === "failed") line.append(peButton(t("agents.skills.retry"), () => loadSkillCommands(), `${id}-retry`), peSpan("", t("agents.skills.keep")));
|
|
6259
|
+
box.append(line);
|
|
6260
|
+
}
|
|
6261
|
+
const list = document.createElement("div");
|
|
6262
|
+
list.className = "pe-skill-list";
|
|
6263
|
+
const noMatch = document.createElement("p");
|
|
6264
|
+
noMatch.className = "pe-skills-empty";
|
|
6265
|
+
noMatch.textContent = t(loaded && options.length === 0 ? "agents.skills.none" : "agents.skills.noMatch");
|
|
6266
|
+
noMatch.hidden = !(loaded && options.length === 0);
|
|
6267
|
+
if (loaded && options.length > 10) {
|
|
6268
|
+
const search = document.createElement("input");
|
|
6269
|
+
search.type = "search";
|
|
6270
|
+
search.className = "pe-input pe-skill-search";
|
|
6271
|
+
search.id = `${id}-search`;
|
|
6272
|
+
search.placeholder = t("agents.skills.search");
|
|
6273
|
+
search.setAttribute("aria-label", t("agents.skills.search"));
|
|
6274
|
+
search.addEventListener("input", () => {
|
|
6275
|
+
const query = search.value.trim().toLowerCase();
|
|
6276
|
+
let shownRows = 0;
|
|
6277
|
+
for (const item of list.querySelectorAll(".pe-skill")) {
|
|
6278
|
+
const hit = !query || item.dataset.name.includes(query);
|
|
6279
|
+
item.hidden = !hit;
|
|
6280
|
+
if (hit) shownRows += 1;
|
|
6281
|
+
}
|
|
6282
|
+
noMatch.hidden = shownRows > 0;
|
|
6283
|
+
});
|
|
6284
|
+
box.append(search);
|
|
6285
|
+
}
|
|
6286
|
+
const checked = new Set(options.filter((option) => option.checked).map((option) => option.name));
|
|
6287
|
+
for (const option of options) {
|
|
6288
|
+
const item = document.createElement("label");
|
|
6289
|
+
item.className = `pe-skill${option.installed === false ? " missing" : ""}`;
|
|
6290
|
+
item.dataset.name = option.name.toLowerCase();
|
|
6291
|
+
const input = document.createElement("input");
|
|
6292
|
+
input.type = "checkbox";
|
|
6293
|
+
input.value = option.name;
|
|
6294
|
+
input.checked = option.checked;
|
|
6295
|
+
input.disabled = !loaded;
|
|
6296
|
+
input.addEventListener("change", () => {
|
|
6297
|
+
if (input.checked) checked.add(option.name);
|
|
6298
|
+
else checked.delete(option.name);
|
|
6299
|
+
apply(options.filter((candidate) => checked.has(candidate.name)).map((candidate) => candidate.name));
|
|
6300
|
+
});
|
|
6301
|
+
item.append(input, peSpan("pe-skill-name", option.name));
|
|
6302
|
+
if (option.installed === false) {
|
|
6303
|
+
const tag = peSpan("pe-skill-missing", t("agents.skills.notInstalled"));
|
|
6304
|
+
tag.setAttribute("data-tip-keep", "");
|
|
6305
|
+
tag.setAttribute("data-tip", t("agents.skills.notInstalled.tip"));
|
|
6306
|
+
item.append(tag);
|
|
6307
|
+
}
|
|
6308
|
+
if (option.description) {
|
|
6309
|
+
// The full description is the row's page tooltip (web/tooltip.js); the line shows it clipped.
|
|
6310
|
+
item.setAttribute("data-tip-keep", "");
|
|
6311
|
+
item.setAttribute("data-tip", option.description);
|
|
6312
|
+
item.append(peSpan("pe-skill-desc", option.description));
|
|
6313
|
+
}
|
|
6314
|
+
list.append(item);
|
|
6315
|
+
}
|
|
6316
|
+
list.append(noMatch);
|
|
6317
|
+
box.append(list);
|
|
6318
|
+
return box;
|
|
6319
|
+
}
|
|
6320
|
+
|
|
6321
|
+
/**
|
|
6322
|
+
* A prohibited field (agent-ux todo 8): its label, a switch that stays off and cannot be turned on, and the reason as
|
|
6323
|
+
* visible text the switch is described by. Prohibited rows that share one reason share one paragraph: [helpId] names
|
|
6324
|
+
* it and [drawHelp] says whether this row draws it (the last row of the run does). Nothing here touches the editor state,
|
|
6325
|
+
* so the saved source never carries a value for the field.
|
|
6326
|
+
*/
|
|
6327
|
+
function renderForbiddenRow(row, helpId, drawHelp) {
|
|
6328
|
+
const wrap = document.createElement("div");
|
|
6329
|
+
wrap.className = "pe-row pe-forbidden";
|
|
6330
|
+
wrap.dataset.field = row.name;
|
|
6331
|
+
const id = `pe-field-${row.name.replaceAll(".", "-")}`;
|
|
6332
|
+
const label = document.createElement("label");
|
|
6333
|
+
label.className = "pe-label";
|
|
6334
|
+
label.htmlFor = id;
|
|
6335
|
+
label.textContent = row.label;
|
|
6336
|
+
const control = document.createElement("div");
|
|
6337
|
+
control.className = "pe-control";
|
|
6338
|
+
const line = document.createElement("div");
|
|
6339
|
+
line.className = "pe-control-line";
|
|
6340
|
+
const holder = document.createElement("div");
|
|
6341
|
+
holder.className = "pe-toggle";
|
|
6342
|
+
const toggle = document.createElement("button");
|
|
6343
|
+
toggle.type = "button";
|
|
6344
|
+
toggle.className = "switch";
|
|
6345
|
+
toggle.id = id;
|
|
6346
|
+
toggle.setAttribute("role", "switch");
|
|
6347
|
+
toggle.setAttribute("aria-checked", "false");
|
|
6348
|
+
toggle.setAttribute("aria-disabled", "true");
|
|
6349
|
+
toggle.disabled = true;
|
|
6350
|
+
toggle.setAttribute("aria-describedby", helpId);
|
|
6351
|
+
holder.append(toggle, peSpan("", t("common.offShort")));
|
|
6352
|
+
line.append(holder);
|
|
6353
|
+
control.append(line);
|
|
6354
|
+
if (drawHelp) {
|
|
6355
|
+
const help = document.createElement("p");
|
|
6356
|
+
help.className = "pe-help";
|
|
6357
|
+
help.id = helpId;
|
|
6358
|
+
help.textContent = row.help;
|
|
6359
|
+
control.append(help);
|
|
6360
|
+
}
|
|
6361
|
+
wrap.append(label, control);
|
|
6362
|
+
return wrap;
|
|
6363
|
+
}
|
|
6364
|
+
|
|
4847
6365
|
function peClearButton(wrap, name, id) {
|
|
4848
6366
|
const clear = peButton(t("agents.clearOverride"), () => {
|
|
4849
|
-
|
|
6367
|
+
commitEditor(clearField(agentView.editor, name));
|
|
4850
6368
|
const row = editorRows(agentView.editor).find((r) => r.kind === "field" && r.name === name);
|
|
4851
6369
|
if (row) wrap.replaceWith(renderEditorRow(row));
|
|
4852
6370
|
}, `${id}-clear`, "pe-clear");
|
|
@@ -4867,42 +6385,750 @@ function refreshEditorRow(wrap, name) {
|
|
|
4867
6385
|
const clear = wrap.querySelector(".pe-clear");
|
|
4868
6386
|
if (row.clearable && !clear && origin) origin.after(peClearButton(wrap, name, `pe-field-${name.replaceAll(".", "-")}`));
|
|
4869
6387
|
else if (!row.clearable && clear) clear.remove();
|
|
6388
|
+
const toggleSwitch = wrap.querySelector(".pe-toggle .switch");
|
|
6389
|
+
if (toggleSwitch) renderSwitch(toggleSwitch, row.value === true);
|
|
4870
6390
|
const toggleText = wrap.querySelector(".pe-toggle span");
|
|
4871
6391
|
if (toggleText) toggleText.textContent = t(row.value === true ? "common.onShort" : "common.offShort");
|
|
4872
6392
|
const select = wrap.querySelector("select.pe-select");
|
|
4873
6393
|
if (select?.options[0]) select.options[0].textContent = t(row.own ? "agents.clearOption" : "agents.inheritOption");
|
|
4874
6394
|
}
|
|
4875
6395
|
|
|
6396
|
+
/**
|
|
6397
|
+
* The how-to-write box above the role instructions (agent-ux todo 9): a native disclosure with the four headings the body
|
|
6398
|
+
* should cover (web/agents.js roleGuideItems), open while creating an agent and folded in an existing agent's editor.
|
|
6399
|
+
*/
|
|
6400
|
+
function renderRoleGuide(open) {
|
|
6401
|
+
const guide = document.createElement("details");
|
|
6402
|
+
guide.className = "pe-guide";
|
|
6403
|
+
guide.id = "pe-role-guide";
|
|
6404
|
+
guide.open = open;
|
|
6405
|
+
const summary = document.createElement("summary");
|
|
6406
|
+
summary.textContent = t("agents.roleGuide.title");
|
|
6407
|
+
const list = document.createElement("ul");
|
|
6408
|
+
for (const item of roleGuideItems()) {
|
|
6409
|
+
const li = document.createElement("li");
|
|
6410
|
+
li.dataset.item = item.key;
|
|
6411
|
+
const label = document.createElement("b");
|
|
6412
|
+
label.textContent = item.label;
|
|
6413
|
+
li.append(label, `: ${item.text}`);
|
|
6414
|
+
list.append(li);
|
|
6415
|
+
}
|
|
6416
|
+
guide.append(summary, list);
|
|
6417
|
+
return guide;
|
|
6418
|
+
}
|
|
6419
|
+
|
|
6420
|
+
/**
|
|
6421
|
+
* The knowledge file section (agent-ux todo 15): three blocks, each headed by its name and its knowledge/ path - 올린 자료
|
|
6422
|
+
* (the upload zone and the source/ files), 결과물 (outputs/) and 기록 (MEMORY.md and notes/, read-only, with the newest daily
|
|
6423
|
+
* note date). Uploads apply at once; the note says so.
|
|
6424
|
+
*/
|
|
4876
6425
|
function renderAgentFiles(id) {
|
|
4877
6426
|
const section = peSection(t("agents.files"), t("agents.files.note"));
|
|
6427
|
+
const areas = fileAreas(agentView.files);
|
|
6428
|
+
const blocks = document.createElement("div");
|
|
6429
|
+
blocks.className = "pe-areas";
|
|
6430
|
+
blocks.id = "pe-files";
|
|
6431
|
+
const source = renderFileArea("source", "knowledge/source/", areas.source, id);
|
|
6432
|
+
source.insertBefore(renderUploadZone({ id: "pe-source-upload", label: t("agents.sourceFile.label"), rows: uploadQueue(id), onFiles: (files) => void uploadAgentSources(id, files), onRetry: (key) => retryAgentUpload(id, key) }), source.lastElementChild);
|
|
6433
|
+
const notes = renderFileArea("notes", "notes/, MEMORY.md", areas.notes, id);
|
|
6434
|
+
const daily = document.createElement("p");
|
|
6435
|
+
daily.className = "pe-last-daily";
|
|
6436
|
+
daily.id = "pe-last-daily";
|
|
6437
|
+
daily.append(peSpan("", lastDailyLine(agentView.lastDaily)), infoTipButton(t("agents.lastDaily.tip")));
|
|
6438
|
+
notes.append(daily);
|
|
6439
|
+
blocks.append(source, renderFileArea("outputs", "knowledge/outputs/", areas.outputs, id), notes);
|
|
6440
|
+
section.append(blocks, renderFileTrash(id, agentView.fileTrash));
|
|
6441
|
+
return section;
|
|
6442
|
+
}
|
|
6443
|
+
|
|
6444
|
+
/**
|
|
6445
|
+
* The create screen's 올린 자료 block (agent-ux todo 18): the same zone, but the picked files wait in the browser as queued
|
|
6446
|
+
* rows (만들기 뒤 올립니다) until the create request answered; only their names and sizes go to the draft.
|
|
6447
|
+
*/
|
|
6448
|
+
function renderNewAgentFiles() {
|
|
6449
|
+
const section = peSection(t("agents.area.source"), t("agents.newFiles.note"));
|
|
6450
|
+
section.append(renderUploadZone({ id: "pe-new-upload", label: t("agents.sourceFile.label"), rows: agentView.editor?.pendingUploads ?? [], onFiles: queueNewAgentFiles }));
|
|
6451
|
+
return section;
|
|
6452
|
+
}
|
|
6453
|
+
|
|
6454
|
+
/** The picked files join the create screen's own queue, on its editor state, and the draft autosave gets that state (todo 18 fix). */
|
|
6455
|
+
function queueNewAgentFiles(files) {
|
|
6456
|
+
const editor = agentView.editor;
|
|
6457
|
+
if (editor?.kind !== "new") return;
|
|
6458
|
+
agentView.editor = { ...editor, pendingUploads: [...(editor.pendingUploads ?? []), ...queuedUploadRows(files)] };
|
|
6459
|
+
redrawUploadRows("pe-new-upload", agentView.editor.pendingUploads, null);
|
|
6460
|
+
if (agentView.editor.draftId) draftAutosave.touch(agentView.editor);
|
|
6461
|
+
}
|
|
6462
|
+
|
|
6463
|
+
/** The file section drawn again in place after a file action (the rest of the form, and what is typed in it, stays). */
|
|
6464
|
+
function redrawAgentFiles(id) {
|
|
6465
|
+
document.getElementById("pe-files")?.closest(".pe-section")?.replaceWith(renderAgentFiles(id));
|
|
6466
|
+
}
|
|
6467
|
+
|
|
6468
|
+
/** The file list and the deleted files read again after an action (todo 16); a failure keeps the lists as they were. */
|
|
6469
|
+
async function refreshKnowledge(id) {
|
|
6470
|
+
const [files, trash] = await Promise.all([agentApi({ method: "GET", path: filesPath(id) }), agentApi({ method: "GET", path: fileTrashPath(id) })]);
|
|
6471
|
+
if (agentView.editor?.id !== id) return;
|
|
6472
|
+
agentView.files = fileRows(files);
|
|
6473
|
+
agentView.fileTrash = fileTrashRows(trash);
|
|
6474
|
+
}
|
|
6475
|
+
|
|
6476
|
+
/** After that agent's turn ended (todo 17): the lists and the last daily note date are read again and the section redrawn; a failure leaves it as it was. */
|
|
6477
|
+
async function refreshKnowledgeAfterTurn(id) {
|
|
6478
|
+
try {
|
|
6479
|
+
const detail = await agentApi({ method: "GET", path: `${AGENTS_PATH}/${encodeURIComponent(id)}` });
|
|
6480
|
+
await refreshKnowledge(id);
|
|
6481
|
+
if (agentView.editor?.id !== id) return;
|
|
6482
|
+
agentView.lastDaily = typeof detail.lastDaily === "string" ? detail.lastDaily : null;
|
|
6483
|
+
redrawAgentFiles(id);
|
|
6484
|
+
} catch (error) {
|
|
6485
|
+
console.warn("agent files refresh:", error);
|
|
6486
|
+
}
|
|
6487
|
+
}
|
|
6488
|
+
|
|
6489
|
+
/**
|
|
6490
|
+
* 내려받기 (todo 17): the file is fetched first so a refusal (a file deleted meanwhile, 404) becomes a sentence and a fresh
|
|
6491
|
+
* list instead of a saved error body; a good answer is saved through a blob link with the file's own name.
|
|
6492
|
+
*/
|
|
6493
|
+
async function downloadKnowledgeFile(id, file) {
|
|
6494
|
+
let res;
|
|
6495
|
+
try {
|
|
6496
|
+
res = await fetch(downloadPath(id, file.path));
|
|
6497
|
+
} catch (error) {
|
|
6498
|
+
showAgentStatus(t("agents.file.download.failed", { name: file.name, reason: error instanceof Error ? error.message : String(error) }), "error");
|
|
6499
|
+
return;
|
|
6500
|
+
}
|
|
6501
|
+
if (!res.ok) {
|
|
6502
|
+
const body = await res.json().catch(() => null);
|
|
6503
|
+
showAgentStatus(t("agents.file.download.failed", { name: file.name, reason: errorLine(body, res.status) }), "error");
|
|
6504
|
+
await refreshKnowledge(id).catch(() => {});
|
|
6505
|
+
if (agentView.editor?.id === id) redrawAgentFiles(id);
|
|
6506
|
+
return;
|
|
6507
|
+
}
|
|
6508
|
+
const url = URL.createObjectURL(await res.blob());
|
|
6509
|
+
const link = document.createElement("a");
|
|
6510
|
+
link.href = url;
|
|
6511
|
+
link.download = file.name;
|
|
6512
|
+
document.body.append(link);
|
|
6513
|
+
link.click();
|
|
6514
|
+
link.remove();
|
|
6515
|
+
URL.revokeObjectURL(url);
|
|
6516
|
+
}
|
|
6517
|
+
|
|
6518
|
+
function trashIcon() {
|
|
6519
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
6520
|
+
svg.setAttribute("viewBox", "0 0 16 16");
|
|
6521
|
+
svg.setAttribute("width", "13");
|
|
6522
|
+
svg.setAttribute("height", "13");
|
|
6523
|
+
svg.setAttribute("aria-hidden", "true");
|
|
6524
|
+
const glyph = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
6525
|
+
glyph.setAttribute("fill", "currentColor");
|
|
6526
|
+
glyph.setAttribute("d", "M6 1h4l1 1.5h3V5H2V2.5h3L6 1zM3 6h10l-1 9H4L3 6z");
|
|
6527
|
+
svg.append(glyph);
|
|
6528
|
+
return svg;
|
|
6529
|
+
}
|
|
6530
|
+
|
|
6531
|
+
/**
|
|
6532
|
+
* One block of the file section: the area's name, its path as a code tag, the info tooltip with the detail, and its rows. A
|
|
6533
|
+
* source/ or outputs/ file carries a 삭제 button (todo 16); pressing it opens the confirmation under that row.
|
|
6534
|
+
*/
|
|
6535
|
+
function renderFileArea(area, path, rows, id) {
|
|
6536
|
+
const block = document.createElement("div");
|
|
6537
|
+
block.className = `pe-area pe-area-${area}`;
|
|
6538
|
+
block.id = `pe-area-${area}`;
|
|
6539
|
+
const head = document.createElement("div");
|
|
6540
|
+
head.className = "pe-area-head";
|
|
6541
|
+
const code = document.createElement("code");
|
|
6542
|
+
code.className = "pe-area-path";
|
|
6543
|
+
code.textContent = path;
|
|
6544
|
+
head.append(peSpan("pe-area-title", t(`agents.area.${area}`)), code, infoTipButton(t(`agents.area.${area}.tip`)));
|
|
4878
6545
|
const list = document.createElement("ul");
|
|
4879
6546
|
list.className = "pe-files";
|
|
4880
|
-
list.id =
|
|
4881
|
-
if (
|
|
6547
|
+
list.id = `pe-files-${area}`;
|
|
6548
|
+
if (rows.length === 0) {
|
|
4882
6549
|
const empty = document.createElement("li");
|
|
4883
6550
|
empty.className = "pe-empty";
|
|
4884
|
-
empty
|
|
6551
|
+
// An empty 결과물 block says how results get there (todo 17).
|
|
6552
|
+
empty.textContent = area === "outputs" ? t("agents.outputs.empty") : t("agents.files.none");
|
|
4885
6553
|
list.append(empty);
|
|
4886
6554
|
}
|
|
4887
|
-
for (const file of
|
|
6555
|
+
for (const file of rows) {
|
|
4888
6556
|
const li = document.createElement("li");
|
|
4889
6557
|
li.className = "pe-file";
|
|
6558
|
+
li.dataset.path = file.path;
|
|
4890
6559
|
const name = peSpan(`pe-file-name ${file.type}`, `${"\u00a0\u00a0".repeat(file.depth)}${file.type === "directory" ? `${file.name}/` : file.name}`);
|
|
4891
6560
|
name.title = file.path;
|
|
4892
|
-
li.append(name
|
|
6561
|
+
li.append(name);
|
|
6562
|
+
const originTag = fileOriginLabel(file);
|
|
6563
|
+
if (originTag) {
|
|
6564
|
+
const tag = peSpan(`pe-file-origin ${originTag.origin}`, originTag.text);
|
|
6565
|
+
// Not a kept-tip spot (web/tooltip.js, bug 161): the explanation becomes the tag's aria-description.
|
|
6566
|
+
tag.title = originTag.tip;
|
|
6567
|
+
li.append(tag);
|
|
6568
|
+
}
|
|
6569
|
+
const modified = modifiedLabel(file.modifiedAt, uiLocale());
|
|
6570
|
+
if (modified) li.append(peSpan("pe-file-time", modified));
|
|
6571
|
+
li.append(peSpan("pe-file-size", file.label));
|
|
6572
|
+
if (downloadable(file)) {
|
|
6573
|
+
const link = document.createElement("a");
|
|
6574
|
+
link.className = "text-btn pe-file-download";
|
|
6575
|
+
link.href = downloadPath(id, file.path);
|
|
6576
|
+
link.download = file.name;
|
|
6577
|
+
link.textContent = t("agents.file.download");
|
|
6578
|
+
link.dataset.act = "download-file";
|
|
6579
|
+
link.setAttribute("aria-label", t("agents.file.download.label", { name: file.name }));
|
|
6580
|
+
link.addEventListener("click", (ev) => {
|
|
6581
|
+
ev.preventDefault();
|
|
6582
|
+
void downloadKnowledgeFile(id, file);
|
|
6583
|
+
});
|
|
6584
|
+
li.append(link);
|
|
6585
|
+
}
|
|
6586
|
+
if (deletableFile(file)) {
|
|
6587
|
+
const remove = peButton(t("agents.file.delete"), () => {
|
|
6588
|
+
agentView.fileConfirm = agentView.fileConfirm === file.path ? null : file.path;
|
|
6589
|
+
redrawAgentFiles(id);
|
|
6590
|
+
document.querySelector(`.pe-file-confirm [data-act="${agentView.fileConfirm === null ? "delete-file" : "keep-file"}"]`)?.focus();
|
|
6591
|
+
}, null, "text-btn danger pe-file-delete", "delete-file-open");
|
|
6592
|
+
remove.prepend(trashIcon());
|
|
6593
|
+
remove.setAttribute("aria-label", t("agents.file.delete.label", { name: file.name }));
|
|
6594
|
+
remove.setAttribute("aria-expanded", String(agentView.fileConfirm === file.path));
|
|
6595
|
+
li.append(remove);
|
|
6596
|
+
}
|
|
6597
|
+
list.append(li);
|
|
6598
|
+
if (agentView.fileConfirm === file.path) list.append(renderFileConfirm(id, file));
|
|
6599
|
+
}
|
|
6600
|
+
block.append(head, list);
|
|
6601
|
+
return block;
|
|
6602
|
+
}
|
|
6603
|
+
|
|
6604
|
+
/** The in-row confirmation under a file (the inbox-dismiss box the agent delete uses): what the archive means, then 삭제 or 취소. */
|
|
6605
|
+
function renderFileConfirm(id, file) {
|
|
6606
|
+
const li = document.createElement("li");
|
|
6607
|
+
li.className = "pe-file-confirm-row";
|
|
6608
|
+
const box = document.createElement("div");
|
|
6609
|
+
box.className = "inbox-dismiss mode-cancel pe-file-confirm";
|
|
6610
|
+
box.dataset.path = file.path;
|
|
6611
|
+
const text = document.createElement("p");
|
|
6612
|
+
text.className = "inbox-dismiss-text";
|
|
6613
|
+
text.textContent = fileDeleteText(file);
|
|
6614
|
+
const buttons = document.createElement("div");
|
|
6615
|
+
buttons.className = "pe-actions";
|
|
6616
|
+
const confirm = peButton(t("agents.file.delete"), () => void deleteKnowledgeFile(id, file.path), null, "text-btn danger", "delete-file");
|
|
6617
|
+
confirm.setAttribute("aria-label", t("agents.file.delete.label", { name: file.name }));
|
|
6618
|
+
const keep = peButton(t("common.cancel"), () => {
|
|
6619
|
+
agentView.fileConfirm = null;
|
|
6620
|
+
redrawAgentFiles(id);
|
|
6621
|
+
document.querySelector(`.pe-file[data-path="${CSS.escape(file.path)}"] .pe-file-delete`)?.focus();
|
|
6622
|
+
}, null, "text-btn", "keep-file");
|
|
6623
|
+
buttons.append(confirm, keep);
|
|
6624
|
+
box.append(text, buttons);
|
|
6625
|
+
li.append(box);
|
|
6626
|
+
return li;
|
|
6627
|
+
}
|
|
6628
|
+
|
|
6629
|
+
/** The 삭제한 파일 disclosure under the blocks: the archived files of this agent with 되살리기, and what happens after 30 days. */
|
|
6630
|
+
function renderFileTrash(id, rows) {
|
|
6631
|
+
const details = document.createElement("details");
|
|
6632
|
+
details.className = "pe-guide pe-file-trash";
|
|
6633
|
+
details.id = "pe-file-trash";
|
|
6634
|
+
details.open = agentView.fileTrashOpen;
|
|
6635
|
+
details.addEventListener("toggle", () => { agentView.fileTrashOpen = details.open; });
|
|
6636
|
+
const summary = document.createElement("summary");
|
|
6637
|
+
summary.textContent = fileTrashLabel(rows.length);
|
|
6638
|
+
const note = document.createElement("p");
|
|
6639
|
+
note.className = "pe-note";
|
|
6640
|
+
note.textContent = t("agents.files.trash.note");
|
|
6641
|
+
const list = document.createElement("ul");
|
|
6642
|
+
list.className = "pe-files pe-file-trash-list";
|
|
6643
|
+
list.id = "pe-file-trash-list";
|
|
6644
|
+
if (rows.length === 0) {
|
|
6645
|
+
const empty = document.createElement("li");
|
|
6646
|
+
empty.className = "pe-empty";
|
|
6647
|
+
empty.textContent = t("agents.files.trash.empty");
|
|
6648
|
+
list.append(empty);
|
|
6649
|
+
}
|
|
6650
|
+
for (const row of rows) {
|
|
6651
|
+
const li = document.createElement("li");
|
|
6652
|
+
li.className = "pe-file pe-file-trash-row";
|
|
6653
|
+
li.dataset.entry = row.entry;
|
|
6654
|
+
const name = peSpan("pe-file-name", row.path);
|
|
6655
|
+
name.title = row.path;
|
|
6656
|
+
const when = peSpan("pe-file-size", row.deletedAt ? `${row.label}${t("agents.deletedAt", { time: new Date(row.deletedAt).toLocaleString(uiLocale()) })}` : row.label);
|
|
6657
|
+
const restore = peButton(t("agents.file.restore"), () => void restoreKnowledgeFile(id, row.entry), null, "text-btn", "restore-file");
|
|
6658
|
+
restore.setAttribute("aria-label", t("agents.file.restore.label", { name: row.name }));
|
|
6659
|
+
li.append(name, when, restore);
|
|
4893
6660
|
list.append(li);
|
|
4894
6661
|
}
|
|
4895
|
-
|
|
4896
|
-
|
|
6662
|
+
details.append(summary, note, list);
|
|
6663
|
+
return details;
|
|
6664
|
+
}
|
|
6665
|
+
|
|
6666
|
+
/** POST files/delete (todo 2): the file moves to this agent's archive; both lists are read again and the line says how to get it back. */
|
|
6667
|
+
async function deleteKnowledgeFile(id, path) {
|
|
6668
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
6669
|
+
for (const button of document.querySelectorAll(".pe-file-confirm .text-btn")) button.disabled = true;
|
|
6670
|
+
try {
|
|
6671
|
+
await agentApi(fileDeleteRequest(id, path));
|
|
6672
|
+
agentView.fileConfirm = null;
|
|
6673
|
+
await refreshKnowledge(id);
|
|
6674
|
+
showAgentStatus(t("agents.file.deleted", { name }), "ok");
|
|
6675
|
+
} catch (error) {
|
|
6676
|
+
showAgentStatus(error.message, "error");
|
|
6677
|
+
}
|
|
6678
|
+
if (agentView.editor?.id === id) redrawAgentFiles(id);
|
|
6679
|
+
}
|
|
6680
|
+
|
|
6681
|
+
/** POST files/trash/<entry>/restore (todo 2): the file returns to its folder (under a new name when the old one is taken again). */
|
|
6682
|
+
async function restoreKnowledgeFile(id, entry) {
|
|
6683
|
+
for (const button of document.querySelectorAll(".pe-file-trash-row .text-btn")) button.disabled = true;
|
|
6684
|
+
try {
|
|
6685
|
+
const result = await agentApi(fileRestoreRequest(id, entry));
|
|
6686
|
+
await refreshKnowledge(id);
|
|
6687
|
+
showAgentStatus(t("agents.file.restored", { path: typeof result?.path === "string" ? result.path : entry }), "ok");
|
|
6688
|
+
} catch (error) {
|
|
6689
|
+
showAgentStatus(error.message, "error");
|
|
6690
|
+
}
|
|
6691
|
+
if (agentView.editor?.id === id) redrawAgentFiles(id);
|
|
6692
|
+
}
|
|
6693
|
+
|
|
6694
|
+
/**
|
|
6695
|
+
* The upload component (agent-ux todo 14): a dashed drop zone with the styled 파일 고르기 button over a hidden `multiple`
|
|
6696
|
+
* file input (the browser's own control is never shown), a drag highlight, and one progress row per queued file (name,
|
|
6697
|
+
* size, bar, state word with its icon, 다시 시도 on a refused upload). The caller keeps the queue (web/agents.js uploadRows)
|
|
6698
|
+
* and gets the picked FileList in onFiles and a failed row's key in onRetry.
|
|
6699
|
+
*/
|
|
6700
|
+
function renderUploadZone({ id, label, onFiles, onRetry = null, rows = [], multiple = true }) {
|
|
6701
|
+
const zone = document.createElement("div");
|
|
6702
|
+
zone.className = "pe-upload-zone";
|
|
6703
|
+
zone.id = id;
|
|
4897
6704
|
const input = document.createElement("input");
|
|
4898
6705
|
input.type = "file";
|
|
4899
|
-
input.
|
|
4900
|
-
input.
|
|
4901
|
-
|
|
4902
|
-
|
|
6706
|
+
input.multiple = multiple;
|
|
6707
|
+
input.hidden = true;
|
|
6708
|
+
input.id = `${id}-input`;
|
|
6709
|
+
input.setAttribute("aria-label", label);
|
|
6710
|
+
input.addEventListener("change", () => {
|
|
6711
|
+
if (input.files?.length) onFiles(input.files);
|
|
6712
|
+
input.value = "";
|
|
6713
|
+
});
|
|
6714
|
+
const prompt = document.createElement("div");
|
|
6715
|
+
prompt.className = "pe-upload-prompt";
|
|
6716
|
+
const pick = peButton(t("agents.upload.pick"), () => input.click(), `${id}-pick`, "text-btn pe-upload-pick");
|
|
6717
|
+
pick.setAttribute("data-tip-keep", "");
|
|
6718
|
+
pick.setAttribute("data-tip", t("agents.upload.pick.tip", { max: formatBytes(UPLOAD_MAX_BYTES) }));
|
|
6719
|
+
prompt.append(peSpan("pe-upload-hint", t("agents.upload.drop")), pick);
|
|
6720
|
+
zone.append(input, prompt);
|
|
6721
|
+
// Files dragged over the zone: the chat and the memo accept a drop the same way (dragover preventDefault, then drop);
|
|
6722
|
+
// the zone also shows it. The depth counter keeps the highlight while the pointer crosses the zone's children.
|
|
6723
|
+
const draggingFiles = (ev) => [...(ev.dataTransfer?.types ?? [])].includes("Files");
|
|
6724
|
+
let depth = 0;
|
|
6725
|
+
zone.addEventListener("dragenter", (ev) => {
|
|
6726
|
+
if (!draggingFiles(ev)) return;
|
|
6727
|
+
ev.preventDefault();
|
|
6728
|
+
depth += 1;
|
|
6729
|
+
zone.classList.toggle("dragging", true);
|
|
6730
|
+
});
|
|
6731
|
+
zone.addEventListener("dragover", (ev) => {
|
|
6732
|
+
if (!draggingFiles(ev)) return;
|
|
6733
|
+
ev.preventDefault();
|
|
6734
|
+
zone.classList.toggle("dragging", true);
|
|
6735
|
+
});
|
|
6736
|
+
zone.addEventListener("dragleave", () => {
|
|
6737
|
+
depth = Math.max(0, depth - 1);
|
|
6738
|
+
if (depth === 0) zone.classList.toggle("dragging", false);
|
|
6739
|
+
});
|
|
6740
|
+
zone.addEventListener("drop", (ev) => {
|
|
6741
|
+
if (!ev.dataTransfer?.files?.length) return;
|
|
6742
|
+
ev.preventDefault();
|
|
6743
|
+
depth = 0;
|
|
6744
|
+
zone.classList.toggle("dragging", false);
|
|
6745
|
+
onFiles(ev.dataTransfer.files);
|
|
6746
|
+
});
|
|
6747
|
+
zone.append(renderUploadRows(id, rows, onRetry));
|
|
6748
|
+
return zone;
|
|
6749
|
+
}
|
|
6750
|
+
|
|
6751
|
+
/** The queue rows of one zone: drawn by renderUploadZone and replaced in place while an upload runs (the form is not rebuilt). */
|
|
6752
|
+
function renderUploadRows(zoneId, rows, onRetry) {
|
|
6753
|
+
const list = document.createElement("ul");
|
|
6754
|
+
list.className = "pe-upload-rows";
|
|
6755
|
+
list.id = `${zoneId}-rows`;
|
|
6756
|
+
list.setAttribute("aria-label", t("agents.upload.rows.label"));
|
|
6757
|
+
list.hidden = rows.length === 0;
|
|
6758
|
+
for (const row of rows) {
|
|
6759
|
+
const li = document.createElement("li");
|
|
6760
|
+
li.className = `pe-upload-row ${row.status}`;
|
|
6761
|
+
li.dataset.key = row.key;
|
|
6762
|
+
const head = document.createElement("div");
|
|
6763
|
+
head.className = "pe-upload-head";
|
|
6764
|
+
const name = peSpan("pe-file-name", row.name);
|
|
6765
|
+
name.title = row.name;
|
|
6766
|
+
const state = peSpan(`pe-upload-state ${row.status}`, uploadStateLine(row));
|
|
6767
|
+
state.setAttribute("role", "status");
|
|
6768
|
+
head.append(name, peSpan("pe-file-size", formatBytes(row.size)), state);
|
|
6769
|
+
// 다시 시도 only where a new request could succeed: a file the web itself refused stays failed (retryUpload says so).
|
|
6770
|
+
if (onRetry && row.status === "failed" && retryUpload([row], row.key)[0].status === "waiting") {
|
|
6771
|
+
const retry = peButton(t("agents.upload.retry"), () => onRetry(row.key), null, "text-btn pe-upload-retry", "retry");
|
|
6772
|
+
retry.setAttribute("aria-label", t("agents.upload.retry.label", { name: row.name }));
|
|
6773
|
+
head.append(retry);
|
|
6774
|
+
}
|
|
6775
|
+
const bar = document.createElement("div");
|
|
6776
|
+
bar.className = "pe-upload-bar";
|
|
6777
|
+
bar.setAttribute("role", "progressbar");
|
|
6778
|
+
bar.setAttribute("aria-label", t("agents.upload.progress.label", { name: row.name }));
|
|
6779
|
+
bar.setAttribute("aria-valuemin", "0");
|
|
6780
|
+
bar.setAttribute("aria-valuemax", "100");
|
|
6781
|
+
bar.setAttribute("aria-valuenow", String(Math.round(row.progress * 100)));
|
|
6782
|
+
const fill = document.createElement("div");
|
|
6783
|
+
fill.className = "pe-upload-fill";
|
|
6784
|
+
fill.style.width = `${Math.round(row.progress * 100)}%`;
|
|
6785
|
+
bar.append(fill);
|
|
6786
|
+
li.append(head, bar);
|
|
6787
|
+
list.append(li);
|
|
6788
|
+
}
|
|
6789
|
+
return list;
|
|
6790
|
+
}
|
|
6791
|
+
|
|
6792
|
+
/** One zone's rows redrawn from the queue; nothing happens when the zone is not on the page any more. */
|
|
6793
|
+
function redrawUploadRows(zoneId, rows, onRetry) {
|
|
6794
|
+
document.getElementById(`${zoneId}-rows`)?.replaceWith(renderUploadRows(zoneId, rows, onRetry));
|
|
6795
|
+
}
|
|
6796
|
+
|
|
6797
|
+
/**
|
|
6798
|
+
* POST one file as multipart through XMLHttpRequest (fetch cannot report upload progress). Resolves the gateway's JSON
|
|
6799
|
+
* answer; a refusal rejects like agentApi (the UI line as the message, `code` and `status` on the error), a request that
|
|
6800
|
+
* never got an answer with `network: true`.
|
|
6801
|
+
*/
|
|
6802
|
+
function uploadWithProgress(path, file, onProgress) {
|
|
6803
|
+
return new Promise((resolve, reject) => {
|
|
6804
|
+
const xhr = new XMLHttpRequest();
|
|
6805
|
+
xhr.open("POST", path);
|
|
6806
|
+
xhr.responseType = "json";
|
|
6807
|
+
xhr.upload.onprogress = (ev) => {
|
|
6808
|
+
if (ev.lengthComputable && ev.total > 0) onProgress(ev.loaded / ev.total);
|
|
6809
|
+
};
|
|
6810
|
+
xhr.onload = () => {
|
|
6811
|
+
const body = xhr.response;
|
|
6812
|
+
if (xhr.status >= 200 && xhr.status < 300) resolve(body);
|
|
6813
|
+
else reject(Object.assign(new Error(errorLine(body, xhr.status)), { code: typeof body?.error === "string" ? body.error : null, field: null, status: xhr.status, network: false }));
|
|
6814
|
+
};
|
|
6815
|
+
xhr.onerror = () => reject(Object.assign(new Error(t("agents.upload.network")), { code: null, field: null, status: 0, network: true }));
|
|
6816
|
+
xhr.onabort = xhr.onerror;
|
|
6817
|
+
const form = new FormData();
|
|
6818
|
+
form.append("file", file, file.name);
|
|
6819
|
+
xhr.send(form);
|
|
6820
|
+
});
|
|
6821
|
+
}
|
|
6822
|
+
|
|
6823
|
+
/**
|
|
6824
|
+
* The 도구 권한 section of an agent's editor (294 C8/C10): one agent at a time, its loaded page
|
|
6825
|
+
* (GET /api/agents/:id/tool-permissions), the grants busy with a request, the open scope drafts and the revoke-all
|
|
6826
|
+
* confirmation. Drafts survive a reload; a conflict shows the current content beside the draft.
|
|
6827
|
+
*/
|
|
6828
|
+
const agentToolView = { agentId: null, status: "idle", page: null, note: null, busy: new Set(), editing: new Map(), confirmAll: false, seq: 0 };
|
|
6829
|
+
|
|
6830
|
+
function refreshToolPermissions() {
|
|
6831
|
+
if (agentToolView.agentId) document.getElementById("pe-tools")?.replaceWith(renderToolPermissions(agentToolView.agentId));
|
|
6832
|
+
}
|
|
6833
|
+
|
|
6834
|
+
function renderToolPermissions(agentId) {
|
|
6835
|
+
const view = agentToolView;
|
|
6836
|
+
if (view.agentId !== agentId) {
|
|
6837
|
+
Object.assign(view, { agentId, status: "loading", page: null, note: null, busy: new Set(), editing: new Map(), confirmAll: false });
|
|
6838
|
+
void loadToolPermissions(agentId, { force: false });
|
|
6839
|
+
}
|
|
6840
|
+
const section = peSection(t("agents.tool.title"), t("agents.tool.note"));
|
|
6841
|
+
section.id = "pe-tools";
|
|
6842
|
+
section.classList.add("pe-tools");
|
|
6843
|
+
section.setAttribute("aria-busy", String(view.status === "loading"));
|
|
6844
|
+
const status = make("p", `pe-tool-status${view.note?.error ? " error" : ""}`,
|
|
6845
|
+
view.status === "loading" ? t("agents.tool.loading") : view.status === "unsupported" ? t("agents.tool.unsupported") : view.note?.text ?? "");
|
|
6846
|
+
status.setAttribute("role", "status");
|
|
6847
|
+
section.append(status);
|
|
6848
|
+
if (view.status === "failed") section.append(peButton(t("agents.tool.retry"), () => void loadToolPermissions(agentId, { force: true }), null, "text-btn pe-tool-retry"));
|
|
6849
|
+
const page = view.page;
|
|
6850
|
+
if (!page) return section;
|
|
6851
|
+
section.append(make("p", "pe-note pe-tool-summary", accessSummary(page.policy)));
|
|
6852
|
+
const pending = Array.isArray(page.pending) ? page.pending.length : 0;
|
|
6853
|
+
if (pending > 0) {
|
|
6854
|
+
const line = make("p", "pe-note pe-tool-pending", t("agents.tool.pending", { count: pending }));
|
|
6855
|
+
line.append(peButton(t("agents.tool.openInbox"), () => openInbox(), null, "link-btn"));
|
|
6856
|
+
section.append(line);
|
|
6857
|
+
}
|
|
6858
|
+
const rows = toolGrantRows(page, { toolName, operationLabel, scopeText });
|
|
6859
|
+
if (rows.length === 0) section.append(make("p", "pe-empty", t("agents.tool.empty")));
|
|
6860
|
+
else {
|
|
6861
|
+
const list = make("ul", "pe-list pe-tool-list");
|
|
6862
|
+
for (const row of rows) list.append(renderToolGrantRow(agentId, row, page));
|
|
6863
|
+
section.append(list);
|
|
6864
|
+
}
|
|
6865
|
+
const all = make("div", "pe-tool-all");
|
|
6866
|
+
const revokeAll = peButton(t("agents.tool.revokeAll"), () => {
|
|
6867
|
+
view.confirmAll = true;
|
|
6868
|
+
refreshToolPermissions();
|
|
6869
|
+
document.getElementById("pe-tools")?.querySelector(".pe-tool-all-confirm .text-btn")?.focus();
|
|
6870
|
+
}, "pe-tool-revoke-all", "text-btn danger");
|
|
6871
|
+
revokeAll.disabled = page.policy?.accessState === "blocked" || view.busy.has("all") || rows.every((row) => row.state === "revoked");
|
|
6872
|
+
all.append(revokeAll);
|
|
6873
|
+
if (view.confirmAll && !revokeAll.disabled) {
|
|
6874
|
+
const confirm = make("div", "inbox-dismiss mode-cancel pe-tool-all-confirm");
|
|
6875
|
+
confirm.append(make("p", "inbox-dismiss-text", t("agents.tool.all.confirm")));
|
|
6876
|
+
const actions = make("div", "pe-actions");
|
|
6877
|
+
actions.append(
|
|
6878
|
+
peButton(t("agents.tool.all.keep"), () => {
|
|
6879
|
+
view.confirmAll = false;
|
|
6880
|
+
refreshToolPermissions();
|
|
6881
|
+
}, null, "text-btn"),
|
|
6882
|
+
peButton(t("agents.tool.all.go"), () => void revokeAllGrants(agentId), null, "text-btn danger"),
|
|
6883
|
+
);
|
|
6884
|
+
confirm.append(actions);
|
|
6885
|
+
all.append(confirm);
|
|
6886
|
+
}
|
|
6887
|
+
section.append(all);
|
|
4903
6888
|
return section;
|
|
4904
6889
|
}
|
|
4905
6890
|
|
|
6891
|
+
/** One grant: tool, state chip, operations and target, the original confirmation, the role=switch (off = revoke at once) and the row actions. */
|
|
6892
|
+
function renderToolGrantRow(agentId, row, page) {
|
|
6893
|
+
const view = agentToolView;
|
|
6894
|
+
const grant = page.items.find((item) => item.id === row.id);
|
|
6895
|
+
const busy = view.busy.has(row.id);
|
|
6896
|
+
const off = row.state === "revoked" || row.state === "blocked";
|
|
6897
|
+
const li = make("li", "pe-list-row pe-tool-row");
|
|
6898
|
+
li.dataset.grant = row.id;
|
|
6899
|
+
li.setAttribute("aria-busy", String(busy));
|
|
6900
|
+
const main = make("div", "pe-list-main pe-tool-main");
|
|
6901
|
+
const name = make("div", "pe-list-name pe-tool-name", row.tool);
|
|
6902
|
+
name.append(make("span", `inbox-chip chip-${row.state}`, row.stateLabel));
|
|
6903
|
+
main.append(name, make("div", "pe-list-sub pe-tool-sub", [row.operation, row.target].filter(Boolean).join(" · ")));
|
|
6904
|
+
main.append(make("div", "pe-tool-meta", t("agents.tool.meta", { decision: row.origin, revision: row.revision })));
|
|
6905
|
+
li.append(main);
|
|
6906
|
+
const holder = make("div", "pe-tool-switch");
|
|
6907
|
+
const toggle = make("button", "switch");
|
|
6908
|
+
toggle.type = "button";
|
|
6909
|
+
toggle.id = `pe-tool-switch-${row.id}`;
|
|
6910
|
+
toggle.setAttribute("role", "switch");
|
|
6911
|
+
toggle.setAttribute("aria-label", t("agents.tool.switch.label", { tool: row.tool }));
|
|
6912
|
+
renderSwitch(toggle, !off, busy || off);
|
|
6913
|
+
holder.append(toggle, make("span", null, t(off ? "common.offShort" : "common.onShort")));
|
|
6914
|
+
li.append(holder);
|
|
6915
|
+
if (off) {
|
|
6916
|
+
// Back on is not a switch flip: the agent's next request is allowed in the inbox (C8).
|
|
6917
|
+
const why = make("p", "pe-note pe-tool-reallow", t("agents.tool.reallow"));
|
|
6918
|
+
why.id = `pe-tool-reallow-${row.id}`;
|
|
6919
|
+
toggle.setAttribute("aria-describedby", why.id);
|
|
6920
|
+
li.append(why);
|
|
6921
|
+
return li;
|
|
6922
|
+
}
|
|
6923
|
+
toggle.onclick = () => void revokeGrant(agentId, grant);
|
|
6924
|
+
const actions = make("div", "pe-list-actions pe-tool-actions");
|
|
6925
|
+
const edit = peButton(t("agents.tool.edit"), () => {
|
|
6926
|
+
if (view.editing.has(row.id)) view.editing.delete(row.id);
|
|
6927
|
+
else view.editing.set(row.id, scopeDraft(grant));
|
|
6928
|
+
refreshToolPermissions();
|
|
6929
|
+
}, null, "text-btn");
|
|
6930
|
+
edit.disabled = busy;
|
|
6931
|
+
edit.setAttribute("aria-expanded", String(view.editing.has(row.id)));
|
|
6932
|
+
const revoke = peButton(t("agents.tool.revoke"), () => void revokeGrant(agentId, grant), null, "text-btn");
|
|
6933
|
+
revoke.disabled = busy;
|
|
6934
|
+
actions.append(edit, revoke);
|
|
6935
|
+
li.append(actions);
|
|
6936
|
+
if (view.editing.has(row.id)) li.append(renderScopeEditor(agentId, grant, row));
|
|
6937
|
+
return li;
|
|
6938
|
+
}
|
|
6939
|
+
|
|
6940
|
+
/** The edit line: the kind of change (C8) and the exact operations and values it adds or removes. */
|
|
6941
|
+
function diffLine(grant, edit) {
|
|
6942
|
+
const parts = [editLine(edit.kind)];
|
|
6943
|
+
const operations = (list) => list.map((operation) => operationLabel(grant.toolKey, operation)).join(", ");
|
|
6944
|
+
const values = (scope) => Object.entries(scope).map(([key, list]) => `${key} ${list.join(", ")}`).join("; ");
|
|
6945
|
+
if (edit.added.operations.length) parts.push(t("agents.tool.edit.addedOps", { list: operations(edit.added.operations) }));
|
|
6946
|
+
if (Object.keys(edit.added.scope).length) parts.push(t("agents.tool.edit.addedScope", { list: values(edit.added.scope) }));
|
|
6947
|
+
if (edit.removed.operations.length) parts.push(t("agents.tool.edit.removedOps", { list: operations(edit.removed.operations) }));
|
|
6948
|
+
if (Object.keys(edit.removed.scope).length) parts.push(t("agents.tool.edit.removedScope", { list: values(edit.removed.scope) }));
|
|
6949
|
+
return parts.join(" ");
|
|
6950
|
+
}
|
|
6951
|
+
|
|
6952
|
+
/**
|
|
6953
|
+
* The inline scope editor of one grant: the granted operations as role=switch rows (off narrows) and every list value
|
|
6954
|
+
* of the scope as text (fewer values narrow, more widen). The diff line and the save button update in place, so typing
|
|
6955
|
+
* never redraws the field under the caret.
|
|
6956
|
+
*/
|
|
6957
|
+
function renderScopeEditor(agentId, grant, row) {
|
|
6958
|
+
const view = agentToolView;
|
|
6959
|
+
const draft = view.editing.get(row.id);
|
|
6960
|
+
const box = make("div", "pe-tool-edit");
|
|
6961
|
+
box.setAttribute("role", "group");
|
|
6962
|
+
box.setAttribute("aria-label", t("agents.tool.edit"));
|
|
6963
|
+
const diff = make("p", "pe-note pe-tool-diff");
|
|
6964
|
+
const save = peButton(t("common.save"), () => void submitScopeEdit(agentId, grant), null, "text-btn primary");
|
|
6965
|
+
const update = () => {
|
|
6966
|
+
const edit = classifyEdit(grant, draft);
|
|
6967
|
+
diff.textContent = diffLine(grant, edit);
|
|
6968
|
+
diff.classList.toggle("widen", edit.kind === "widen");
|
|
6969
|
+
save.disabled = edit.kind === "same" || view.busy.has(row.id);
|
|
6970
|
+
};
|
|
6971
|
+
const ops = make("div", "pe-tool-ops");
|
|
6972
|
+
ops.append(make("div", "pe-tool-edit-label", t("agents.tool.edit.operations")));
|
|
6973
|
+
for (const operation of row.operations) {
|
|
6974
|
+
const line = make("div", "switch-row pe-tool-op");
|
|
6975
|
+
const toggle = make("button", "switch");
|
|
6976
|
+
toggle.type = "button";
|
|
6977
|
+
toggle.id = `pe-tool-op-${row.id}-${operation}`;
|
|
6978
|
+
toggle.setAttribute("role", "switch");
|
|
6979
|
+
toggle.dataset.operation = operation;
|
|
6980
|
+
const on = () => draft.operations.includes(operation);
|
|
6981
|
+
renderSwitch(toggle, on());
|
|
6982
|
+
toggle.onclick = () => {
|
|
6983
|
+
draft.operations = on() ? draft.operations.filter((entry) => entry !== operation) : row.operations.filter((entry) => entry === operation || draft.operations.includes(entry));
|
|
6984
|
+
renderSwitch(toggle, on());
|
|
6985
|
+
update();
|
|
6986
|
+
};
|
|
6987
|
+
const label = make("label", "switch-label", operationLabel(grant.toolKey, operation));
|
|
6988
|
+
label.htmlFor = toggle.id;
|
|
6989
|
+
line.append(toggle, label);
|
|
6990
|
+
ops.append(line);
|
|
6991
|
+
}
|
|
6992
|
+
box.append(ops);
|
|
6993
|
+
for (const [key, text] of Object.entries(draft.text)) {
|
|
6994
|
+
const field = make("label", "pe-tool-scope-field");
|
|
6995
|
+
field.append(make("span", "pe-tool-edit-label", t("agents.tool.edit.scope", { key })));
|
|
6996
|
+
const area = make("textarea", "pe-textarea pe-tool-scope");
|
|
6997
|
+
area.rows = 2;
|
|
6998
|
+
area.value = text;
|
|
6999
|
+
area.setAttribute("aria-label", key);
|
|
7000
|
+
area.addEventListener("input", () => {
|
|
7001
|
+
draft.text[key] = area.value;
|
|
7002
|
+
draft.scope[key] = parseScopeText(area.value);
|
|
7003
|
+
update();
|
|
7004
|
+
});
|
|
7005
|
+
field.append(area);
|
|
7006
|
+
box.append(field);
|
|
7007
|
+
}
|
|
7008
|
+
const actions = make("div", "pe-actions pe-tool-edit-actions");
|
|
7009
|
+
actions.append(save, peButton(t("common.cancel"), () => {
|
|
7010
|
+
view.editing.delete(row.id);
|
|
7011
|
+
refreshToolPermissions();
|
|
7012
|
+
}, null, "text-btn"));
|
|
7013
|
+
box.append(diff, actions);
|
|
7014
|
+
update();
|
|
7015
|
+
return box;
|
|
7016
|
+
}
|
|
7017
|
+
|
|
7018
|
+
async function loadToolPermissions(agentId, { force = false } = {}) {
|
|
7019
|
+
const view = agentToolView;
|
|
7020
|
+
if (view.agentId !== agentId) return;
|
|
7021
|
+
const seq = ++view.seq;
|
|
7022
|
+
view.status = "loading";
|
|
7023
|
+
if (force) view.note = null;
|
|
7024
|
+
refreshToolPermissions();
|
|
7025
|
+
const request = toolPermissionsRequest(agentId);
|
|
7026
|
+
let answer = await agentToolFetch(request.path, { method: "GET" });
|
|
7027
|
+
// A read may take the renewed session once; a POST never repeats by itself.
|
|
7028
|
+
if (answer.client && answer.status === 403 && clientExpired(answer.body?.error)) answer = await agentToolFetch(request.path, { method: "GET" });
|
|
7029
|
+
if (view.agentId !== agentId || seq !== view.seq) return;
|
|
7030
|
+
if (!answer.client) {
|
|
7031
|
+
view.status = "failed";
|
|
7032
|
+
view.note = { text: t(agentToolClientRefused ? "agents.tool.error.forbidden" : "agents.tool.error.network"), error: true };
|
|
7033
|
+
} else {
|
|
7034
|
+
const result = interpretPermissionResponse(answer.status, answer.body);
|
|
7035
|
+
if (result.kind === "ok") {
|
|
7036
|
+
view.status = "ready";
|
|
7037
|
+
view.page = answer.body;
|
|
7038
|
+
} else if (result.kind === "unsupported") {
|
|
7039
|
+
view.status = "unsupported";
|
|
7040
|
+
view.page = null;
|
|
7041
|
+
} else {
|
|
7042
|
+
view.status = "failed";
|
|
7043
|
+
view.note = { text: toolPermissionErrorLine(result.error ?? null, answer.status), error: true };
|
|
7044
|
+
}
|
|
7045
|
+
}
|
|
7046
|
+
refreshToolPermissions();
|
|
7047
|
+
}
|
|
7048
|
+
|
|
7049
|
+
function toolPermissionFailure(answer) {
|
|
7050
|
+
if (!answer.client) return t(agentToolClientRefused ? "agents.tool.error.forbidden" : "agents.tool.error.network");
|
|
7051
|
+
const result = interpretPermissionResponse(answer.status, answer.body);
|
|
7052
|
+
return toolPermissionErrorLine(result.error ?? null, answer.status);
|
|
7053
|
+
}
|
|
7054
|
+
|
|
7055
|
+
const RELOAD_AFTER = new Set(["conflict", "stale", "gone"]);
|
|
7056
|
+
|
|
7057
|
+
/** OFF on the switch or 권한 회수: revoked at once, no second confirmation (C8); the gateway's answer names any running work. */
|
|
7058
|
+
async function revokeGrant(agentId, grant) {
|
|
7059
|
+
const view = agentToolView;
|
|
7060
|
+
if (!grant || view.agentId !== agentId || view.busy.has(grant.id)) return;
|
|
7061
|
+
view.busy.add(grant.id);
|
|
7062
|
+
view.note = null;
|
|
7063
|
+
refreshToolPermissions();
|
|
7064
|
+
const request = revokeRequest(agentId, grant, crypto.randomUUID());
|
|
7065
|
+
const answer = await agentToolFetch(request.path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(request.body) });
|
|
7066
|
+
view.busy.delete(grant.id);
|
|
7067
|
+
if (view.agentId !== agentId) return;
|
|
7068
|
+
const result = answer.client ? interpretPermissionResponse(answer.status, answer.body) : { kind: "network" };
|
|
7069
|
+
if (result.kind === "ok") {
|
|
7070
|
+
view.page = applyRevoke(view.page, grant.id, answer.body);
|
|
7071
|
+
view.editing.delete(grant.id);
|
|
7072
|
+
view.note = { text: revokeLine(answer.body), error: false };
|
|
7073
|
+
refreshToolPermissions();
|
|
7074
|
+
return;
|
|
7075
|
+
}
|
|
7076
|
+
view.note = { text: toolPermissionFailure(answer), error: true };
|
|
7077
|
+
if (RELOAD_AFTER.has(result.kind)) void loadToolPermissions(agentId);
|
|
7078
|
+
else refreshToolPermissions();
|
|
7079
|
+
}
|
|
7080
|
+
|
|
7081
|
+
/** C10 revoke-all after the inline confirmation: every grant revoked, new requests blocked until the inbox allows one. */
|
|
7082
|
+
async function revokeAllGrants(agentId) {
|
|
7083
|
+
const view = agentToolView;
|
|
7084
|
+
if (view.agentId !== agentId || !view.page || view.busy.has("all")) return;
|
|
7085
|
+
view.busy.add("all");
|
|
7086
|
+
view.confirmAll = false;
|
|
7087
|
+
view.note = null;
|
|
7088
|
+
refreshToolPermissions();
|
|
7089
|
+
const request = revokeAllRequest(agentId, view.page.policy, crypto.randomUUID());
|
|
7090
|
+
const answer = await agentToolFetch(request.path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(request.body) });
|
|
7091
|
+
view.busy.delete("all");
|
|
7092
|
+
if (view.agentId !== agentId) return;
|
|
7093
|
+
const result = answer.client ? interpretPermissionResponse(answer.status, answer.body) : { kind: "network" };
|
|
7094
|
+
view.note = result.kind === "ok" ? { text: t("agents.tool.revokedAll"), error: false } : { text: toolPermissionFailure(answer), error: true };
|
|
7095
|
+
if (result.kind === "ok" || RELOAD_AFTER.has(result.kind)) void loadToolPermissions(agentId);
|
|
7096
|
+
else refreshToolPermissions();
|
|
7097
|
+
}
|
|
7098
|
+
|
|
7099
|
+
/** POST .../edit: 200 narrowed and applied, 202 widened and sent to the inbox, 409 keeps the draft beside the reloaded content (C8). */
|
|
7100
|
+
async function submitScopeEdit(agentId, grant) {
|
|
7101
|
+
const view = agentToolView;
|
|
7102
|
+
const draft = view.editing.get(grant.id);
|
|
7103
|
+
if (!draft || view.agentId !== agentId || view.busy.has(grant.id) || classifyEdit(grant, draft).kind === "same") return;
|
|
7104
|
+
view.busy.add(grant.id);
|
|
7105
|
+
view.note = null;
|
|
7106
|
+
refreshToolPermissions();
|
|
7107
|
+
const request = editRequest(agentId, grant, draft, crypto.randomUUID());
|
|
7108
|
+
const answer = await agentToolFetch(request.path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(request.body) });
|
|
7109
|
+
view.busy.delete(grant.id);
|
|
7110
|
+
if (view.agentId !== agentId) return;
|
|
7111
|
+
const result = answer.client ? interpretPermissionResponse(answer.status, answer.body) : { kind: "network" };
|
|
7112
|
+
if (result.kind === "ok" || result.kind === "confirm") {
|
|
7113
|
+
const updated = result.kind === "ok" && answer.body && typeof answer.body === "object" ? answer.body.grant : null;
|
|
7114
|
+
if (updated && typeof updated === "object" && view.page) {
|
|
7115
|
+
const policyRevision = typeof answer.body.policyRevision === "number" ? answer.body.policyRevision : view.page.policy.policyRevision;
|
|
7116
|
+
view.page = { ...view.page, items: view.page.items.map((item) => (item.id === grant.id ? updated : item)), policy: { ...view.page.policy, policyRevision } };
|
|
7117
|
+
}
|
|
7118
|
+
view.editing.delete(grant.id);
|
|
7119
|
+
view.note = { text: t(result.kind === "ok" ? "agents.tool.edit.narrowed" : "agents.tool.edit.sent"), error: false };
|
|
7120
|
+
refreshToolPermissions();
|
|
7121
|
+
return;
|
|
7122
|
+
}
|
|
7123
|
+
view.note = { text: toolPermissionFailure(answer), error: true };
|
|
7124
|
+
if (RELOAD_AFTER.has(result.kind)) void loadToolPermissions(agentId);
|
|
7125
|
+
else refreshToolPermissions();
|
|
7126
|
+
}
|
|
7127
|
+
|
|
7128
|
+
function receiveToolPermissionsEvent(data) {
|
|
7129
|
+
if (agentToolView.agentId && policyEventStale(agentToolView.page, data)) void loadToolPermissions(agentToolView.agentId);
|
|
7130
|
+
}
|
|
7131
|
+
|
|
4906
7132
|
function renderAgentEditor() {
|
|
4907
7133
|
const editor = agentView.editor;
|
|
4908
7134
|
if (!editor) return;
|
|
@@ -4918,7 +7144,30 @@ function renderAgentEditor() {
|
|
|
4918
7144
|
const isDefaults = editor.kind === "defaults";
|
|
4919
7145
|
const fields = peSection(isDefaults ? t("agents.defaultsSection") : editor.kind === "new" ? t("agents.new") : t("agents.settingsFor", { name: editor.name ?? "" }),
|
|
4920
7146
|
t(isDefaults ? "agents.defaults.note" : "agents.editor.note"));
|
|
4921
|
-
|
|
7147
|
+
if (editor.kind === "new") {
|
|
7148
|
+
// The draft line (todo 11): what the autosave did last; a reopened draft lists the files it had picked.
|
|
7149
|
+
const note = document.createElement("p");
|
|
7150
|
+
note.className = "pe-note";
|
|
7151
|
+
note.textContent = t("agents.draft.note");
|
|
7152
|
+
const status = document.createElement("p");
|
|
7153
|
+
status.className = "pe-draft-status";
|
|
7154
|
+
status.id = "pe-draft-status";
|
|
7155
|
+
status.setAttribute("role", "status");
|
|
7156
|
+
// A redraw keeps the last line; a reopened draft (it has text already) says so; a fresh screen says nothing yet.
|
|
7157
|
+
status.textContent = document.getElementById("pe-draft-status")?.textContent ?? (Object.keys(editor.own).length > 0 || editor.body ? t("agents.draft.loaded") : "");
|
|
7158
|
+
fields.append(note, status);
|
|
7159
|
+
if (editor.draftFiles?.length) {
|
|
7160
|
+
const files = document.createElement("p");
|
|
7161
|
+
files.className = "pe-note pe-draft-files";
|
|
7162
|
+
files.id = "pe-draft-files";
|
|
7163
|
+
files.textContent = t("agents.draft.files", { names: editor.draftFiles.map((file) => file.name).join(", ") });
|
|
7164
|
+
fields.append(files);
|
|
7165
|
+
}
|
|
7166
|
+
}
|
|
7167
|
+
if (!isDefaults) fields.append(renderCapabilities());
|
|
7168
|
+
const rows = editorRows(editor);
|
|
7169
|
+
for (let index = 0; index < rows.length; index += 1) {
|
|
7170
|
+
const row = rows[index];
|
|
4922
7171
|
if (row.kind === "group") {
|
|
4923
7172
|
const group = document.createElement("div");
|
|
4924
7173
|
group.className = "section-label pe-group";
|
|
@@ -4927,18 +7176,25 @@ function renderAgentEditor() {
|
|
|
4927
7176
|
fields.append(group);
|
|
4928
7177
|
continue;
|
|
4929
7178
|
}
|
|
7179
|
+
if (row.kind === "forbidden") {
|
|
7180
|
+
// Consecutive prohibited rows with one reason share one paragraph, drawn under the last of them.
|
|
7181
|
+
let last = index;
|
|
7182
|
+
while (rows[last + 1]?.kind === "forbidden" && rows[last + 1].help === row.help) last += 1;
|
|
7183
|
+
fields.append(renderForbiddenRow(row, `pe-field-${rows[last].name.replaceAll(".", "-")}-help`, last === index));
|
|
7184
|
+
continue;
|
|
7185
|
+
}
|
|
4930
7186
|
fields.append(renderEditorRow(row));
|
|
4931
7187
|
}
|
|
4932
7188
|
const instructionsKey = isDefaults ? "agents.sharedInstructions" : "agents.roleInstructions";
|
|
4933
7189
|
const bodySection = peSection(t(instructionsKey), t(`${instructionsKey}.note`));
|
|
7190
|
+
if (!isDefaults) bodySection.append(renderRoleGuide(editor.kind === "new"));
|
|
4934
7191
|
const bodyArea = document.createElement("textarea");
|
|
4935
7192
|
bodyArea.className = "pe-textarea pe-body";
|
|
4936
7193
|
bodyArea.id = "pe-body";
|
|
4937
7194
|
bodyArea.value = editor.body;
|
|
7195
|
+
if (!isDefaults) bodyArea.placeholder = t("agents.roleInstructions.placeholder");
|
|
4938
7196
|
bodyArea.setAttribute("aria-label", t(instructionsKey));
|
|
4939
|
-
bodyArea.addEventListener("
|
|
4940
|
-
agentView.editor = setBody(agentView.editor, bodyArea.value);
|
|
4941
|
-
});
|
|
7197
|
+
bodyArea.addEventListener("input", () => commitEditor(setBody(agentView.editor, bodyArea.value)));
|
|
4942
7198
|
bodySection.append(bodyArea);
|
|
4943
7199
|
const rawSection = peSection(t("agents.rawSection"), t("agents.raw.note"));
|
|
4944
7200
|
const rawArea = document.createElement("textarea");
|
|
@@ -4947,13 +7203,13 @@ function renderAgentEditor() {
|
|
|
4947
7203
|
rawArea.value = editor.unknownRaw;
|
|
4948
7204
|
rawArea.spellcheck = false;
|
|
4949
7205
|
rawArea.setAttribute("aria-label", t("agents.raw.label"));
|
|
4950
|
-
rawArea.addEventListener("
|
|
4951
|
-
agentView.editor = setUnknownRaw(agentView.editor, rawArea.value);
|
|
4952
|
-
});
|
|
7206
|
+
rawArea.addEventListener("input", () => commitEditor(setUnknownRaw(agentView.editor, rawArea.value)));
|
|
4953
7207
|
rawSection.append(rawArea);
|
|
4954
7208
|
form.append(fields, bodySection, rawSection);
|
|
4955
7209
|
const existing = editor.kind === "agent" && editor.id;
|
|
7210
|
+
if (existing) form.append(renderToolPermissions(editor.id));
|
|
4956
7211
|
if (existing) form.append(renderAgentFiles(editor.id));
|
|
7212
|
+
if (editor.kind === "new") form.append(renderNewAgentFiles());
|
|
4957
7213
|
let deleteBox = null;
|
|
4958
7214
|
let deleteInput = null;
|
|
4959
7215
|
const error = document.createElement("p");
|
|
@@ -5011,44 +7267,6 @@ function renderAgentEditor() {
|
|
|
5011
7267
|
if (focused) document.getElementById(focused)?.focus();
|
|
5012
7268
|
}
|
|
5013
7269
|
|
|
5014
|
-
async function saveAgentEditor() {
|
|
5015
|
-
const editor = agentView.editor;
|
|
5016
|
-
if (!editor) return;
|
|
5017
|
-
const errors = validateEditor(editor);
|
|
5018
|
-
const errorEl = document.getElementById("pe-form-error");
|
|
5019
|
-
if (errors.length) {
|
|
5020
|
-
if (errorEl) errorEl.textContent = errors.map((e) => e.message).join(" · ");
|
|
5021
|
-
showAgentStatus(t("agents.notSaved"), "error");
|
|
5022
|
-
return;
|
|
5023
|
-
}
|
|
5024
|
-
if (errorEl) errorEl.textContent = "";
|
|
5025
|
-
const save = document.getElementById("pe-save");
|
|
5026
|
-
if (save) save.disabled = true;
|
|
5027
|
-
try {
|
|
5028
|
-
const body = await agentApi(saveRequest(editor));
|
|
5029
|
-
if (editor.kind === "defaults") {
|
|
5030
|
-
agentView.editor = editorState(body, "defaults");
|
|
5031
|
-
showAgentStatus(t("agents.defaultsSaved"), "ok");
|
|
5032
|
-
renderAgentEditor();
|
|
5033
|
-
return;
|
|
5034
|
-
}
|
|
5035
|
-
receiveAgentUpdate(body);
|
|
5036
|
-
if (editor.kind === "new") {
|
|
5037
|
-
closeAgents();
|
|
5038
|
-
systemLine(t("agents.created.line", { name: body.name }));
|
|
5039
|
-
return;
|
|
5040
|
-
}
|
|
5041
|
-
agentView.editor = editorState(body, "agent");
|
|
5042
|
-
showAgentStatus(t("agents.saved"), "ok");
|
|
5043
|
-
renderAgentEditor();
|
|
5044
|
-
} catch (error) {
|
|
5045
|
-
showAgentStatus(error.message, "error");
|
|
5046
|
-
} finally {
|
|
5047
|
-
const button = document.getElementById("pe-save");
|
|
5048
|
-
if (button) button.disabled = false;
|
|
5049
|
-
}
|
|
5050
|
-
}
|
|
5051
|
-
|
|
5052
7270
|
async function cloneAgent(id) {
|
|
5053
7271
|
try {
|
|
5054
7272
|
const body = await agentApi(cloneRequest(id));
|
|
@@ -5072,22 +7290,70 @@ async function deleteAgent(id, typed) {
|
|
|
5072
7290
|
}
|
|
5073
7291
|
}
|
|
5074
7292
|
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
7293
|
+
/**
|
|
7294
|
+
* The files picked or dropped for an agent's source/ folder (agent-ux todo 14): they join that agent's queue under the zone
|
|
7295
|
+
* (a file over the gateway limit fails there at once and is never sent) and go out one by one, each row showing its
|
|
7296
|
+
* progress. The file list is read again after the run and the status line says what was uploaded.
|
|
7297
|
+
*/
|
|
7298
|
+
async function uploadAgentSources(id, files) {
|
|
7299
|
+
setUploadQueue(id, [...uploadQueue(id), ...uploadRows(files)]);
|
|
7300
|
+
await runAgentUploads(id);
|
|
7301
|
+
}
|
|
7302
|
+
|
|
7303
|
+
/** One agent's upload queue; empty when nothing was picked for it. */
|
|
7304
|
+
function uploadQueue(id) {
|
|
7305
|
+
return agentView.uploads.get(id) ?? [];
|
|
7306
|
+
}
|
|
7307
|
+
|
|
7308
|
+
/** Replaces one agent's queue and redraws its zone when that agent's editor is the open one; another editor's zone is left alone. */
|
|
7309
|
+
function setUploadQueue(id, rows) {
|
|
7310
|
+
agentView.uploads.set(id, rows);
|
|
7311
|
+
if (agentView.editor?.id === id) redrawUploadRows("pe-source-upload", rows, (key) => retryAgentUpload(id, key));
|
|
7312
|
+
}
|
|
7313
|
+
|
|
7314
|
+
function retryAgentUpload(id, key) {
|
|
7315
|
+
setUploadQueue(id, retryUpload(uploadQueue(id), key));
|
|
7316
|
+
void runAgentUploads(id);
|
|
7317
|
+
}
|
|
7318
|
+
|
|
7319
|
+
function setAgentUpload(id, key, patch) {
|
|
7320
|
+
setUploadQueue(id, advanceUpload(uploadQueue(id), key, patch));
|
|
7321
|
+
}
|
|
7322
|
+
|
|
7323
|
+
/**
|
|
7324
|
+
* Sends one agent's waiting rows in order, one request at a time: one runner per agent (a second call while it runs only
|
|
7325
|
+
* leaves its rows in the queue) and the run goes on when another editor opens (review fix B1). The list and the status
|
|
7326
|
+
* line are refreshed only while that agent's editor is the open one - after the run, and again after the list answered.
|
|
7327
|
+
*/
|
|
7328
|
+
async function runAgentUploads(id) {
|
|
7329
|
+
if (agentView.uploading.has(id)) return;
|
|
7330
|
+
agentView.uploading.add(id);
|
|
7331
|
+
const saved = [];
|
|
7332
|
+
try {
|
|
7333
|
+
for (let row = nextUpload(uploadQueue(id)); row; row = nextUpload(uploadQueue(id))) {
|
|
7334
|
+
setAgentUpload(id, row.key, { status: "uploading", progress: 0 });
|
|
7335
|
+
try {
|
|
7336
|
+
const result = await uploadWithProgress(sourceUploadPath(id), row.file, (progress) => setAgentUpload(id, row.key, { status: "uploading", progress }));
|
|
7337
|
+
setAgentUpload(id, row.key, { status: "done", savedName: result.name });
|
|
7338
|
+
saved.push({ name: row.name, savedName: result.name, size: result.size });
|
|
7339
|
+
} catch (error) {
|
|
7340
|
+
setAgentUpload(id, row.key, { status: "failed", error: error.code ?? (error.network ? "network" : "rejected"), errorLine: error.message });
|
|
7341
|
+
}
|
|
7342
|
+
}
|
|
7343
|
+
} finally {
|
|
7344
|
+
agentView.uploading.delete(id);
|
|
5080
7345
|
}
|
|
5081
|
-
|
|
5082
|
-
form.append("file", file, file.name);
|
|
7346
|
+
if (agentView.editor?.id !== id) return;
|
|
5083
7347
|
try {
|
|
5084
|
-
const
|
|
5085
|
-
agentView.
|
|
5086
|
-
|
|
5087
|
-
renderAgentEditor();
|
|
7348
|
+
const files = await agentApi({ method: "GET", path: filesPath(id) });
|
|
7349
|
+
if (agentView.editor?.id !== id) return;
|
|
7350
|
+
agentView.files = fileRows(files);
|
|
5088
7351
|
} catch (error) {
|
|
7352
|
+
if (agentView.editor?.id !== id) return;
|
|
5089
7353
|
showAgentStatus(error.message, "error");
|
|
5090
7354
|
}
|
|
7355
|
+
if (saved.length) showAgentStatus(uploadedLine(saved), "ok");
|
|
7356
|
+
renderAgentEditor();
|
|
5091
7357
|
}
|
|
5092
7358
|
|
|
5093
7359
|
async function renderAgentTrash(load) {
|
|
@@ -5124,7 +7390,9 @@ async function renderAgentTrash(load) {
|
|
|
5124
7390
|
main.append(name, sub);
|
|
5125
7391
|
const rowActions = document.createElement("div");
|
|
5126
7392
|
rowActions.className = "pe-list-actions";
|
|
5127
|
-
|
|
7393
|
+
const restore = peButton(t("agents.restore"), () => restoreAgent(row.entry), undefined, "text-btn primary", "restore");
|
|
7394
|
+
restore.disabled = restoreInFlight.has(row.entry);
|
|
7395
|
+
rowActions.append(restore);
|
|
5128
7396
|
li.append(peSpan("agent-icon", row.icon), main, rowActions);
|
|
5129
7397
|
list.append(li);
|
|
5130
7398
|
}
|
|
@@ -5132,21 +7400,98 @@ async function renderAgentTrash(load) {
|
|
|
5132
7400
|
agentEls.body.replaceChildren(section);
|
|
5133
7401
|
}
|
|
5134
7402
|
|
|
5135
|
-
|
|
7403
|
+
/**
|
|
7404
|
+
* Archive entries with a restore in flight: entry -> the token of the request that owns it (gate review T22-B1). One
|
|
7405
|
+
* request per entry at a time, plain or under a new id: the row's 되살리기 and the notice's retry are locked while it
|
|
7406
|
+
* runs (a row or notice drawn meanwhile comes up locked too), and a reply whose request no longer owns the entry is
|
|
7407
|
+
* dropped, so a late refusal can never overwrite a completed restore.
|
|
7408
|
+
*/
|
|
7409
|
+
const restoreInFlight = new Map();
|
|
7410
|
+
|
|
7411
|
+
/**
|
|
7412
|
+
* A restore from the archive (plan todo 22). The gateway's 409 `exists` means the entry's id is live again (the agent
|
|
7413
|
+
* was deleted twice and one copy restored): a notice under the row explains it and offers the same restore with
|
|
7414
|
+
* `{ newId: true }`, which keeps both. There is no overwrite choice.
|
|
7415
|
+
*/
|
|
7416
|
+
async function restoreAgent(entry, newId = false) {
|
|
7417
|
+
if (restoreInFlight.has(entry)) return;
|
|
7418
|
+
const token = {};
|
|
7419
|
+
restoreInFlight.set(entry, token);
|
|
7420
|
+
lockRestore(entry, true);
|
|
7421
|
+
const request = restoreRequest(entry, { newId });
|
|
7422
|
+
let outcome;
|
|
5136
7423
|
try {
|
|
5137
|
-
const
|
|
5138
|
-
|
|
5139
|
-
showAgentStatus(t("agents.restored", { name: body.name }), "ok");
|
|
5140
|
-
await showAgentView("list");
|
|
7424
|
+
const res = await fetch(request.path, { method: request.method, headers: request.headers, body: request.body });
|
|
7425
|
+
outcome = restoreOutcome(res.status, await res.json().catch(() => null));
|
|
5141
7426
|
} catch (error) {
|
|
5142
|
-
|
|
7427
|
+
outcome = { kind: "error", message: error.message };
|
|
7428
|
+
}
|
|
7429
|
+
if (restoreInFlight.get(entry) !== token) return;
|
|
7430
|
+
restoreInFlight.delete(entry);
|
|
7431
|
+
lockRestore(entry, false);
|
|
7432
|
+
if (outcome.kind === "restored") {
|
|
7433
|
+
receiveAgentUpdate(outcome.detail);
|
|
7434
|
+
showAgentStatus(t(newId ? "agents.restored.newId" : "agents.restored", { name: outcome.detail.name }), "ok");
|
|
7435
|
+
await showAgentView("list");
|
|
7436
|
+
} else if (outcome.kind === "conflict") showRestoreConflict(entry, outcome.id);
|
|
7437
|
+
else if (newId) showRestoreConflictError(entry, outcome.message);
|
|
7438
|
+
else showAgentStatus(outcome.message, "error");
|
|
7439
|
+
}
|
|
7440
|
+
|
|
7441
|
+
/** The row's 되살리기 and the notice's retry of one entry, as drawn now, locked while its request runs. */
|
|
7442
|
+
function lockRestore(entry, locked) {
|
|
7443
|
+
for (const button of [archiveRow(entry)?.querySelector('[data-act="restore"]'), restoreConflictBox(entry)?.querySelector('[data-act="restore-new-id"]')]) {
|
|
7444
|
+
if (button) button.disabled = locked;
|
|
5143
7445
|
}
|
|
5144
7446
|
}
|
|
5145
7447
|
|
|
7448
|
+
function archiveRow(entry) {
|
|
7449
|
+
return [...document.querySelectorAll("#pe-trash-list li[data-entry]")].find((li) => li.dataset.entry === entry) ?? null;
|
|
7450
|
+
}
|
|
7451
|
+
|
|
7452
|
+
/** The notice under an archive row after a 409 `exists`: the cause, [새 ID로 되살리기] and [취소]. A second 409 replaces it. */
|
|
7453
|
+
function showRestoreConflict(entry, id) {
|
|
7454
|
+
const row = archiveRow(entry);
|
|
7455
|
+
if (!row) {
|
|
7456
|
+
showAgentStatus(t("agents.restore.conflict", { id }), "error");
|
|
7457
|
+
return;
|
|
7458
|
+
}
|
|
7459
|
+
restoreConflictBox(entry)?.remove();
|
|
7460
|
+
const box = document.createElement("li");
|
|
7461
|
+
box.className = "inbox-dismiss pe-restore-conflict";
|
|
7462
|
+
box.dataset.conflict = entry;
|
|
7463
|
+
box.tabIndex = -1;
|
|
7464
|
+
const text = document.createElement("p");
|
|
7465
|
+
text.className = "inbox-dismiss-text";
|
|
7466
|
+
text.textContent = t("agents.restore.conflict", { id });
|
|
7467
|
+
const error = document.createElement("p");
|
|
7468
|
+
error.className = "pe-error";
|
|
7469
|
+
const buttons = document.createElement("div");
|
|
7470
|
+
buttons.className = "pe-actions";
|
|
7471
|
+
const retry = peButton(t("agents.restore.newId"), () => restoreAgent(entry, true), undefined, "text-btn primary", "restore-new-id");
|
|
7472
|
+
retry.disabled = restoreInFlight.has(entry);
|
|
7473
|
+
buttons.append(retry, peButton(t("common.cancel"), () => box.remove(), undefined, "text-btn", "restore-cancel"));
|
|
7474
|
+
box.append(text, error, buttons);
|
|
7475
|
+
row.after(box);
|
|
7476
|
+
box.focus();
|
|
7477
|
+
}
|
|
7478
|
+
|
|
7479
|
+
/** A failed retry under a new id: the reason stays in the notice, next to the button; without the notice it is the status line. */
|
|
7480
|
+
function showRestoreConflictError(entry, message) {
|
|
7481
|
+
const line = t("agents.restore.newId.failed", { reason: message });
|
|
7482
|
+
const error = restoreConflictBox(entry)?.querySelector(".pe-error");
|
|
7483
|
+
if (error) error.textContent = line;
|
|
7484
|
+
else showAgentStatus(line, "error");
|
|
7485
|
+
}
|
|
7486
|
+
|
|
7487
|
+
function restoreConflictBox(entry) {
|
|
7488
|
+
return [...document.querySelectorAll("#pe-trash-list li[data-conflict]")].find((li) => li.dataset.conflict === entry) ?? null;
|
|
7489
|
+
}
|
|
7490
|
+
|
|
5146
7491
|
agentEls.manage.onclick = () => (agentView.open ? closeAgents() : openAgents("list"));
|
|
5147
7492
|
agentEls.toggle.onclick = () => (agentView.open ? closeAgents() : openAgents("list"));
|
|
5148
|
-
agentEls.stale.onclick = () => send({ type: "conversation.rotate", conv: state.current });
|
|
5149
7493
|
agentEls.close.onclick = closeAgents;
|
|
7494
|
+
agentEls.applyNow.onclick = () => void applyAgentNow();
|
|
5150
7495
|
for (const [view, button] of Object.entries(agentEls.nav)) button.onclick = () => openAgents(view);
|
|
5151
7496
|
agentEls.el.addEventListener("click", (ev) => {
|
|
5152
7497
|
if (ev.target === agentEls.el) closeAgents();
|
|
@@ -5173,6 +7518,76 @@ agentEls.el.addEventListener("keydown", (ev) => {
|
|
|
5173
7518
|
});
|
|
5174
7519
|
void loadAgents();
|
|
5175
7520
|
|
|
7521
|
+
/**
|
|
7522
|
+
* Save the open editor (todo 13, bug 285). An unchanged text is not sent. After a save, the merged sha the gateway
|
|
7523
|
+
* answered decides the line: unchanged, or saved while an open session keeps the previous definition (the gateway
|
|
7524
|
+
* accepts a save while the agent is answering; the stale state then shows in the conversation header), or saved for
|
|
7525
|
+
* the next session. A refusal becomes a sentence and the editor keeps its state.
|
|
7526
|
+
*/
|
|
7527
|
+
async function saveAgentEditor() {
|
|
7528
|
+
const editor = agentView.editor;
|
|
7529
|
+
if (!editor) return;
|
|
7530
|
+
const errors = validateEditor(editor);
|
|
7531
|
+
const errorEl = document.getElementById("pe-form-error");
|
|
7532
|
+
if (errors.length) {
|
|
7533
|
+
if (errorEl) errorEl.textContent = errors.map((e) => e.message).join(" \u00b7 ");
|
|
7534
|
+
showAgentStatus(t("agents.notSaved"), "error");
|
|
7535
|
+
return;
|
|
7536
|
+
}
|
|
7537
|
+
if (errorEl) errorEl.textContent = "";
|
|
7538
|
+
if (unchangedSource(editor)) {
|
|
7539
|
+
showAgentStatus(t("agents.saved.unchanged"), "ok");
|
|
7540
|
+
return;
|
|
7541
|
+
}
|
|
7542
|
+
const save = document.getElementById("pe-save");
|
|
7543
|
+
if (save) save.disabled = true;
|
|
7544
|
+
try {
|
|
7545
|
+
// A new agent: what waits for this draft is saved and its wire awaited first, so no draft request can follow the create
|
|
7546
|
+
// request that archives the draft; the gateway refuses a late one from another tab (draft_closed).
|
|
7547
|
+
if (editor.kind === "new") await draftAutosave.settle(editor.draftId);
|
|
7548
|
+
editorSaving = true;
|
|
7549
|
+
const body = await agentApi(saveRequest(editor)).finally(() => { editorSaving = false; });
|
|
7550
|
+
if (editor.kind === "defaults") {
|
|
7551
|
+
agentView.editor = editorState(body, "defaults");
|
|
7552
|
+
showAgentStatus(t("agents.defaultsSaved"), "ok");
|
|
7553
|
+
renderAgentEditor();
|
|
7554
|
+
return;
|
|
7555
|
+
}
|
|
7556
|
+
receiveAgentUpdate(body);
|
|
7557
|
+
if (editor.kind === "new") {
|
|
7558
|
+
agentView.drafts = agentView.drafts.filter((draft) => draft.id !== editor.draftId);
|
|
7559
|
+
// The files picked on the create screen that sent this request (captured with its editor state, todo 18) go out only now,
|
|
7560
|
+
// after the definition exists, into the created agent's own queue - never another screen's files (T18-B1). While that screen
|
|
7561
|
+
// is still the open one, the dialog moves to the new agent's editor, whose zone shows the progress, or closes when nothing
|
|
7562
|
+
// was picked; a screen that was closed or replaced meanwhile is left alone and the uploads run behind it (review fix B1).
|
|
7563
|
+
const queued = uploadsAfterCreate(body, editor.pendingUploads);
|
|
7564
|
+
const stillOpen = agentView.open && agentView.editor?.kind === "new" && agentView.editor.draftId === editor.draftId;
|
|
7565
|
+
systemLine(t("agents.created.line", { name: body.name }));
|
|
7566
|
+
if (!queued) {
|
|
7567
|
+
if (stillOpen) closeAgents();
|
|
7568
|
+
return;
|
|
7569
|
+
}
|
|
7570
|
+
if (stillOpen) await showAgentView("editor", queued.id);
|
|
7571
|
+
setUploadQueue(queued.id, queued.rows);
|
|
7572
|
+
if (stillOpen) showAgentStatus(t("agents.created.uploading", { name: body.name, count: queued.rows.filter((row) => row.status === "waiting").length }), "ok");
|
|
7573
|
+
void runAgentUploads(queued.id);
|
|
7574
|
+
return;
|
|
7575
|
+
}
|
|
7576
|
+
const outcome = saveOutcome(editor, body);
|
|
7577
|
+
agentView.editor = editorState(body, "agent");
|
|
7578
|
+
// The save answer's merged fields are the gateway's evaluation of the saved text; a preview still on the wire is superseded.
|
|
7579
|
+
capabilityPreview.discard();
|
|
7580
|
+
resetPreview(body.fields);
|
|
7581
|
+
showAgentStatus(t(!outcome.changed ? "agents.saved.unchanged" : outcome.stale ? "agents.saved" : "agents.saved.next"), "ok", applyNowTarget(outcome, body));
|
|
7582
|
+
renderAgentEditor();
|
|
7583
|
+
} catch (error) {
|
|
7584
|
+
showAgentStatus(saveErrorLine(error, editor.registry), "error");
|
|
7585
|
+
} finally {
|
|
7586
|
+
const button = document.getElementById("pe-save");
|
|
7587
|
+
if (button) button.disabled = false;
|
|
7588
|
+
}
|
|
7589
|
+
}
|
|
7590
|
+
|
|
5176
7591
|
/** The open agent dialog redrawn from its state: the list, the archive (fetched again) or the editor (unsaved edits kept). */
|
|
5177
7592
|
function rerenderAgentDialog() {
|
|
5178
7593
|
if (!agentView.open) return;
|
|
@@ -5206,6 +7621,7 @@ function rerenderLanguage() {
|
|
|
5206
7621
|
renderWorkStatusAll();
|
|
5207
7622
|
renderTray();
|
|
5208
7623
|
renderReferences();
|
|
7624
|
+
documents.render();
|
|
5209
7625
|
renderDeviceRequests();
|
|
5210
7626
|
if (!slash.el.hidden) renderSlashMenu();
|
|
5211
7627
|
renderInboxBadge();
|
|
@@ -5232,4 +7648,12 @@ function rerenderLanguage() {
|
|
|
5232
7648
|
|
|
5233
7649
|
onLanguageChange(rerenderLanguage);
|
|
5234
7650
|
|
|
7651
|
+
// Plan agent-ux todo 23: the first-run tour starts once per browser (localStorage zgw.tour.done); the settings button replays it.
|
|
7652
|
+
const tour = installTour(document, window, localStorage);
|
|
7653
|
+
$("tour-replay").onclick = () => {
|
|
7654
|
+
closeSettings();
|
|
7655
|
+
tour.start();
|
|
7656
|
+
};
|
|
7657
|
+
if (autoStartWanted(localStorage)) tour.start();
|
|
7658
|
+
|
|
5235
7659
|
connect();
|