@simmalugnt-se/payload-editor-assistant 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from "react";
3
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
4
+ import { carriesOver, conversationKey, readConversation, sameConversation, writeConversation, } from "./conversation-store.js";
4
5
  const AssistantContext = createContext(null);
5
- export function AssistantStateProvider({ children, shortcuts, collections, }) {
6
+ export function AssistantStateProvider({ children, shortcuts, collections, userId, }) {
6
7
  const [open, setOpenState] = useState(false);
7
8
  const [status, setStatus] = useState("idle");
8
9
  const [messages, setMessages] = useState([]);
@@ -20,6 +21,35 @@ export function AssistantStateProvider({ children, shortcuts, collections, }) {
20
21
  setOpenState(next);
21
22
  storeOpen(next);
22
23
  }, []);
24
+ // One conversation per document: switching documents stores the thread and loads the next one.
25
+ // An open proposal belongs to the form it was made for, so it does not follow; an unfinished
26
+ // request is stopped so its answer does not land in another document's thread.
27
+ const collectionSlug = bridge?.collectionSlug;
28
+ const globalSlug = bridge?.globalSlug;
29
+ const documentId = bridge?.documentId;
30
+ const hasBridge = bridge !== null;
31
+ const key = useMemo(() => conversationKey(userId, hasBridge ? { collectionSlug, globalSlug, documentId } : null), [userId, hasBridge, collectionSlug, globalSlug, documentId]);
32
+ const [loadedKey, setLoadedKey] = useState(null);
33
+ const abortRef = useRef(abortController);
34
+ abortRef.current = abortController;
35
+ useEffect(() => {
36
+ if (!key || sameConversation(key, loadedKey)) {
37
+ return;
38
+ }
39
+ abortRef.current?.abort();
40
+ setAbortController(null);
41
+ setStatus("idle");
42
+ setPendingApproval(null);
43
+ if (!carriesOver(loadedKey, key)) {
44
+ setMessages(readConversation(key));
45
+ }
46
+ setLoadedKey(key);
47
+ }, [key, loadedKey]);
48
+ useEffect(() => {
49
+ if (loadedKey && sameConversation(key, loadedKey)) {
50
+ writeConversation(loadedKey, messages);
51
+ }
52
+ }, [key, loadedKey, messages]);
23
53
  const stop = useCallback(() => {
24
54
  abortController?.abort();
25
55
  setAbortController(null);
@@ -0,0 +1,18 @@
1
+ import type { AssistantMessage, DocumentBridgeSnapshot } from "./assistant-context.tsx";
2
+ export type ConversationKey = {
3
+ kind: "stored";
4
+ key: string;
5
+ }
6
+ /** A create view: the thread lives in memory until the document is saved. */
7
+ | {
8
+ kind: "unsaved";
9
+ entity: string;
10
+ };
11
+ export declare function conversationKey(userId: string | number | undefined, bridge: Pick<DocumentBridgeSnapshot, "collectionSlug" | "globalSlug" | "documentId"> | null): ConversationKey | null;
12
+ export declare function sameConversation(a: ConversationKey | null, b: ConversationKey | null): boolean;
13
+ /** Saving a new document: the thread that created it becomes the document's own. */
14
+ export declare function carriesOver(from: ConversationKey | null, to: ConversationKey): boolean;
15
+ /** Storage can be unavailable or hold anything; a thread that cannot be read starts empty. */
16
+ export declare function readConversation(key: ConversationKey): AssistantMessage[];
17
+ export declare function writeConversation(key: ConversationKey, messages: AssistantMessage[]): void;
18
+ export declare function parseStored(raw: string, now: number): AssistantMessage[];
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Conversations are kept in the browser, one per user and document, plus one for everything outside
3
+ * a document (lists, dashboard). A document that is not saved yet has none of its own: its thread is
4
+ * carried over to the document once it gets an id.
5
+ */
6
+ const PREFIX = "payload-editor-assistant:chat";
7
+ const MAX_MESSAGES = 50;
8
+ const MAX_AGE_MS = 30 * 24 * 60 * 60_000;
9
+ export function conversationKey(userId, bridge) {
10
+ if (userId === undefined) {
11
+ return null;
12
+ }
13
+ const user = `${PREFIX}:${userId}`;
14
+ if (!bridge || (!bridge.collectionSlug && !bridge.globalSlug)) {
15
+ return { kind: "stored", key: `${user}:general` };
16
+ }
17
+ if (bridge.globalSlug) {
18
+ return { kind: "stored", key: `${user}:global:${bridge.globalSlug}` };
19
+ }
20
+ if (bridge.documentId === undefined || bridge.documentId === null || bridge.documentId === "") {
21
+ return { kind: "unsaved", entity: `collection:${bridge.collectionSlug}` };
22
+ }
23
+ return {
24
+ kind: "stored",
25
+ key: `${user}:collection:${bridge.collectionSlug}:${bridge.documentId}`,
26
+ };
27
+ }
28
+ export function sameConversation(a, b) {
29
+ if (!a || !b) {
30
+ return a === b;
31
+ }
32
+ return a.kind === "stored"
33
+ ? b.kind === "stored" && a.key === b.key
34
+ : b.kind === "unsaved" && a.entity === b.entity;
35
+ }
36
+ /** Saving a new document: the thread that created it becomes the document's own. */
37
+ export function carriesOver(from, to) {
38
+ return (from?.kind === "unsaved" &&
39
+ to.kind === "stored" &&
40
+ to.key.includes(`:${from.entity}:`) &&
41
+ readConversation(to).length === 0);
42
+ }
43
+ /** Storage can be unavailable or hold anything; a thread that cannot be read starts empty. */
44
+ export function readConversation(key) {
45
+ if (key.kind !== "stored") {
46
+ return [];
47
+ }
48
+ try {
49
+ const raw = window.localStorage.getItem(key.key);
50
+ return raw ? parseStored(raw, Date.now()) : [];
51
+ }
52
+ catch {
53
+ return [];
54
+ }
55
+ }
56
+ export function writeConversation(key, messages) {
57
+ if (key.kind !== "stored") {
58
+ return;
59
+ }
60
+ try {
61
+ if (messages.length === 0) {
62
+ window.localStorage.removeItem(key.key);
63
+ return;
64
+ }
65
+ const stored = { savedAt: Date.now(), messages: messages.slice(-MAX_MESSAGES) };
66
+ window.localStorage.setItem(key.key, JSON.stringify(stored));
67
+ }
68
+ catch {
69
+ // Not remembered (full or blocked storage); the thread still works for this page.
70
+ }
71
+ }
72
+ export function parseStored(raw, now) {
73
+ try {
74
+ const stored = JSON.parse(raw);
75
+ if (typeof stored.savedAt !== "number" || now - stored.savedAt > MAX_AGE_MS) {
76
+ return [];
77
+ }
78
+ return Array.isArray(stored.messages)
79
+ ? stored.messages.filter(isMessage).map((message) => ({
80
+ id: message.id,
81
+ role: message.role,
82
+ text: message.text,
83
+ candidates: Array.isArray(message.candidates)
84
+ ? message.candidates.filter(isCandidate)
85
+ : undefined,
86
+ }))
87
+ : [];
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ }
93
+ function isMessage(value) {
94
+ const message = value;
95
+ return (typeof message?.id === "string" &&
96
+ typeof message.text === "string" &&
97
+ (message.role === "user" || message.role === "assistant" || message.role === "system"));
98
+ }
99
+ function isCandidate(value) {
100
+ const candidate = value;
101
+ return typeof candidate?.href === "string" && typeof candidate.label === "string";
102
+ }
@@ -14,6 +14,16 @@ export declare const COPY: {
14
14
  readonly send: "Send";
15
15
  readonly stop: "Stop";
16
16
  readonly working: "Working…";
17
+ readonly steps: {
18
+ readonly thinking: "Thinking…";
19
+ readonly searching: "Searching the content…";
20
+ readonly reading: "Reading a document…";
21
+ readonly reading_form: "Reading the form…";
22
+ readonly proposing: "Preparing the change…";
23
+ readonly drafting: "Preparing the draft…";
24
+ readonly navigating: "Finding the page…";
25
+ readonly writing: "Writing the reply…";
26
+ };
17
27
  readonly selected: "Selected";
18
28
  readonly clearSelection: "Clear selection";
19
29
  readonly approve: "Approve";
@@ -25,6 +35,7 @@ export declare const COPY: {
25
35
  readonly previewHint: "Shown in the form and the preview. Nothing is saved yet.";
26
36
  readonly previewHintAutosave: "Shown in the form and the preview. The draft saves itself, but nothing is published.";
27
37
  readonly undoLosesEdits: "You edited the form after the suggestion. Undo removes those edits too.";
38
+ readonly refinedHint: "The list shows the latest adjustment. Undo takes the form back to before the first suggestion.";
28
39
  readonly requestFailed: "I could not complete that request.";
29
40
  readonly done: "Done.";
30
41
  readonly proposalFailed: "The proposal could not be applied.";
@@ -43,6 +54,7 @@ export declare const COPY: {
43
54
  readonly rate_limited: "Too many messages at once. Wait a moment and try again.";
44
55
  readonly timeout: "The assistant took too long to answer. Try again.";
45
56
  readonly provider_error: "The AI service did not answer. The server log has the cause.";
57
+ readonly no_model: "No AI model is set up yet, for example because an API key is missing. Ask whoever runs the site to add one.";
46
58
  readonly tool_denied: "That is not something the assistant may do here.";
47
59
  readonly stale_context: "The form changed in the meantime. Try again.";
48
60
  readonly aborted: "Stopped.";
@@ -64,6 +76,16 @@ export declare const COPY: {
64
76
  readonly send: "Skicka";
65
77
  readonly stop: "Stoppa";
66
78
  readonly working: "Arbetar…";
79
+ readonly steps: {
80
+ readonly thinking: "Tänker…";
81
+ readonly searching: "Söker bland innehållet…";
82
+ readonly reading: "Läser ett dokument…";
83
+ readonly reading_form: "Läser formuläret…";
84
+ readonly proposing: "Förbereder ändringen…";
85
+ readonly drafting: "Förbereder utkastet…";
86
+ readonly navigating: "Letar upp sidan…";
87
+ readonly writing: "Skriver svaret…";
88
+ };
67
89
  readonly selected: "Markerat";
68
90
  readonly clearSelection: "Ta bort markering";
69
91
  readonly approve: "Godkänn";
@@ -75,6 +97,7 @@ export declare const COPY: {
75
97
  readonly previewHint: "Syns i formuläret och förhandsvisningen. Inget är sparat än.";
76
98
  readonly previewHintAutosave: "Syns i formuläret och förhandsvisningen. Utkastet sparas automatiskt, men inget publiceras.";
77
99
  readonly undoLosesEdits: "Du har ändrat i formuläret efter förslaget. Ångra tar bort de ändringarna också.";
100
+ readonly refinedHint: "Listan visar den senaste justeringen. Ångra tar formuläret tillbaka till hur det var före första förslaget.";
78
101
  readonly requestFailed: "Jag kunde inte slutföra det.";
79
102
  readonly done: "Klart.";
80
103
  readonly proposalFailed: "Förslaget kunde inte genomföras.";
@@ -93,6 +116,7 @@ export declare const COPY: {
93
116
  readonly rate_limited: "För många meddelanden på en gång. Vänta en stund och försök igen.";
94
117
  readonly timeout: "Assistenten tog för lång tid på sig. Försök igen.";
95
118
  readonly provider_error: "AI-tjänsten svarade inte. Orsaken finns i serverloggen.";
119
+ readonly no_model: "Ingen AI-modell är inställd än, till exempel för att en API-nyckel saknas. Be den som sköter webbplatsen att lägga till en.";
96
120
  readonly tool_denied: "Det får assistenten inte göra här.";
97
121
  readonly stale_context: "Formuläret ändrades under tiden. Försök igen.";
98
122
  readonly aborted: "Stoppat.";
@@ -15,6 +15,16 @@ export const COPY = {
15
15
  send: "Send",
16
16
  stop: "Stop",
17
17
  working: "Working…",
18
+ steps: {
19
+ thinking: "Thinking…",
20
+ searching: "Searching the content…",
21
+ reading: "Reading a document…",
22
+ reading_form: "Reading the form…",
23
+ proposing: "Preparing the change…",
24
+ drafting: "Preparing the draft…",
25
+ navigating: "Finding the page…",
26
+ writing: "Writing the reply…",
27
+ },
18
28
  selected: "Selected",
19
29
  clearSelection: "Clear selection",
20
30
  approve: "Approve",
@@ -26,6 +36,7 @@ export const COPY = {
26
36
  previewHint: "Shown in the form and the preview. Nothing is saved yet.",
27
37
  previewHintAutosave: "Shown in the form and the preview. The draft saves itself, but nothing is published.",
28
38
  undoLosesEdits: "You edited the form after the suggestion. Undo removes those edits too.",
39
+ refinedHint: "The list shows the latest adjustment. Undo takes the form back to before the first suggestion.",
29
40
  requestFailed: "I could not complete that request.",
30
41
  done: "Done.",
31
42
  proposalFailed: "The proposal could not be applied.",
@@ -44,6 +55,7 @@ export const COPY = {
44
55
  rate_limited: "Too many messages at once. Wait a moment and try again.",
45
56
  timeout: "The assistant took too long to answer. Try again.",
46
57
  provider_error: "The AI service did not answer. The server log has the cause.",
58
+ no_model: "No AI model is set up yet, for example because an API key is missing. Ask whoever runs the site to add one.",
47
59
  tool_denied: "That is not something the assistant may do here.",
48
60
  stale_context: "The form changed in the meantime. Try again.",
49
61
  aborted: "Stopped.",
@@ -65,6 +77,16 @@ export const COPY = {
65
77
  send: "Skicka",
66
78
  stop: "Stoppa",
67
79
  working: "Arbetar…",
80
+ steps: {
81
+ thinking: "Tänker…",
82
+ searching: "Söker bland innehållet…",
83
+ reading: "Läser ett dokument…",
84
+ reading_form: "Läser formuläret…",
85
+ proposing: "Förbereder ändringen…",
86
+ drafting: "Förbereder utkastet…",
87
+ navigating: "Letar upp sidan…",
88
+ writing: "Skriver svaret…",
89
+ },
68
90
  selected: "Markerat",
69
91
  clearSelection: "Ta bort markering",
70
92
  approve: "Godkänn",
@@ -76,6 +98,7 @@ export const COPY = {
76
98
  previewHint: "Syns i formuläret och förhandsvisningen. Inget är sparat än.",
77
99
  previewHintAutosave: "Syns i formuläret och förhandsvisningen. Utkastet sparas automatiskt, men inget publiceras.",
78
100
  undoLosesEdits: "Du har ändrat i formuläret efter förslaget. Ångra tar bort de ändringarna också.",
101
+ refinedHint: "Listan visar den senaste justeringen. Ångra tar formuläret tillbaka till hur det var före första förslaget.",
79
102
  requestFailed: "Jag kunde inte slutföra det.",
80
103
  done: "Klart.",
81
104
  proposalFailed: "Förslaget kunde inte genomföras.",
@@ -94,6 +117,7 @@ export const COPY = {
94
117
  rate_limited: "För många meddelanden på en gång. Vänta en stund och försök igen.",
95
118
  timeout: "Assistenten tog för lång tid på sig. Försök igen.",
96
119
  provider_error: "AI-tjänsten svarade inte. Orsaken finns i serverloggen.",
120
+ no_model: "Ingen AI-modell är inställd än, till exempel för att en API-nyckel saknas. Be den som sköter webbplatsen att lägga till en.",
97
121
  tool_denied: "Det får assistenten inte göra här.",
98
122
  stale_context: "Formuläret ändrades under tiden. Försök igen.",
99
123
  aborted: "Stoppat.",
@@ -0,0 +1,22 @@
1
+ import type { PendingApproval } from "./assistant-context.tsx";
2
+ /** One server-side proposal and the form it was made for; keeping it is checked against that form. */
3
+ export type ProposalStep = {
4
+ approvalId: string;
5
+ nonce: string;
6
+ basis: Record<string, unknown>;
7
+ };
8
+ /**
9
+ * A suggestion the editor refined before keeping it ("shorter", "another tone"). The new proposal
10
+ * was made against the form with the earlier one in it, so the chain keeps the form from before the
11
+ * first step for undo, and every step so each can be kept in order against its own form.
12
+ */
13
+ export declare function refineProposal(open: PendingApproval, next: PendingApproval, basis: Record<string, unknown>): PendingApproval;
14
+ /** Every step of an applied proposal, oldest first. */
15
+ export declare function proposalSteps(pending: PendingApproval): ProposalStep[];
16
+ /**
17
+ * The same turn produced a second proposal. It was made against the same form as the one it
18
+ * replaces, so it takes that step's place: undo and the earlier steps stay as they were.
19
+ */
20
+ export declare function replaceLatestStep(current: PendingApproval, next: PendingApproval, basis: Record<string, unknown>): PendingApproval;
21
+ /** A form proposal with the form it would produce: the only kind shown in the form before keeping. */
22
+ export declare function isFormProposal(value: unknown): value is PendingApproval;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * A suggestion the editor refined before keeping it ("shorter", "another tone"). The new proposal
3
+ * was made against the form with the earlier one in it, so the chain keeps the form from before the
4
+ * first step for undo, and every step so each can be kept in order against its own form.
5
+ */
6
+ export function refineProposal(open, next, basis) {
7
+ if (!open.applied) {
8
+ return next;
9
+ }
10
+ const changed = new Set([
11
+ ...(open.preview?.changedPaths ?? []),
12
+ ...(next.preview?.changedPaths ?? []),
13
+ ]);
14
+ return {
15
+ ...next,
16
+ preview: next.preview && { ...next.preview, changedPaths: [...changed] },
17
+ earlier: proposalSteps(open),
18
+ applied: { ...open.applied, basis },
19
+ };
20
+ }
21
+ /** Every step of an applied proposal, oldest first. */
22
+ export function proposalSteps(pending) {
23
+ if (!pending.applied) {
24
+ return [];
25
+ }
26
+ return [
27
+ ...(pending.earlier ?? []),
28
+ { approvalId: pending.approvalId, nonce: pending.nonce, basis: pending.applied.basis },
29
+ ];
30
+ }
31
+ /**
32
+ * The same turn produced a second proposal. It was made against the same form as the one it
33
+ * replaces, so it takes that step's place: undo and the earlier steps stay as they were.
34
+ */
35
+ export function replaceLatestStep(current, next, basis) {
36
+ if (!current.applied) {
37
+ return next;
38
+ }
39
+ const changed = new Set([
40
+ ...(current.preview?.changedPaths ?? []),
41
+ ...(next.preview?.changedPaths ?? []),
42
+ ]);
43
+ return {
44
+ ...next,
45
+ preview: next.preview && { ...next.preview, changedPaths: [...changed] },
46
+ earlier: current.earlier,
47
+ applied: { ...current.applied, basis },
48
+ };
49
+ }
50
+ /** A form proposal with the form it would produce: the only kind shown in the form before keeping. */
51
+ export function isFormProposal(value) {
52
+ const pending = value;
53
+ return (pending?.capability === "form.propose" &&
54
+ typeof pending.approvalId === "string" &&
55
+ typeof pending.nonce === "string" &&
56
+ Boolean(pending.preview && typeof pending.preview.data === "object"));
57
+ }
@@ -1,3 +1,7 @@
1
1
  import type { Endpoint } from "payload";
2
2
  import type { ValidatedEditorAssistantOptions } from "../config/validate.ts";
3
3
  export declare function createChatEndpoint(options: ValidatedEditorAssistantOptions): Endpoint;
4
+ /** Only a short list of form paths; the prompt names them as data. */
5
+ export declare function sanitizeOpenProposal(value: unknown): {
6
+ changedPaths: string[];
7
+ } | undefined;
@@ -26,6 +26,8 @@ export function createChatEndpoint(options) {
26
26
  if (!beginInflight(actor)) {
27
27
  return Response.json({ error: "rate_limited" }, { status: 429 });
28
28
  }
29
+ // A streamed turn ends when its stream does, not when the handler returns.
30
+ let streaming = false;
29
31
  try {
30
32
  let body;
31
33
  try {
@@ -45,7 +47,8 @@ export function createChatEndpoint(options) {
45
47
  ? body.messages.filter(isChatMessage)
46
48
  : [{ role: "user", text: body.message }];
47
49
  const language = resolveChatLanguage(messages.filter((message) => message.role === "user").map((message) => message.text), nonEmpty(body.uiLanguage));
48
- const result = await runAgentTurn(req, options, {
50
+ const run = (onEvent) => runAgentTurn(req, options, {
51
+ onEvent,
49
52
  message: body.message,
50
53
  messages,
51
54
  bridge: {
@@ -58,9 +61,15 @@ export function createChatEndpoint(options) {
58
61
  uiLanguage: nonEmpty(body.uiLanguage)?.startsWith("sv") ? "sv" : "en",
59
62
  view: sanitizeAdminView(body.view, options),
60
63
  selection: typeof body.selection === "string" ? body.selection : undefined,
64
+ openProposal: sanitizeOpenProposal(body.openProposal),
61
65
  },
62
66
  formData: body.formData,
63
67
  });
68
+ if (body.stream === true) {
69
+ streaming = true;
70
+ return streamTurn(run, language, () => endInflight(actor));
71
+ }
72
+ const result = await run();
64
73
  if (!result.ok) {
65
74
  const status = result.error === "timeout" ? 504 : 503;
66
75
  return Response.json({ error: result.error, message: result.message }, { status });
@@ -68,11 +77,69 @@ export function createChatEndpoint(options) {
68
77
  return Response.json({ ...result, language });
69
78
  }
70
79
  finally {
71
- endInflight(actor);
80
+ if (!streaming) {
81
+ endInflight(actor);
82
+ }
72
83
  }
73
84
  },
74
85
  };
75
86
  }
87
+ /**
88
+ * One JSON object per line: `step` and `proposal` events while the turn runs, then `done` with the
89
+ * same result the JSON answer has, or `error`. Errors after the stream started cannot change the
90
+ * HTTP status, so they travel as an event.
91
+ */
92
+ function streamTurn(run, language, finished) {
93
+ const encoder = new TextEncoder();
94
+ const stream = new ReadableStream({
95
+ async start(controller) {
96
+ const send = (value) => {
97
+ try {
98
+ controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`));
99
+ }
100
+ catch {
101
+ // The editor stopped the request; the turn is aborted through the request signal.
102
+ }
103
+ };
104
+ try {
105
+ const result = await run(send);
106
+ send(result.ok
107
+ ? { type: "done", result: { ...result, language } }
108
+ : { type: "error", error: result.error, message: result.message });
109
+ }
110
+ catch {
111
+ send({ type: "error", error: "provider_error" });
112
+ }
113
+ finally {
114
+ finished();
115
+ try {
116
+ controller.close();
117
+ }
118
+ catch {
119
+ // Already closed by the client.
120
+ }
121
+ }
122
+ },
123
+ });
124
+ return new Response(stream, {
125
+ headers: {
126
+ "Content-Type": "application/x-ndjson; charset=utf-8",
127
+ "Cache-Control": "no-cache, no-transform",
128
+ "X-Accel-Buffering": "no",
129
+ },
130
+ });
131
+ }
132
+ /** Only a short list of form paths; the prompt names them as data. */
133
+ export function sanitizeOpenProposal(value) {
134
+ const paths = value?.changedPaths;
135
+ if (!Array.isArray(paths)) {
136
+ return undefined;
137
+ }
138
+ const changedPaths = paths
139
+ .filter((path) => typeof path === "string" && /^[\w.]{1,200}$/.test(path))
140
+ .slice(0, 50);
141
+ return changedPaths.length > 0 ? { changedPaths } : undefined;
142
+ }
76
143
  function nonEmpty(value) {
77
144
  return typeof value === "string" && value.trim() ? value : undefined;
78
145
  }
@@ -32,11 +32,26 @@ type ChatMessage = {
32
32
  role: "user" | "assistant" | "system";
33
33
  text: string;
34
34
  };
35
+ /** What the assistant is doing right now, for the panel to show while the turn runs. */
36
+ export type AgentStep = "thinking" | "searching" | "reading" | "reading_form" | "proposing" | "drafting" | "navigating" | "writing";
37
+ export type AgentEvent = {
38
+ type: "step";
39
+ step: AgentStep;
40
+ }
41
+ /** A form proposal, sent as soon as it exists so Admin can show it before the reply is written. */
42
+ | {
43
+ type: "proposal";
44
+ pendingApproval: NonNullable<Extract<AgentChatResult, {
45
+ ok: true;
46
+ }>["pendingApproval"]>;
47
+ };
48
+ export type AgentEventSink = (event: AgentEvent) => void;
35
49
  export declare function runAgentTurn(req: PayloadRequest, options: ValidatedEditorAssistantOptions, input: {
36
50
  message: string;
37
51
  messages?: ChatMessage[];
38
52
  bridge: ToolContext["bridge"];
39
53
  formData?: Record<string, unknown>;
54
+ onEvent?: AgentEventSink;
40
55
  }): Promise<AgentChatResult>;
41
56
  export declare function collectResult(text: string, result: {
42
57
  steps?: unknown;
@@ -7,11 +7,28 @@ import { editorFacingText } from "./voice.js";
7
7
  const MAX_MESSAGES = 20;
8
8
  const MAX_STEPS = 8;
9
9
  const TIMEOUT_MS = 60_000;
10
+ const STEP_FOR_TOOL = {
11
+ "schema.discover": "thinking",
12
+ "content.find": "searching",
13
+ "content.findById": "reading",
14
+ "form.read": "reading_form",
15
+ "form.propose": "proposing",
16
+ "draft.create": "drafting",
17
+ "admin.navigate": "navigating",
18
+ };
10
19
  export async function runAgentTurn(req, options, input) {
20
+ const emit = (event) => {
21
+ try {
22
+ input.onEvent?.(event);
23
+ }
24
+ catch {
25
+ // A listener that went away (closed stream) does not stop the turn.
26
+ }
27
+ };
11
28
  const model = await resolveModel(options, req.user?.id);
12
29
  if (!model) {
13
- logProviderError(req, "no model: the `model` option resolved to nothing for this user");
14
- return { ok: false, error: "provider_error" };
30
+ req.payload?.logger?.warn(`${PACKAGE_NAME}: no model: the \`model\` option resolved to nothing for this user`);
31
+ return { ok: false, error: "no_model" };
15
32
  }
16
33
  const ai = await loadAiSdk();
17
34
  if (!ai) {
@@ -29,10 +46,11 @@ export async function runAgentTurn(req, options, input) {
29
46
  const history = truncateMessages(input.messages ?? [{ role: "user", text: input.message }]);
30
47
  const captured = [];
31
48
  const previews = new Map();
32
- const tools = buildSdkTools(ai, ctx, captured, previews);
49
+ const tools = buildSdkTools(ai, ctx, captured, previews, emit);
33
50
  const turnContext = await loadTurnContext(req, options, input.bridge, input.formData);
34
51
  const system = buildSystemPrompt(options.instructions, input.bridge, turnContext);
35
52
  const sdkMessages = toSdkMessages(history);
53
+ emit({ type: "step", step: "thinking" });
36
54
  try {
37
55
  const first = await generate(ai, {
38
56
  model,
@@ -60,7 +78,7 @@ export async function runAgentTurn(req, options, input) {
60
78
  return collected;
61
79
  }
62
80
  const retryCaptured = [];
63
- const retryTools = buildSdkTools(ai, ctx, retryCaptured, previews);
81
+ const retryTools = buildSdkTools(ai, ctx, retryCaptured, previews, emit);
64
82
  const retry = await generate(ai, {
65
83
  model,
66
84
  system,
@@ -168,7 +186,7 @@ function isToolOutcome(value) {
168
186
  const record = value;
169
187
  return typeof record.ok === "boolean" && typeof record.capability === "string";
170
188
  }
171
- function buildSdkTools(ai, ctx, captured, previews) {
189
+ function buildSdkTools(ai, ctx, captured, previews, emit) {
172
190
  const tools = {};
173
191
  for (const capability of Object.keys(TOOL_JSON_SCHEMAS)) {
174
192
  const schema = TOOL_JSON_SCHEMAS[capability];
@@ -180,6 +198,7 @@ function buildSdkTools(ai, ctx, captured, previews) {
180
198
  execute: async (raw) => {
181
199
  try {
182
200
  const input = raw && typeof raw === "object" ? raw : {};
201
+ emit({ type: "step", step: STEP_FOR_TOOL[capability] ?? "thinking" });
183
202
  const executed = await executeTool(fromProviderToolName(name), input, ctx);
184
203
  // The whole form with the change applied is for Admin, not another round through the model.
185
204
  const { preview, ...outcome } = executed.ok
@@ -187,7 +206,12 @@ function buildSdkTools(ai, ctx, captured, previews) {
187
206
  : { ...executed, preview: undefined };
188
207
  if (preview && executed.ok && executed.pendingApproval) {
189
208
  previews.set(executed.pendingApproval.approvalId, preview);
209
+ emit({ type: "proposal", pendingApproval: { ...executed.pendingApproval, preview } });
190
210
  }
211
+ emit({
212
+ type: "step",
213
+ step: executed.ok && executed.pendingApproval ? "writing" : "thinking",
214
+ });
191
215
  captured.push(outcome);
192
216
  return outcome;
193
217
  }
@@ -12,9 +12,9 @@ export function offeredValue(text, request) {
12
12
  }
13
13
  // A request for a change, or a yes to one the assistant asked about. Letter lookarounds rather
14
14
  // than \b, which treats å, ä and ö as word boundaries.
15
- const ASKS_FOR_CHANGE = /(?<!\p{L})(make|change|rewrite|write|add|replace|shorten|tighten|fill|translate|update|improve|set|rename|remove|delete|yes|yeah|sure|ok|okay|do it|go ahead|gör|göra|ändra|skriv|skriva|lägg|lägga|byt|byta|korta|fyll|fylla|översätt|uppdatera|förbättra|sätt|ta bort|ja|japp|kör|gärna)(?!\p{L})/iu;
15
+ const ASKS_FOR_CHANGE = /(?<!\p{L})(make|change|rewrite|write|add|replace|shorten|tighten|fill|translate|update|improve|set|rename|shorter|longer|simpler|remove|delete|yes|yeah|sure|ok|okay|do it|go ahead|gör|göra|ändra|skriv|skriva|lägg|lägga|byt|byta|korta|fyll|fylla|översätt|uppdatera|förbättra|sätt|ta bort|kortare|längre|enklare|ja|japp|kör|gärna)(?!\p{L})/iu;
16
16
  const QUOTED = /["“”«»][^"“”«»]{2,}["“”«»]|'[^']{2,}'/;
17
- const CLAIMED_CHANGE = /godkänn|godkann|approve|rubriken är nu|the headline is now|sparas den på sidan|fälten i på sidan|pending form change|i've applied|i have applied/i;
17
+ const CLAIMED_CHANGE = /godkänn|godkann|approve|rubriken är nu|the headline is now|sparas den på sidan|fälten i på sidan|pending form change|i've applied|i have applied|har lagt in|i've put|i have put/i;
18
18
  const ASKED_TO_MUTATE = /ska jag (lägga|fylla|ändra|sätta|uppdatera)|should i (put|fill|change|set|update|apply)|shall i (put|fill|change|set|update|apply)|want me to (put|fill|change|set|update|apply)/i;
19
19
  export function needsProposeRetry(input) {
20
20
  if (!input.hasOpenDocument || input.pendingApproval || input.navigate) {
@@ -7,7 +7,7 @@ export function buildSystemPrompt(instructions, bridge, turnContext) {
7
7
  "Two to five short sentences. Use visible field labels (Title, Headline, Summary).",
8
8
  "Never ask whether you should proceed. When you have the new values, call form.propose in the same turn.",
9
9
  "If the editor confirms a change you already described, call form.propose with those values. Do not ask again.",
10
- "Do not claim the form already has the new values. Do not tell the editor to approve; the panel does that when the proposal exists.",
10
+ 'When you call form.propose, the change is shown in the form and preview for the editor to keep or undo. Then say in one short sentence what you changed ("Här är en kortare rubrik."). Never say it is saved, published or kept, and do not tell them to approve or click anything; the panel does that. Without a form.propose call, do not claim the form has new values.',
11
11
  "If form.propose fails, say you could not apply it and they can try again. Never tell them to copy-paste.",
12
12
  "When the editor asks you to create a document, call draft.create in the same turn with sensible content based on their request. Do not ask for a title or summary first; they review the draft before it is created.",
13
13
  "Never save, publish, unpublish, delete, bulk-mutate, or upload files.",
@@ -62,7 +62,7 @@ function viewLine(turnContext) {
62
62
  function thisTurnBlock(bridge, turnContext) {
63
63
  const locale = bridge?.locale ?? turnContext?.open?.locale;
64
64
  const chat = bridge?.language ?? locale;
65
- if (!chat && !turnContext?.form) {
65
+ if (!chat && !turnContext?.form && !bridge?.openProposal) {
66
66
  return "";
67
67
  }
68
68
  const lines = [
@@ -81,6 +81,9 @@ function thisTurnBlock(bridge, turnContext) {
81
81
  ? `Selected field: ${selection.field.path}.`
82
82
  : `Selected: ${selection.path}; its fields are under that path in the form.`);
83
83
  }
84
+ if (bridge?.openProposal) {
85
+ lines.push(`The form shows your earlier suggestion, not kept yet; it changed ${bridge.openProposal.changedPaths.join(", ")}. Those values are in the form now. If the editor asks to adjust it (shorter, another tone, other wording, one more field), call form.propose now with the adjusted values; it replaces the suggestion in the preview. Do not say it is saved or kept.`);
86
+ }
84
87
  const form = turnContext?.form;
85
88
  if (form) {
86
89
  const empty = listEmptyTextPaths(form);
@@ -18,6 +18,10 @@ export type ToolBridge = {
18
18
  view?: AdminView;
19
19
  /** Form path of what the editor last clicked or focused in the open form, unverified. */
20
20
  selection?: string;
21
+ /** A suggestion shown in the form that the editor has not kept yet: the paths it changed. */
22
+ openProposal?: {
23
+ changedPaths: string[];
24
+ };
21
25
  };
22
26
  export type ToolContext = {
23
27
  req: PayloadRequest;