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 @@ -98,6 +98,7 @@ jobs:
- ai-llm-proxy
- web-app-version-changelog
- web-app-group-management
- web-app-ai-data-insights-sidebar
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 @@ -25,3 +25,9 @@ version-changelog:
llm:
endpoint: 'https://host.docker.internal:9200/ai-llm-proxy/v1'
model: 'llama3.2'

ai-data-insights-sidebar:
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 @@ -47,6 +47,7 @@ services:
- ./packages/web-app-chat-with-file/dist:/web/apps/chat-with-file
- ./packages/web-app-version-changelog/dist:/web/apps/version-changelog
- ./packages/web-app-group-management/dist:/web/apps/group-management
- ./packages/web-app-ai-data-insights-sidebar/dist:/web/apps/ai-data-insights-sidebar
depends_on:
- traefik

Expand Down
106 changes: 106 additions & 0 deletions packages/web-app-ai-data-insights-sidebar/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# AI CSV / Spreadsheet Insights Sidebar

Adds an **Insights** sidebar panel and an **Insights** context-menu action for
CSV and TSV files. On demand it downloads the file via WebDAV, parses it
client-side (headers plus up to the first 200 rows), and sends a compact
structured preview to an admin-configured, OpenAI-compatible LLM endpoint. The
panel renders detected column types, value ranges for numeric columns, and
2–3 natural-language observations about the data.

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
lives in `packages/ai-llm-proxy`, validates the user's oCIS bearer token
against the oCIS OIDC userinfo endpoint, and forwards the request to the
configured LLM with the LLM API key injected server-side. The API key
never reaches the browser.

## Supported File Types

CSV (`.csv`), TSV (`.tsv`)

Files are fetched via WebDAV and parsed client-side with a lightweight
RFC-4180 state machine. The parser handles quoted fields (including embedded
newlines and commas), Windows line endings, and single-column files. At most
30 columns and 5 sample values per column are sent to the LLM, capping prompt
size regardless of file width.

## Extension Points

| ID | Type |
|----|------|
| `global.files.sidebar` | `sidebarPanel` — "Insights" tab, visible for single supported files |
| `global.files.context-actions` | `action` — "Insights" entry that opens the Insights sidebar tab |

Both extension points are hidden entirely when no LLM endpoint is configured,
so users on unconfigured deployments never see empty or broken UI.

## Configuration

### Web App Config

Admins set the proxy endpoint and model in the oCIS Web app config:

```yaml
ai-data-insights-sidebar:
config:
llm:
endpoint: "https://your-ocis.example.com/ai-llm-proxy/v1"
model: "llama3.1:70b"
```

The panel reads `applicationConfig.llm` at startup. If `endpoint` or `model`
is absent both the sidebar panel and the context-menu action are hidden.

