Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ebcf1f3
feat: Implement the backend for memories
marcelklehr Dec 10, 2025
75f95dc
fix: run cs:fix
marcelklehr Dec 10, 2025
3ed329b
fix: Fix psalm errors
marcelklehr Dec 10, 2025
9e58fea
fix: Fix psalm errors pre 32
marcelklehr Dec 10, 2025
40a9419
fix: Fix REUSE compatibility
marcelklehr Dec 10, 2025
83531e0
fix: Update openapi.json
marcelklehr Dec 10, 2025
daa1acd
fix: Fix copy-pasta
marcelklehr Dec 10, 2025
1f26762
Fix: Address review comments
marcelklehr Dec 11, 2025
2611c47
feat: Implement RESTful session endpoints
marcelklehr Dec 11, 2025
7980915
fix: Inject at most 10 summaries into one LLM call
marcelklehr Dec 11, 2025
04a4d7a
feat: adjust updateChatSession, only update what's necessary, fix Ope…
julien-nc Dec 12, 2025
24e3c22
fix: typo in SessionSummaryService
julien-nc Dec 12, 2025
86b68e5
feat: add remember button in Chat UI
janepie Dec 12, 2025
5b45cdf
fix(ChattyLLMInputForm): Make spelling of is_remembered consistent
marcelklehr Dec 15, 2025
35421d9
fix(ChattyLLMController): Actually store updated values in db
marcelklehr Dec 15, 2025
c0b8879
fix: Fix psalm issues
marcelklehr Dec 15, 2025
ff12e78
chore: Update openapi spec
marcelklehr Dec 15, 2025
1ef3c0f
fix: replace custom getters/setters with magic methods for session's …
julien-nc Dec 16, 2025
2c57729
Revert "fix: replace custom getters/setters with magic methods for se…
marcelklehr Dec 16, 2025
b652986
fix: Try to fix boolean entity field issues
marcelklehr Dec 16, 2025
56377f6
feat(ui): list of summaries in personal settings
janepie Dec 22, 2025
0705128
feat(ui): change button icon and make text reactive
janepie Dec 23, 2025
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
4 changes: 4 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
['name' => 'assistantApi#getOutputFile', 'url' => '/api/{apiVersion}/task/{ocpTaskId}/output-file/{fileId}/download', 'verb' => 'GET', 'requirements' => $requirements],
['name' => 'assistantApi#runFileAction', 'url' => '/api/{apiVersion}/file-action/{fileId}/{taskTypeId}', 'verb' => 'POST', 'requirements' => $requirements],

['name' => 'chattyLLM#newSession', 'url' => '/chat/sessions', 'verb' => 'POST', 'postfix' => 'restful'],
['name' => 'chattyLLM#updateChatSession', 'url' => '/chat/sessions/{sessionId}', 'verb' => 'PUT', 'postfix' => 'restful'],
['name' => 'chattyLLM#deleteSession', 'url' => '/chat/sessions/{sessionId}', 'verb' => 'DELETE', 'postfix' => 'restful'],

['name' => 'chattyLLM#newSession', 'url' => '/chat/new_session', 'verb' => 'PUT'],
['name' => 'chattyLLM#updateSessionTitle', 'url' => '/chat/update_session', 'verb' => 'PATCH'],
['name' => 'chattyLLM#deleteSession', 'url' => '/chat/delete_session', 'verb' => 'DELETE'],
Expand Down
28 changes: 28 additions & 0 deletions lib/BackgroundJob/GenerateNewChatSummaries.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Assistant\BackgroundJob;

use OCA\Assistant\Service\SessionSummaryService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;

class GenerateNewChatSummaries extends TimedJob {
public function __construct(
ITimeFactory $timeFactory,
private SessionSummaryService $sessionSummaryService,
) {
parent::__construct($timeFactory);
$this->setInterval(60 * 10); // 10min
}
public function run($argument) {
$userId = $argument['userId'];
$this->sessionSummaryService->generateSummariesForNewSessions($userId);
}
}
29 changes: 29 additions & 0 deletions lib/BackgroundJob/RegenerateOutdatedChatSummariesJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Assistant\BackgroundJob;

