This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
KnowledgeBase/files/custom/Espo/Modules/KnowledgeBase/EntryPoints/KnowledgeBaseAskStream.php
T
chaim ba75463661 feat(kb): topic picker + topic-scoped client (Phase 1, no manifest bump)
Wires the EspoCRM extension to the multi-topic backend introduced in
shira-hermes commit f03721e. The KB page header now shows a topic
<select> ('נושא:'); the user's selection persists in localStorage
('kb-topic') and is sent with every search/ask/sources call so cross-
domain bleed-over is impossible. Single seeded topic ('ביטוח לאומי')
means existing users see no functional change until a second topic is
added in Phase 4 (Task Master #15).

- Resources/routes.json: GET /KnowledgeBase/action/topics.
- Controllers/KnowledgeBase.php: actionTopics + topicId passthrough on
  search/sources/ask. Numeric coercion for the request payload.
- Services/KnowledgeBaseService.php: listTopics(); search/listSources/
  ask take an optional topicId and forward it as topic_id to upstream.
- EntryPoints/KnowledgeBaseAskStream.php: ?topicId=N becomes topic_id
  in the JSON body sent to /kb/ask/stream.
- res/templates/kb/index.tpl: topic <select> in the page header,
  with a kb-topic-name-suffix span that JS fills with the active name.
- src/views/kb/index.js: module-level _topics cache + LS_TOPIC. setup
  reads localStorage; afterRender gates the picker fill + sources fetch
  on topics being loaded so a stale id from a deleted topic doesn't
  fire a 400-ing /sources call. switchTopic clears _activeAsk/Search +
  _lastAsk/Search + sessionStorage entries + this._sources, then
  reRenders. Cross-topic guard in afterRender drops cached state whose
  topicId !== this.topicId. _activeAsk / _lastAsk / _activeSearch /
  _lastSearch all carry topicId. EventSource URL gains &topicId.

Refs Task Master #12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:15:36 +00:00

141 lines
5.2 KiB
PHP

<?php
namespace Espo\Modules\KnowledgeBase\EntryPoints;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\EntryPoint\EntryPoint;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\InjectableFactory;
use Espo\Entities\User;
use Espo\Modules\KnowledgeBase\Services\KnowledgeBaseService;
/**
* Streams Server-Sent Events from shira-hermes /kb/ask/stream to the
* browser's EventSource. The browser opens this with a GET (EventSource
* has no POST), passes the question via ?message=, and we forward it
* to shira-hermes with the firm-wide API key — the credential never
* leaves the EspoCRM server.
*
* Response is text/event-stream with no buffering. We bypass EspoCRM's
* Response wrapper entirely (echo + flush + exit) because the framework
* is wired for buffered JSON responses, not infinite streams.
*
* Access: `?entryPoint=KnowledgeBaseAskStream&message=<urlencoded>`
*/
class KnowledgeBaseAskStream implements EntryPoint
{
public function __construct(
private InjectableFactory $injectableFactory,
private User $user,
) {}
public function run(Request $request, Response $response): void
{
if ($this->user->isPortal()) {
throw new Forbidden('Portal users have no KB access.');
}
$message = $request->getQueryParam('message');
if (!is_string($message) || trim($message) === '') {
throw new BadRequest('message (string) is required.');
}
if (strlen($message) > 2000) {
throw new BadRequest('message is too long.');
}
// Optional topic scope. EventSource is GET-only so the client passes
// it via ?topicId=N; we forward it as topic_id in the JSON body to
// /kb/ask/stream. Bad ints just become null and the upstream falls
// back to the lowest active topic.
$topicIdRaw = $request->getQueryParam('topicId');
$topicId = (is_string($topicIdRaw) && is_numeric($topicIdRaw))
? (int) $topicIdRaw : null;
$service = $this->injectableFactory->create(KnowledgeBaseService::class);
try {
$upstreamUrl = $service->getStreamingUrl('/kb/ask/stream');
$apiKey = $service->getStreamingApiKey();
} catch (\Throwable $e) {
throw new Error('SmartAssistant integration is not configured: ' . $e->getMessage());
}
// Disable PHP/Apache output buffering and compression so each SSE
// event flushes to the wire as soon as it arrives from upstream.
// Order matters: clean any open buffers BEFORE setting headers.
while (ob_get_level() > 0) {
@ob_end_clean();
}
@ini_set('zlib.output_compression', 'Off');
@ini_set('output_buffering', 'Off');
@ini_set('implicit_flush', '1');
if (function_exists('apache_setenv')) {
@apache_setenv('no-gzip', '1');
}
ignore_user_abort(false);
@set_time_limit(300);
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache, no-transform');
header('X-Accel-Buffering: no');
header('Connection: keep-alive');
// Defense in depth: same locked-down CSP we use for the PDF entry.
header("Content-Security-Policy: default-src 'self'");
$payloadArr = ['message' => $message];
if ($topicId !== null) {
$payloadArr['topic_id'] = $topicId;
}
$payload = json_encode($payloadArr, JSON_UNESCAPED_UNICODE);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $upstreamUrl,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: text/event-stream',
'X-Api-Key: ' . $apiKey,
],
CURLOPT_RETURNTRANSFER => false,
CURLOPT_BUFFERSIZE => 1024,
CURLOPT_TIMEOUT => 300,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_WRITEFUNCTION => function ($ch, $data) {
if (connection_aborted()) {
// Browser closed the EventSource — bail so cURL stops.
return -1;
}
echo $data;
@ob_flush();
@flush();
return strlen($data);
},
]);
$ok = curl_exec($ch);
$errno = curl_errno($ch);
$errmsg = curl_error($ch);
curl_close($ch);
if (!$ok && $errno !== 23 /* aborted by callback */) {
// Best effort: if upstream errored before any data was
// flushed, surface a final SSE error event so the client
// doesn't silently spin.
echo "data: " . json_encode(
['type' => 'error', 'message' => 'upstream: ' . $errmsg],
JSON_UNESCAPED_UNICODE,
) . "\n\n";
@ob_flush();
@flush();
}
// We've fully written the response. Bypass EspoCRM's Response
// emit() to avoid double-headers / extra buffering.
exit;
}
}