pgkiln 0.79.0 → 0.80.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/144_workflow_migrate_branches.sql +186 -0
- package/db/migrations/145_remote_databases.sql +41 -0
- package/docs/apex-feature-parity.md +5 -5
- package/docs/guide/03-builder.md +32 -0
- package/docs/guide/04-pages-and-regions.md +8 -2
- package/docs/guide/06-processing.md +13 -2
- package/docs/guide/15-extensions.md +1 -1
- package/package.json +1 -1
- package/public/app.css +2 -0
- package/public/app.js +83 -0
- package/src/ai/chat.ts +32 -5
- package/src/app.ts +2 -0
- package/src/builder/home.ts +1 -0
- package/src/builder/remote-databases.ts +215 -0
- package/src/builder/workflow-migrate.ts +19 -13
- package/src/remote-db.ts +145 -0
- package/src/runtime/assistant.ts +54 -17
package/src/runtime/assistant.ts
CHANGED
|
@@ -382,7 +382,7 @@ export async function renderAssistant(ctx: PageContext, r: Region): Promise<Raw>
|
|
|
382
382
|
}
|
|
383
383
|
const welcome = c.welcome ? substitute(ctx.locale.tr(c.welcome), ctx, (v) => v) : '';
|
|
384
384
|
const usable = mayUse(ctx, r);
|
|
385
|
-
return html`<div class="assistant" data-assistant="${r.id}">
|
|
385
|
+
return html`<div class="assistant" data-assistant="${r.id}" data-you="${t('assistant.you')}" data-who="${t('assistant.assistant')}" data-used="${t('assistant.used', { tools: '{tools}' })}">
|
|
386
386
|
<ol class="assistant-log" aria-label="${t('assistant.conversation')}" aria-live="polite">
|
|
387
387
|
${welcome ? html`<li class="assistant-msg assistant-msg-assistant"><span class="assistant-who">${t('assistant.assistant')}</span><div class="assistant-text">${formatAnswer(welcome)}</div></li>` : ''}
|
|
388
388
|
${turns.map((x) => html`<li class="assistant-msg assistant-msg-${x.role === 'user' ? 'user' : 'assistant'}">
|
|
@@ -427,7 +427,10 @@ async function contextData(ctx: PageContext, c: AssistantConfig, question: strin
|
|
|
427
427
|
return parts;
|
|
428
428
|
}
|
|
429
429
|
|
|
430
|
-
|
|
430
|
+
/** (sprint 148, WS-4) where a streamed answer goes while the model writes it */
|
|
431
|
+
interface Live { text(delta: string): void; tool(name: string): void }
|
|
432
|
+
|
|
433
|
+
async function ask(ctx: PageContext, r: Region, message: string, live?: Live) {
|
|
431
434
|
const t = ctx.locale.t;
|
|
432
435
|
const c = configOf(r);
|
|
433
436
|
const agent = c.agent ? await agentFor(ctx.app.id, c.agent) : null;
|
|
@@ -482,6 +485,7 @@ async function ask(ctx: PageContext, r: Region, message: string) {
|
|
|
482
485
|
maxRounds: agent ? (agent.agent.max_steps ?? DEFAULT_STEPS) : (c.max_rounds ?? DEFAULT_ROUNDS),
|
|
483
486
|
runTool: runLogged,
|
|
484
487
|
...(agent ? { effort: agent.agent.effort ?? null, maxTokens: agent.agent.max_tokens ?? null } : {}),
|
|
488
|
+
...(live ? { onText: live.text, onTool: live.tool } : {}),
|
|
485
489
|
}, { appId: ctx.app.id, pageNo: ctx.page.page_no, user: ctx.user, source: 'assistant', debug: (l, x) => dbg(ctx, l, 'ai', x) });
|
|
486
490
|
dbg(ctx, 9, 'ai', () => `answer: ${res.text}`);
|
|
487
491
|
const turns: Turn[] = [...(conv?.turns ?? []), { role: 'user', text: message },
|
|
@@ -499,8 +503,11 @@ async function ask(ctx: PageContext, r: Region, message: string) {
|
|
|
499
503
|
if (extras.actions.length) ctx.session.state[`__AIACT${r.id}`] = JSON.stringify(extras.actions);
|
|
500
504
|
}
|
|
501
505
|
|
|
506
|
+
/** (sprint 148) the browser asked for the answer as it is written (app.js sends this with JavaScript) */
|
|
507
|
+
const wantsStream = (req: Req) => String(req.headers.accept ?? '').includes('text/event-stream');
|
|
508
|
+
|
|
502
509
|
export async function assistantRoutes(app: FastifyInstance) {
|
|
503
|
-
const action = async (req: Req, reply: FastifyReply, run: (ctx: PageContext, r: Region) => Promise<void
|
|
510
|
+
const action = async (req: Req, reply: FastifyReply, run: (ctx: PageContext, r: Region, live?: Live) => Promise<void>, stream = false) => {
|
|
504
511
|
const ctx = await loadContext(req, reply);
|
|
505
512
|
if (!ctx) return;
|
|
506
513
|
const body = req.body ?? {};
|
|
@@ -512,30 +519,60 @@ export async function assistantRoutes(app: FastifyInstance) {
|
|
|
512
519
|
const r = ctx.page.regions.find((x) => x.id === Number(req.params.id) && x.type === 'ai_assistant');
|
|
513
520
|
if (!r) return refuse(`ai assistant: region ${req.params.id} on page ${ctx.page.page_no}`);
|
|
514
521
|
if (!mayUse(ctx, r)) return refuse(`ai assistant: region ${r.id}: not signed in`);
|
|
522
|
+
const q = typeof body.params === 'string' ? new URLSearchParams(body.params.slice(0, 4000)).toString() : '';
|
|
523
|
+
const url = `${ctx.base}/${ctx.page.page_no}${q ? `?${q}` : ''}#R${r.id}`;
|
|
524
|
+
// (sprint 148, WS-4) a streamed answer: server-sent events with the text as the model writes it (JSON strings,
|
|
525
|
+
// shown as text by app.js), each tool before it runs, then "done" with the page to load: it shows the saved
|
|
526
|
+
// answer, its links and actions, or the error, exactly as the redirect below does without JavaScript.
|
|
527
|
+
let live: Live | undefined;
|
|
528
|
+
let out: FastifyReply['raw'] | null = null;
|
|
529
|
+
if (stream) {
|
|
530
|
+
reply.hijack();
|
|
531
|
+
out = reply.raw;
|
|
532
|
+
out.writeHead(200, {
|
|
533
|
+
'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-store', 'x-accel-buffering': 'no',
|
|
534
|
+
'x-content-type-options': 'nosniff', 'referrer-policy': 'same-origin',
|
|
535
|
+
});
|
|
536
|
+
const raw = out;
|
|
537
|
+
const send = (event: string, data: unknown) => {
|
|
538
|
+
if (!raw.writableEnded && !raw.destroyed) raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
539
|
+
};
|
|
540
|
+
live = { text: (d) => send('text', d), tool: (n) => send('tool', n) };
|
|
541
|
+
}
|
|
542
|
+
let forbidden = false;
|
|
515
543
|
try {
|
|
516
|
-
await run(ctx, r);
|
|
544
|
+
await run(ctx, r, live);
|
|
517
545
|
} catch (e) {
|
|
518
|
-
if (e instanceof Forbidden)
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
546
|
+
if (e instanceof Forbidden) {
|
|
547
|
+
if (!out) return refuse(`ai assistant: region ${r.id} on page ${ctx.page.page_no}`);
|
|
548
|
+
logActivity({ appId: ctx.app.id, pageNo: ctx.page.page_no, username: ctx.user, event: 'forbidden', ip: ctx.ip, detail: `ai assistant: region ${r.id} on page ${ctx.page.page_no}` });
|
|
549
|
+
ctx.session.state.__FLASH_ERROR = ctx.locale.t('error.access_denied');
|
|
550
|
+
forbidden = true;
|
|
551
|
+
} else {
|
|
552
|
+
const c = configOf(r);
|
|
553
|
+
const msg = e instanceof InputError ? e.message
|
|
554
|
+
: e instanceof AiError ? (c.error_message && e.kind !== 'limit' ? ctx.locale.tr(c.error_message) : e.message)
|
|
555
|
+
: await publicError(ctx, e, 'AI assistant');
|
|
556
|
+
if (e instanceof AiError) dbg(ctx, 1, 'ai', `AI assistant: ${e.kind}: ${e.message}`);
|
|
557
|
+
ctx.session.state.__FLASH_ERROR = msg;
|
|
558
|
+
if (typeof body.message === 'string') ctx.session.state[`__AIDRAFT${r.id}`] = body.message.slice(0, MAX_MESSAGE_CHARS);
|
|
559
|
+
}
|
|
526
560
|
}
|
|
527
561
|
await saveState(ctx.session);
|
|
528
|
-
|
|
529
|
-
|
|
562
|
+
if (out) {
|
|
563
|
+
if (!out.writableEnded && !out.destroyed) out.end(`event: done\ndata: ${JSON.stringify({ url, ...(forbidden ? { forbidden: true } : {}) })}\n\n`);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
return reply.redirect(url, 303);
|
|
530
567
|
};
|
|
531
568
|
|
|
532
569
|
app.post('/a/:alias/:page/assistant/:id/send', async (req: Req, reply) =>
|
|
533
|
-
action(req, reply, async (ctx, r) => {
|
|
570
|
+
action(req, reply, async (ctx, r, live) => {
|
|
534
571
|
const message = typeof req.body?.message === 'string' ? req.body.message.replace(/\r\n?/g, '\n').trim() : '';
|
|
535
572
|
if (!message) throw new InputError(ctx.locale.t('assistant.empty'));
|
|
536
573
|
if (message.length > MAX_MESSAGE_CHARS) throw new InputError(ctx.locale.t('assistant.too_long', { max: String(MAX_MESSAGE_CHARS) }));
|
|
537
|
-
await ask(ctx, r, message);
|
|
538
|
-
}),
|
|
574
|
+
await ask(ctx, r, message, live);
|
|
575
|
+
}, wantsStream(req)),
|
|
539
576
|
);
|
|
540
577
|
|
|
541
578
|
app.post('/a/:alias/:page/assistant/:id/clear', async (req: Req, reply) =>
|