use OCA\Assistant\Service\SessionSummaryService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;

class RegenerateOutdatedChatSummariesJob extends TimedJob {

public function __construct(
ITimeFactory $timeFactory,
private SessionSummaryService $sessionSummaryService,
) {
parent::__construct($timeFactory);
$this->setInterval(60 * 60 * 24); // 24h
}
public function run($argument) {
$userId = $argument['userId'];
$this->sessionSummaryService->regenerateSummariesForOutdatedSessions($userId);
}
}
61 changes: 61 additions & 0 deletions lib/Controller/ChattyLLMController.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use OCA\Assistant\Db\ChattyLLM\Session;
use OCA\Assistant\Db\ChattyLLM\SessionMapper;
use OCA\Assistant\ResponseDefinitions;
use OCA\Assistant\Service\SessionSummaryService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\AppFramework\Http;
Expand Down Expand Up @@ -55,6 +56,7 @@ public function __construct(
private IAppConfig $appConfig,
private IUserManager $userManager,
private ?string $userId,
private SessionSummaryService $sessionSummaryService,
) {
parent::__construct($appName, $request);
$this->agencyActionData = [
Expand Down Expand Up @@ -190,6 +192,49 @@ public function updateSessionTitle(int $sessionId, string $title): JSONResponse
}
}

/**
* Update session
*
* @param integer $sessionId The chat session ID
* @param string|null $title The new chat session title
* @param bool|null $is_remembered The new is_remembered status: Whether to remember the insights from this chat session across all chat session
* @return JSONResponse<Http::STATUS_OK, list{}, array{}>|JSONResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{error: string}, array{}>
*
* 200: The title has been updated successfully
* 404: The session was not found
*/
#[NoAdminRequired]
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['chat_api'])]
public function updateChatSession(int $sessionId, ?string $title = null, ?bool $is_remembered = null): JSONResponse {
if ($this->userId === null) {
return new JSONResponse(['error' => $this->l10n->t('Could not find session')], Http::STATUS_NOT_FOUND);
}
if ($title === null && $is_remembered === null) {
return new JSONResponse();
}

try {
$session = $this->sessionMapper->getUserSession($this->userId, $sessionId);
if ($title !== null) {
$session->setTitle($title);
}
if ($is_remembered !== null) {
$session->setIsRemembered($is_remembered);
// schedule summarizer jobs for this chat user
if ($is_remembered) {
$this->sessionSummaryService->scheduleJobsForUser($this->userId);
}
}
$this->sessionMapper->update($session);
return new JSONResponse();
} catch (\OCP\DB\Exception|\RuntimeException $e) {
$this->logger->warning('Failed to update the chat session', ['exception' => $e]);
return new JSONResponse(['error' => $this->l10n->t('Failed to update the chat session')], Http::STATUS_INTERNAL_SERVER_ERROR);
} catch (DoesNotExistException|MultipleObjectsReturnedException $e) {
return new JSONResponse(['error' => $this->l10n->t('Could not find session')], Http::STATUS_NOT_FOUND);
}
}

