Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 20 additions & 20 deletions assets/dist/roundtable.js

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions assets/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ export function resetChat() {
return post('/reset', {});
}

/**
* Fetch the current chat's stored history for rehydrate.
* @returns {Promise<{events:Array}|{error:{kind:string}}>}
*/
export function fetchHistory() {
return get('/history');
}

/**
* Stream a chat turn. Calls onEvent per decoded event; onError on failure; onDone at end.
* @param {string} text
Expand Down
22 changes: 21 additions & 1 deletion assets/src/components.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
import { h } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { renderMarkdown } from './markdown.js';
import { streamMessage, sendMessage, resetChat, createDraft, publishCase } from './api.js';
import {
streamMessage, sendMessage, resetChat, createDraft, publishCase, fetchHistory,
} from './api.js';
import { eventsToTurn } from './events.js';
import { canDraftTopic, turnsToConversation } from './conversation.js';
import { rehydratedTurns } from './rehydrate.js';
import { PublishDialog } from './publish-dialog.jsx';
import { AgentAvatar } from './avatars.jsx';
import { agentDisplayName, initialMessage, projectDisplayName } from './config.js';
Expand Down Expand Up @@ -433,6 +436,7 @@ export function ChatPanel({ resetNonce = 0, onTopicPublished }) {
const composerRef = useRef(null);
const threadRef = useRef(null);
const stickToBottomRef = useRef(true);
const userInteractedRef = useRef(false);

useEffect(() => {
if (resetNonce > 0) newTopic();
Expand Down Expand Up @@ -465,20 +469,36 @@ export function ChatPanel({ resetNonce = 0, onTopicPublished }) {
if (el && stickToBottomRef.current) el.scrollTop = el.scrollHeight;
}, [turns]);

useEffect(() => {
let cancelled = false;
(async () => {
const res = await fetchHistory();
if (cancelled) return;
if (userInteractedRef.current) return;
setTurns((cur) => {
const next = rehydratedTurns(cur[0], res);
return next ?? cur;
});
})();
return () => { cancelled = true; };
}, []);

function handleSignal(ev) {
const next = reduceSignal({ topicWorthy, topicDraft }, ev);
setTopicWorthy(next.topicWorthy);
setTopicDraft(next.topicDraft);
}

async function send(textOverride) {
userInteractedRef.current = true;
const text = (textOverride ?? composer).trim();
if (!text || busy || draftBusy) return;
setComposer('');
await sendTurn(text, { setTurns, setBusy, onSignal: handleSignal });
}

async function newTopic() {
userInteractedRef.current = true;
if (busy || draftBusy) return;
setBusy(true);
const res = await resetChat();
Expand Down
56 changes: 56 additions & 0 deletions assets/src/rehydrate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// assets/src/rehydrate.js — reconstruct ChatPanel `turns` from stored hub events.
import { eventsToTurn } from './events.js';
import { labelStep } from './steps.js';

/**
* Split a flat, ordered event list (as returned by /history) into the panel's
* `turns` model. Each `user_text` becomes a completed user turn; the agent
* events that follow it (up to and including a `result`) fold into one agent
* turn via the shared `eventsToTurn` reducer.
* @param {Array<{type:string, data?:object}>} events
* @returns {Array<object>} turns
*/
export function eventsToTurns(events) {
const list = Array.isArray(events) ? events : [];
const turns = [];
let agentBuf = [];

const flushAgent = () => {
if (!agentBuf.length) return;
const t = eventsToTurn(agentBuf);
turns.push({
role: 'agent',
reply: t.reply,
steps: t.steps.map((s) => ({ summary: labelStep(s) })),
error: t.error,
done: true,
});
agentBuf = [];
};

for (const ev of list) {
if (ev.type === 'user_text') {
flushAgent();
turns.push({ role: 'user', reply: ev.data?.text || '', steps: [], done: true, error: false });
} else {
agentBuf.push(ev);
}
}
flushAgent();
return turns;
}

