Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
27bc674
chore(web-app-ai-smart-collections-nav): scaffold package
LukasHirt Jul 7, 2026
0ff8791
feat(web-app-ai-smart-collections-nav): Core logic: implement `src/co…
LukasHirt Jul 7, 2026
36cceec
fix(web-app-ai-smart-collections-nav): repair failing stage
LukasHirt Jul 7, 2026
e734466
fix(web-app-ai-smart-collections-nav): repair failing stage
LukasHirt Jul 7, 2026
b7a5529
feat(web-app-ai-smart-collections-nav): UI: build `src/views/Collecti…
LukasHirt Jul 7, 2026
0c42738
test(web-app-ai-smart-collections-nav): Unit tests: write `tests/unit…
LukasHirt Jul 7, 2026
4104f14
fix(web-app-ai-smart-collections-nav): repair failing stage
LukasHirt Jul 7, 2026
e6b6775
fix(web-app-ai-smart-collections-nav): repair failing stage
LukasHirt Jul 7, 2026
c3a46b6
Revert "fix(web-app-ai-smart-collections-nav): repair failing stage"
LukasHirt Jul 7, 2026
46f44ae
test(web-app-ai-smart-collections-nav): E2E tests: replace the `tests…
LukasHirt Jul 7, 2026
0540fbe
fix(web-app-ai-smart-collections-nav): repair failing stage
LukasHirt Jul 7, 2026
cfd89ad
fix(web-app-ai-smart-collections-nav): repair failing stage
LukasHirt Jul 7, 2026
649282d
fix(web-app-ai-smart-collections-nav): repair failing stage
LukasHirt Jul 7, 2026
a791608
fix(web-app-ai-smart-collections-nav): move manifest.json into Vite's…
LukasHirt Jul 7, 2026
7e4af7e
fix(web-app-ai-smart-collections-nav): repair failing stage
LukasHirt Jul 7, 2026
2689f71
fix(web-app-ai-smart-collections-nav): repair failing stage
LukasHirt Jul 7, 2026
7b8cc8d
fix(web-app-ai-smart-collections-nav): repair failing stage
LukasHirt Jul 7, 2026
ee19c2f
fix(web-app-ai-smart-collections-nav): don't split lenient collection…
LukasHirt Jul 7, 2026
3f6045c
docs(web-app-ai-smart-collections-nav): Update README.md (and CLAUDE.…
LukasHirt Jul 7, 2026
edf329e
chore(web-app-ai-smart-collections-nav): register in docker-compose, …
LukasHirt Jul 7, 2026
5098a40
fix(web-app-ai-smart-collections-nav): sanitize prompt input and pres…
LukasHirt Jul 13, 2026
7ad2253
fix(web-app-ai-smart-collections-nav): distinguish an all-spaces outa…
LukasHirt Jul 13, 2026
a86d6fa
fix(web-app-ai-smart-collections-nav): render partial results inline …
LukasHirt Jul 13, 2026
0154d69
chore(web-app-ai-smart-collections-nav): bump @ownclouders deps to ma…
LukasHirt Jul 13, 2026
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
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ jobs:
- web-app-ai-data-insights-sidebar
- web-app-ai-multi-doc-synthesizer
- web-app-ai-smart-file-tagger-qa
- web-app-ai-smart-collections-nav
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
Expand Down
6 changes: 6 additions & 0 deletions dev/docker/ocis.apps.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,9 @@ ai-smart-file-tagger-qa:
llm:
endpoint: 'https://host.docker.internal:9200/ai-llm-proxy/v1'
model: 'llama3.2'

ai-smart-collections-nav:
config:
llm:
endpoint: 'https://host.docker.internal:9200/ai-llm-proxy/v1'
model: 'llama3.2'
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ services:
- ./packages/web-app-ai-multi-doc-synthesizer/dist:/web/apps/ai-multi-doc-synthesizer
- ./packages/web-app-ai-smart-file-tagger-qa/dist:/web/apps/ai-smart-file-tagger-qa
- ./packages/web-app-vim-nav/dist:/web/apps/vim-nav
- ./packages/web-app-ai-smart-collections-nav/dist:/web/apps/ai-smart-collections-nav
depends_on:
- traefik

Expand Down
132 changes: 132 additions & 0 deletions packages/web-app-ai-smart-collections-nav/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# AI Smart Collections Nav Item

An ownCloud Infinite Scale (oCIS) extension that adds a **Collections** entry to the
global app switcher. It lists the current user's most recently modified files across
all their accessible spaces, sends file names and short text excerpts to an
admin-configured, OpenAI-compatible LLM endpoint, and renders the LLM's clustering
response as clickable collection cards (e.g. "Invoices", "Contracts", "Meeting notes").
Clicking a card shows the filtered list of files in that theme.

Nothing is moved, renamed, or tagged server-side — collections are a **read-only,
derived view** recomputed each time the page loads. No LLM provider is bundled and no
API keys are embedded in the browser (BYO-LLM, operator-controlled endpoint).

Requests flow **browser → `ai-llm-proxy` sidecar → LLM**. The sidecar validates the
user's oCIS bearer token and forwards the request to the configured LLM with the API
key injected server-side. The API key never reaches the browser.

## Note on placement

The original spec asked for a Files-app left-nav entry via the `app.files.navItems`
(`sidebarNav`-typed) extension point. A live gate run confirmed the installed
`@ownclouders/web-pkg` (12.4.2) does not render that extension point at all — the
Files app's sidebar only lists its four built-in items. The extension is registered
as an `appMenuItem` instead (global app switcher), the same proven pattern used by
`draw-io` and `group-management`. This is a placement change from the original spec,
not a missing feature.

## Features

- Fetches recent files across **all** of the user's accessible spaces via one WebDAV
`REPORT` (KQL) request per space, merges and sorts by modification date, and caps
the result to the 100 most recent files
- Fetches capped text excerpts (≤ 1 MB, plain-text-like extensions only — `.txt`,
`.md`, `.csv`, `.tsv`, `.json`, `.yaml`/`.yml`, `.log`, `.rtf`) to give the LLM more
than just file names to cluster on
- One-time per-session consent dialog before any file name or excerpt is sent to the
LLM; declining skips the AI call entirely with no network request made
- Structured `{fileId, collection}` clustering via `response_format: json_object`,
with a lenient line-based fallback (`fileId: collection label`) when the LLM
ignores structured output
- Large file sets are split into batches of 30 files, processed sequentially (not in
parallel, to stay within any per-user rate limit the proxy enforces), and merged
client-side by file ID — collection labels are reconciled by exact string match
only (no cross-batch re-clustering; e.g. "Invoices" and "Invoice" from different
batches are **not** unified)
- Loading, empty, error, and consent-declined states, each with a retry/continue path

## Extension Point

| ID | Type |
|----|------|
| `app.ai-smart-collections-nav.menuItem` | `appMenuItem` — "Collections" entry in the global app switcher |

## Configuration

### Web App Config

Admins set the proxy endpoint and model in the oCIS Web app config. The key **must**
match the Docker mount target directory (`ai-smart-collections-nav`), not the
package name:

```yaml
ai-smart-collections-nav:
config:
llm:
endpoint: 'https://your-ocis.example.com/ai-llm-proxy/v1'
model: 'llama3.2'
```

The view reads `applicationConfig.llm` at startup. If `endpoint` or `model` is
missing, or the endpoint isn't same-origin, the "Collections" view surfaces an
admin-actionable error and makes no LLM call — there is no non-AI fallback for
clustering itself (unlike the recent-files listing, which always works).

### `ai-llm-proxy` Sidecar

The sidecar is configured entirely via environment variables — the LLM API key
stays server-side and never reaches the browser:

| Variable | Required | Description |
|----------|----------|--------------|
| `OCIS_URL` | yes | oCIS base URL, used to validate OIDC bearer tokens |
| `LLM_ENDPOINT` | yes | LLM base URL (OpenAI-compatible, e.g. `http://localhost:11434/v1`) |
| `LLM_API_KEY` | no | API key forwarded to the LLM in `Authorization: Bearer` |
| `PORT` | no | Listening port, default `3030` |
| `NODE_TLS_REJECT_UNAUTHORIZED` | no | Set to `0` for dev stacks with self-signed certs |

In the dev docker-compose stack the sidecar is exposed at
`https://host.docker.internal:9200/ai-llm-proxy/v1` via Traefik.

## Output Format

The LLM is prompted to return a JSON object of the shape
`{ "assignments": [{ "fileId": string, "collection": string }, ...] }` (a bare
top-level array is also accepted). When the LLM ignores `response_format` and
returns plain text, each line is parsed leniently as `fileId: collection label`
(`fileId - collection` is also tolerated); malformed or blank lines are skipped
rather than failing the whole batch.

## States

| State | Shown when |
|-------|-----------|
| Loading | Recent-files listing or LLM clustering request in flight |
| Consent prompt | First clustering attempt this session, LLM configured and same-origin |
| Consent declined | User cancelled the consent prompt — no data was sent, "Group my files" retry available |
| Collection grid | Clustering succeeded — one card per inferred collection |
| Collection file list | A collection card was clicked — filtered file list with a back action |
| Empty | No recent files found, or no collections could be inferred |
| Error | Recent-files listing failed, LLM not configured/cross-origin, or the LLM request failed — includes a Retry action |

## Development

```bash
# Install dependencies (from repo root)
pnpm install

# Build the extension
pnpm -F web-app-ai-smart-collections-nav build

# Type check
pnpm -F web-app-ai-smart-collections-nav check:types

# Unit tests
pnpm -F web-app-ai-smart-collections-nav test:unit

# E2E tests (requires a running oCIS instance — see root README for setup)
pnpm -F web-app-ai-smart-collections-nav test:e2e
```

See the root `README.md` for how to run a local oCIS development environment with
the extension loaded.
10 changes: 10 additions & 0 deletions packages/web-app-ai-smart-collections-nav/l10n/.tx/config
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[main]
host = https://www.transifex.com

[o:owncloud-org:p:owncloud-web:r:web-extensions-ai-smart-collections-nav]
file_filter = locale/<lang>/app.po
minimum_perc = 0
resource_name = web-extensions-ai-smart-collections-nav
source_file = template.pot
source_lang = en
type = PO
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
35 changes: 35 additions & 0 deletions packages/web-app-ai-smart-collections-nav/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "web-app-ai-smart-collections-nav",
"version": "0.1.0",
"description": "AI Smart Collections — appMenuItem extension that clusters recent files into AI-inferred thematic groups",
"license": "Apache-2.0",
"author": "ownCloud",
"type": "module",
"scripts": {
"build": "pnpm vite build",
"build:w": "pnpm vite build --watch --mode development",
"check:types": "vue-tsc --noEmit",
"test:unit": "NODE_OPTIONS=--unhandled-rejections=throw vitest run",
"test:e2e": "pnpm playwright test"
},
"dependencies": {
"@ownclouders/web-client": "^12.5.0",
"@ownclouders/web-pkg": "^12.5.0"
},
"devDependencies": {
"@ownclouders/extension-sdk": "12.5.0",
"@ownclouders/tsconfig": "0.0.6",
"@types/node": "22.19.19",
"@vue/test-utils": "^2.4.6",
"eslint": "9.39.4",
"happy-dom": "^20.9.0",
"prettier": "3.8.3",
"typescript": "5.9.3",
"vite": "7.2.2",
"vitest": "4.1.7",
"vue": "^3.4.21",
"vue-router": "^5.0.7",
"vue-tsc": "3.3.2",
"vue3-gettext": "^2.4.0"
}
}
22 changes: 22 additions & 0 deletions packages/web-app-ai-smart-collections-nav/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { defineConfig } from '@playwright/test'
import baseConfig from '../../playwright.config'

