Compare commits

...
Author SHA1 Message Date
kasun b1a8985b29 added RequiredArgsConstructor annotation 2026-08-11 01:17:40 +02:00
kasun 9073c52b5c refactor: clean up build.gradle 2026-07-21 16:01:12 +02:00
kasun 6981734bda Merge branch 'main' into feature/Implement-AI-search-function 2026-07-21 01:36:43 +02:00
kasun 5f4f417edc docs: add git and ticket conventions
SonarQube Analysis / Build and Analyze (push) Successful in 2m44s
2026-07-21 01:36:24 +02:00
kasun 3736cc73ae Nest extraData by section for clearer embedding content
Homebox's location fields were flattened into single-purpose keys
(locationName/locationDescription), which gave EmbeddingService no
way to group related fields when rendering vector-store content.
extraData is now Map<String, Map<String, Object>>, letting
HomeboxItemClient express "location" as its own section; buildExtraData
renders each section as a header with indented fields instead of a flat
"Extradata: key: value" line.
2026-07-21 01:04:19 +02:00
kasun 29027d4515 formatting 2026-07-21 00:57:48 +02:00
kasun 444ce804ed introduced uiText.ts 2026-07-20 16:44:21 +02:00
kasunandClaude Sonnet 5 f2ab2b31ed Add manual sync trigger to SearchModal
Wires the existing /sync endpoint to a "Refresh Data" button so
Homebox data can be re-vectorized before searching, without waiting
for it to be triggered automatically on login.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-20 16:32:25 +02:00
kasun bb7bef18d9 format 2026-07-20 16:31:01 +02:00
kasun 1047e839f5 format 2026-07-20 14:15:01 +02:00
kasun 87108a9ebd added SyncControllerTests 2026-07-19 16:07:47 +02:00
kasun 1e0a5c0af1 removoed unused variables 2026-07-17 00:36:53 +02:00
kasun 8184db0c46 update CLAUDE.md to reflect the sync/vector package layout
Documents the homebox/, sync/, and vector/ packages as they actually
exist now (the doc still described a since-renamed ai/ package and a
VectorStoreConfig class that was never added - PgVectorStore is
auto-configured from application.yaml). Also flags what's still
pending rather than implying it's done: HomeboxAiSearchProvider is an
unimplemented stub, SearchService doesn't route on aiSearch yet, and
/sync isn't wired to login or the frontend.
2026-07-04 01:11:07 +02:00
kasun 65ff109800 add session-gated /sync endpoint and prune stale vectors after reindex
SyncController/SyncService/SyncProvider mirror the existing search
dispatch pattern: a session-gated controller routes a SyncRequest to
the provider registered for its ServiceType. HomeboxSyncProvider pages
through the full Homebox catalog, embedding each page via
EmbeddingService, and now collects every item ID it sees across all
pages before pruning anything no longer present upstream in one
deleteStaleVectorEntries call at the end - deleting per page would
treat items on other pages as stale and wipe them out.
2026-07-04 01:08:42 +02:00
kasun 48d120fbff extract HomeboxItemClient for reuse between search and sync
HomeboxSearchProvider used to own the RestClient call and item mapping
directly; pulling it into a shared HomeboxItemClient lets the upcoming
sync pipeline page through the same Homebox items without duplicating
the fetch/mapping logic. ConnectionIdentifiable lets both SearchRequest
and SyncRequest be resolved to a connection by the same client.
2026-07-04 01:08:26 +02:00
31 changed files with 852 additions and 309 deletions
+28 -15
View File
@@ -49,13 +49,15 @@ Package-by-feature layout. Server context path is `/api`. Main endpoints:
- `GET /api/connections/status` — lists connected services for the current session
- `DELETE /api/connections/{serviceType}` — removes a service from the session; invalidates the session if no connections remain
- `POST /api/search` — paged search against the requested service; returns 401 if no active session
- `POST /api/sync` — triggers a vector-store sync for the requested service; returns 401 if no active session. Not yet triggered on login or called from the frontend — manual/internal trigger only for now.
Six packages:
Eight packages:
**`shared/`** — cross-cutting types used by more than one feature package
- `ServiceType` (enum): identifies each integrated app (e.g. `HOMEBOX`); used in both `connection/` and `search/`
- `ServiceProvider` (interface): base for `ConnectionProvider` and `SearchProvider`; declares `getServiceType()`
- `ServiceType` (enum): identifies each integrated app (e.g. `HOMEBOX`); used across `connection/`, `search/`, `sync/`
- `ServiceProvider` (interface): base for `ConnectionProvider`, `SearchProvider`, `SyncProvider`; declares `getServiceType()`
- `ServiceItem`: normalized item shape (`id`, `title`, `description`, `extraData`) returned by both search and sync fetches
- `Endpoint` (enum): API path constants for all external service calls
- `SessionKeys`: builds session attribute names of the form `{SERVICE_TYPE}_CONNECTION_ID`
@@ -67,29 +69,40 @@ Six packages:
**`connection/`** — connecting to and persisting service credentials
- `ConnectionProvider` interface: extends `ServiceProvider`; each integrated app implements `login()` and credential checking
- `ConnectionIdentifiable` interface: `appUrl()`/`username()`/`serviceType()`; implemented by `SearchRequest` and `SyncRequest` so `HomeboxItemClient` can resolve the underlying connection regardless of which feature is calling it
- `ConnectionService`: auto-discovers providers via Spring injection, dispatches login by `ServiceType`
- `ConnectionController`: stores `{serviceType}_CONNECTION_ID` in `HttpSession` after login; reads session attributes to build status responses
- Entity (`ConnectionEntity`) uses **Single Table Inheritance** — one `connections` table with app-specific nullable columns
- `HomeboxConnectionProvider` / `HomeboxEntity`: Homebox-specific implementation
- `HomeboxConnectionProvider` / `HomeboxConnectionEntity`: Homebox-specific implementation
**`ai/`** — shared AI infrastructure used by multiple features (search, future image analysis)
**`homebox/`** — shared Homebox API access
- `EmbeddingService`: wraps Spring AI's `EmbeddingModel`; reused by any feature that needs to generate vectors
- `VectorStoreConfig`: Spring bean configuration for `PgVectorStore` (pgvector-backed `VectorStore`)
- All classes here are provider-agnostic — the OpenAI starter is pointed at LiteLLM, so the underlying model is configurable without code changes
- `HomeboxItemClient`: fetches a page of items from the Homebox entities API and maps them to `ServiceItem`; used by both `HomeboxSearchProvider` (keyword search) and `HomeboxSyncProvider` (vector-store sync) so the fetch/mapping logic isn't duplicated
**`vector/`** — vectorization
- `EmbeddingService`: embeds `ServiceItem`s into pgvector's `VectorStore` and prunes entries that no longer exist upstream. Document ID is `{connectionId}:{itemId}`, so re-syncing upserts existing items instead of duplicating them. Deliberately stateless (only field is the injected `VectorStore`) since it's a singleton bean — the per-sync ID accumulator used for pruning is owned by the caller (`HomeboxSyncProvider`), not held as instance state
- `PgVectorStore` itself is auto-configured by `spring-ai-starter-vector-store-pgvector` from `application.yaml` (`spring.ai.vectorstore.pgvector.dimensions`) — no manual config class in this codebase
**`search/`** — querying connected services (keyword and AI)
- `SearchProvider` interface: extends `ServiceProvider`; each integrated app implements `getSearchResults()`
- `SearchService`: maintains two provider mapskeyword providers and AI providers; routes based on `SearchRequest.aiSearch` flag
- `SearchService`: dispatches by `ServiceType` via a single provider map — **does not yet route on `SearchRequest.aiSearch`**; `HomeboxSearchProvider` and `HomeboxAiSearchProvider` both currently register for `ServiceType.HOMEBOX`, so only one wins the registration (known gap, pending AI search work)
- `SearchController`: guards with session check before delegating to `SearchService`
- `SearchRequest`: includes `aiSearch: boolean` — when true, routes to AI provider instead of keyword provider
- `SearchRequest`: includes `aiSearch: boolean` (not yet consumed, see above) and implements `ConnectionIdentifiable`
- `PagedSearchResponse`: includes nullable `summary` field — populated only for AI search results; null for keyword search
- `HomeboxSearchProvider`: keyword search via Homebox API; unchanged from original implementation
- `HomeboxAiSearchProvider`: AI search via pgvector similarity; returns ranked items + generated summary
- `HomeboxSyncService`: fetches all Homebox items page by page, embeds them via `EmbeddingService`, stores in `VectorStore`; triggered on connection login (background sync)
- `HomeboxSearchProvider`: keyword search; delegates the remote fetch to `HomeboxItemClient`
- `HomeboxAiSearchProvider`: **stub only**`getSearchResults` throws `UnsupportedOperationException`; the actual similarity search + summary generation is pending
**AI search flow:** on login → background sync indexes all Homebox items into pgvector. On AI search → embed query → pgvector similarity search → top N results passed to `ChatClient` for summary generation → return list + summary. Sync is idempotent (delete-then-reindex per connection).
**`sync/`** — indexing connected services into the vector store
- `SyncProvider` interface: extends `ServiceProvider`; each integrated app implements `syncVectorStore()`
- `SyncService`: dispatches by `ServiceType` via a provider map, same pattern as `SearchService`
- `SyncController`: session-gated `POST /sync`; not currently called by the frontend or triggered on login
- `SyncRequest`: implements `ConnectionIdentifiable`
- `HomeboxSyncProvider`: pages through the full Homebox catalog via `HomeboxItemClient`, embedding each page through `EmbeddingService.vectorizeData` and collecting every item ID seen along the way. Once the full page loop finishes, calls `EmbeddingService.deleteStaleVectorEntries` **once** with the complete ID set, removing any previously indexed item no longer present upstream. Order matters — pruning per page instead of once at the end would treat items on other pages as stale and delete them too.
**Pending work (not yet implemented):** `HomeboxAiSearchProvider`'s actual similarity search + summary generation; `SearchService` routing two provider maps by `aiSearch`; triggering `/sync` on login or from the frontend; a frontend sync button and AI search UI.
**`exception/`** — `GlobalExceptionHandler` via `@ControllerAdvice`
@@ -110,7 +123,7 @@ React 19 + TypeScript + SCSS, Vite 6 build. Package-by-feature under `components
- PostgreSQL + pgvector (semantic search via embeddings); also used as the Spring Session store (JDBC)
- LiteLLM as a unified AI proxy; Spring AI OpenAI starter wired to it — `OPENAI_BASE_URL` points to LiteLLM, not OpenAI directly, keeping the underlying model provider configurable
- `spring-ai-starter-vector-store-pgvector` provides `PgVectorStore`; configured in `ai/VectorStoreConfig`
- `spring-ai-starter-vector-store-pgvector` provides `PgVectorStore`, auto-configured from `application.yaml` (no manual config class)
- Embedding dimensions must stay consistent with the configured LiteLLM embedding model — changing models requires re-syncing all indexed items
- Processing pipeline (Phase 2): stage in DB → LLM inference → refine via UI → export to target app
+52 -55
View File
@@ -1,83 +1,80 @@
val wiremockVersion = "3.12.0"
val postgresqlVersion = "42.7.11"
val springAiVersion by extra("2.0.0")
plugins {
java
jacoco
id("org.springframework.boot") version "4.1.0"
id("io.spring.dependency-management") version "1.1.7"
id("org.sonarqube") version "7.3.0.8198"
java
jacoco
id("org.springframework.boot") version "4.1.0"
id("io.spring.dependency-management") version "1.1.7"
id("org.sonarqube") version "7.3.0.8198"
}
group = "com.vaessl"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
sonar {
properties {
property("sonar.projectKey", "Vaessl")
property("sonar.projectName", "Vaessl")
property("sonar.coverage.jacoco.xmlReportPaths",
"${layout.buildDirectory.get()}/reports/jacoco/test/jacocoTestReport.xml")
}
properties {
property("sonar.projectKey", "Vaessl")
property("sonar.projectName", "Vaessl")
property("sonar.coverage.jacoco.xmlReportPaths",
"${layout.buildDirectory.get()}/reports/jacoco/test/jacocoTestReport.xml")
}
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
repositories {
mavenCentral()
}
extra["springAiVersion"] = "2.0.0"
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-session-jdbc")
// implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.ai:spring-ai-starter-model-openai")
implementation("org.postgresql:postgresql:$postgresqlVersion")
implementation("org.springframework.ai:spring-ai-starter-vector-store-pgvector")
compileOnly("org.projectlombok:lombok")
developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("org.postgresql:postgresql")
annotationProcessor("org.projectlombok:lombok")
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
// testImplementation("org.springframework.boot:spring-boot-starter-security-test")
testImplementation("org.springframework.boot:spring-boot-starter-validation-test")
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testImplementation("org.wiremock:wiremock-standalone:$wiremockVersion")
testImplementation("org.springframework.boot:spring-boot-starter-session-jdbc-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
mavenCentral()
}
dependencyManagement {
imports {
mavenBom("org.springframework.ai:spring-ai-bom:${property("springAiVersion")}")
}
imports {
mavenBom("org.springframework.ai:spring-ai-bom:$springAiVersion")
}
}
dependencies {
// Spring Boot Starters
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-session-jdbc")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-webmvc")
// Spring AI Starters (versions managed by spring-ai-bom)
implementation("org.springframework.ai:spring-ai-starter-model-openai")
implementation("org.springframework.ai:spring-ai-starter-vector-store-pgvector")
// Database
implementation("org.postgresql:postgresql:$postgresqlVersion")
// Tooling & Code Generation
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
developmentOnly("org.springframework.boot:spring-boot-devtools")
// Testing
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
testImplementation("org.springframework.boot:spring-boot-starter-validation-test")
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testImplementation("org.springframework.boot:spring-boot-starter-session-jdbc-test")
testImplementation("org.wiremock:wiremock-standalone:$wiremockVersion")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.withType<JavaCompile> {
options.compilerArgs.add("-parameters")
options.compilerArgs.add("-parameters")
}
tasks.withType<Test> {
@@ -0,0 +1,17 @@
package com.vaessl.app.connection;
import com.vaessl.app.shared.ServiceType;
/**
* Identifies the connection a request targets. Implemented by request records
* (e.g. {@code SearchRequest}, {@code SyncRequest}) so a single {@code HomeboxItemClient} can
* resolve the underlying connection regardless of which feature is calling it.
*/
public interface ConnectionIdentifiable {
String appUrl();
String username();
ServiceType serviceType();
}
@@ -0,0 +1,112 @@
package com.vaessl.app.homebox;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.vaessl.app.connection.ConnectionEntity;
import com.vaessl.app.connection.ConnectionIdentifiable;
import com.vaessl.app.connection.ConnectionRepository;
import com.vaessl.app.connection.HomeboxConnectionEntity;
import com.vaessl.app.exception.ConnectionNotFoundException;
import com.vaessl.app.exception.RemoteApiException;
import com.vaessl.app.shared.ServiceItem;
import static com.vaessl.app.shared.Endpoint.HOMEBOX_QUERY_ALL_ITEMS;
/**
* Fetches items from the Homebox entities API. Shared by {@code HomeboxSearchProvider} and
* {@code HomeboxSyncProvider} so both keyword search and vector-store sync page through the same
* remote call and item mapping.
*/
@Component
public class HomeboxItemClient {
private final RestClient.Builder restClientBuilder;
private final ConnectionRepository cRepository;
public HomeboxItemClient(RestClient.Builder restClienBuilder,
ConnectionRepository cRepository) {
this.restClientBuilder = restClienBuilder;
this.cRepository = cRepository;
}
/**
* Fetches one page of items from Homebox for the given connection.
*
* @param connection identifies which stored connection (app URL, username) to query
* @param query optional keyword filter; {@code null} returns all items for the page
* @param pageable page number and size to request
* @return the mapped page of items along with the resolved connection ID
* @throws com.vaessl.app.exception.ConnectionNotFoundException if no matching connection is
* stored
* @throws com.vaessl.app.exception.RemoteApiException if Homebox returns an empty response body
*/
public HomeboxItemPage hbResponse(ConnectionIdentifiable connection, String query,
Pageable pageable) {
ConnectionEntity entity =
cRepository.findByAppUrlAndUsername(connection.appUrl(), connection.username());
if (!(entity instanceof HomeboxConnectionEntity hbEntity)) {
throw new ConnectionNotFoundException();
}
HomeboxItemsResponse response = restClientBuilder.baseUrl(connection.appUrl()).build().get()
.uri(u -> u.path(HOMEBOX_QUERY_ALL_ITEMS.getValue()).queryParam("q", query)
.queryParam("page", pageable.getPageNumber() + 1)
.queryParam("pageSize", pageable.getPageSize()).build())
.headers(h -> h.setBearerAuth(hbEntity.getToken())).retrieve()
.body(HomeboxItemsResponse.class);
if (response == null) {
throw new RemoteApiException(connection.appUrl(), HOMEBOX_QUERY_ALL_ITEMS.getValue());
}
List<ServiceItem> items = response.items().stream().map(i -> {
String id = i.id();
String title = i.name();
String description = i.description();
HomeboxParent parent = i.parent();
Map<String, Map<String, Object>> extraData = new LinkedHashMap<>();
Map<String, Object> locationData = new LinkedHashMap<>();
if (parent.name() != null && !parent.name().isBlank()) {
locationData.put("name", parent.name());
}
if (parent.description() != null && !parent.description().isBlank()) {
locationData.put("description", parent.description());
}
if (locationData != null && !locationData.isEmpty()) {
extraData.put("location", locationData);
}
return new ServiceItem(id, title, description, extraData);
}).toList();
return new HomeboxItemPage(new PageImpl<>(items, pageable, response.total()),
hbEntity.getId());
}
public record HomeboxItemPage(Page<ServiceItem> page, Long connectionId) {
}
private record HomeboxItemsResponse(int page, int pageSize, int total,
List<HomeboxItem> items) {
}
private record HomeboxItem(String id, String name, String description, HomeboxParent parent) {
}
private record HomeboxParent(String name, String description) {
}
}
@@ -1,36 +1,19 @@
package com.vaessl.app.search;
import java.util.List;
import java.util.Map;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.vaessl.app.connection.ConnectionEntity;
import com.vaessl.app.connection.ConnectionRepository;
import com.vaessl.app.connection.HomeboxConnectionEntity;
import com.vaessl.app.exception.ConnectionNotFoundException;
import com.vaessl.app.exception.RemoteApiException;
import com.vaessl.app.homebox.HomeboxItemClient;
import com.vaessl.app.shared.ServiceItem;
import com.vaessl.app.shared.ServiceType;
import static com.vaessl.app.shared.Endpoint.*;
import lombok.RequiredArgsConstructor;
@Component
@RequiredArgsConstructor
public class HomeboxSearchProvider implements SearchProvider {
private final RestClient.Builder restClientBuilder;
private final ConnectionRepository cRepository;
public HomeboxSearchProvider(RestClient.Builder restClientBuilder,
ConnectionRepository cRepository) {
this.restClientBuilder = restClientBuilder;
this.cRepository = cRepository;
}
private final HomeboxItemClient client;
@Override
public ServiceType getServiceType() {
@@ -39,45 +22,6 @@ public class HomeboxSearchProvider implements SearchProvider {
@Override
public Page<ServiceItem> getSearchResults(SearchRequest request, Pageable pageable) {
ConnectionEntity entity =
cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
if (!(entity instanceof HomeboxConnectionEntity hbEntity)) {
throw new ConnectionNotFoundException();
}
HomeboxSearchResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()).build().get()
.uri(u -> u.path(HOMEBOX_QUERY_ALL_ITEMS.getValue())
.queryParam("q", request.query())
.queryParam("page", pageable.getPageNumber() + 1)
.queryParam("pageSize", pageable.getPageSize()).build())
.headers(h -> h.setBearerAuth(hbEntity.getToken())).retrieve()
.body(HomeboxSearchResponse.class);
if (hbResponse == null) {
throw new RemoteApiException(request.appUrl(), HOMEBOX_QUERY_ALL_ITEMS.getValue());
}
List<ServiceItem> items = hbResponse.items().stream().map(i -> {
String id = i.id();
String title = i.name();
String description = i.description();
Map<String, Object> extraSearchResponseData = Map.of("parent", i.parent());
return new ServiceItem(id, title, description, extraSearchResponseData);
}).toList();
return new PageImpl<>(items, pageable, hbResponse.total());
}
private record HomeboxSearchResponse(int page, int pageSize, int total,
List<HomeboxItem> items) {
}
private record HomeboxItem(String id, String name, String description,
HomeboxExtraData parent) {
}
private record HomeboxExtraData(String name, String description) {
return client.hbResponse(request, request.query(), pageable).page();
}
}
@@ -1,9 +1,10 @@
package com.vaessl.app.search;
import com.vaessl.app.connection.ConnectionIdentifiable;
import com.vaessl.app.shared.ServiceType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record SearchRequest(@NotBlank String appUrl, @NotBlank String username, String query,
@NotNull ServiceType serviceType, boolean aiSearch) {
@NotNull ServiceType serviceType, boolean aiSearch) implements ConnectionIdentifiable {
}
@@ -2,7 +2,8 @@ package com.vaessl.app.shared;
public enum Endpoint {
HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"), CONNECTION_STATUS(
"/connections/status"), HOMEBOX_QUERY_ALL_ITEMS("/api/v1/entities"), SEARCH("/search");
"/connections/status"), HOMEBOX_QUERY_ALL_ITEMS(
"/api/v1/entities"), SEARCH("/search"), SYNC("/sync");
private final String value;
@@ -4,7 +4,7 @@ import java.util.Map;
import jakarta.validation.constraints.NotNull;
public record ServiceItem(String id, @NotNull String title, String description,
Map<String, Object> extraData) {
Map<String, Map<String, Object>> extraData) {
public String getExtra(String key) {
if (extraData == null) {
@@ -1,17 +1,31 @@
package com.vaessl.app.sync;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import com.vaessl.app.homebox.HomeboxItemClient;
import com.vaessl.app.homebox.HomeboxItemClient.HomeboxItemPage;
import com.vaessl.app.shared.ServiceItem;
import com.vaessl.app.shared.ServiceType;
import com.vaessl.app.vector.EmbeddingService;
import lombok.RequiredArgsConstructor;
@Service
/**
* Pages through the full Homebox catalog and re-indexes it into the vector store. Item IDs are
* collected across all pages before {@link EmbeddingService#deleteStaleVectorEntries} is called
* once at the end, rather than after each page: deleting per page would treat every item outside
* the current page as stale, wiping out entries from pages already synced.
*/
@Component
@RequiredArgsConstructor
public class HomeboxSyncProvider implements SyncProvider {
private final EmbeddingService embeddingService;
public HomeboxSyncProvider(EmbeddingService embeddingService) {
this.embeddingService = embeddingService;
}
private final HomeboxItemClient client;
@Override
public ServiceType getServiceType() {
@@ -19,9 +33,25 @@ public class HomeboxSyncProvider implements SyncProvider {
}
@Override
public void syncVectorStore() {
// TODO Auto-generated method stub
throw new UnsupportedOperationException("Unimplemented method 'syncVectorStore'");
}
public void syncVectorStore(SyncRequest request) {
int batchSize = 100;
int page = 0;
Page<ServiceItem> current;
Long connectionId = null;
List<String> currentItemIds = new ArrayList<>();
do {
Pageable pageable = PageRequest.of(page, batchSize);
HomeboxItemPage result = client.hbResponse(request, null, pageable);
current = result.page();
if (connectionId == null) {
connectionId = result.connectionId();
}
embeddingService.vectorizeData(current.getContent(), request.serviceType(),
result.connectionId(), currentItemIds);
page++;
} while (page < current.getTotalPages());
embeddingService.deleteStaleVectorEntries(connectionId, currentItemIds);
}
}
@@ -0,0 +1,40 @@
package com.vaessl.app.sync;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import com.vaessl.app.shared.SessionKeys;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
@RestController
public class SyncController {
private final SyncService syncService;
public SyncController(SyncService syncService) {
this.syncService = syncService;
}
/**
* Triggers a vector-store sync for the requested service. Returns {@code 401 Unauthorized}
* if there is no active session.
*/
@PostMapping("/sync")
public ResponseEntity<Object> syncVectorStore(@Valid @RequestBody SyncRequest request,
HttpServletRequest httpReq) {
HttpSession session = httpReq.getSession(false);
if (session == null
|| session.getAttribute(SessionKeys.connectionId(request.serviceType())) == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
syncService.syncServiceVectorStore(request);
return ResponseEntity.noContent().build();
}
}
@@ -2,6 +2,16 @@ package com.vaessl.app.sync;
import com.vaessl.app.shared.ServiceProvider;
public interface SyncProvider extends ServiceProvider{
public void syncVectorStore();
/**
* Implemented by any service that supports indexing its items into the vector store.
*/
public interface SyncProvider extends ServiceProvider {
/**
* Fetches all items from the remote service and indexes them into the vector store,
* removing any previously indexed items that no longer exist upstream.
*
* @param request the sync request containing the app URL and user credentials
*/
public void syncVectorStore(SyncRequest request);
}
@@ -0,0 +1,10 @@
package com.vaessl.app.sync;
import com.vaessl.app.connection.ConnectionIdentifiable;
import com.vaessl.app.shared.ServiceType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record SyncRequest(@NotBlank String appUrl, @NotBlank String username,
@NotNull ServiceType serviceType) implements ConnectionIdentifiable {
}
@@ -0,0 +1,40 @@
package com.vaessl.app.sync;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
import com.vaessl.app.exception.WrongServiceTypeException;
import com.vaessl.app.shared.ServiceType;
@Service
public class SyncService {
private final Map<ServiceType, SyncProvider> providerRegistry;
public SyncService(List<SyncProvider> providers) {
Map<ServiceType, SyncProvider> registry = new EnumMap<>(ServiceType.class);
for (SyncProvider provider : providers) {
registry.put(provider.getServiceType(), provider);
}
this.providerRegistry = registry;
}
/**
* Dispatches the vector-store sync request to the provider registered for
* {@link SyncRequest#serviceType()}.
*
* @param request the sync request
* @throws WrongServiceTypeException if no provider is registered for the given service type
*/
public void syncServiceVectorStore(SyncRequest request) {
SyncProvider provider = providerRegistry.get(request.serviceType());
if (provider == null) {
throw new WrongServiceTypeException();
}
provider.syncVectorStore(request);
}
}
@@ -4,80 +4,116 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.stereotype.Service;
import com.vaessl.app.shared.ServiceItem;
import com.vaessl.app.shared.ServiceType;
import lombok.RequiredArgsConstructor;
/**
* Embeds {@link ServiceItem}s into the shared vector store and prunes entries that no longer exist
* upstream. Kept stateless (no instance fields besides {@code vectorStore}) since this is a
* singleton bean shared across concurrent sync runs; callers own the per-sync ID accumulator.
*/
@Service
@RequiredArgsConstructor
public class EmbeddingService {
private static final float THRESHOLD = 0.7f;
private static final int LIMIT = 2;
private final VectorStore vectorStore;
public EmbeddingService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
public void vectorizeData(List<ServiceItem> items, ServiceType serviceType, Long connectionId) {
/**
* Embeds and upserts {@code items} into the vector store, appending each item's ID to
* {@code currentItemIds} so the caller can later prune stale entries via
* {@link #deleteStaleVectorEntries}. Safe to call repeatedly (e.g. once per page) against the
* same accumulator across a single sync run.
*
* @param items the items to embed for this batch
* @param serviceType the originating service, stored in each document's metadata
* @param connectionId the connection these items belong to
* @param currentItemIds accumulator collecting every item ID seen so far in this sync run
*/
public void vectorizeData(List<ServiceItem> items, ServiceType serviceType, Long connectionId,
List<String> currentItemIds) {
List<Document> documents = new ArrayList<>();
for (ServiceItem item : items) {
documents.add(toDocument(item, serviceType, connectionId));
currentItemIds.add(item.id());
}
vectorStore.add(documents);
}
private Document toDocument(ServiceItem item, ServiceType serviceType, Long connectionId) {
String extraData = buildExtraData(item.extraData());
Map<String, Object> metadata = buildMetadata(item, serviceType, connectionId, extraData);
Map<String, Object> metadata = buildMetadata(item, serviceType, connectionId);
String content = buildContent(item, extraData);
return new Document(connectionId + ":" + item.id(), content, metadata);
}
private String buildExtraData(Map<String, Object> extraData) {
private String buildExtraData(Map<String, Map<String, Object>> extraData) {
if (extraData == null) {
return "";
}
StringBuilder data = new StringBuilder();
for (Map.Entry<String, Object> entry : extraData.entrySet()) {
StringBuilder formattedData = new StringBuilder();
for (Entry<String, Map<String, Object>> entry : extraData.entrySet()) {
StringBuilder data = new StringBuilder();
if (entry.getValue() == null || entry.getValue().toString().isEmpty()) {
continue;
}
if (!data.isEmpty()) {
data.append(" \n");
if (entry.getValue().entrySet() != null
&& !entry.getValue().entrySet().toString().isEmpty()) {
for (Entry<String, Object> nestedEntry : entry.getValue().entrySet()) {
data.append(" ").append(nestedEntry.getKey()).append(": ")
.append(nestedEntry.getValue()).append("\n");
}
}
data.append(entry.getKey()).append(": ").append(entry.getValue());
formattedData.append(entry.getKey()).append(": ").append("\n").append(data.toString());
}
return data.toString();
return formattedData.toString();
}
private Map<String, Object> buildMetadata(ServiceItem item, ServiceType serviceType,
Long connectionId, String extraData) {
Long connectionId) {
Map<String, Object> metadata = new HashMap<>();
metadata.put("connectionId", connectionId);
metadata.put("serviceType", serviceType.name());
metadata.put("itemId", item.id());
metadata.put("title", item.title());
if (item.description() != null && !item.description().isEmpty()) {
metadata.put("description", item.description());
}
if (!extraData.isEmpty()) {
metadata.put("extraData", extraData);
}
return metadata;
}
private String buildContent(ServiceItem item, String extraData) {
StringBuilder content = new StringBuilder("Title: ").append(item.title());
StringBuilder content = new StringBuilder("title: ").append(item.title());
if (item.description() != null && !item.description().isEmpty()) {
content.append("\n Description: ").append(item.description());
content.append("\ndescription: ").append(item.description());
}
if (!extraData.isEmpty()) {
content.append("\n Extradata: ").append(extraData);
content.append("\n").append(extraData);
}
return content.toString();
}
/**
* Deletes every vector for {@code connectionId} whose item ID is not in {@code currentItemIds},
* then clears the accumulator. Must be called once, after every page for this sync run has gone
* through {@link #vectorizeData}, not per page — otherwise items from pages other than the most
* recent one would look stale and get deleted too.
*
* @param connectionId the connection to prune stale vectors for
* @param currentItemIds every item ID seen across the full sync run; cleared after this call
*/
public void deleteStaleVectorEntries(Long connectionId, List<String> currentItemIds) {
FilterExpressionBuilder b = new FilterExpressionBuilder();
vectorStore.delete(b.and(b.eq("connectionId", connectionId),
b.nin("itemId", new ArrayList<>(currentItemIds))).build());
currentItemIds.clear();
}
}
@@ -13,4 +13,43 @@ public final class Mockdata {
public static final String MOCK_ID = "item-1";
public static final String MOCK_TITLE = "title";
public static final String MOCK_DESCRIPTION = "desc";
public static final String VALID_HOMEBOX_LOGIN_RESPONSE = """
{
"token": "fake-bearer-token",
"attachmentToken": "fake-attach-token",
"expiresAt": "2099-01-01T00:00:00Z"
}
""";
public static final String VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE = """
{
"page": -1,
"pageSize": -1,
"total": 1,
"items": [
{
"id": "c643e7f9-93d0-4b5f-ae4d-e1c2d90389e0",
"assetId": "000-001",
"name": "MacBook Pro A1398",
"description": "Running Linux (Fedora)",
"quantity": 1,
"insured": false,
"archived": false,
"createdAt": "2026-05-13T19:52:20.016176Z",
"updatedAt": "2026-05-14T12:39:11.836403Z",
"purchasePrice": 0,
"parent": {
"id": "b6f60ab8-3a2a-4a8d-a4bf-897d0555f636",
"name": "Server Schrank Ikea weiß",
"description": "Weißer Ikea Schrank, wo sich der Server befindet.",
"createdAt": "2026-05-13T19:55:55.817576Z",
"updatedAt": "2026-05-14T12:37:24.396651Z"
},
"tags": [],
"imageId": "cb3e44d5-ccd4-421e-9f5a-f52cd5f40ca6",
"thumbnailId": "2bfd53fa-1bf1-483c-8d76-7720464532fa",
"soldTime": "0001-01-01T00:00:00Z"
}
]
}
""";
}
@@ -10,8 +10,8 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import com.vaessl.app.connection.ConnectionRepository;
import com.vaessl.app.exception.ConnectionNotFoundException;
import com.vaessl.app.homebox.HomeboxItemClient;
import static com.vaessl.app.shared.ServiceType.HOMEBOX;
@@ -19,7 +19,7 @@ import static com.vaessl.app.shared.ServiceType.HOMEBOX;
class HomeboxSearchProviderTest {
@Mock
private ConnectionRepository mockRepo;
private HomeboxItemClient client;
@InjectMocks
private HomeboxSearchProvider provider;
@@ -27,11 +27,11 @@ class HomeboxSearchProviderTest {
@Test
void shouldReturnConnectionNotFoundException() {
when(mockRepo.findByAppUrlAndUsername(MOCK_URL, MOCK_USER)).thenReturn(null);
SearchRequest request =
new SearchRequest(MOCK_URL, MOCK_USER, "test query", HOMEBOX, false);
Pageable pageable = PageRequest.of(0, 10);
SearchRequest request = new SearchRequest(MOCK_URL, MOCK_USER, "", HOMEBOX, false);
when(client.hbResponse(request, "", pageable)).thenThrow(new ConnectionNotFoundException());
assertThrows(ConnectionNotFoundException.class,
() -> provider.getSearchResults(request, pageable));
}
@@ -2,7 +2,14 @@ package com.vaessl.app.search;
import static com.vaessl.app.Mockdata.MOCK_PASS;
import static com.vaessl.app.Mockdata.MOCK_USER;
import static com.vaessl.app.shared.Endpoint.*;
import static com.vaessl.app.Mockdata.VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE;
import static com.vaessl.app.Mockdata.VALID_HOMEBOX_LOGIN_RESPONSE;
import static com.vaessl.app.shared.Endpoint.LOGIN;
import static com.vaessl.app.shared.Endpoint.SEARCH;
import static com.vaessl.app.shared.Endpoint.HOMEBOX_QUERY_ALL_ITEMS;
import static com.vaessl.app.shared.Endpoint.HOMEBOX_LOGIN;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -18,6 +25,11 @@ import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import jakarta.servlet.http.Cookie;
/**
* Integration tests for {@code POST /api/search}, verifying the session-gated contract and request
* validation against a mocked Homebox backend (via WireMock) and a real Spring MVC dispatch chain
* (via {@link MockMvc}).
*/
@SpringBootTest
@AutoConfigureMockMvc
@WireMockTest
@@ -32,47 +44,14 @@ class SearchControllerTest {
private static final String SEARCH_REQUEST = SEARCH.getValue();
private static final String VALID_HOMEBOX_LOGIN_RESPONSE = """
{
"token": "fake-bearer-token",
"attachmentToken": "fake-attach-token",
"expiresAt": "2099-01-01T00:00:00Z"
}
""";
private static final String VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE = """
{
"page": -1,
"pageSize": -1,
"total": 1,
"items": [
{
"id": "c643e7f9-93d0-4b5f-ae4d-e1c2d90389e0",
"assetId": "000-001",
"name": "MacBook Pro A1398",
"description": "Running Linux (Fedora)",
"quantity": 1,
"insured": false,
"archived": false,
"createdAt": "2026-05-13T19:52:20.016176Z",
"updatedAt": "2026-05-14T12:39:11.836403Z",
"purchasePrice": 0,
"parent": {
"id": "b6f60ab8-3a2a-4a8d-a4bf-897d0555f636",
"name": "Server Schrank Ikea weiß",
"description": "Weißer Ikea Schrank, wo sich der Server befindet.",
"createdAt": "2026-05-13T19:55:55.817576Z",
"updatedAt": "2026-05-14T12:37:24.396651Z"
},
"tags": [],
"imageId": "cb3e44d5-ccd4-421e-9f5a-f52cd5f40ca6",
"thumbnailId": "2bfd53fa-1bf1-483c-8d76-7720464532fa",
"soldTime": "0001-01-01T00:00:00Z"
}
]
}
""";
/**
* Logs in against a stubbed Homebox instance, then performs a keyword search using the
* resulting session cookie. Expects {@code 200 OK} with the mocked item mapped into the paged
* response body.
*
* @param wm WireMock runtime info, injected by {@link WireMockTest}, used to point the client
* at the stub server
*/
@Test
void shouldReturnListOfQueriedHomeboxItems(WireMockRuntimeInfo wm) throws Exception {
@@ -93,9 +72,14 @@ class SearchControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].title").value("MacBook Pro A1398"))
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].extraData.parent.name").value("Server Schrank Ikea weiß"));
.andExpect(jsonPath("$.content[0].extraData.location.name")
.value("Server Schrank Ikea weiß"));
}
/**
* Calls {@code /api/search} with no session cookie attached and expects
* {@code 401 Unauthorized}, confirming the endpoint is session-gated.
*/
@Test
void shouldReturnUnauthorizedWhenNoSession() throws Exception {
mockMvc.perform(post(SEARCH_REQUEST).contentType(MediaType.APPLICATION_JSON).content("""
@@ -109,6 +93,10 @@ class SearchControllerTest {
""")).andExpect(status().isUnauthorized());
}
/**
* Sends a search request with a {@code serviceType} that doesn't map to any known
* {@link com.vaessl.app.shared.ServiceType} and expects {@code 400 Bad Request}.
*/
@Test
void shouldReturnBadRequestWhenServiceTypeIsInvalid() throws Exception {
mockMvc.perform(post(SEARCH_REQUEST).contentType(MediaType.APPLICATION_JSON)
@@ -18,7 +18,7 @@ class SearchResponseTest {
@Test
void shouldReturnNullWhenExtraDataKeyIsMissing() {
ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", Map.of("key", "value")));
assertThat(response.getExtra("missing")).isNull();
}
@@ -26,7 +26,8 @@ class SearchResponseTest {
@Test
void shouldReturnExtraDataValue() {
ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key",
Map.of("key", "value")));
assertThat(response.id()).isEqualTo(MOCK_ID);
assertThat(response.getExtra("key")).contains("value");
@@ -0,0 +1,103 @@
package com.vaessl.app.sync;
import static com.vaessl.app.shared.Endpoint.HOMEBOX_LOGIN;
import static com.vaessl.app.shared.Endpoint.HOMEBOX_QUERY_ALL_ITEMS;
import static com.vaessl.app.shared.Endpoint.LOGIN;
import static com.vaessl.app.shared.Endpoint.SYNC;
import static com.vaessl.app.Mockdata.MOCK_USER;
import static com.vaessl.app.Mockdata.MOCK_SERVICE_TYPE;
import static com.vaessl.app.Mockdata.MOCK_PASS;
import static com.vaessl.app.Mockdata.VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE;
import static com.vaessl.app.Mockdata.VALID_HOMEBOX_LOGIN_RESPONSE;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import jakarta.servlet.http.Cookie;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
/**
* Integration tests for {@code POST /api/sync}, verifying the session-gated contract against a
* mocked Homebox backend (via WireMock) and a real Spring MVC dispatch chain (via {@link MockMvc}).
*/
@SpringBootTest
@AutoConfigureMockMvc
@WireMockTest
class SyncControllerTest {
@Autowired
MockMvc mockMvc;
private static final String SYNC_PATH = SYNC.getValue();
/**
* Logs in against a stubbed Homebox instance, then performs a sync using the resulting session
* cookie. Expects a {@code 204 No Content} response once the mocked item catalog has been paged
* through and embedded.
*
* @param wm WireMock runtime info, injected by {@link WireMockTest}, used to point the client
* at the stub server
*/
@Test
void shouldReturn204NoContentOnSuccessfulSync(WireMockRuntimeInfo wm) throws Exception {
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
.willReturn(WireMock.okJson(VALID_HOMEBOX_LOGIN_RESPONSE)));
WireMock.stubFor(WireMock.get(WireMock.urlPathEqualTo(HOMEBOX_QUERY_ALL_ITEMS.getValue()))
.willReturn(WireMock.okJson(VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE)));
MvcResult loginResult =
mockMvc.perform(post(LOGIN.getValue()).contentType(MediaType.APPLICATION_JSON)
.content(connectionRequestBody(wm))).andExpect(status().isOk()).andReturn();
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
mockMvc.perform(post(SYNC_PATH).cookie(sessionCookie)
.contentType(MediaType.APPLICATION_JSON).content(syncRequestBody(wm)))
.andExpect(status().isNoContent());
}
/**
* Calls {@code /api/sync} with no session cookie attached and expects {@code 401 Unauthorized},
* confirming the endpoint is session-gated.
*
* @param wm WireMock runtime info, injected by {@link WireMockTest}, used only to build a
* well-formed request body
*/
@Test
void shouldReturn401UnauthorizedWhenSessionIsNull(WireMockRuntimeInfo wm) throws Exception {
mockMvc.perform(post(SYNC_PATH).contentType(MediaType.APPLICATION_JSON)
.content(syncRequestBody(wm))).andExpect(status().isUnauthorized());
}
private String connectionRequestBody(WireMockRuntimeInfo wm) {
return """
{
"appUrl": "%s",
"serviceType": "%s",
"username": "%s",
"password": "%s"
}
""".formatted(wm.getHttpBaseUrl(), MOCK_SERVICE_TYPE, MOCK_USER, MOCK_PASS);
}
private String syncRequestBody(WireMockRuntimeInfo wm) {
return """
{
"appUrl": "%s",
"serviceType": "%s",
"username": "%s"
}
""".formatted(wm.getHttpBaseUrl(), MOCK_SERVICE_TYPE, MOCK_USER);
}
}
+61
View File
@@ -0,0 +1,61 @@
# Git Conventions
## Branch Naming
```
<type>/<short-kebab-case-description>
```
- All lowercase, hyphens only — no camelCase or Title-Case
- Keep the description short (35 words); the branch name is not the place for detail
- If using a ticket tracker, include the ID: `feature/VAE-123-ai-search`
| Type | Use for |
|---|---|
| `feature/` | New functionality |
| `fix/` | Bug fixes |
| `refactor/` | Code changes with no behavior change |
| `chore/` | Tooling, deps, config, cleanup |
| `docs/` | Documentation only |
**Example:** `feature/ai-search`
---
## Commit Messages
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
<type>(<scope>): <imperative, present-tense summary>
<optional body — explain WHY, not WHAT>
```
- **Type** — same list as branch types above, plus `test`, `style`
- **Scope** — the module/package touched (e.g. `search`, `sync`, `connection`, `vector`, `frontend`)
- **Summary** — imperative mood ("add", not "added" or "adds"); no period at the end
- **Body** — only when the reasoning isn't obvious from the diff (a constraint, a bug workaround, a decision). Skip it for simple/self-explanatory changes.
**Examples:**
```
feat(search): route SearchRequest.aiSearch to AI provider map
fix(sync): prune stale vectors only after full page loop completes
style: reformat
refactor(vector): nest extraData by section for clearer embedding
```
---
## Ticket Titles
Frame as the outcome, not a task log:
```
<Type>: <what changes for the user/system>
```
**Examples:**
- `Feature: Add AI-powered search to Homebox connector`
- `Bug: /api/sync not triggered on login`
- `Chore: Consolidate SearchService provider maps`
+13 -5
View File
@@ -1,11 +1,19 @@
import { apiFetch } from './client'
import type { AuthResponse, ConnectionStatus, LoginRequest, ServiceType } from '../types/connection'
import { apiFetch } from "./client";
import type {
AuthResponse,
ConnectionStatus,
LoginRequest,
ServiceType,
} from "../types/connection";
export const login = (req: LoginRequest) =>
apiFetch<AuthResponse>('/login', { method: 'POST', body: JSON.stringify(req) })
apiFetch<AuthResponse>("/login", {
method: "POST",
body: JSON.stringify(req),
});
export const getStatuses = () =>
apiFetch<ConnectionStatus[]>('/connections/status')
apiFetch<ConnectionStatus[]>("/connections/status");
export const logout = (serviceType: ServiceType) =>
apiFetch<void>(`/connections/${serviceType}`, { method: 'DELETE' })
apiFetch<void>(`/connections/${serviceType}`, { method: "DELETE" });
+4
View File
@@ -3,6 +3,7 @@ import type {
PagedSearchResponse,
SearchRequest,
ServiceItem,
SyncRequest,
} from "../types/search";
export const search = (req: SearchRequest) =>
@@ -10,3 +11,6 @@ export const search = (req: SearchRequest) =>
method: "POST",
body: JSON.stringify(req),
});
export const syncVectorData = (req: SyncRequest) =>
apiFetch<Object>("/sync", { method: "POST", body: JSON.stringify(req) });
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type SyntheticEvent } from 'react'
import { login } from '../../api/connections'
import type { LoginRequest, ServiceType } from '../../types/connection'
import { uiText } from '../../text/uiText'
import '../ui/Modal.scss'
interface Props {
@@ -44,7 +45,7 @@ export function ConnectModal({ serviceType, label, onClose, onSuccess }: Readonl
await login(req)
onSuccess()
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
setError(err instanceof Error ? err.message : uiText.connections.connectModal.loginFailed)
} finally {
setLoading(false)
}
@@ -53,31 +54,31 @@ export function ConnectModal({ serviceType, label, onClose, onSuccess }: Readonl
return (
<dialog className="modal" ref={dialogRef}>
<div className="modal__header">
<h2 className="modal__title" id="modal-title">Connect to {label}</h2>
<button className="modal__close" onClick={onClose} aria-label="Close">×</button>
<h2 className="modal__title" id="modal-title">{uiText.connections.connectModal.title(label)}</h2>
<button className="modal__close" onClick={onClose} aria-label={uiText.modal.closeAriaLabel}>{uiText.modal.closeSymbol}</button>
</div>
<form className="modal__form" onSubmit={handleSubmit}>
<div className="modal__field">
<label className="modal__label" htmlFor="appUrl">App URL</label>
<label className="modal__label" htmlFor="appUrl">{uiText.connections.connectModal.appUrlLabel}</label>
<input id="appUrl" ref={firstInputRef} className="modal__input" type="url"
placeholder="https://homebox.example.com"
placeholder={uiText.connections.connectModal.appUrlPlaceholder}
value={appUrl} onChange={e => setAppUrl(e.target.value)} required />
</div>
<div className="modal__field">
<label className="modal__label" htmlFor="username">Username</label>
<label className="modal__label" htmlFor="username">{uiText.connections.connectModal.usernameLabel}</label>
<input id="username" className="modal__input" type="text"
autoComplete="username"
value={username} onChange={e => setUsername(e.target.value)} required />
</div>
<div className="modal__field">
<label className="modal__label" htmlFor="password">Password</label>
<label className="modal__label" htmlFor="password">{uiText.connections.connectModal.passwordLabel}</label>
<input id="password" className="modal__input" type="password"
autoComplete="current-password"
value={password} onChange={e => setPassword(e.target.value)} required />
</div>
{error && <p className="modal__error">{error}</p>}
<button className="modal__submit" type="submit" disabled={loading}>
{loading ? 'Connecting…' : 'Connect'}
{loading ? uiText.connections.connectModal.connectingButton : uiText.connections.connectModal.connectButton}
</button>
</form>
</dialog>
@@ -5,6 +5,7 @@ import { ServiceCard } from "./ServiceCard"
import { ConnectModal } from "./ConnectModal"
import "./Dashboard.scss"
import { SearchModal } from "../search/SearchModal"
import { uiText } from "../../text/uiText"
const SERVICES = [
{ serviceType: ServiceType.HOMEBOX, label: 'Homebox', icon: '📦' },
@@ -32,10 +33,10 @@ export function Dashboard() {
return (
<div className="dashboard">
<div className="dashboard__header">
<h1 className="dashboard__title">Vaessl Dashboard</h1>
<h1 className="dashboard__title">{uiText.dashboard.title}</h1>
</div>
<p className="dashboard__section-label">Services</p>
<p className="dashboard__section-label">{uiText.dashboard.servicesLabel}</p>
<div className="dashboard__cards">
{SERVICES.map(({ serviceType, label, icon }) => (
<ServiceCard
@@ -32,7 +32,6 @@
color: var(--text-h);
margin: 0 0 10px;
text-align: left;
}
&__meta {
@@ -54,7 +53,7 @@
border-radius: 999px;
&::before {
content: '';
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
@@ -1,5 +1,6 @@
import type { ConnectionStatus, ServiceType } from '../../types/connection'
import { ActionButton } from '../ui/ActionButton'
import { uiText } from '../../text/uiText'
import './ServiceCard.scss'
interface Props {
@@ -29,20 +30,20 @@ export function ServiceCard({ serviceType: _serviceType, label, icon, status, on
<p className="service-card__name">{label}</p>
<p className="service-card__meta">
<span className={`service-card__badge service-card__badge--${connected ? 'connected' : 'disconnected'}`}>
{connected ? 'Connected' : 'Not connected'}
{connected ? uiText.connections.serviceCard.connected : uiText.connections.serviceCard.notConnected}
</span>
{connected && status?.username && <span>{status.username}</span>}
{connected && status?.expiresAt && (
<span>· expires {formatExpiry(status.expiresAt)}</span>
<span>{uiText.connections.serviceCard.expiresPrefix(formatExpiry(status.expiresAt) ?? '')}</span>
)}
</p>
</div>
</div>
<div className="service-card__actions">
<ActionButton variant={connected ? 'disconnect' : 'connect'} onClick={connected ? onDisconnect : onConnect}>
{connected ? 'Disconnect' : 'Connect'}
{connected ? uiText.connections.serviceCard.disconnectButton : uiText.connections.serviceCard.connectButton}
</ActionButton>
{connected && (<ActionButton variant='search' onClick={onSearch}>Search</ActionButton>)}
{connected && (<ActionButton variant='search' onClick={onSearch}>{uiText.connections.serviceCard.searchButton}</ActionButton>)}
</div>
</div>
)
+29 -14
View File
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState, type SyntheticEvent } from 'react'
import '../ui/Modal.scss'
import { type PagedSearchResponse, type ServiceItem, type SearchRequest } from '../../types/search'
import { search } from '../../api/searches'
import { type PagedSearchResponse, type ServiceItem, type SearchRequest, type SyncRequest } from '../../types/search'
import { syncVectorData, search } from '../../api/searches'
import type { ServiceType } from '../../types/connection'
import { uiText } from '../../text/uiText'
interface Props {
serviceType: ServiceType
@@ -17,9 +18,10 @@ export function SearchModal({ serviceType, label, appUrl, username, onClose }: R
const [results, setResults] = useState<PagedSearchResponse<ServiceItem> | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [searchError, setSearchError] = useState<string | null>(null)
const [syncError, setSyncError] = useState<string | null>(null)
//TODO: implement aiSearch
const [aiSearch, setAiSearch] = useState(false);
const [aiSearch, setAiSearch] = useState(false)
const firstInputRef = useRef<HTMLInputElement>(null)
const dialogRef = useRef<HTMLDialogElement>(null)
@@ -40,16 +42,27 @@ export function SearchModal({ serviceType, label, appUrl, username, onClose }: R
const handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => {
e.preventDefault()
setError(null)
setSearchError(null)
setLoading(true)
try {
const req: SearchRequest = { appUrl, serviceType, username, query, aiSearch}
const res = await search(req)
console.log(req)
setResults(res)
console.log('results', results)
} catch (err) {
setError(err instanceof Error ? err.message : 'Search failed')
setSearchError(err instanceof Error ? err.message : uiText.search.searchFailed)
} finally {
setLoading(false)
}
}
const handleSync = async () => {
setSyncError(null)
setLoading(true)
try {
const req: SyncRequest = { appUrl, serviceType, username}
await syncVectorData(req)
} catch (err) {
setSyncError(err instanceof Error ? err.message: uiText.search.syncFailed)
} finally {
setLoading(false)
}
@@ -58,18 +71,20 @@ export function SearchModal({ serviceType, label, appUrl, username, onClose }: R
return (
<dialog className='modal' ref={dialogRef}>
<div className='modal__header'>
<h2 className='modal__title' id='modal-title'>Search in {label}</h2>
<button className='modal__close' onClick={onClose} aria-label='Close'>×</button>
<h2 className='modal__title' id='modal-title'>{uiText.search.title(label)}</h2>
<button className='modal__close' onClick={onClose} aria-label={uiText.modal.closeAriaLabel}>{uiText.modal.closeSymbol}</button>
</div>
<form className='modal__form' onSubmit={handleSubmit}>
<div className='modal__field'>
<input id='search' className='modal__input'
value={query} onChange={e => setQuery(e.target.value)} />
</div>
{error && <p className='modal__error'>{error}</p>}
<button className='modal__submit' type='submit' disabled={loading}>
Search
</button>
{searchError && <p className='modal__error'>{searchError}</p>}
{syncError && <p className='modal__error'>{syncError}</p>}
<div className='modal__actions'>
<button className='modal__other' title={uiText.search.syncTooltip(label)} type='button' disabled={loading} onClick={handleSync}>{uiText.search.refreshDataButton}</button>
<button className='modal__submit' type='submit' disabled={loading}>{uiText.search.searchButton}</button>
</div>
</form>
{results && (
<div className='modal__results'>
+3 -1
View File
@@ -5,7 +5,9 @@
border-radius: 6px;
border: 1px solid transparent;
cursor: pointer;
transition: box-shadow 0.2s, opacity 0.2s;
transition:
box-shadow 0.2s,
opacity 0.2s;
&:hover {
opacity: 0.85;
+67 -43
View File
@@ -44,8 +44,13 @@
line-height: 1;
padding: 4px;
border-radius: 4px;
&:hover { color: var(--text-h); }
&:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
&:hover {
color: var(--text-h);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
&__form {
@@ -94,62 +99,81 @@
border: 1px solid rgba(239, 68, 68, 0.2);
}
&__submit {
&__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
&__submit,
&__other {
padding: 10px 20px;
font-size: 15px;
font-weight: 500;
border-radius: 6px;
border: none;
background: var(--accent);
color: #fff;
cursor: pointer;
transition: opacity 0.2s;
align-self: flex-end;
min-width: 100px;
color: #fff;
&:hover:not(:disabled) { opacity: 0.85; }
&:disabled { opacity: 0.6; cursor: not-allowed; }
&:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
&:hover:not(:disabled) {
opacity: 0.85;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
&--expanded {
max-height: 70vh;
}
&__submit {
background: var(--accent);
}
&__results {
overflow-y: auto;
flex: 1;
min-height: 0; // required: flex children won't shrink without this
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 8px;
}
&--expanded {
max-height: 70vh;
}
&__results-count {
font-size: 12px;
color: var(--text);
margin: 0 0 8px 0;
}
&__results {
overflow-y: auto;
flex: 1;
min-height: 0; // required: flex children won't shrink without this
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 8px;
}
&__result-item {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
}
&__results-count {
font-size: 12px;
color: var(--text);
margin: 0 0 8px 0;
}
&__result-title {
font-weight: 500;
font-size: 14px;
color: var(--text-h);
margin: 0;
}
&__result-item {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
}
&__result-desc {
font-size: 13px;
color: var(--text);
margin: 4px 0 0 0;
}
&__result-title {
font-weight: 500;
font-size: 14px;
color: var(--text-h);
margin: 0;
}
&__result-desc {
font-size: 13px;
color: var(--text);
margin: 4px 0 0 0;
}
}
+39
View File
@@ -0,0 +1,39 @@
export const uiText = {
modal: {
closeAriaLabel: "Close",
closeSymbol: "×",
},
dashboard: {
title: "Vaessl Dashboard",
servicesLabel: "Services",
},
connections: {
connectModal: {
title: (label: string) => `Connect to ${label}`,
appUrlLabel: "App URL",
appUrlPlaceholder: "https://homebox.example.com",
usernameLabel: "Username",
passwordLabel: "Password",
loginFailed: "Login failed",
connectButton: "Connect",
connectingButton: "Connecting…",
},
serviceCard: {
connected: "Connected",
notConnected: "Not connected",
expiresPrefix: (date: string) => `· expires ${date}`,
disconnectButton: "Disconnect",
connectButton: "Connect",
searchButton: "Search",
},
},
search: {
title: (label: string) => `Search in ${label}`,
searchFailed: "Search failed",
syncFailed: "Refreshing data failed",
syncTooltip: (label: string) =>
`Syncs your ${label} database for vectorization`,
refreshDataButton: "Refresh Data",
searchButton: "Search",
},
} as const;
+6
View File
@@ -24,3 +24,9 @@ export interface PagedSearchResponse<T> {
last: boolean;
sort: string;
}
export interface SyncRequest {
appUrl: string;
username: string;
serviceType: ServiceType;
}