/**
* Compute the panel's initial turns from a /history response, or null to keep
* the default greeting. Non-empty history → the greeting (chips off) followed
* by the reconstructed turns; empty history or an error → null (no change).
* @param {object} greeting The default greeting turn.
* @param {{events?:Array, error?:object}} res The /history response.
* @returns {Array<object>|null}
*/
export function rehydratedTurns(greeting, res) {
if (!res || res.error) return null;
const turns = eventsToTurns(res.events);
if (!turns.length) return null;
return [{ ...greeting, introChips: false }, ...turns];
}
28 changes: 28 additions & 0 deletions assets/test/chatpanel-rehydrate.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// assets/test/chatpanel-rehydrate.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { rehydratedTurns } from '../src/rehydrate.js';

const greeting = {
role: 'agent', reply: 'Hi', steps: [], error: false, introChips: true,
};

test('non-empty history renders greeting without chips, then history', () => {
const out = rehydratedTurns(greeting, {
events: [
{ type: 'user_text', data: { text: 'hi' } },
{ type: 'assistant_text', data: { text: 'hey' } },
{ type: 'result', data: { is_error: false } },
],
});
assert.equal(out[0].introChips, false);
assert.equal(out.length, 3); // greeting + user + agent
});

test('empty history keeps the default greeting with chips', () => {
assert.equal(rehydratedTurns(greeting, { events: [] }), null);
});

test('error response keeps the default greeting', () => {
assert.equal(rehydratedTurns(greeting, { error: { kind: 'http_502' } }), null);
});
33 changes: 33 additions & 0 deletions assets/test/rehydrate.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// assets/test/rehydrate.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { eventsToTurns } from '../src/rehydrate.js';

test('splits user_text and agent turns', () => {
const turns = eventsToTurns([
{ type: 'user_text', data: { text: 'hi' } },
{ type: 'assistant_text', data: { text: 'hey there' } },
{ type: 'result', data: { is_error: false } },
{ type: 'user_text', data: { text: 'more?' } },
{ type: 'assistant_text', data: { text: 'sure' } },
{ type: 'result', data: { is_error: false } },
]);
assert.equal(turns.length, 4);
assert.deepEqual(turns.map((t) => t.role), ['user', 'agent', 'user', 'agent']);
assert.equal(turns[1].reply, 'hey there');
assert.equal(turns[3].reply, 'sure');
assert.ok(turns.every((t) => t.done === true));
});

test('empty history yields no turns', () => {
assert.deepEqual(eventsToTurns([]), []);
});

test('trailing agent events without a result still form a turn', () => {
const turns = eventsToTurns([
{ type: 'user_text', data: { text: 'hi' } },
{ type: 'assistant_text', data: { text: 'partial' } },
]);
assert.equal(turns.length, 2);
assert.equal(turns[1].reply, 'partial');
});
103 changes: 103 additions & 0 deletions src/HistoryController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);

namespace EpicWP\Roundtable;

