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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ jobs:
- web-app-ai-smart-file-tagger-qa
- web-app-ai-smart-collections-nav
- web-app-ai-folder-readme-generator
- web-app-ai-project-board-view
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 @@ -73,3 +73,9 @@ ai-folder-readme-generator:
llm:
endpoint: 'https://host.docker.internal:9200/ai-llm-proxy/v1'
model: 'llama3.2'

ai-project-board-view:
config:
llm:
endpoint: 'https://host.docker.internal:9200/ai-llm-proxy/v1'
model: 'llama3.2'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make it and env variable please

1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ services:
- ./packages/web-app-vim-nav/dist:/web/apps/vim-nav
- ./packages/web-app-ai-smart-collections-nav/dist:/web/apps/ai-smart-collections-nav
- ./packages/web-app-ai-folder-readme-generator/dist:/web/apps/ai-folder-readme-generator
- ./packages/web-app-ai-project-board-view/dist:/web/apps/ai-project-board-view
depends_on:
- traefik

Expand Down
140 changes: 140 additions & 0 deletions packages/web-app-ai-project-board-view/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# AI Project Space Status Board

An ownCloud Infinite Scale (oCIS) folder-view extension that adds a **Status Board**
view mode to the file list, alongside the built-in "Table" and "Tiles" views. Instead of
a flat grid, files are grouped into three lanes — **Draft**, **In Review**, and **Final**
— so a project/space manager can see deliverable status at a glance without opening every
file.

The board reuses the file listing the file view has already loaded (no extra WebDAV
listing call). For each file it sends the name plus a short content excerpt to an
admin-configured, OpenAI-compatible LLM endpoint, which returns a lane classification per
file.

No LLM provider is bundled and no API keys are embedded in the browser — the endpoint is
fully operator-controlled (BYO-LLM).

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.

## Features

- Adds a "Status Board" entry to the file view's view-mode switcher
- Classifies every file into Draft / In Review / Final on load; a **Re-run classification**
button re-classifies on demand
- Batches all files in the current folder into a single LLM call per run (not one call per
file), bounded to the first 60 files — the rest default to Draft and a banner notes the
truncation
- Structured output via `response_format: json_object`; falls back to parsing one
`"<fileId-or-name>: <lane>"` line per file when the LLM returns malformed or non-JSON text
- Per-file content excerpts (first ~2 KB) are fetched for text-like files only; binary files,
unsupported mime types, and files above the fetch-size cap fall back to filename-only
classification
- Files with no match in either the structured or line-parsed response default to the Draft
lane rather than being dropped, so every file is always shown somewhere
- Graceful degradation when no LLM is configured: the board still renders with every file in
the Draft lane and no network calls are made
- User's preferred UI language is forwarded to the LLM for any explanatory text (lane values
themselves are always the English wire vocabulary `draft` / `in-review` / `final`)

## Extension Point

| ID | Type |
|----|------|
| `app.files.folder-views.folder` | `folderView` — "Status Board" view mode |

`app.files.folder-views.project-spaces` (the project-spaces *overview*, i.e. the grid of
space tiles) has no files to classify. The file listing inside an opened space — where this
board actually renders — is the generic `app.files.folder-views.folder` extension point, so
the board is registered there.

## 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-project-board-view`), not the package name:

```yaml
ai-project-board-view:
config:
llm:
endpoint: "https://your-ocis.example.com/ai-llm-proxy/v1"
model: "llama3.1:70b"
```

The board reads `applicationConfig.llm` at startup. If `endpoint` or `model` is missing, the
Status Board view mode is still available but every file is shown in the Draft lane and no
LLM calls are made.

### `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.

## Classification Output

The LLM is prompted to return a JSON object with one key:

- **classifications** — an array of `{ fileId, lane }` objects, where `fileId` matches a
file's resource ID verbatim and `lane` is one of `draft`, `in-review`, `final`

When the response is not valid JSON, the raw text is parsed line by line
(`"<fileId-or-name>: <lane>"`, matched against keywords `final`/`approved`/`done`,
`review`/`in-review`, `draft`) instead.

## Board States

| State | Shown when |
|-------|-----------|
| Loading | Classification request in flight — spinner over the lane area |
| Board | Classification complete — three lanes with file cards, "Re-run classification" enabled |
| Empty | The current folder has no files |
| Unconfigured notice | No LLM endpoint configured — all files shown in Draft |
| Error | Endpoint unreachable, auth failure, rate-limit, cross-origin block, or malformed response |
| Truncated notice | More than 60 files in the folder — only the first 60 were classified |

