Skip to content

Commit ac86e4e

Browse files
LukasHirtclaude
andauthored
feat(web-app-ai-quick-draft-creator): add AI quick draft creator action (#465)
* feat(web-app-ai-quick-draft-creator): initial extension scaffold Signed-off-by: Lukas Hirt <info@hirt.cz> * fix(web-app-ai-quick-draft-creator): repair failing integration Signed-off-by: Lukas Hirt <info@hirt.cz> * chore(web-app-ai-quick-draft-creator): move extension from extensions/ to packages/ Relocate ai-quick-draft-creator from the non-standard extensions/ directory to packages/web-app-ai-quick-draft-creator/ so it is picked up by the pnpm-workspace.yaml glob (packages/*) and follows the repo convention. Rename the package to web-app-ai-quick-draft-creator. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Lukas Hirt <info@hirt.cz> * feat(web-app-ai-quick-draft-creator): address review feedback Security (blockers): - Route all LLM requests through the same-origin ai-llm-proxy; the browser now sends Authorization: Bearer <oCIS OIDC token> to the proxy rather than calling the LLM endpoint directly. - Remove apiKey from LLMConfig and from applicationConfig entirely; the provider API key is held server-side by the proxy only. - useLLM.ts enforces a same-origin check at call time and throws a user-visible error if the endpoint is cross-origin. Structural: - Delete unused generated App.vue stub. - Add l10n/translations.json (initially empty) and l10n/template.pot; wire translations into defineWebApplication return value. - Update docker-compose.yml with dist volume mount for the extension. - Add ai-quick-draft-creator entry to dev/docker/ocis.apps.yaml. - Add web-app-ai-quick-draft-creator entry to support/actions/ocis.apps.yaml. - Add web-app-ai-quick-draft-creator to the CI test matrix in test.yml. Code quality: - Remove ~190 lines of unused code from useLLM.ts: capability probe (4 network requests on every modal open), stream(), completeJSON(), and summariseMessages(). - Remove capability-based tier logic from useDraftCreator.ts; always generate a well-structured draft prompt. - Add HH-mm-ss timestamp suffix to derived filenames so two same-day drafts never silently overwrite each other. - Replace raw <button> elements in DraftCreatorModal.vue with <oc-button> and add an <oc-spinner> during creation. Tests: - Unit tests updated to match the slimmed UseLLMReturn interface (no stream/completeJSON/capabilities); add a collision-suffix test. - E2E tests rewritten: all no-op assertions replaced with real behavioral checks; proxy mock now targets **/ai-llm-proxy/v1/** (matching the same-origin path enforced by useLLM.ts); happy-path test verifies proxy is called and modal closes on success. Docs: - Update README to document the proxy security model, configuration, and environment variables. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Lukas Hirt <info@hirt.cz> * fix(web-app-ai-quick-draft-creator): remove unused ref import in test Signed-off-by: Lukas Hirt <info@hirt.cz> * fix(web-app-ai-quick-draft-creator): remove canUpload guard from isVisible Signed-off-by: Lukas Hirt <info@hirt.cz> * chore(web-app-ai-quick-draft-creator): bump deps to ^12.4.2 to fix type errors Signed-off-by: Lukas Hirt <info@hirt.cz> * fix(web-app-ai-quick-draft-creator): register action in new-file menu instead of upload menu Use appInfo.extensions with newFileMenu + customHandler to place the 'Draft from description' action in the correct #new-file-menu-drop dropdown (the + New button), not the upload dropdown. The newFileMenu.isVisible callback receives currentFolder directly as a parameter, so the canUpload check is accurate and race-condition-free. Update E2E tests to open #new-file-menu-btn and locate the item by its menu title text. Signed-off-by: Lukas Hirt <info@hirt.cz> * feat(web-app-ai-quick-draft-creator): open created draft in editor automatically After a draft is created, trigger the default file action so the new file opens immediately without requiring manual navigation. Return `{ resource, space }` from `createDraft` instead of the filename string so the modal can pass the created resource to `triggerDefaultAction`. Also adds `oc-modal-body-actions-cancel` class to the cancel button for reliable E2E targeting, dismisses open modals before logout in `LoginPage`, drops the WebDAV PUT stub from E2E setup now that tests run against real oCIS, and adds `public/manifest.json`. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Lukas Hirt <info@hirt.cz> --------- Signed-off-by: Lukas Hirt <info@hirt.cz> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 47e9f24 commit ac86e4e

20 files changed

Lines changed: 1187 additions & 339 deletions

File tree

.github/workflows/test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ jobs:
9393
- web-app-progress-bars
9494
- web-app-unzip
9595
- web-app-ai-doc-summary
96+
- web-app-ai-quick-draft-creator
9697
- web-app-ai-image-alt-text-sidebar
9798
- web-app-chat-with-file
9899
- ai-llm-proxy

dev/docker/ocis.apps.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ importer:
22
config:
33
companionUrl: 'https://host.docker.internal:9200/companion'
44

5+
ai-quick-draft-creator:
6+
config:
7+
llm:
8+
endpoint: 'https://host.docker.internal:9200/ai-llm-proxy/v1'
9+
model: 'llama3.2'
10+
511
ai-doc-summary:
612
config:
713
llm:

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ services:
4444
- ./packages/web-app-unzip/dist:/web/apps/unzip
4545
- ./packages/web-app-ai-doc-summary/dist:/web/apps/ai-doc-summary
4646
- ./packages/web-app-ai-image-alt-text-sidebar/dist:/web/apps/ai-image-alt-text-sidebar
47+
- ./packages/web-app-ai-quick-draft-creator/dist:/web/apps/ai-quick-draft-creator
4748
- ./packages/web-app-chat-with-file/dist:/web/apps/chat-with-file
4849
- ./packages/web-app-version-changelog/dist:/web/apps/version-changelog
4950
- ./packages/web-app-group-management/dist:/web/apps/group-management
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# web-app-ai-quick-draft-creator
2+
3+
Adds a **"Draft from description"** entry to the oCIS Files upload menu. A modal lets the user describe the document they need; an LLM generates a well-structured draft and saves it as a new file in the current folder.
4+
5+
**Extension point:** `app.files.upload-menu`
6+
7+
## What it does
8+
9+
1. The user clicks the upload menu in Files and selects **Draft from description**.
10+
2. A modal prompts for a document description and an output format (Markdown or plain text).
11+
3. The extension calls the `ai-llm-proxy` (same-origin) which validates the user's oCIS OIDC token and proxies the request to the configured LLM.
12+
4. The generated draft is written to the current folder via WebDAV. Filenames include a timestamp suffix (`slug-YYYY-MM-DD-HH-mm-ss.ext`) so same-day drafts never overwrite each other.
13+
5. The menu item is hidden automatically when no LLM endpoint is configured.
14+
15+
## Security model
16+
17+
- **The browser never sees the provider API key.** The `ai-llm-proxy` holds `LLM_API_KEY` server-side.
18+
- The client authenticates to the proxy using the user's oCIS OIDC bearer token (`Authorization: Bearer <accessToken>`).
19+
- The proxy validates the token against `OCIS_URL` before forwarding any request.
20+
- `useLLM.ts` enforces a same-origin check on the endpoint URL at call time and refuses to send credentials to a cross-origin host.
21+
22+
## Configuration
23+
24+
Add the following to `ocis.apps.yaml` (key must match the mount directory):
25+
26+
```yaml
27+
ai-quick-draft-creator:
28+
config:
29+
llm:
30+
endpoint: 'https://<ocis-host>/ai-llm-proxy/v1'
31+
model: 'llama3.2'
32+
```
33+
34+
The `ai-llm-proxy` must be configured with:
35+
36+
| Variable | Description |
37+
|----------|-------------|
38+
| `LLM_ENDPOINT` | OpenAI-compatible base URL of your LLM (e.g. Ollama) |
39+
| `LLM_API_KEY` | Provider API key (optional for keyless endpoints) |
40+
| `OCIS_URL` | Used to validate OIDC tokens and enforce origin checks |
41+
42+
## Local development
43+
44+
```bash
45+
# From repo root:
46+
pnpm install
47+
pnpm build && docker compose up -d
48+
```
49+
50+
Access oCIS at `https://host.docker.internal:9200` (admin / admin).
51+
The extension dist is mounted automatically from `docker-compose.yml`.
52+
53+
## Running tests
54+
55+
```bash
56+
pnpm --filter web-app-ai-quick-draft-creator test:unit
57+
pnpm --filter web-app-ai-quick-draft-creator test:e2e
58+
pnpm --filter web-app-ai-quick-draft-creator check:types
59+
```
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# AI Quick Draft Creator — translatable strings
2+
# Copyright (C) ownCloud GmbH
3+
# This file is distributed under the Apache-2.0 license.
4+
#
5+
#, fuzzy
6+
msgid ""
7+
msgstr ""
8+
"Project-Id-Version: web-extensions-ai-quick-draft-creator\n"
9+
"Report-Msgid-Bugs-To: \n"
10+
"POT-Creation-Date: 2026-06-18 00:00+0000\n"
11+
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
12+
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
13+
"Language-Team: LANGUAGE <LL@li.org>\n"
14+
"Language: \n"
15+
"MIME-Version: 1.0\n"
16+
"Content-Type: text/plain; charset=UTF-8\n"
17+
"Content-Transfer-Encoding: 8bit\n"
18+
19+
msgctxt "AI Quick Draft Creator extension name"
20+
msgid "AI Quick Draft Creator"
21+
msgstr ""
22+
23+
msgctxt "Modal title for AI draft creator"
24+
msgid "Create a draft"
25+
msgstr ""
26+
27+
msgctxt "Upload menu action label"
28+
msgid "Draft from description"
29+
msgstr ""
30+
31+
msgid "Describe the document you need"
32+
msgstr ""
33+
34+
msgid "e.g. Q3 budget review for EMEA team, include agenda and action-items table"
35+
msgstr ""
36+
37+
msgid "Output format"
38+
msgstr ""
39+
40+
msgid "Markdown"
41+
msgstr ""
42+
43+
msgid "Plain text"
44+
msgstr ""
45+
46+
msgid "Cancel"
47+
msgstr ""
48+
49+
msgid "Creating draft\342\200\246"
50+
msgstr ""
51+
52+
msgid "Creating\342\200\246"
53+
msgstr ""
54+
55+
msgid "Create draft"
56+
msgstr ""
57+
58+
msgid "LLM is not configured."
59+
msgstr ""
60+
61+
msgid "No folder is currently open."
62+
msgstr ""
63+
64+
msgid "Could not resolve the current folder space."
65+
msgstr ""
66+
67+
msgid "Failed to create the draft. Please try again."
68+
msgstr ""
69+
70+
msgid "The AI endpoint must be on the same server as ownCloud. Contact your administrator."
71+
msgstr ""
72+
73+
msgid "Access to the AI service was denied. Your session may have expired \342\200\224 try reloading the page."
74+
msgstr ""
75+
76+
msgid "The AI service is currently busy. Please try again in a moment."
77+
msgstr ""
78+
79+
msgid "The AI service is temporarily unavailable. Please try again later."
80+
msgstr ""
81+
82+
msgid "The AI service returned an unexpected response. Please try again."
83+
msgstr ""
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "web-app-ai-quick-draft-creator",
3+
"version": "0.1.0",
4+
"description": "",
5+
"license": "Apache-2.0",
6+
"author": "ownCloud",
7+
"type": "module",
8+
"scripts": {
9+
"build": "pnpm vite build",
10+
"build:w": "pnpm vite build --watch --mode development",
11+
"check:types": "vue-tsc --noEmit",
12+
"lint": "eslint src --max-warnings 0",
13+
"test": "NODE_OPTIONS=--unhandled-rejections=throw vitest run",
14+
"test:unit": "NODE_OPTIONS=--unhandled-rejections=throw vitest run",
15+
"test:e2e": "pnpm playwright test"
16+
},
17+
"dependencies": {
18+
"@ownclouders/web-client": "^12.4.2",
19+
"@ownclouders/web-pkg": "^12.4.2"
20+
},
21+
"devDependencies": {
22+
"@ownclouders/extension-sdk": "12.4.2",
23+
"@ownclouders/tsconfig": "0.0.6",
24+
"@types/node": "22.19.19",
25+
"@vue/test-utils": "^2.4.6",
26+
"eslint": "9.39.4",
27+
"happy-dom": "^20.9.0",
28+
"prettier": "3.9.3",
29+
"typescript": "5.9.3",
30+
"vite": "7.2.2",
31+
"vitest": "4.1.7",
32+
"vue": "^3.5.39",
33+
"vue-router": "^5.0.7",
34+
"vue-tsc": "3.3.5",
35+
"vue3-gettext": "^2.4.0"
36+
}
37+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { defineConfig } from '@playwright/test'
2+
import baseConfig from '../../playwright.config'
3+
4+
/**
5+
* See https://playwright.dev/docs/test-configuration.
6+
*/
7+
export default defineConfig({
8+
...baseConfig,
9+
testDir: './tests/e2e'
10+
})
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"entrypoint": "index.js"
3+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<template>
2+
<div class="draft-creator-modal">
3+
<div v-if="error" class="draft-creator-modal__error oc-mt-s oc-text-error" role="alert">
4+
{{ error }}
5+
</div>
6+
7+
<div class="oc-mb-m">
8+
<label class="oc-label" for="draft-description">
9+
{{ $gettext('Describe the document you need') }}
10+
</label>
11+
<textarea
12+
id="draft-description"
13+
v-model="description"
14+
class="oc-input draft-creator-modal__description"
15+
:placeholder="
16+
$gettext('e.g. Q3 budget review for EMEA team, include agenda and action-items table')
17+
"
18+
rows="4"
19+
:disabled="creating"
20+
data-testid="draft-description"
21+
/>
22+
</div>
23+
24+
<div class="oc-mb-m">
25+
<label class="oc-label" for="draft-format">
26+
{{ $gettext('Output format') }}
27+
</label>
28+
<select
29+
id="draft-format"
30+
v-model="format"
31+
class="oc-input"
32+
:disabled="creating"
33+
data-testid="draft-format"
34+
>
35+
<option value="markdown">{{ $gettext('Markdown') }}</option>
36+
<option value="plain">{{ $gettext('Plain text') }}</option>
37+
</select>
38+
</div>
39+
40+
<div class="draft-creator-modal__actions oc-flex oc-flex-right oc-mt-m">
41+
<oc-button
42+
appearance="outline"
43+
class="oc-mr-s oc-modal-body-actions-cancel"
44+
:disabled="creating"
45+
data-testid="draft-cancel"
46+
@click="$emit('cancel')"
47+
>
48+
{{ $gettext('Cancel') }}
49+
</oc-button>
50+
<oc-button
51+
appearance="filled"
52+
variation="primary"
53+
:disabled="creating || !description.trim()"
54+
data-testid="draft-create"
55+
@click="handleCreate"
56+
>
57+
<oc-spinner v-if="creating" size="small" :aria-label="$gettext('Creating draft…')" />
58+
<span v-if="creating">{{ $gettext('Creating…') }}</span>
59+
<span v-else>{{ $gettext('Create draft') }}</span>
60+
</oc-button>
61+
</div>
62+
</div>
63+
</template>
64+
65+
<script setup lang="ts">
66+
import { ref } from 'vue'
67+
import { useGettext } from 'vue3-gettext'
68+
import { useFileActions } from '@ownclouders/web-pkg'
69+
import { useDraftCreator, type DraftFormat } from '../composables/useDraftCreator'
70+
import type { LLMConfig } from '../composables/useLLM'
71+
72+
interface Props {
73+
llmConfig: LLMConfig | null
74+
}
75+
76+
const props = defineProps<Props>()
77+
const emit = defineEmits<{
78+
confirm: []
79+
cancel: []
80+
}>()
81+
82+
const { $gettext } = useGettext()
83+
const description = ref('')
84+
const format = ref<DraftFormat>('markdown')
85+
86+
const { creating, error, createDraft } = useDraftCreator(props.llmConfig)
87+
const { triggerDefaultAction } = useFileActions()
88+
89+
async function handleCreate(): Promise<void> {
90+
const result = await createDraft(description.value.trim(), format.value)
91+
if (result) {
92+
emit('confirm')
93+
triggerDefaultAction({ space: result.space, resources: [result.resource] })
94+
}
95+
}
96+
</script>

0 commit comments

Comments
 (0)