/**
* The nonce-, capability- and licence-gated REST route that resolves the current user's
* stored chat history.
*
* Registers `GET /roundtable/v1/history`. Its permission callback enforces the same three
* gates as {@see MessageController}; `handle` resolves the user's session and returns the
* hub's stored events for it — or an empty list, without calling the hub, when the session
* hasn't been primed by a first turn yet.
*/
final class HistoryController {
/** The REST route namespace. */
public const ROUTE_NAMESPACE = 'roundtable/v1';
/** The REST route path (under the namespace). */
public const ROUTE = '/history';

/**
* Creates the controller.
*
* @param Config $config The SDK configuration (licence gate lives on `$config->consumer`).
* @param HubClient $hubClient The client used to fetch the chat's stored history.
*/
public function __construct(
private Config $config,
private HubClient $hubClient,
) {
}

/** Register the REST route. Call on the `rest_api_init` action. */
public function register(): void {
\register_rest_route(
self::ROUTE_NAMESPACE,
self::ROUTE,
array(
'callback' => array( $this, 'handle' ),
'methods' => 'GET',
'permission_callback' => array( $this, 'permission' ),
),
);
}

/**
* Gate a request: valid REST nonce, the required capability, and a valid licence.
*
* @param \WP_REST_Request<array<string, mixed>> $request The incoming request.
*
* @return bool True to proceed; false to refuse (403) before any hub call.
*/
public function permission( // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint -- `\WP_REST_Request<array<string, mixed>>` is a PHPStan generic; the native param type stays `\WP_REST_Request`.
\WP_REST_Request $request,
): bool {
return \EpicWP\Roundtable\Rest\Gate::permits( $this->config, $request );
}

/**
* Return the current user's chat history.
*
* Resolves the session from the current user; if it hasn't been primed by a first turn
* yet, returns an empty events list without calling the hub.
*
* @param \WP_REST_Request<array<string, mixed>> $request The incoming request.
*
* @return \WP_REST_Response The events payload, or a structured error.
*/
public function handle( // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint,Generic.CodeAnalysis.UnusedFunctionParameter.Found -- `\WP_REST_Request<array<string, mixed>>` is a PHPStan generic; REST callback signature.
\WP_REST_Request $request,
): \WP_REST_Response {
$session = new Session( (int) \wp_get_current_user()->ID );
if ( ! $session->isPrimed() ) {
return new \WP_REST_Response( array( 'events' => array() ), 200 );
}

try {
$events = $this->hubClient->getHistory( $session->chatId() );
} catch ( \EpicWP\Roundtable\HubException $e ) {
return new \WP_REST_Response(
array( 'error' => array( 'kind' => $e->kind() ) ),
$this->statusFor( $e ),
);
}

return new \WP_REST_Response( array( 'events' => $events ), 200 );
}

/**
* Maps a hub exception's kind to the HTTP status the response should carry.
*
* @param \EpicWP\Roundtable\HubException $error The caught exception.
*
* @return int The HTTP status code.
*/
private function statusFor( \EpicWP\Roundtable\HubException $error ): int {
return match ( $error->kind() ) {
\EpicWP\Roundtable\HubException::BLOCKED => 403,
\EpicWP\Roundtable\HubException::OVER_QUOTA => 429,
default => 502,
};
}
}
16 changes: 16 additions & 0 deletions src/HubClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,22 @@ public function getProjectConfig(): array {
return $this->getJsonObject( $url );
}

/**
* Fetch a chat's stored history for rehydrate.
*
* @param string $chatId The consumer chat id (resumes/reads the hub chat).
*
* @return list<array{type: string, data: array<string, mixed>}> The events, in order.
*
* @throws \EpicWP\Roundtable\HubException On transport failure or a non-2xx response.
*/
public function getHistory( string $chatId ): array {
$url = ( $this->config->hubBaseUrl ?? self::HUB_URL ) . '/chats/' . $chatId . '/messages';
$body = $this->getJsonObject( $url );
$events = $body['events'] ?? array();
return \is_array( $events ) ? \array_values( $events ) : array();
}

/**
* List public cases from the hub browse API.
*
Expand Down
2 changes: 2 additions & 0 deletions src/Roundtable.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public static function mount( Config $config, string $menuSlug ): void {
\add_action( 'admin_enqueue_scripts', array( $page, 'enqueue' ) );

$message = new MessageController( $config, $hub );
$history = new HistoryController( $config, $hub );
$stream = new StreamController( $config, $hub, new Http\CurlStreamingTransport() );
$session = new SessionController( $config );
$cases = new CasesController( $config, $hub );
Expand All @@ -36,6 +37,7 @@ public static function mount( Config $config, string $menuSlug ): void {
$draft = new CaseDraftController( $config, $hub );
$publish = new PublishController( $config, $hub );
\add_action( 'rest_api_init', array( $message, 'register' ) );
\add_action( 'rest_api_init', array( $history, 'register' ) );
\add_action( 'rest_api_init', array( $stream, 'register' ) );
\add_action( 'rest_api_init', array( $session, 'register' ) );
\add_action( 'rest_api_init', array( $cases, 'register' ) );
Expand Down
Loading
Loading