### `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 discover the OIDC userinfo endpoint |
| `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.
Set `AI_LLM_ENDPOINT` and `AI_LLM_API_KEY` in a `.env` file or as shell
variables before running `docker-compose up`.

## Insights Output

The LLM is prompted to return a JSON object with three fields:

- **columnTypes** — an array of `{ column, type }` objects confirming the
detected type (`number`, `date`, `boolean`, or `string`) for each column
- **ranges** — an array of `{ column, min, max }` objects for numeric and date
columns
- **observations** — an array of 2–3 plain natural-language observations about
the data

The panel renders this as a column-type table with range annotations and a
bullet list of observations. Observations are returned in the user's preferred
oCIS language (via the `preferredLanguage` profile setting).

## Panel States

| State | Shown when |
|-------|-----------|
| Idle | Panel mounted, no analysis started yet — shows "Analyze" button |
| Analyzing | LLM request in flight — shows "Analyzing…" placeholder |
| Result | Column-type table, ranges, and observations rendered — shows "Re-analyze" button |
| Error | Endpoint unreachable, auth failure, rate-limit, cross-origin block, or malformed response |

Errors are shown only inside the panel and include admin-actionable guidance.

## 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-panel error message.
- No API key is ever stored in or passed through extension config — the key
lives exclusively in the `ai-llm-proxy` server environment.
10 changes: 10 additions & 0 deletions packages/web-app-ai-data-insights-sidebar/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-data-insights-sidebar]
file_filter = locale/<lang>/app.po
minimum_perc = 0
resource_name = web-extensions-ai-data-insights-sidebar
source_file = template.pot
source_lang = en
type = PO
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
37 changes: 37 additions & 0 deletions packages/web-app-ai-data-insights-sidebar/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "web-app-ai-data-insights-sidebar",
"version": "0.1.0",
"description": "CANDIDATE",
"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",
"lint": "eslint src --max-warnings 0",
"test": "NODE_OPTIONS=--unhandled-rejections=throw vitest run",
"test:unit": "NODE_OPTIONS=--unhandled-rejections=throw vitest run",
"test:e2e": "pnpm playwright test"
},
"dependencies": {
"@ownclouders/web-client": "^12.3.2",
"@ownclouders/web-pkg": "^12.3.2"
},
"devDependencies": {
"@ownclouders/extension-sdk": "12.3.2",
"@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"
}
}
10 changes: 10 additions & 0 deletions packages/web-app-ai-data-insights-sidebar/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineConfig } from '@playwright/test'
import baseConfig from '../../playwright.config'

/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
...baseConfig,
testDir: './tests/e2e'
})
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,116 @@
<template>
<div
data-testid="ai-data-insights-panel"
class="ai-insights-panel oc-background-muted oc-p-m oc-rounded"
>
<div v-if="isAnalyzing" class="ai-insights-placeholder">
{{ $gettext('Analyzing…') }}
</div>

<template v-else>
<div v-if="panelError" class="ai-insights-error" role="alert">
{{ panelError }}
</div>

<template v-else-if="insightsResult">
<table class="ai-insights-table oc-mt-rm">
<thead>
<tr>
<th>{{ $gettext('Column') }}</th>
<th>{{ $gettext('Type') }}</th>
<th>{{ $gettext('Range') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="col in insightsResult.columnTypes" :key="col.column">
<td>{{ col.column }}</td>
<td>{{ col.type }}</td>
<td>{{ rangeFor(col.column) }}</td>
</tr>
</tbody>
</table>
<ul v-if="insightsResult.observations.length" class="oc-mt-s">
<li v-for="obs in insightsResult.observations" :key="obs">{{ obs }}</li>
</ul>
</template>

<div v-else class="oc-flex oc-flex-column oc-flex-center oc-text-center">
<p class="ai-insights-placeholder oc-mb-m oc-mt-rm">
{{ $gettext('Analyze this file to get column insights and observations.') }}
</p>
<oc-button size="small" variant="primary" @click="triggerInsights">
{{ $pgettext('Button to analyze data file', 'Analyze') }}
</oc-button>
</div>

<div v-if="insightsResult && !panelError" class="oc-flex oc-flex-right">
<oc-button
size="small"
variant="primary"
appearance="raw"
class="oc-mt-s"
@click="triggerInsights"
>
{{ $pgettext('Button to re-analyze data file', 'Re-analyze') }}
</oc-button>
</div>
</template>
</div>
</template>

<script setup lang="ts">
import { toRef, onMounted } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useInsights, type InsightsResource } from '../composables/useInsights'
import type { LLMConfig } from '../composables/useLLM'

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

const props = defineProps<{
resource?: InsightsResource | null
llmConfig?: LLMConfig | null
}>()

const { isAnalyzing, insightsResult, panelError, triggerInsights, ensureReady } = useInsights(
props.llmConfig ?? null,
toRef(props, 'resource')
)

onMounted(() => {
ensureReady()
})

function rangeFor(column: string): string {
if (!insightsResult.value) return ''
const r = insightsResult.value.ranges.find((ri) => ri.column === column)
if (!r) return ''
if (r.min !== undefined && r.max !== undefined) return `${r.min} – ${r.max}`
if (r.min !== undefined) return `≥ ${r.min}`
if (r.max !== undefined) return `≤ ${r.max}`
return ''
}
</script>

<style scoped>
.ai-insights-panel {
min-height: 8rem;
}
.ai-insights-placeholder {
color: var(--oc-color-text-muted, #6f6f6f);
font-style: italic;
}
.ai-insights-error {
color: var(--oc-color-swatch-danger-default, #c00);
}
.ai-insights-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.ai-insights-table th,
.ai-insights-table td {
text-align: left;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--oc-color-border, #e0e0e0);
}
</style>
Loading
Loading