diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fee90586..4fc68847 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/dev/docker/ocis.apps.yaml b/dev/docker/ocis.apps.yaml index b4161cd7..96cb37c0 100644 --- a/dev/docker/ocis.apps.yaml +++ b/dev/docker/ocis.apps.yaml @@ -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' diff --git a/docker-compose.yml b/docker-compose.yml index 61c7c5d2..4fc4f891 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/packages/web-app-ai-data-insights-sidebar/README.md b/packages/web-app-ai-data-insights-sidebar/README.md new file mode 100644 index 00000000..2118e2c8 --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/README.md @@ -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. diff --git a/packages/web-app-ai-data-insights-sidebar/l10n/.tx/config b/packages/web-app-ai-data-insights-sidebar/l10n/.tx/config new file mode 100644 index 00000000..64051d95 --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/l10n/.tx/config @@ -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//app.po +minimum_perc = 0 +resource_name = web-extensions-ai-data-insights-sidebar +source_file = template.pot +source_lang = en +type = PO diff --git a/packages/web-app-ai-data-insights-sidebar/l10n/translations.json b/packages/web-app-ai-data-insights-sidebar/l10n/translations.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/l10n/translations.json @@ -0,0 +1 @@ +{} diff --git a/packages/web-app-ai-data-insights-sidebar/package.json b/packages/web-app-ai-data-insights-sidebar/package.json new file mode 100644 index 00000000..c42a60c6 --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/package.json @@ -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" + } +} diff --git a/packages/web-app-ai-data-insights-sidebar/playwright.config.ts b/packages/web-app-ai-data-insights-sidebar/playwright.config.ts new file mode 100644 index 00000000..9b2bb286 --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/playwright.config.ts @@ -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' +}) diff --git a/packages/web-app-ai-data-insights-sidebar/public/manifest.json b/packages/web-app-ai-data-insights-sidebar/public/manifest.json new file mode 100644 index 00000000..c8aafc9d --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/public/manifest.json @@ -0,0 +1,3 @@ +{ + "entrypoint": "index.js" +} diff --git a/packages/web-app-ai-data-insights-sidebar/src/components/InsightsPanel.vue b/packages/web-app-ai-data-insights-sidebar/src/components/InsightsPanel.vue new file mode 100644 index 00000000..00ddfb23 --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/src/components/InsightsPanel.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/packages/web-app-ai-data-insights-sidebar/src/composables/useInsights.ts b/packages/web-app-ai-data-insights-sidebar/src/composables/useInsights.ts new file mode 100644 index 00000000..ca390606 --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/src/composables/useInsights.ts @@ -0,0 +1,238 @@ +import { ref, type Ref } from 'vue' +import { useClientService, useSpacesStore, useUserStore } from '@ownclouders/web-pkg' +import { useGettext } from 'vue3-gettext' +import { useLLM, type LLMConfig, type LLMStatus } from './useLLM' +import { parseCSV } from '../utils/csv-parse' + +const MAX_COLUMNS = 30 +const MAX_SAMPLES = 5 + +export interface InsightsResource { + id?: string + name?: string + extension?: string + storageId?: string + path?: string +} + +export interface ColumnInsight { + column: string + type: string +} + +export interface RangeInsight { + column: string + min?: string + max?: string +} + +export interface InsightsResult { + columnTypes: ColumnInsight[] + ranges: RangeInsight[] + observations: string[] +} + +export interface UseInsightsResult { + status: Ref + isAnalyzing: Ref + insightsResult: Ref + panelError: Ref + triggerInsights: () => Promise + ensureReady: () => Promise +} + +function inferType(samples: string[]): string { + const nonEmpty = samples.filter((s) => s !== '') + if (nonEmpty.length === 0) return 'string' + const boolValues = new Set(['true', 'false', 'yes', 'no', '1', '0']) + if (nonEmpty.every((s) => boolValues.has(s.toLowerCase()))) return 'boolean' + if (nonEmpty.every((s) => !isNaN(parseFloat(s)) && isFinite(Number(s)))) return 'number' + if (nonEmpty.every((s) => !isNaN(Date.parse(s)))) return 'date' + return 'string' +} + +function numericRange(samples: string[]): { min?: string; max?: string } { + const nums = samples.map(Number).filter((n) => !isNaN(n)) + if (nums.length === 0) return {} + return { min: String(Math.min(...nums)), max: String(Math.max(...nums)) } +} + +export function useInsights( + llmConfig: LLMConfig | null, + resource: Ref +): UseInsightsResult { + const { $gettext, current: gettextLanguage } = useGettext() + const llm = useLLM(llmConfig) + const clientService = useClientService() + const spacesStore = useSpacesStore() + const userStore = useUserStore() + + const isAnalyzing = ref(false) + const panelError = ref(null) + const insightsResult = ref(null) + + function handleLlmError(err: unknown): string { + if (err instanceof DOMException && err.name === 'TimeoutError') { + return $gettext('The AI service did not respond in time. Please try again later.') + } + if (err instanceof TypeError) { + return $gettext( + 'Could not reach the AI service. Check your network connection and try again.' + ) + } + if (err instanceof Error) { + const match = /LLM request failed: (\d+)/.exec(err.message) + if (match) { + const code = parseInt(match[1], 10) + if (code === 401 || code === 403) { + return $gettext( + 'Access to the AI service was denied. Your session may have expired — try reloading the page.' + ) + } + if (code === 404) { + return $gettext( + 'The AI endpoint could not be found. Check the endpoint URL in admin settings.' + ) + } + if (code === 429) { + return $gettext('The AI service is currently busy. Please try again in a moment.') + } + if (code >= 500) { + return $gettext('The AI service is temporarily unavailable. Please try again later.') + } + } + return err.message + } + return $gettext('Something went wrong while analyzing the file. Please try again.') + } + + function getUserLanguage(): string { + return userStore.user?.preferredLanguage || gettextLanguage + } + + async function fetchInsights(): Promise { + const res = resource.value + if (!res?.storageId || !res?.path) { + throw new Error($gettext('Resource location not available')) + } + + const space = spacesStore.getSpace(res.storageId) + if (!space) { + throw new Error($gettext('Could not resolve file space')) + } + + const { response } = await clientService.webdav.getFileContents( + space, + { path: res.path }, + { responseType: 'text' } + ) + const csvText = response.data as string + + const delimiter = res.extension?.toLowerCase() === 'tsv' ? '\t' : ',' + const preview = parseCSV(csvText, delimiter) + + if (preview.headers.length === 0) { + throw new Error($gettext('The file appears to be empty or has no recognizable columns.')) + } + + // Cap columns and build compact column summaries for the prompt + const cappedHeaders = preview.headers.slice(0, MAX_COLUMNS) + const cappedColumns = preview.columns.slice(0, MAX_COLUMNS) + + const columnSummaries = cappedHeaders.map((name, ci) => { + const allSamples = cappedColumns[ci] + const type = inferType(allSamples) + const samples = allSamples.filter((s) => s !== '').slice(0, MAX_SAMPLES) + const summary: Record = { column: name, type, samples } + if (type === 'number') { + const range = numericRange(allSamples) + if (range.min !== undefined) summary.min = range.min + if (range.max !== undefined) summary.max = range.max + } + return summary + }) + + const lang = getUserLanguage() + const promptContent = [ + `Analyze the following CSV/spreadsheet file "${res.name ?? 'this file'}".`, + `Respond in the language with BCP 47 tag "${lang}".`, + 'Respond with a JSON object with exactly three keys:', + '"columnTypes": an array of objects with "column" (string) and "type" (string) fields — the confirmed type for each column (number, date, boolean, or string).', + '"ranges": an array of objects with "column" (string) and optional "min" and "max" (strings) — include only numeric or date columns.', + '"observations": an array of 2-3 plain strings, each one natural-language observation about the data.', + 'Return only the JSON object. No markdown, no code fences, no extra text.', + '\n\nColumn summaries:\n' + JSON.stringify(columnSummaries) + ].join(' ') + + const responseText = await llm.complete([{ role: 'user', content: promptContent }], { + maxTokens: 512, + responseFormat: { type: 'json_object' } + }) + + let parsed: Record + try { + parsed = JSON.parse(responseText) as Record + } catch { + parsed = {} + } + + const toColumnInsights = (val: unknown): ColumnInsight[] => { + if (!Array.isArray(val)) return [] + return val + .filter((v) => v && typeof v === 'object' && 'column' in v && 'type' in v) + .map((v) => { + const obj = v as Record + return { column: String(obj.column), type: String(obj.type) } + }) + } + + const toRangeInsights = (val: unknown): RangeInsight[] => { + if (!Array.isArray(val)) return [] + return val + .filter((v) => v && typeof v === 'object' && 'column' in v) + .map((v) => { + const obj = v as Record + const ri: RangeInsight = { column: String(obj.column) } + if (typeof obj.min === 'string') ri.min = obj.min + if (typeof obj.max === 'string') ri.max = obj.max + return ri + }) + } + + const toObservations = (val: unknown): string[] => + Array.isArray(val) ? val.map((s) => String(s).trim()).filter(Boolean) : [] + + return { + columnTypes: toColumnInsights(parsed.columnTypes), + ranges: toRangeInsights(parsed.ranges), + observations: toObservations(parsed.observations) + } + } + + function ensureReady(): Promise { + // useLLM sets status synchronously at initialisation; nothing to do here + return Promise.resolve() + } + + async function triggerInsights(): Promise { + if (llm.status.value === 'cross-origin') { + panelError.value = $gettext( + 'The AI endpoint must be on the same server as ownCloud. Cross-origin requests are not supported.' + ) + return + } + if (llm.status.value !== 'ready') return + + isAnalyzing.value = true + panelError.value = null + try { + insightsResult.value = await fetchInsights() + } catch (err) { + panelError.value = handleLlmError(err) + } finally { + isAnalyzing.value = false + } + } + + return { status: llm.status, isAnalyzing, insightsResult, panelError, triggerInsights, ensureReady } +} diff --git a/packages/web-app-ai-data-insights-sidebar/src/composables/useLLM.ts b/packages/web-app-ai-data-insights-sidebar/src/composables/useLLM.ts new file mode 100644 index 00000000..59bd27e4 --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/src/composables/useLLM.ts @@ -0,0 +1,97 @@ +import { ref, type Ref } from 'vue' + +export interface LLMConfig { + endpoint: string + model: string +} + +export type LLMStatus = 'unconfigured' | 'ready' | 'cross-origin' + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant' + content: string +} + +export interface CompletionOptions { + maxTokens?: number + temperature?: number + responseFormat?: { type: 'json_object' | 'text' } +} + +export interface UseLLMReturn { + status: Ref + complete(messages: ChatMessage[], opts?: CompletionOptions): Promise + stream(messages: ChatMessage[], onChunk: (chunk: string) => void): Promise +} + +export function useLLM(cfg: LLMConfig | null): UseLLMReturn { + const status = ref('unconfigured') + + if (cfg) { + let endpointOrigin: string + try { + endpointOrigin = new URL(cfg.endpoint).origin + } catch { + endpointOrigin = '' + } + status.value = endpointOrigin === window.location.origin ? 'ready' : 'cross-origin' + } + + async function complete(messages: ChatMessage[], opts: CompletionOptions = {}): Promise { + if (!cfg || status.value !== 'ready') { + throw new Error('LLM is not configured or endpoint is not same-origin') + } + const base = cfg.endpoint.replace(/\/$/, '') + const r = await fetch(`${base}/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(60_000), + body: JSON.stringify({ + model: cfg.model, + messages, + max_tokens: opts.maxTokens ?? 1024, + temperature: opts.temperature ?? 0.7, + ...(opts.responseFormat && { response_format: opts.responseFormat }) + }) + }) + if (!r.ok) throw new Error(`LLM request failed: ${r.status} ${r.statusText}`) + const d = (await r.json()) as { choices: { message: { content: string } }[] } + return d.choices[0]?.message?.content ?? '' + } + + async function stream(messages: ChatMessage[], onChunk: (chunk: string) => void): Promise { + if (!cfg || status.value !== 'ready') { + throw new Error('LLM is not configured or endpoint is not same-origin') + } + const base = cfg.endpoint.replace(/\/$/, '') + const r = await fetch(`${base}/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(60_000), + body: JSON.stringify({ model: cfg.model, messages, stream: true, max_tokens: 1024 }) + }) + if (!r.ok) throw new Error(`LLM stream failed: ${r.status}`) + const reader = r.body!.getReader() + const decoder = new TextDecoder() + let buf = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + const lines = buf.split('\n') + buf = lines.pop() ?? '' + for (const line of lines) { + if (!line.startsWith('data: ') || line === 'data: [DONE]') continue + try { + const data = JSON.parse(line.slice(6)) as { choices: { delta: { content?: string } }[] } + const chunk = data.choices[0]?.delta?.content + if (chunk) onChunk(chunk) + } catch { + /* malformed SSE chunk */ + } + } + } + } + + return { status, complete, stream } +} diff --git a/packages/web-app-ai-data-insights-sidebar/src/index.ts b/packages/web-app-ai-data-insights-sidebar/src/index.ts new file mode 100644 index 00000000..351da783 --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/src/index.ts @@ -0,0 +1,80 @@ +import { + defineWebApplication, + eventBus, + SideBarEventTopics, + useResourcesStore +} from '@ownclouders/web-pkg' +import type { SidebarPanelExtension, ActionExtension, FileActionOptions } from '@ownclouders/web-pkg' +import { computed } from 'vue' +import { useGettext } from 'vue3-gettext' +import type { Resource, SpaceResource } from '@ownclouders/web-client' +import InsightsPanel from './components/InsightsPanel.vue' +import { isSupportedFile } from './utils/file-support' +import type { LLMConfig } from './composables/useLLM' + +const SUPPORTED_EXTS = ['csv', 'tsv'] +const APP_ID = 'ai-data-insights-sidebar' + +export default defineWebApplication({ + setup({ applicationConfig }) { + const { $pgettext } = useGettext() + const resourcesStore = useResourcesStore() + + const rawLlm = applicationConfig?.llm as Record | undefined + const llmConfig: LLMConfig | null = + rawLlm?.endpoint && rawLlm?.model + ? { endpoint: rawLlm.endpoint as string, model: rawLlm.model as string } + : null + + const extensions = computed(() => [ + { + id: `${APP_ID}.panel`, + type: 'sidebarPanel', + extensionPointIds: ['global.files.sidebar'], + panel: { + name: APP_ID, + icon: 'sparkling-2', + title: () => $pgettext('Sidebar panel tab title', 'Insights'), + isVisible: ({ items }: { items?: Resource[] }) => + llmConfig !== null && + items?.length === 1 && + isSupportedFile(items[0], SUPPORTED_EXTS), + component: InsightsPanel, + componentAttrs: ({ items }: { items?: Resource[] }) => ({ + resource: items?.[0] ?? null, + llmConfig + }) + } + } as SidebarPanelExtension, + { + id: `${APP_ID}.action`, + type: 'action', + extensionPointIds: ['global.files.context-actions'], + action: { + name: `${APP_ID}-insights`, + icon: 'sparkling-2', + label: () => $pgettext('Context menu action to open data insights', 'Insights'), + isVisible: ({ resources }: { resources?: Resource[] }) => + llmConfig !== null && + resources?.length === 1 && + isSupportedFile(resources[0], SUPPORTED_EXTS), + handler: ({ resources }: FileActionOptions) => { + resourcesStore.setSelection(resources.map(({ id }) => id)) + eventBus.publish(SideBarEventTopics.openWithPanel, APP_ID) + } + } + } as ActionExtension + ]) + + return { + appInfo: { + name: $pgettext( + 'AI CSV / Spreadsheet Insights Sidebar extension name', + 'AI CSV / Spreadsheet Insights Sidebar' + ), + id: APP_ID + }, + extensions + } + } +}) diff --git a/packages/web-app-ai-data-insights-sidebar/src/public/manifest.json b/packages/web-app-ai-data-insights-sidebar/src/public/manifest.json new file mode 100644 index 00000000..854d3e7e --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/src/public/manifest.json @@ -0,0 +1,3 @@ +{ + "entrypoint": "index.js" +} diff --git a/packages/web-app-ai-data-insights-sidebar/src/utils/csv-parse.ts b/packages/web-app-ai-data-insights-sidebar/src/utils/csv-parse.ts new file mode 100644 index 00000000..30c7ef31 --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/src/utils/csv-parse.ts @@ -0,0 +1,73 @@ +export interface CSVPreview { + headers: string[] + columns: string[][] +} + +export function parseCSV(text: string, delimiter = ',', maxRows = 200): CSVPreview { + const rows: string[][] = [] + let row: string[] = [] + let field = '' + let inQuote = false + let i = 0 + const len = text.length + + const pushField = () => { + row.push(field) + field = '' + } + const pushRow = () => { + rows.push(row) + row = [] + } + + while (i < len) { + const ch = text[i] + + if (inQuote) { + if (ch === '"' && i + 1 < len && text[i + 1] === '"') { + // Escaped double-quote inside a quoted field (RFC-4180 §2 rule 7) + field += '"' + i += 2 + } else if (ch === '"') { + inQuote = false + i++ + } else { + field += ch + i++ + } + } else if (ch === '"') { + inQuote = true + i++ + } else if (ch === delimiter) { + pushField() + i++ + } else if (ch === '\r') { + pushField() + pushRow() + i = i + 1 < len && text[i + 1] === '\n' ? i + 2 : i + 1 + if (rows.length > maxRows) break + } else if (ch === '\n') { + pushField() + pushRow() + i++ + if (rows.length > maxRows) break + } else { + field += ch + i++ + } + } + + // Capture any trailing content not terminated by a newline + if (row.length > 0 || field !== '') { + row.push(field) + rows.push(row) + } + + if (rows.length === 0) return { headers: [], columns: [] } + + const headers = rows[0] + const dataRows = rows.slice(1, maxRows + 1) + const columns: string[][] = headers.map((_, ci) => dataRows.map((r) => r[ci] ?? '')) + + return { headers, columns } +} diff --git a/packages/web-app-ai-data-insights-sidebar/src/utils/file-support.ts b/packages/web-app-ai-data-insights-sidebar/src/utils/file-support.ts new file mode 100644 index 00000000..ab742c0c --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/src/utils/file-support.ts @@ -0,0 +1,11 @@ +export interface FileResourceLike { + extension?: string +} + +export function isSupportedFile( + resource: FileResourceLike | undefined, + supported: string[] +): boolean { + if (!resource?.extension) return false + return supported.includes(resource.extension.toLowerCase()) +} diff --git a/packages/web-app-ai-data-insights-sidebar/tests/e2e/acceptance.spec.ts b/packages/web-app-ai-data-insights-sidebar/tests/e2e/acceptance.spec.ts new file mode 100644 index 00000000..12332e44 --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/tests/e2e/acceptance.spec.ts @@ -0,0 +1,47 @@ +import { test, Page, expect } from '@playwright/test' +import { loginAsUser, logout } from '../../../../support/helpers/authHelper' +import { FilesAppBar } from '../../../../support/pages/filesAppBarActions' +import { FilesPage } from '../../../../support/pages/filesPage' +import { AiDataInsightsSidebarPage } from './pages/aiDataInsightsSidebarPage' + +let adminPage: Page + +test.beforeEach(async ({ browser }) => { + const admin = await loginAsUser(browser, 'admin', 'admin') + adminPage = admin.page +}) + +test.afterEach(async () => { + const filesPage = new FilesPage(adminPage) + await filesPage.deleteAllFromPersonal() + await logout(adminPage) +}) + +test('"Insights" action is visible for CSV files and invisible for JPEG files', async () => { + const appBar = new FilesAppBar(adminPage) + const insights = new AiDataInsightsSidebarPage(adminPage) + + const csvName = await insights.uploadCsvFile() + expect(await insights.isInsightsActionVisible(csvName)).toBe(true) + + await appBar.uploadFile('logo.jpeg') + expect(await insights.isInsightsActionVisible('logo.jpeg')).toBe(false) +}) + +test('clicking "Insights" opens the Insights sidebar panel', async () => { + const insights = new AiDataInsightsSidebarPage(adminPage) + + await adminPage.route('**/ai-llm-proxy/**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ choices: [{ message: { content: 'mock result' } }] }) + }) + ) + + const csvName = await insights.uploadCsvFile() + await insights.clickInsights(csvName) + + await expect(insights.sidebar).toBeVisible() + await expect(insights.panel).toBeVisible() +}) diff --git a/packages/web-app-ai-data-insights-sidebar/tests/e2e/fixtures/sample.csv b/packages/web-app-ai-data-insights-sidebar/tests/e2e/fixtures/sample.csv new file mode 100644 index 00000000..fa1869eb --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/tests/e2e/fixtures/sample.csv @@ -0,0 +1,4 @@ +name,age,score +Alice,30,95 +Bob,25,87 +Charlie,35,92 diff --git a/packages/web-app-ai-data-insights-sidebar/tests/e2e/pages/aiDataInsightsSidebarPage.ts b/packages/web-app-ai-data-insights-sidebar/tests/e2e/pages/aiDataInsightsSidebarPage.ts new file mode 100644 index 00000000..e91b35b7 --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/tests/e2e/pages/aiDataInsightsSidebarPage.ts @@ -0,0 +1,64 @@ +import { Locator, Page, expect } from '@playwright/test' +import { FilesPage } from '../../../../../support/pages/filesPage' +import { fileURLToPath } from 'url' + +export class AiDataInsightsSidebarPage { + readonly page: Page + readonly sidebar: Locator + readonly panel: Locator + + constructor(page: Page) { + this.page = page + this.sidebar = this.page.getByTestId('app-sidebar') + this.panel = this.page.getByTestId('ai-data-insights-panel') + } + + insightsTab(): Locator { + return this.sidebar.getByRole('button', { name: 'Insights' }) + } + + analyzeButton(): Locator { + return this.panel.getByRole('button', { name: 'Analyze' }) + } + + async isInsightsActionVisible(resource: string): Promise { + const files = new FilesPage(this.page) + await files.openFileContextMenu(resource) + const visible = await this.page + .getByTestId('action-label') + .filter({ hasText: 'Insights' }) + .isVisible() + await this.page.keyboard.press('Escape') + return visible + } + + async clickInsights(resource: string): Promise { + const files = new FilesPage(this.page) + await files.openFileContextMenu(resource) + await this.page.getByTestId('action-label').filter({ hasText: 'Insights' }).click() + } + + /** Uploads the bundled sample.csv fixture and returns its filename. */ + async uploadCsvFile(): Promise { + const fixturePath = fileURLToPath(new URL('../fixtures/sample.csv', import.meta.url)) + const uploadBtn = this.page.locator('#upload-menu-btn') + const uploadInput = this.page.locator('#files-file-upload-input') + const closeBtn = this.page.locator('#close-upload-bar-btn') + const uploadMenuDrop = this.page.locator('#upload-menu-drop') + const newFileMenuDrop = this.page.locator('#new-file-menu-drop') + + await uploadBtn.click() + await Promise.all([ + this.page.waitForResponse( + (resp) => + [201, 204].includes(resp.status()) && + ['POST', 'PUT', 'PATCH'].includes(resp.request().method()) + ), + uploadInput.setInputFiles(fixturePath) + ]) + await closeBtn.click() + await expect(newFileMenuDrop).not.toBeVisible() + await expect(uploadMenuDrop).not.toBeVisible() + return 'sample.csv' + } +} diff --git a/packages/web-app-ai-data-insights-sidebar/tests/unit/.gitkeep b/packages/web-app-ai-data-insights-sidebar/tests/unit/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/web-app-ai-data-insights-sidebar/tests/unit/InsightsPanel.spec.ts b/packages/web-app-ai-data-insights-sidebar/tests/unit/InsightsPanel.spec.ts new file mode 100644 index 00000000..dae68cb2 --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/tests/unit/InsightsPanel.spec.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ref } from 'vue' +import { mount, flushPromises } from '@vue/test-utils' +import InsightsPanel from '../../src/components/InsightsPanel.vue' + +// Module-level mocks — hoisted by vitest before any import +vi.mock('../../src/composables/useInsights') + +vi.mock('vue3-gettext', () => ({ + useGettext: () => ({ + $gettext: (s: string) => s, + $pgettext: (_ctx: string, s: string) => s + }) +})) + +import { useInsights } from '../../src/composables/useInsights' +import type { InsightsResult } from '../../src/composables/useInsights' +import type { LLMConfig } from '../../src/composables/useLLM' + +// Minimal OcButton stub that forwards click events +const OcButton = { + name: 'OcButton', + props: ['disabled', 'size', 'variant', 'appearance'], + emits: ['click'], + template: '' +} + +const triggerInsightsMock = vi.fn() +const ensureReadyMock = vi.fn().mockResolvedValue(undefined) + +function setupUseInsightsMock({ + isAnalyzing = false, + insightsResult = null as InsightsResult | null, + panelError = null as string | null +} = {}) { + vi.mocked(useInsights).mockReturnValue({ + status: ref('ready' as const), + isAnalyzing: ref(isAnalyzing), + insightsResult: ref(insightsResult), + panelError: ref(panelError), + triggerInsights: triggerInsightsMock, + ensureReady: ensureReadyMock + }) +} + +function createWrapper(props: { llmConfig?: LLMConfig | null; resource?: object | null } = {}) { + return mount(InsightsPanel, { + props: { llmConfig: null, resource: null, ...props }, + global: { + components: { OcButton }, + stubs: { OcButton: false } + } + }) +} + +describe('InsightsPanel', () => { + beforeEach(() => { + triggerInsightsMock.mockReset() + ensureReadyMock.mockReset().mockResolvedValue(undefined) + setupUseInsightsMock() + }) + + describe('analyzing state', () => { + it('shows the analyzing placeholder when isAnalyzing is true', async () => { + setupUseInsightsMock({ isAnalyzing: true }) + const wrapper = createWrapper() + await flushPromises() + const placeholder = wrapper.find('.ai-insights-placeholder') + expect(placeholder.exists()).toBe(true) + expect(placeholder.text()).toContain('Analyzing') + }) + + it('does not show the error element while analyzing', async () => { + setupUseInsightsMock({ isAnalyzing: true }) + const wrapper = createWrapper() + await flushPromises() + expect(wrapper.find('.ai-insights-error').exists()).toBe(false) + }) + + it('does not show any buttons while analyzing', async () => { + setupUseInsightsMock({ isAnalyzing: true }) + const wrapper = createWrapper() + await flushPromises() + expect(wrapper.findAllComponents(OcButton)).toHaveLength(0) + }) + }) + + describe('error state', () => { + it('shows the error message in .ai-insights-error', async () => { + setupUseInsightsMock({ panelError: 'AI service unreachable.' }) + const wrapper = createWrapper() + await flushPromises() + const err = wrapper.find('.ai-insights-error') + expect(err.exists()).toBe(true) + expect(err.text()).toBe('AI service unreachable.') + }) + + it('renders the error with role="alert"', async () => { + setupUseInsightsMock({ panelError: 'Something went wrong.' }) + const wrapper = createWrapper() + await flushPromises() + expect(wrapper.find('[role="alert"]').exists()).toBe(true) + }) + + it('does not show the Re-analyze button when panelError is set', async () => { + const result: InsightsResult = { columnTypes: [], ranges: [], observations: [] } + setupUseInsightsMock({ insightsResult: result, panelError: 'Error occurred.' }) + const wrapper = createWrapper() + await flushPromises() + expect(wrapper.find('.oc-flex.oc-flex-right').exists()).toBe(false) + }) + + it('does not render the result table alongside the error', async () => { + const result: InsightsResult = { + columnTypes: [{ column: 'name', type: 'string' }], + ranges: [], + observations: [] + } + setupUseInsightsMock({ insightsResult: result, panelError: 'LLM failed.' }) + const wrapper = createWrapper() + await flushPromises() + expect(wrapper.find('.ai-insights-table').exists()).toBe(false) + }) + }) + + describe('result state', () => { + const result: InsightsResult = { + columnTypes: [ + { column: 'name', type: 'string' }, + { column: 'age', type: 'number' } + ], + ranges: [{ column: 'age', min: '20', max: '45' }], + observations: ['The dataset has two columns.', 'Age values are numeric.'] + } + + it('renders the column type table', async () => { + setupUseInsightsMock({ insightsResult: result }) + const wrapper = createWrapper() + await flushPromises() + expect(wrapper.find('.ai-insights-table').exists()).toBe(true) + const rows = wrapper.findAll('.ai-insights-table tbody tr') + expect(rows).toHaveLength(2) + }) + + it('renders the column name and type in each table row', async () => { + setupUseInsightsMock({ insightsResult: result }) + const wrapper = createWrapper() + await flushPromises() + const firstRow = wrapper.findAll('.ai-insights-table tbody tr')[0] + expect(firstRow.text()).toContain('name') + expect(firstRow.text()).toContain('string') + }) + + it('renders the numeric range in the third column', async () => { + setupUseInsightsMock({ insightsResult: result }) + const wrapper = createWrapper() + await flushPromises() + const secondRow = wrapper.findAll('.ai-insights-table tbody tr')[1] + expect(secondRow.text()).toContain('20') + expect(secondRow.text()).toContain('45') + }) + + it('renders observations as list items', async () => { + setupUseInsightsMock({ insightsResult: result }) + const wrapper = createWrapper() + await flushPromises() + const items = wrapper.findAll('ul.oc-mt-s li') + expect(items).toHaveLength(2) + expect(items[0].text()).toBe('The dataset has two columns.') + expect(items[1].text()).toBe('Age values are numeric.') + }) + + it('shows the Re-analyze button', async () => { + setupUseInsightsMock({ insightsResult: result }) + const wrapper = createWrapper() + await flushPromises() + expect(wrapper.find('.oc-flex.oc-flex-right').exists()).toBe(true) + expect(wrapper.find('.oc-flex.oc-flex-right').text()).toContain('Re-analyze') + }) + + it('calls triggerInsights when Re-analyze is clicked', async () => { + setupUseInsightsMock({ insightsResult: result }) + const wrapper = createWrapper() + await flushPromises() + triggerInsightsMock.mockReset() + await wrapper.findComponent(OcButton).trigger('click') + expect(triggerInsightsMock).toHaveBeenCalledTimes(1) + }) + }) + + describe('idle state', () => { + it('shows the description and Analyze button before any analysis', async () => { + setupUseInsightsMock() + const wrapper = createWrapper() + await flushPromises() + const idle = wrapper.find('.oc-flex.oc-flex-center') + expect(idle.exists()).toBe(true) + expect(idle.find('p').text()).toContain('Analyze') + }) + + it('shows the Analyze button in the idle state', async () => { + setupUseInsightsMock() + const wrapper = createWrapper() + await flushPromises() + expect(wrapper.findComponent(OcButton).text()).toBe('Analyze') + }) + + it('calls triggerInsights when Analyze is clicked', async () => { + setupUseInsightsMock() + const wrapper = createWrapper() + await flushPromises() + await wrapper.findComponent(OcButton).trigger('click') + expect(triggerInsightsMock).toHaveBeenCalledTimes(1) + }) + + it('does not show the Re-analyze button in idle state', async () => { + setupUseInsightsMock() + const wrapper = createWrapper() + await flushPromises() + expect(wrapper.find('.oc-flex.oc-flex-right').exists()).toBe(false) + }) + }) + + describe('lifecycle', () => { + it('calls ensureReady on mount', async () => { + setupUseInsightsMock() + createWrapper() + await flushPromises() + expect(ensureReadyMock).toHaveBeenCalledTimes(1) + }) + + it('does not auto-trigger analysis on mount', async () => { + setupUseInsightsMock() + createWrapper() + await flushPromises() + expect(triggerInsightsMock).not.toHaveBeenCalled() + }) + + it('passes llmConfig and resource props to useInsights', async () => { + const llmConfig: LLMConfig = { endpoint: 'http://llm.local/v1', model: 'gpt-4' } + const resource = { id: 'file-1', extension: 'csv', name: 'data.csv' } + setupUseInsightsMock() + createWrapper({ llmConfig, resource }) + await flushPromises() + expect(vi.mocked(useInsights)).toHaveBeenCalledWith( + llmConfig, + expect.objectContaining({ value: resource }) + ) + }) + + it('passes null as llmConfig when prop is not provided', async () => { + setupUseInsightsMock() + createWrapper() + await flushPromises() + expect(vi.mocked(useInsights)).toHaveBeenCalledWith( + null, + expect.anything() + ) + }) + }) +}) diff --git a/packages/web-app-ai-data-insights-sidebar/tests/unit/csv-parse.spec.ts b/packages/web-app-ai-data-insights-sidebar/tests/unit/csv-parse.spec.ts new file mode 100644 index 00000000..f48b9792 --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/tests/unit/csv-parse.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest' +import { parseCSV } from '../../src/utils/csv-parse' + +describe('parseCSV', () => { + it('returns empty result for empty input', () => { + expect(parseCSV('')).toEqual({ headers: [], columns: [] }) + }) + + it('parses headers and a single data row', () => { + const { headers, columns } = parseCSV('name,age\nAlice,30') + expect(headers).toEqual(['name', 'age']) + expect(columns).toEqual([['Alice'], ['30']]) + }) + + it('handles quoted fields containing commas', () => { + const { columns } = parseCSV('city,label\n"Portland, OR",home') + expect(columns[0]).toEqual(['Portland, OR']) + }) + + it('unescapes doubled double-quotes inside quoted fields (RFC-4180)', () => { + const { columns } = parseCSV('note\n"say ""hello"""') + expect(columns[0]).toEqual(['say "hello"']) + }) + + it('handles CRLF line endings', () => { + const { headers, columns } = parseCSV('a,b\r\n1,2\r\n3,4') + expect(headers).toEqual(['a', 'b']) + expect(columns[0]).toEqual(['1', '3']) + expect(columns[1]).toEqual(['2', '4']) + }) + + it('respects maxRows and does not include the header row in the limit', () => { + const rows = Array.from({ length: 5 }, (_, i) => `${i},val`).join('\n') + const csv = 'id,v\n' + rows + const { columns } = parseCSV(csv, ',', 3) + expect(columns[0].length).toBeLessThanOrEqual(3) + }) + + it('parses TSV when delimiter is set to tab', () => { + const { headers, columns } = parseCSV('x\ty\n10\t20', '\t') + expect(headers).toEqual(['x', 'y']) + expect(columns[0]).toEqual(['10']) + }) + + it('handles quoted fields with internal newlines (RFC-4180 embedded newlines)', () => { + const csv = 'name,bio\nAlice,"Line one\nLine two"' + const { headers, columns } = parseCSV(csv) + expect(headers).toEqual(['name', 'bio']) + expect(columns[0]).toEqual(['Alice']) + expect(columns[1]).toEqual(['Line one\nLine two']) + }) + + it('handles a single-column CSV', () => { + const { headers, columns } = parseCSV('value\nalpha\nbeta\ngamma') + expect(headers).toEqual(['value']) + expect(columns[0]).toEqual(['alpha', 'beta', 'gamma']) + }) +}) diff --git a/packages/web-app-ai-data-insights-sidebar/tests/unit/file-support.spec.ts b/packages/web-app-ai-data-insights-sidebar/tests/unit/file-support.spec.ts new file mode 100644 index 00000000..8eea7fc1 --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/tests/unit/file-support.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { isSupportedFile } from '../../src/utils/file-support' + +const SUPPORTED_EXTS = ['csv', 'tsv'] + +describe('isSupportedFile', () => { + it('accepts .csv extension', () => { + expect(isSupportedFile({ extension: 'csv' }, SUPPORTED_EXTS)).toBe(true) + }) + + it('accepts .tsv extension', () => { + expect(isSupportedFile({ extension: 'tsv' }, SUPPORTED_EXTS)).toBe(true) + }) + + it('accepts uppercase CSV extension (case-insensitive)', () => { + expect(isSupportedFile({ extension: 'CSV' }, SUPPORTED_EXTS)).toBe(true) + }) + + it('accepts uppercase TSV extension (case-insensitive)', () => { + expect(isSupportedFile({ extension: 'TSV' }, SUPPORTED_EXTS)).toBe(true) + }) + + it('rejects .pdf extension', () => { + expect(isSupportedFile({ extension: 'pdf' }, SUPPORTED_EXTS)).toBe(false) + }) + + it('rejects .txt extension', () => { + expect(isSupportedFile({ extension: 'txt' }, SUPPORTED_EXTS)).toBe(false) + }) + + it('rejects .jpg extension', () => { + expect(isSupportedFile({ extension: 'jpg' }, SUPPORTED_EXTS)).toBe(false) + }) + + it('rejects a resource with no extension property', () => { + expect(isSupportedFile({}, SUPPORTED_EXTS)).toBe(false) + }) + + it('rejects undefined resource', () => { + expect(isSupportedFile(undefined, SUPPORTED_EXTS)).toBe(false) + }) +}) diff --git a/packages/web-app-ai-data-insights-sidebar/tests/unit/useInsights.spec.ts b/packages/web-app-ai-data-insights-sidebar/tests/unit/useInsights.spec.ts new file mode 100644 index 00000000..ff1ff982 --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/tests/unit/useInsights.spec.ts @@ -0,0 +1,279 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ref } from 'vue' + +// Module-level mocks — hoisted by vitest before any import +vi.mock('../../src/composables/useLLM', () => ({ useLLM: vi.fn() })) + +vi.mock('vue3-gettext', () => ({ + useGettext: () => ({ $gettext: (s: string) => s, current: 'en' }) +})) + +vi.mock('@ownclouders/web-pkg', () => ({ + useClientService: vi.fn(), + useSpacesStore: vi.fn(), + useUserStore: vi.fn() +})) + +import { useInsights } from '../../src/composables/useInsights' +import { useLLM } from '../../src/composables/useLLM' +import { useClientService, useSpacesStore, useUserStore } from '@ownclouders/web-pkg' +import type { LLMConfig, LLMStatus } from '../../src/composables/useLLM' + +const BASE_CONFIG: LLMConfig = { + endpoint: 'http://localhost:3000/ai-proxy/v1', + model: 'test-model' +} + +function makeResource(overrides: Record = {}) { + return ref({ + id: 'f1', + name: 'data.csv', + extension: 'csv', + storageId: 'space-1', + path: '/data.csv', + ...overrides + }) +} + +const HAPPY_PATH_LLM_RESPONSE = JSON.stringify({ + columnTypes: [{ column: 'name', type: 'string' }, { column: 'age', type: 'number' }], + ranges: [{ column: 'age', min: '20', max: '45' }], + observations: ['The file has two columns.', 'Age values are numeric.'] +}) + +let completeMock: ReturnType + +function setupLLMMock({ status = 'ready' as LLMStatus, response = HAPPY_PATH_LLM_RESPONSE } = {}) { + completeMock = vi.fn().mockResolvedValue(response) + vi.mocked(useLLM).mockReturnValue({ + status: ref(status), + complete: completeMock, + stream: vi.fn() + } as any) +} + +let getFileContentsMock: ReturnType + +function setupWebdavMock({ csvText = 'name,age\nAlice,30\nBob,25' } = {}) { + getFileContentsMock = vi.fn().mockResolvedValue({ response: { data: csvText } }) + vi.mocked(useClientService).mockReturnValue({ + webdav: { getFileContents: getFileContentsMock } + } as any) +} + +beforeEach(() => { + vi.restoreAllMocks() + setupLLMMock() + setupWebdavMock() + vi.mocked(useSpacesStore).mockReturnValue({ + getSpace: vi.fn().mockReturnValue({ id: 'space-1' }) + } as any) + vi.mocked(useUserStore).mockReturnValue({ + user: { preferredLanguage: 'en' } + } as any) +}) + +describe('useInsights', () => { + describe('initial state', () => { + it('starts with isAnalyzing = false', () => { + const { isAnalyzing } = useInsights(BASE_CONFIG, makeResource()) + expect(isAnalyzing.value).toBe(false) + }) + + it('starts with insightsResult = null', () => { + const { insightsResult } = useInsights(BASE_CONFIG, makeResource()) + expect(insightsResult.value).toBeNull() + }) + + it('starts with panelError = null', () => { + const { panelError } = useInsights(BASE_CONFIG, makeResource()) + expect(panelError.value).toBeNull() + }) + + it('exposes the status from useLLM', () => { + setupLLMMock({ status: 'unconfigured' }) + const { status } = useInsights(null, makeResource()) + expect(status.value).toBe('unconfigured') + }) + }) + + describe('ensureReady', () => { + it('resolves immediately — useLLM sets status synchronously at init', async () => { + const { ensureReady } = useInsights(BASE_CONFIG, makeResource()) + await expect(ensureReady()).resolves.toBeUndefined() + }) + }) + + describe('triggerInsights — guard conditions', () => { + it('returns early without fetching when LLM status is unconfigured', async () => { + setupLLMMock({ status: 'unconfigured' }) + const { triggerInsights, panelError } = useInsights(null, makeResource()) + await triggerInsights() + expect(getFileContentsMock).not.toHaveBeenCalled() + expect(panelError.value).toBeNull() + }) + + it('sets a cross-origin panelError when LLM status is cross-origin', async () => { + setupLLMMock({ status: 'cross-origin' }) + const { triggerInsights, panelError } = useInsights(BASE_CONFIG, makeResource()) + await triggerInsights() + expect(getFileContentsMock).not.toHaveBeenCalled() + expect(panelError.value).toMatch(/same server|cross-origin/i) + }) + + it('sets panelError when the resource has no storageId', async () => { + const { triggerInsights, panelError } = useInsights( + BASE_CONFIG, + makeResource({ storageId: undefined, path: undefined }) + ) + await triggerInsights() + expect(panelError.value).not.toBeNull() + }) + + it('sets panelError when the space cannot be resolved', async () => { + vi.mocked(useSpacesStore).mockReturnValue({ + getSpace: vi.fn().mockReturnValue(null) + } as any) + const { triggerInsights, panelError } = useInsights(BASE_CONFIG, makeResource()) + await triggerInsights() + expect(panelError.value).not.toBeNull() + }) + }) + + describe('triggerInsights — WebDAV fetch', () => { + it('fetches the file via WebDAV with responseType text', async () => { + const { triggerInsights } = useInsights(BASE_CONFIG, makeResource()) + await triggerInsights() + expect(getFileContentsMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ path: '/data.csv' }), + expect.objectContaining({ responseType: 'text' }) + ) + }) + + it('uses tab delimiter for .tsv files', async () => { + setupWebdavMock({ csvText: 'col1\tcol2\nval1\tval2' }) + const { triggerInsights } = useInsights(BASE_CONFIG, makeResource({ extension: 'tsv', name: 'data.tsv' })) + await triggerInsights() + expect(completeMock).toHaveBeenCalled() + // TSV should be parsed — completeMock called means the CSV was processed + expect(completeMock.mock.calls[0][0][0].content).toContain('col1') + }) + }) + + describe('triggerInsights — happy path', () => { + it('sets insightsResult with parsed columnTypes, ranges, and observations', async () => { + const { triggerInsights, insightsResult } = useInsights(BASE_CONFIG, makeResource()) + await triggerInsights() + expect(insightsResult.value).toEqual({ + columnTypes: [ + { column: 'name', type: 'string' }, + { column: 'age', type: 'number' } + ], + ranges: [{ column: 'age', min: '20', max: '45' }], + observations: ['The file has two columns.', 'Age values are numeric.'] + }) + }) + + it('sets isAnalyzing to true while the call is in flight and false after', async () => { + let observedDuring = false + completeMock.mockImplementation(() => { + observedDuring = true + return Promise.resolve(HAPPY_PATH_LLM_RESPONSE) + }) + const { triggerInsights, isAnalyzing } = useInsights(BASE_CONFIG, makeResource()) + const promise = triggerInsights() + expect(isAnalyzing.value).toBe(true) + await promise + expect(isAnalyzing.value).toBe(false) + expect(observedDuring).toBe(true) + }) + + it('clears panelError at the start of a subsequent successful call', async () => { + completeMock + .mockRejectedValueOnce(new Error('LLM request failed: 500 Internal Server Error')) + .mockResolvedValueOnce(JSON.stringify({ columnTypes: [], ranges: [], observations: [] })) + + const { triggerInsights, panelError } = useInsights(BASE_CONFIG, makeResource()) + await triggerInsights() + expect(panelError.value).not.toBeNull() + await triggerInsights() + expect(panelError.value).toBeNull() + }) + + it('sends the LLM request to the configured endpoint', async () => { + const { triggerInsights } = useInsights(BASE_CONFIG, makeResource()) + await triggerInsights() + expect(completeMock).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ role: 'user' })]), + expect.objectContaining({ responseFormat: { type: 'json_object' } }) + ) + }) + + it('treats a non-JSON LLM response as empty results without throwing', async () => { + completeMock.mockResolvedValue('not valid json at all') + const { triggerInsights, insightsResult, panelError } = useInsights(BASE_CONFIG, makeResource()) + await triggerInsights() + expect(insightsResult.value).toEqual({ columnTypes: [], ranges: [], observations: [] }) + expect(panelError.value).toBeNull() + }) + }) + + describe('triggerInsights — error handling', () => { + it('sets a human-readable panelError on HTTP 401', async () => { + completeMock.mockRejectedValue(new Error('LLM request failed: 401 Unauthorized')) + const { triggerInsights, panelError } = useInsights(BASE_CONFIG, makeResource()) + await triggerInsights() + expect(panelError.value).toMatch(/denied|session/i) + }) + + it('sets a human-readable panelError on HTTP 403', async () => { + completeMock.mockRejectedValue(new Error('LLM request failed: 403 Forbidden')) + const { triggerInsights, panelError } = useInsights(BASE_CONFIG, makeResource()) + await triggerInsights() + expect(panelError.value).toMatch(/denied|session/i) + }) + + it('sets a human-readable panelError on HTTP 404', async () => { + completeMock.mockRejectedValue(new Error('LLM request failed: 404 Not Found')) + const { triggerInsights, panelError } = useInsights(BASE_CONFIG, makeResource()) + await triggerInsights() + expect(panelError.value).toMatch(/not be found|endpoint/i) + }) + + it('sets a human-readable panelError on HTTP 429', async () => { + completeMock.mockRejectedValue(new Error('LLM request failed: 429 Too Many Requests')) + const { triggerInsights, panelError } = useInsights(BASE_CONFIG, makeResource()) + await triggerInsights() + expect(panelError.value).toMatch(/busy|try again/i) + }) + + it('sets a human-readable panelError on HTTP 5xx', async () => { + completeMock.mockRejectedValue(new Error('LLM request failed: 503 Service Unavailable')) + const { triggerInsights, panelError } = useInsights(BASE_CONFIG, makeResource()) + await triggerInsights() + expect(panelError.value).toMatch(/unavailable|try again/i) + }) + + it('sets a network-error panelError on TypeError', async () => { + completeMock.mockRejectedValue(new TypeError('Failed to fetch')) + const { triggerInsights, panelError } = useInsights(BASE_CONFIG, makeResource()) + await triggerInsights() + expect(panelError.value).toMatch(/network|connection/i) + }) + + it('sets a timeout panelError on DOMException TimeoutError', async () => { + completeMock.mockRejectedValue(new DOMException('Timeout', 'TimeoutError')) + const { triggerInsights, panelError } = useInsights(BASE_CONFIG, makeResource()) + await triggerInsights() + expect(panelError.value).toMatch(/time|respond/i) + }) + + it('sets isAnalyzing back to false after an error', async () => { + completeMock.mockRejectedValue(new Error('LLM request failed: 500 Error')) + const { triggerInsights, isAnalyzing } = useInsights(BASE_CONFIG, makeResource()) + await triggerInsights() + expect(isAnalyzing.value).toBe(false) + }) + }) +}) diff --git a/packages/web-app-ai-data-insights-sidebar/tests/unit/useLlm.spec.ts b/packages/web-app-ai-data-insights-sidebar/tests/unit/useLlm.spec.ts new file mode 100644 index 00000000..0f5e6769 --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/tests/unit/useLlm.spec.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useLLM } from '../../src/composables/useLLM' +import type { LLMConfig } from '../../src/composables/useLLM' + +// In the happy-dom test environment, window.location.origin is 'http://localhost:3000' +const SAME_ORIGIN = window.location.origin +const SAME_ORIGIN_CONFIG: LLMConfig = { + endpoint: `${SAME_ORIGIN}/ai-proxy/v1`, + model: 'test-model' +} +const CROSS_ORIGIN_CONFIG: LLMConfig = { + endpoint: 'http://external.example.com/api/v1', + model: 'test-model' +} + +describe('useLLM', () => { + describe('status at initialisation', () => { + it('is unconfigured when no config is provided', () => { + const { status } = useLLM(null) + expect(status.value).toBe('unconfigured') + }) + + it('is ready when a same-origin endpoint is provided', () => { + const { status } = useLLM(SAME_ORIGIN_CONFIG) + expect(status.value).toBe('ready') + }) + + it('is cross-origin when a cross-origin endpoint is provided', () => { + const { status } = useLLM(CROSS_ORIGIN_CONFIG) + expect(status.value).toBe('cross-origin') + }) + + it('is cross-origin when the endpoint URL is malformed', () => { + const { status } = useLLM({ endpoint: 'not-a-valid-url', model: 'test' }) + expect(status.value).toBe('cross-origin') + }) + }) + + describe('complete', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + }) + + it('throws when status is unconfigured', async () => { + const { complete } = useLLM(null) + await expect(complete([{ role: 'user', content: 'hello' }])).rejects.toThrow() + }) + + it('throws when status is cross-origin', async () => { + const { complete } = useLLM(CROSS_ORIGIN_CONFIG) + await expect(complete([{ role: 'user', content: 'hello' }])).rejects.toThrow() + }) + + it('calls fetch with the correct URL and POST method', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => ({ choices: [{ message: { content: 'result' } }] }) + }) + vi.stubGlobal('fetch', fetchMock) + + const { complete } = useLLM(SAME_ORIGIN_CONFIG) + await complete([{ role: 'user', content: 'ping' }]) + + expect(fetchMock).toHaveBeenCalledWith( + `${SAME_ORIGIN}/ai-proxy/v1/chat/completions`, + expect.objectContaining({ method: 'POST' }) + ) + }) + + it('returns the content string from the first choice', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => ({ choices: [{ message: { content: 'hello there' } }] }) + })) + + const { complete } = useLLM(SAME_ORIGIN_CONFIG) + const result = await complete([{ role: 'user', content: 'ping' }]) + expect(result).toBe('hello there') + }) + + it('sends the model and messages in the request body', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => ({ choices: [{ message: { content: 'ok' } }] }) + }) + vi.stubGlobal('fetch', fetchMock) + + const { complete } = useLLM(SAME_ORIGIN_CONFIG) + await complete([{ role: 'user', content: 'test' }]) + + const body = JSON.parse(fetchMock.mock.calls[0][1].body) + expect(body.model).toBe('test-model') + expect(body.messages).toEqual([{ role: 'user', content: 'test' }]) + }) + + it('throws with the HTTP status code on a non-ok response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized' + })) + + const { complete } = useLLM(SAME_ORIGIN_CONFIG) + await expect(complete([{ role: 'user', content: 'ping' }])).rejects.toThrow('401') + }) + + it('honours the responseFormat option', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => ({ choices: [{ message: { content: '{}' } }] }) + }) + vi.stubGlobal('fetch', fetchMock) + + const { complete } = useLLM(SAME_ORIGIN_CONFIG) + await complete([{ role: 'user', content: 'test' }], { responseFormat: { type: 'json_object' } }) + + const body = JSON.parse(fetchMock.mock.calls[0][1].body) + expect(body.response_format).toEqual({ type: 'json_object' }) + }) + }) +}) diff --git a/packages/web-app-ai-data-insights-sidebar/tsconfig.json b/packages/web-app-ai-data-insights-sidebar/tsconfig.json new file mode 100644 index 00000000..63b5082a --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/web-app-ai-data-insights-sidebar/vite.config.ts b/packages/web-app-ai-data-insights-sidebar/vite.config.ts new file mode 100644 index 00000000..a6930469 --- /dev/null +++ b/packages/web-app-ai-data-insights-sidebar/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from '@ownclouders/extension-sdk' + +export default defineConfig({ + name: 'ai-data-insights-sidebar', + server: { + port: 9733, + }, + build: { + rollupOptions: { + output: { + entryFileNames: 'index.js', + }, + }, + }, + test: { + exclude: ['**/e2e/**'], + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3eb1bb84..3cf630b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,58 @@ importers: specifier: ^2.4.0 version: 2.4.0(@vue/compiler-sfc@3.5.35)(vue@3.5.35(typescript@5.9.3)) + packages/web-app-ai-data-insights-sidebar: + dependencies: + '@ownclouders/web-client': + specifier: ^12.3.2 + version: 12.3.2 + '@ownclouders/web-pkg': + specifier: ^12.3.2 + version: 12.3.2(@vue/compiler-sfc@3.5.35)(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3)) + devDependencies: + '@ownclouders/extension-sdk': + specifier: 12.3.2 + version: 12.3.2(vite@7.2.2(@types/node@22.19.19)(sass-embedded@1.100.0)(sass@1.100.0)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3)) + '@ownclouders/tsconfig': + specifier: 0.0.6 + version: 0.0.6 + '@types/node': + specifier: 22.19.19 + version: 22.19.19 + '@vue/test-utils': + specifier: ^2.4.6 + version: 2.4.10(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3)) + eslint: + specifier: 9.39.4 + version: 9.39.4 + happy-dom: + specifier: ^20.9.0 + version: 20.9.0 + prettier: + specifier: 3.8.3 + version: 3.8.3 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: 7.2.2 + version: 7.2.2(@types/node@22.19.19)(sass-embedded@1.100.0)(sass@1.100.0)(yaml@2.9.0) + vitest: + specifier: 4.1.7 + version: 4.1.7(@types/node@22.19.19)(happy-dom@20.9.0)(vite@7.2.2(@types/node@22.19.19)(sass-embedded@1.100.0)(sass@1.100.0)(yaml@2.9.0)) + vue: + specifier: ^3.4.21 + version: 3.5.35(typescript@5.9.3) + vue-router: + specifier: ^5.0.7 + version: 5.1.0(@vue/compiler-sfc@3.5.35)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3)))(vite@7.2.2(@types/node@22.19.19)(sass-embedded@1.100.0)(sass@1.100.0)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3)) + vue-tsc: + specifier: 3.3.2 + version: 3.3.2(typescript@5.9.3) + vue3-gettext: + specifier: ^2.4.0 + version: 2.4.0(@vue/compiler-sfc@3.5.35)(vue@3.5.35(typescript@5.9.3)) + packages/web-app-ai-doc-summary: dependencies: '@ownclouders/web-client': @@ -1290,10 +1342,6 @@ packages: resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/object-schema@2.1.6': - resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/object-schema@2.1.7': resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -5654,7 +5702,7 @@ snapshots: '@eslint/config-array@0.21.0': dependencies: - '@eslint/object-schema': 2.1.6 + '@eslint/object-schema': 2.1.7 debug: 4.4.3 minimatch: 3.1.5 transitivePeerDependencies: @@ -5732,8 +5780,6 @@ snapshots: '@eslint/js@9.39.4': {} - '@eslint/object-schema@2.1.6': {} - '@eslint/object-schema@2.1.7': {} '@eslint/plugin-kit@0.4.0': diff --git a/support/actions/ocis.apps.yaml b/support/actions/ocis.apps.yaml index ff0fa770..5bd30c9e 100644 --- a/support/actions/ocis.apps.yaml +++ b/support/actions/ocis.apps.yaml @@ -19,3 +19,9 @@ web-app-version-changelog: llm: endpoint: 'https://localhost:9200/ai-llm-proxy/v1' model: 'llama3.2' + +web-app-ai-data-insights-sidebar: + config: + llm: + endpoint: 'https://localhost:9200/ai-llm-proxy/v1' + model: 'llama3.2'