/**
* Delete a chat session
*
Expand Down Expand Up @@ -779,6 +824,7 @@ public function checkSession(int $sessionId): JSONResponse {
'messageTaskId' => null,
'titleTaskId' => null,
'sessionTitle' => $session->getTitle(),
'is_remembered' => $session->getIsRemembered(),
'sessionAgencyPendingActions' => $p,
];
if (!empty($messageTasks)) {
Expand Down Expand Up @@ -990,6 +1036,9 @@ private function scheduleLLMChatTask(
'system_prompt' => $systemPrompt,
'history' => $history,
];
if (isset($this->taskProcessingManager->getAvailableTaskTypes()[TextToTextChat::ID]['optionalInputShape']['memories'])) {
$input['memories'] = $this->sessionSummaryService->getUserSessionSummaries($this->userId);
}
$task = new Task(TextToTextChat::ID, $input, Application::APP_ID . ':chatty-llm', $this->userId, $customId);
$this->taskProcessingManager->scheduleTask($task);
return $task->getId() ?? 0;
Expand Down Expand Up @@ -1017,6 +1066,10 @@ private function scheduleAgencyTask(string $content, int $confirmation, string $
'conversation_token' => $conversationToken,
];
/** @psalm-suppress UndefinedClass */
if (isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID]['optionalInputShape']['memories'])) {
$taskInput['memories'] = $this->sessionSummaryService->getUserSessionSummaries($this->userId);
}
/** @psalm-suppress UndefinedClass */
$task = new Task(
\OCP\TaskProcessing\TaskTypes\ContextAgentInteraction::ID,
$taskInput,
Expand All @@ -1039,6 +1092,10 @@ private function scheduleAudioChatTask(
'history' => $history,
];
/** @psalm-suppress UndefinedClass */
if (isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\AudioToAudioChat::ID]['optionalInputShape']['memories'])) {
$input['memories'] = $this->sessionSummaryService->getUserSessionSummaries($this->userId);
}
/** @psalm-suppress UndefinedClass */
$task = new Task(
\OCP\TaskProcessing\TaskTypes\AudioToAudioChat::ID,
$input,
Expand All @@ -1061,6 +1118,10 @@ private function scheduleAgencyAudioTask(
'conversation_token' => $conversationToken,
];
/** @psalm-suppress UndefinedClass */
if (isset($this->taskProcessingManager->getAvailableTaskTypes()[\OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID]['optionalInputShape']['memories'])) {
$taskInput['memories'] = $this->sessionSummaryService->getUserSessionSummaries($this->userId);
}
/** @psalm-suppress UndefinedClass */
$task = new Task(
\OCP\TaskProcessing\TaskTypes\ContextAgentAudioInteraction::ID,
$taskInput,
Expand Down
4 changes: 2 additions & 2 deletions lib/Db/ChattyLLM/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,11 @@ class Message extends Entity implements \JsonSerializable {
];

public function __construct() {
$this->addType('session_id', Types::INTEGER);
$this->addType('sessionId', Types::INTEGER);
$this->addType('role', Types::STRING);
$this->addType('content', Types::STRING);
$this->addType('timestamp', Types::INTEGER);
$this->addType('ocp_task_id', Types::INTEGER);
$this->addType('ocpTaskId', Types::INTEGER);
$this->addType('sources', Types::STRING);
$this->addType('attachments', Types::STRING);
}
Expand Down
54 changes: 51 additions & 3 deletions lib/Db/ChattyLLM/Session.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
* @method \void setUserId(string $userId)
* @method \string|null getTitle()
* @method \void setTitle(?string $title)
* @method \string|null getSummary()
* @method \void setSummary(?string $title)
* @method \int getTimestamp()
* @method \void setTimestamp(int $timestamp)
* @method \string|null getAgencyConversationToken()
Expand All @@ -36,13 +38,34 @@ class Session extends Entity implements \JsonSerializable {
/** @var ?string */
protected $agencyPendingActions;

/**
* Will be used to inject into assistant memories upon calling LLM
*
* @var ?string
*/
protected $summary;

/** @var int */
protected $isSummaryUpToDate;

/**
* Whether to remember the insights from this chat session across all chat sessions
*
* @var int
*/
protected $isRemembered;


public static $columns = [
'id',
'user_id',
'title',
'timestamp',
'agency_conversation_token',
'agency_pending_actions',
'summary',
'is_summary_up_to_date',
'is_remembered',
];
public static $fields = [
'id',
Expand All @@ -51,14 +74,20 @@ class Session extends Entity implements \JsonSerializable {
'timestamp',
'agencyConversationToken',
'agencyPendingActions',
'summary',
'isSummaryUpToDate',
'isRemembered',
];

public function __construct() {
$this->addType('user_id', Types::STRING);
$this->addType('userId', Types::STRING);
$this->addType('title', Types::STRING);
$this->addType('timestamp', Types::INTEGER);
$this->addType('agency_conversation_token', Types::STRING);
$this->addType('agency_pending_actions', Types::STRING);
$this->addType('agencyConversationToken', Types::STRING);
$this->addType('agencyPendingActions', Types::STRING);
$this->addType('summary', Types::TEXT);
$this->addType('isSummaryUpToDate', Types::SMALLINT);
$this->addType('isRemembered', Types::SMALLINT);
}

#[\ReturnTypeWillChange]
Expand All @@ -70,6 +99,25 @@ public function jsonSerialize() {
'timestamp' => $this->getTimestamp(),
'agency_conversation_token' => $this->getAgencyConversationToken(),
'agency_pending_actions' => $this->getAgencyPendingActions(),
'summary' => $this->getSummary(),
'is_summary_up_to_date' => $this->getIsSummaryUpToDate(),
'is_remembered' => $this->getIsRemembered(),
];
}

public function setIsSummaryUpToDate(bool $value): void {
$this->setter('isSummaryUpToDate', [$value ? 1 : 0]);
}

public function setIsRemembered(bool $value): void {
$this->setter('isRemembered', [$value ? 1 : 0]);
}

public function getIsSummaryUpToDate(): bool {
return $this->getter('isSummaryUpToDate') === 1;
}

public function getIsRemembered(): bool {
return $this->getter('isRemembered') === 1;
}
}
75 changes: 75 additions & 0 deletions lib/Db/ChattyLLM/SessionMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,75 @@ public function getUserSessions(string $userId): array {
return $this->findEntities($qb);
}

/**
* @return array<Session>
* @throws \OCP\DB\Exception
*/
public function getRememberedUserSessions(string $userId, int $limit = 0): array {
$qb = $this->db->getQueryBuilder();
$qb->select(Session::$columns)
->from($this->getTableName())
->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR)))
->andWhere($qb->expr()->eq('is_remembered', $qb->createPositionalParameter(1, IQueryBuilder::PARAM_INT)))
->orderBy('timestamp', 'DESC');