/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
...baseConfig,
testDir: './tests/e2e',
use: {
...baseConfig.use,
// Trace mode is forced via `--trace` on the gate's own invocation
// (gate/run-gate.sh) instead of here, so it doesn't ship in every
// extension's committed config.
//
// Full video/screenshot capture only during the gate's CI run (CI=true
// is set by gate/run-gate.sh) for demo-media generation; local
// `pnpm test:e2e` stays lightweight.
screenshot: process.env.CI ? 'on' : 'only-on-failure',
video: process.env.CI ? 'on' : 'off'
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"entrypoint": "index.js"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<template>
<button
type="button"
class="collection-card oc-rounded oc-background-muted oc-p-m"
:aria-label="ariaLabel"
@click="emit('click')"
>
<span class="collection-card-badge oc-rounded">
<oc-icon name="sparkling-2" fill-type="line" />
</span>
<span class="collection-card-label oc-text-truncate">{{ label }}</span>
<span class="collection-card-count oc-text-muted">{{ countLabel }}</span>
</button>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useGettext } from 'vue3-gettext'

const { $gettext, $ngettext } = useGettext()

const props = defineProps<{
label: string
fileCount: number
}>()

const emit = defineEmits<{
click: []
}>()

const countLabel = computed(() =>
$ngettext('%{n} file', '%{n} files', props.fileCount, { n: String(props.fileCount) })
)

const ariaLabel = computed(() =>
$gettext('View collection "%{label}" (%{count} files)', {
label: props.label,
count: String(props.fileCount)
})
)
</script>

<style scoped>
.collection-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--oc-space-small);
width: 100%;
border: 1px solid var(--oc-color-border);
cursor: pointer;
text-align: left;
transition: background-color 0.15s;
}

.collection-card:hover,
.collection-card:focus-visible {
background-color: var(--oc-color-background-hover);
}

.collection-card-badge {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
background-color: var(--oc-color-swatch-brand-default);
color: var(--oc-color-swatch-brand-contrast);
}

.collection-card-label {
font-weight: 600;
font-size: 1rem;
max-width: 100%;
}

.collection-card-count {
font-size: 0.8125rem;
}
</style>
Loading