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 @@ -52,6 +52,7 @@ jobs:
- web-app-photo-addon
- web-app-progress-bars
- web-app-unzip
- web-app-ai-doc-summary
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
Expand Down
6 changes: 6 additions & 0 deletions dev/docker/ocis.apps.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
importer:
config:
companionUrl: 'https://host.docker.internal:9200/companion'

ai-doc-summary:
config:
llm:
endpoint: 'https://host.docker.internal:9200/ollama/v1'
model: 'llama3.2'
22 changes: 22 additions & 0 deletions dev/docker/traefik/configs/ollama.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
http:
routers:
ollama:
rule: "Host(`host.docker.internal`) && PathPrefix(`/ollama`)"
entrypoints:
- ocis
tls: true
service: ollama
middlewares:
- ollama-strip-prefix

middlewares:
ollama-strip-prefix:
stripPrefix:
prefixes:
- "/ollama"

services:
ollama:
loadBalancer:
servers:
- url: "http://host.docker.internal:11434"
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ services:
- ./packages/web-app-photo-addon/dist:/web/apps/photo-addon
- ./packages/web-app-progress-bars/dist:/web/apps/progress-bars
- ./packages/web-app-unzip/dist:/web/apps/unzip
- ./packages/web-app-ai-doc-summary/dist:/web/apps/ai-doc-summary
depends_on:
- traefik

Expand Down
73 changes: 73 additions & 0 deletions packages/web-app-ai-doc-summary/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# AI Document Summarizer Sidebar

Adds a **Summary** sidebar panel and a **Summarize** context-menu action for
supported document types. On demand it downloads the file, extracts its text,
and sends it to an admin-configured, OpenAI-compatible LLM endpoint to produce
a short overview and a list of key points.

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

## Supported file types

PDF, TXT, MD

PDF text is extracted client-side with PDF.js (fake-worker mode, no Worker
spawn required). Plain-text files are fetched via WebDAV and truncated to
12 000 characters before being sent to the LLM.

## Extension points

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

## Configuration

Admins set the LLM endpoint, model, and optional API key in the oCIS Web app
config (analogous to the WOPI server URL):

```yaml
llm:
endpoint: "https://ollama.internal.example/v1"
model: "llama3.1:70b"
api_key: ""
```
The panel reads `applicationConfig.llm` at startup. If `endpoint` or `model`
is missing the panel renders the unconfigured placeholder immediately, without
making any network requests.

## Capability probing

When the panel first mounts it probes the endpoint for four capabilities:
structured JSON output (`response_format`), tool/function calling, streaming,
and the model's context window. Probe results are cached per
`endpoint::model` pair for the lifetime of the browser session.

The probing is best-effort — every individual probe failure degrades silently
to a conservative default and never blocks the summary request.

## Summary output

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

- **overview** — a 2–3 sentence paragraph describing the document
- **keyPoints** — an array of 3–4 takeaway strings rendered as a bullet list

The panel auto-triggers a summary when it mounts and exposes a **Regenerate**
button to re-run on demand.

## States

| State | Shown when |
|-------|-----------|
| Unconfigured | `llm.endpoint` or `llm.model` missing from app config |
| Connecting | Capability probe in progress |
| Summarizing | LLM request in flight |
| Result | Overview + key-points rendered |
| Error | Endpoint unreachable, auth failure, rate-limit, or malformed response |

Errors are shown only inside the panel and include admin-actionable guidance
(e.g. "Check the API key in admin settings").
10 changes: 10 additions & 0 deletions packages/web-app-ai-doc-summary/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-doc-summary]
file_filter = locale/<lang>/app.po
minimum_perc = 0
resource_name = web-extensions-ai-doc-summary
source_file = template.pot
source_lang = en
type = PO
1 change: 1 addition & 0 deletions packages/web-app-ai-doc-summary/l10n/translations.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
32 changes: 32 additions & 0 deletions packages/web-app-ai-doc-summary/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "web-app-ai-doc-summary",
"version": "0.1.0",
"private": true,
"description": "AI Document Summarizer Sidebar - sidebarPanel + quick-action extension",
"license": "Apache-2.0",
"type": "module",
"scripts": {
"build": "vite build",
"build:w": "vite build --watch --mode development",
"check:types": "vue-tsc --noEmit",
"test:unit": "vitest run",
"test:e2e": "playwright test"
},
"dependencies": {
"@ownclouders/web-client": "^12.3.2",
"@ownclouders/web-pkg": "^12.3.2",
"pdfjs-dist": "^6.0.227",
"vue": "^3.4.0",
"vue3-gettext": "^2.4.0"
},
"devDependencies": {
"@ownclouders/extension-sdk": "^12.3.2",
"@ownclouders/web-test-helpers": "^12.3.2",
"@playwright/test": "^1.43.0",
"@vitejs/plugin-vue": "^5.0.0",
"eslint": "^8.57.0",
"typescript": "^5.4.0",
"vite": "^5.2.0",
"vitest": "^1.5.0"
}
}
10 changes: 10 additions & 0 deletions packages/web-app-ai-doc-summary/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'
})
3 changes: 3 additions & 0 deletions packages/web-app-ai-doc-summary/public/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"entrypoint": "ai-doc-summary.js"
}
70 changes: 70 additions & 0 deletions packages/web-app-ai-doc-summary/src/components/SummaryPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<template>
<div data-testid="ai-doc-summary-panel" class="ai-doc-summary-panel oc-background-muted oc-p-m oc-rounded">
<div v-if="status === 'unconfigured'" class="ai-summary-placeholder">
{{ $gettext('Document summarization is not set up yet. Contact your administrator to configure an AI endpoint.') }}
</div>

<div v-else-if="status === 'error'" class="ai-summary-placeholder">
{{ $gettext('The AI service is unavailable. Contact your administrator to check the AI endpoint configuration.') }}
</div>

<div v-else-if="status === 'probing'" class="ai-summary-placeholder">
{{ $gettext('Connecting to AI service…') }}
</div>

<div v-else-if="isGenerating" class="ai-summary-placeholder">
{{ $gettext('Summarizing…') }}
</div>

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

<template v-else-if="summaryResult">
<p class="oc-mt-rm">{{ summaryResult.overview }}</p>
<ul class="oc-mt-s">
<li v-for="point in summaryResult.keyPoints" :key="point">{{ point }}</li>
</ul>
</template>

<div v-if="!panelError" class="oc-flex oc-flex-right">
<oc-button size="small" variant="primary" appearance="raw" class="oc-mt-s" @click="triggerSummary">
{{ $pgettext('Button to regenerate document summary', 'Regenerate') }}
</oc-button>
</div>
</template>
</div>
</template>

<script setup lang="ts">
import { toRef, onMounted } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useSummary, type SummaryResource } from '../composables/useSummary'
import type { LlmConfig } from '../composables/useLlm'

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

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

const { status, isGenerating, summaryResult, panelError, triggerSummary, ensureReady } =
useSummary(props.llmConfig ?? null, toRef(props, 'resource'))

onMounted(async () => {
await ensureReady()
triggerSummary()
})
</script>

<style scoped>
.ai-summary-placeholder {
color: var(--oc-color-text-muted, #6f6f6f);
font-style: italic;
}
.ai-summary-error {
color: var(--oc-color-danger, #c00);
}
</style>
Loading