if ($limit > 0) {
$qb->setMaxResults($limit);
}

return $this->findEntities($qb);
}

/**
* @return array<Session>
* @throws \OCP\DB\Exception
*/
public function getRememberedUserSessionsWithOutdatedSummaries(string $userId, int $limit): array {
$qb = $this->db->getQueryBuilder();
$qb->select(Session::$columns)
->from($this->getTableName())
->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR)))
->andWhere($qb->expr()->eq('is_remembered', $qb->createPositionalParameter(1, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('is_summary_up_to_date', $qb->createPositionalParameter(0, IQueryBuilder::PARAM_INT)))
->setMaxResults($limit)
->orderBy('timestamp', 'DESC');

return $this->findEntities($qb);
}

/**
* @return array<Session>
* @throws \OCP\DB\Exception
*/
public function getRememberedSessionsWithOutdatedSummaries(int $limit): array {
$qb = $this->db->getQueryBuilder();
$qb->select(Session::$columns)
->from($this->getTableName())
->where($qb->expr()->eq('is_remembered', $qb->createPositionalParameter(1, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('is_summary_up_to_date', $qb->createPositionalParameter(0, IQueryBuilder::PARAM_INT)))
->setMaxResults($limit)
->orderBy('timestamp', 'DESC');

return $this->findEntities($qb);
}

/**
* @return array<Session>
* @throws \OCP\DB\Exception
*/
public function getRememberedUserSessionsWithoutSummaries(string $userId, int $limit): array {
$qb = $this->db->getQueryBuilder();
$qb->select(Session::$columns)
->from($this->getTableName())
->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR)))
->andWhere($qb->expr()->eq('is_remembered', $qb->createPositionalParameter(1, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->isNull('summary'))
->setMaxResults($limit)
->orderBy('timestamp', 'DESC');

return $this->findEntities($qb);
}

/**
* @param string $userId
* @param integer $sessionId
Expand Down Expand Up @@ -110,4 +179,10 @@ public function deleteSession(string $userId, int $sessionId) {

$qb->executeStatement();
}

public function updateSessionIsRemembered(?string $userId, int $sessionId, bool $is_remembered) {
$session = $this->getUserSession($userId, $sessionId);
$session->setIsRemembered($is_remembered);
$this->update($session);
}
}
Loading
Loading