Errors are shown inside the board and include admin-actionable guidance (e.g. "Check the
endpoint URL in admin settings").

## Security

- The extension enforces a same-origin check on the configured LLM endpoint before attaching
the user's oCIS bearer token. Cross-origin endpoints are rejected with an in-board error
message instead of being called.
- No API key is ever stored in or passed through extension config — the key lives
exclusively in the `ai-llm-proxy` server environment.

## Development

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

# Build the extension
pnpm -F web-app-ai-project-board-view build

# Type check
pnpm -F web-app-ai-project-board-view check:types

# Unit tests
pnpm -F web-app-ai-project-board-view test:unit

# E2E tests (requires a running oCIS instance — see root README for setup)
pnpm -F web-app-ai-project-board-view 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-project-board-view/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-project-board-view]
file_filter = locale/<lang>/app.po
minimum_perc = 0
resource_name = web-extensions-ai-project-board-view
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-project-board-view/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "web-app-ai-project-board-view",
"version": "0.1.0",
"description": "AI Project Space Status Board",
"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.11.0",
"prettier": "3.9.5",
"typescript": "5.9.3",
"vite": "7.2.2",
"vitest": "4.1.10",
"vue": "^3.5.40",
"vue-router": "^5.2.0",
"vue-tsc": "3.3.7",
"vue3-gettext": "^2.4.0"
}
}
22 changes: 22 additions & 0 deletions packages/web-app-ai-project-board-view/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'
}
})
3 changes: 3 additions & 0 deletions packages/web-app-ai-project-board-view/public/manifest.json
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,61 @@
<template>
<div class="board-card oc-rounded oc-p-s oc-mb-s" data-testid="board-card">
<div class="oc-flex oc-flex-middle oc-gap-xs">
<resource-icon :resource="resource" size="small" />
<span class="board-card-name oc-text-truncate" :title="resource.name">
{{ resource.name }}
</span>
</div>
<p class="board-card-mdate oc-text-muted oc-my-rm oc-mt-xs">
{{ lastModifiedLabel }}
</p>
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useGettext } from 'vue3-gettext'
import { ResourceIcon } from '@ownclouders/web-pkg'
import type { Resource } from '@ownclouders/web-client'

const props = defineProps<{ resource: Resource }>()

const { $gettext, current: currentLanguage } = useGettext()

function formatMDate(mdate?: string): string | undefined {
if (!mdate) {
return undefined
}
const date = new Date(mdate)
if (isNaN(date.getTime())) {
return undefined
}
return date.toLocaleDateString(currentLanguage, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}

const lastModifiedLabel = computed(() => {
const formatted = formatMDate(props.resource.mdate)
return formatted
? $gettext('Modified %{date}', { date: formatted })
: $gettext('Modification date unknown')
})
</script>

<style scoped>
.board-card {
border: 1px solid var(--oc-color-border, #e0e0e0);
background-color: var(--oc-color-background-default, #fff);
}
.board-card-name {
flex: 1 1 auto;
min-width: 0;
font-weight: 600;
}
.board-card-mdate {
font-size: 0.8rem;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<template>
<section
class="board-lane oc-rounded oc-p-s oc-background-muted"
:data-testid="`board-lane-${lane}`"
:aria-label="label"
>
<header class="board-lane-header oc-flex oc-flex-middle oc-flex-between oc-mb-s">
<div class="oc-flex oc-flex-middle oc-gap-xs">
<oc-icon :name="icon.name" :fill-type="icon.fillType" />
<span class="board-lane-label">{{ label }}</span>
</div>
<span class="board-lane-count oc-rounded oc-background-default" data-testid="board-lane-count">
{{ resources.length }}
</span>
</header>

<div class="board-lane-cards">
<board-card v-for="resource in resources" :key="resource.id" :resource="resource" />
<p v-if="!resources.length" class="board-lane-empty oc-text-muted">
{{ $gettext('No files in this lane yet.') }}
</p>
</div>
</section>
</template>

<script setup lang="ts">
import { useGettext } from 'vue3-gettext'
import type { Resource } from '@ownclouders/web-client'
import type { IconType } from '@ownclouders/web-pkg'
import BoardCard from './BoardCard.vue'

defineProps<{
lane: string
label: string
icon: IconType
resources: Resource[]
}>()

const { $gettext } = useGettext()
</script>

<style scoped>
.board-lane {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-width: 16rem;
min-height: 0;
}
.board-lane-label {
font-weight: 600;
}
.board-lane-count {
font-size: 0.75rem;
padding: 0.1rem 0.5rem;
}
.board-lane-cards {
overflow-y: auto;
flex: 1 1 auto;
min-height: 0;
}
.board-lane-empty {
font-style: italic;
font-size: 0.85rem;
}
</style>
Loading