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.
@@ -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
- async function ask(ctx: PageContext, r: Region, message: string) {
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) return refuse(`ai assistant: region ${r.id} on page ${ctx.page.page_no}`);
519
- const c = configOf(r);
520
- const msg = e instanceof InputError ? e.message
521
- : e instanceof AiError ? (c.error_message && e.kind !== 'limit' ? ctx.locale.tr(c.error_message) : e.message)
522
- : await publicError(ctx, e, 'AI assistant');
523
- if (e instanceof AiError) dbg(ctx, 1, 'ai', `AI assistant: ${e.kind}: ${e.message}`);
524
- ctx.session.state.__FLASH_ERROR = msg;
525
- if (typeof body.message === 'string') ctx.session.state[`__AIDRAFT${r.id}`] = body.message.slice(0, MAX_MESSAGE_CHARS);
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
- const q = typeof body.params === 'string' ? new URLSearchParams(body.params.slice(0, 4000)).toString() : '';
529
- return reply.redirect(`${ctx.base}/${ctx.page.page_no}${q ? `?${q}` : ''}#R${r.id}`, 303);
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) =>