Add on-demand AI description generation for existing scans
Task #24: Older scans created before description generation existed showed an empty "Was macht dieser Skill?" section. Users can now trigger description generation for any existing scan from the report. Changes: - OpenAPI: added POST /scans/{id}/description (operationId generateScanDescription) returning ScanDetail (200), ApiError (404 not found, 422 cannot generate). Regenerated api-zod and api-client-react via codegen. - api-server (routes/scans.ts): new route loads the scan, its stored files, the enabled provider and prompts, reconstructs ParsedFile[] from scan_files (binary files -> empty content/isBinary), calls existing generateSkillDescription(), persists description and returns full ScanDetail. Clean 422 errors when no provider / no token / generation yields nothing; the scan is never mutated on failure. - skillguard (scan-report.tsx): the description card now always renders; when no description exists it shows a "Beschreibung erzeugen" button wired to the new mutation, with loading state, toast feedback, and query cache update on success. Incidental fix: the dev/test database was missing the `scans.description` column (schema drift from the earlier description task). Ran drizzle-kit push to sync; this unblocked 5 previously failing api-server tests. All 59 tests now pass and full typecheck is green. Rebase: one conflict in scan-report.tsx import line — main added the `ShieldAlert` icon (new KI-disclaimer Alert), this branch added `Loader2`. Resolved by keeping both icons; the rest of the file (disclaimer Alert + new description card) merged cleanly. No semantic divergence. Replit-Task-Id: 0610af4f-aa62-434e-abcd-d742081b6459
This commit is contained in:
parent
487d1f3d3c
commit
9415e184dc
5 changed files with 316 additions and 14 deletions
|
|
@ -8,6 +8,9 @@ import {
|
||||||
type ScanFile,
|
type ScanFile,
|
||||||
type Finding,
|
type Finding,
|
||||||
type ScanRelation,
|
type ScanRelation,
|
||||||
|
aiProvidersTable,
|
||||||
|
promptsTable,
|
||||||
|
type Prompt,
|
||||||
} from "@workspace/db";
|
} from "@workspace/db";
|
||||||
import { eq, desc, count } from "drizzle-orm";
|
import { eq, desc, count } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
|
|
@ -28,6 +31,7 @@ import {
|
||||||
} from "../lib/skillParser";
|
} from "../lib/skillParser";
|
||||||
import { analyzeSkill, type EngineResult } from "../lib/scanEngine";
|
import { analyzeSkill, type EngineResult } from "../lib/scanEngine";
|
||||||
import { STATIC_RULES, AI_RULES, type ParsedFile } from "../lib/ruleCatalog";
|
import { STATIC_RULES, AI_RULES, type ParsedFile } from "../lib/ruleCatalog";
|
||||||
|
import { generateSkillDescription } from "../lib/aiAnalysis";
|
||||||
import { computeFingerprint } from "../lib/skillFingerprint";
|
import { computeFingerprint } from "../lib/skillFingerprint";
|
||||||
import { lineDiff, lineSimilarity } from "../lib/lineDiff";
|
import { lineDiff, lineSimilarity } from "../lib/lineDiff";
|
||||||
import { logger } from "../lib/logger";
|
import { logger } from "../lib/logger";
|
||||||
|
|
@ -732,4 +736,81 @@ router.delete("/scans/:id", async (req, res) => {
|
||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Generate the AI description for an existing scan that has none yet (older
|
||||||
|
// scans were created before description generation existed). Reuses the same
|
||||||
|
// generateSkillDescription() helper and the configured provider. A failure must
|
||||||
|
// never alter the stored scan.
|
||||||
|
router.post("/scans/:id/description", async (req, res) => {
|
||||||
|
const params = GetScanParams.safeParse(req.params);
|
||||||
|
if (!params.success)
|
||||||
|
return res.status(400).json({ error: "Ungültige ID" });
|
||||||
|
|
||||||
|
const [scan] = await db
|
||||||
|
.select()
|
||||||
|
.from(scansTable)
|
||||||
|
.where(eq(scansTable.id, params.data.id));
|
||||||
|
if (!scan) return res.status(404).json({ error: "Scan nicht gefunden" });
|
||||||
|
|
||||||
|
const storedFiles = await db
|
||||||
|
.select()
|
||||||
|
.from(scanFilesTable)
|
||||||
|
.where(eq(scanFilesTable.scanId, scan.id));
|
||||||
|
|
||||||
|
const [provider] = await db
|
||||||
|
.select()
|
||||||
|
.from(aiProvidersTable)
|
||||||
|
.where(eq(aiProvidersTable.enabled, true))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
return res.status(422).json({
|
||||||
|
error:
|
||||||
|
"Kein aktiver KI-Provider konfiguriert. Bitte im Admin-Bereich einrichten.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!provider.apiToken) {
|
||||||
|
return res.status(422).json({
|
||||||
|
error: `Für den Provider "${provider.name}" ist kein API-Token hinterlegt.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompts: Prompt[] = await db.select().from(promptsTable);
|
||||||
|
|
||||||
|
// Reconstruct ParsedFile inputs from the stored scan files. Binary files have
|
||||||
|
// no stored content; generateSkillDescription skips empty content anyway.
|
||||||
|
const files: ParsedFile[] = storedFiles.map((f) => ({
|
||||||
|
path: f.path,
|
||||||
|
kind: f.kind as ParsedFile["kind"],
|
||||||
|
language: f.language,
|
||||||
|
content: f.content ?? "",
|
||||||
|
size: f.size,
|
||||||
|
hash: f.hash,
|
||||||
|
isBinary: f.content === null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const description = await generateSkillDescription(provider, prompts, files);
|
||||||
|
if (!description) {
|
||||||
|
return res.status(422).json({
|
||||||
|
error:
|
||||||
|
"Die Beschreibung konnte nicht erzeugt werden. Bitte Provider-Konfiguration und KI-Prompts prüfen.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(scansTable)
|
||||||
|
.set({ description })
|
||||||
|
.where(eq(scansTable.id, scan.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const findings = await db
|
||||||
|
.select()
|
||||||
|
.from(findingsTable)
|
||||||
|
.where(eq(findingsTable.scanId, scan.id))
|
||||||
|
.orderBy(findingsTable.id);
|
||||||
|
|
||||||
|
return res.json(
|
||||||
|
GetScanResponse.parse(await buildScanDetail(updated, storedFiles, findings)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useRoute, Link } from "wouter";
|
import { useRoute, Link } from "wouter";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
useGetScan,
|
useGetScan,
|
||||||
getGetScanQueryKey,
|
getGetScanQueryKey,
|
||||||
useGetScanLineage,
|
useGetScanLineage,
|
||||||
getGetScanLineageQueryKey,
|
getGetScanLineageQueryKey,
|
||||||
|
useGenerateScanDescription,
|
||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
@ -16,7 +19,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS, RelationBadge } from "@/components/ui-helpers";
|
import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS, RelationBadge } from "@/components/ui-helpers";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import { ShieldQuestion, ShieldAlert, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical, Sparkles } from "lucide-react";
|
import { ShieldQuestion, ShieldAlert, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical, Sparkles, Loader2 } from "lucide-react";
|
||||||
import type { ScanDetail } from "@workspace/api-client-react";
|
import type { ScanDetail } from "@workspace/api-client-react";
|
||||||
|
|
||||||
export default function ScanReport() {
|
export default function ScanReport() {
|
||||||
|
|
@ -33,6 +36,27 @@ export default function ScanReport() {
|
||||||
const [filterAxis, setFilterAxis] = useState<string>("all");
|
const [filterAxis, setFilterAxis] = useState<string>("all");
|
||||||
const [filterSeverity, setFilterSeverity] = useState<string>("all");
|
const [filterSeverity, setFilterSeverity] = useState<string>("all");
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const generateDescription = useGenerateScanDescription({
|
||||||
|
mutation: {
|
||||||
|
onSuccess: (updated) => {
|
||||||
|
queryClient.setQueryData(getGetScanQueryKey(updated.id), updated);
|
||||||
|
toast({ title: "Beschreibung erzeugt" });
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const message =
|
||||||
|
(err as { data?: { error?: string } })?.data?.error ??
|
||||||
|
"Die Beschreibung konnte nicht erzeugt werden.";
|
||||||
|
toast({
|
||||||
|
title: "Fehler",
|
||||||
|
description: message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const filteredFindings = useMemo(() => {
|
const filteredFindings = useMemo(() => {
|
||||||
if (!data?.findings) return [];
|
if (!data?.findings) return [];
|
||||||
return data.findings.filter(f => {
|
return data.findings.filter(f => {
|
||||||
|
|
@ -143,20 +167,39 @@ export default function ScanReport() {
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.description && (
|
<Card>
|
||||||
<Card>
|
<CardHeader className="pb-3">
|
||||||
<CardHeader className="pb-3">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
<Sparkles className="w-5 h-5 text-purple-500" />
|
||||||
<Sparkles className="w-5 h-5 text-purple-500" />
|
Was macht dieser Skill?
|
||||||
Was macht dieser Skill?
|
</CardTitle>
|
||||||
</CardTitle>
|
<CardDescription>KI-generierte Beschreibung des Zwecks und der Funktionsweise.</CardDescription>
|
||||||
<CardDescription>KI-generierte Beschreibung des Zwecks und der Funktionsweise.</CardDescription>
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent>
|
||||||
<CardContent>
|
{data.description ? (
|
||||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">{data.description}</p>
|
<p className="text-sm leading-relaxed whitespace-pre-wrap">{data.description}</p>
|
||||||
</CardContent>
|
) : (
|
||||||
</Card>
|
<div className="flex flex-col items-start gap-3">
|
||||||
)}
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Für diesen Scan wurde noch keine Beschreibung erzeugt. Sie können sie jetzt
|
||||||
|
mit dem konfigurierten KI-Provider nachträglich anfordern.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => generateDescription.mutate({ id: data.id })}
|
||||||
|
disabled={generateDescription.isPending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{generateDescription.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{generateDescription.isPending ? "Wird erzeugt …" : "Beschreibung erzeugen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Alert className="bg-blue-50 text-blue-900 border-blue-200 dark:bg-blue-950/40 dark:text-blue-200 dark:border-blue-900">
|
<Alert className="bg-blue-50 text-blue-900 border-blue-200 dark:bg-blue-950/40 dark:text-blue-200 dark:border-blue-900">
|
||||||
<ShieldAlert className="h-4 w-4" />
|
<ShieldAlert className="h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -666,6 +666,76 @@ export const useDeleteScan = <TError = ErrorType<unknown>,
|
||||||
return useMutation(getDeleteScanMutationOptions(options));
|
return useMutation(getDeleteScanMutationOptions(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getGenerateScanDescriptionUrl = (id: number,) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/scans/${id}/description`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Generate the AI description for an existing scan
|
||||||
|
*/
|
||||||
|
export const generateScanDescription = async (id: number, options?: RequestInit): Promise<ScanDetail> => {
|
||||||
|
|
||||||
|
return customFetch<ScanDetail>(getGenerateScanDescriptionUrl(id),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'POST'
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getGenerateScanDescriptionMutationOptions = <TError = ErrorType<ApiError>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof generateScanDescription>>, TError,{id: number}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof generateScanDescription>>, TError,{id: number}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['generateScanDescription'];
|
||||||
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, request: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof generateScanDescription>>, {id: number}> = (props) => {
|
||||||
|
const {id} = props ?? {};
|
||||||
|
|
||||||
|
return generateScanDescription(id,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type GenerateScanDescriptionMutationResult = NonNullable<Awaited<ReturnType<typeof generateScanDescription>>>
|
||||||
|
|
||||||
|
export type GenerateScanDescriptionMutationError = ErrorType<ApiError>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Generate the AI description for an existing scan
|
||||||
|
*/
|
||||||
|
export const useGenerateScanDescription = <TError = ErrorType<ApiError>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof generateScanDescription>>, TError,{id: number}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof generateScanDescription>>,
|
||||||
|
TError,
|
||||||
|
{id: number},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(getGenerateScanDescriptionMutationOptions(options));
|
||||||
|
}
|
||||||
|
|
||||||
export const getListProvidersUrl = () => {
|
export const getListProvidersUrl = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,37 @@ paths:
|
||||||
"204":
|
"204":
|
||||||
description: Deleted
|
description: Deleted
|
||||||
|
|
||||||
|
/scans/{id}/description:
|
||||||
|
post:
|
||||||
|
operationId: generateScanDescription
|
||||||
|
tags: [scans]
|
||||||
|
summary: Generate the AI description for an existing scan
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Scan report with the newly generated description
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ScanDetail"
|
||||||
|
"404":
|
||||||
|
description: Not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ApiError"
|
||||||
|
"422":
|
||||||
|
description: Description could not be generated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ApiError"
|
||||||
|
|
||||||
/providers:
|
/providers:
|
||||||
get:
|
get:
|
||||||
operationId: listProviders
|
operationId: listProviders
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,83 @@ export const DeleteScanParams = zod.object({
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Generate the AI description for an existing scan
|
||||||
|
*/
|
||||||
|
export const GenerateScanDescriptionParams = zod.object({
|
||||||
|
"id": zod.coerce.number()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const GenerateScanDescriptionResponse = zod.object({
|
||||||
|
"id": zod.number(),
|
||||||
|
"name": zod.string(),
|
||||||
|
"description": zod.string().nullish().describe('AI-generated summary of the skill\'s purpose (null when no AI description is available)'),
|
||||||
|
"source": zod.enum(['zip', 'file', 'text']),
|
||||||
|
"status": zod.enum(['completed', 'failed']),
|
||||||
|
"verdict": zod.enum(['pass', 'review', 'block']),
|
||||||
|
"riskScore": zod.number(),
|
||||||
|
"fileCount": zod.number(),
|
||||||
|
"aiUsed": zod.boolean(),
|
||||||
|
"aiError": zod.string().nullish(),
|
||||||
|
"findingCounts": zod.object({
|
||||||
|
"critical": zod.number(),
|
||||||
|
"high": zod.number(),
|
||||||
|
"medium": zod.number(),
|
||||||
|
"low": zod.number(),
|
||||||
|
"info": zod.number(),
|
||||||
|
"security": zod.number(),
|
||||||
|
"privacy": zod.number(),
|
||||||
|
"total": zod.number()
|
||||||
|
}),
|
||||||
|
"fingerprint": zod.string().describe('Deterministic hash over all files (path + per-file hash)'),
|
||||||
|
"relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation to previously stored skills'),
|
||||||
|
"similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)'),
|
||||||
|
"comparedScanId": zod.number().nullable().describe('The scan this one was compared against, if any'),
|
||||||
|
"createdAt": zod.string()
|
||||||
|
}).and(zod.object({
|
||||||
|
"files": zod.array(zod.object({
|
||||||
|
"path": zod.string(),
|
||||||
|
"kind": zod.enum(['instruction', 'script', 'resource']),
|
||||||
|
"language": zod.string().nullish(),
|
||||||
|
"size": zod.number(),
|
||||||
|
"hash": zod.string().describe('SHA-256 hash of the file content'),
|
||||||
|
"hasContent": zod.boolean().describe('Whether the text content was stored (false for binary files)')
|
||||||
|
})),
|
||||||
|
"findings": zod.array(zod.object({
|
||||||
|
"id": zod.number(),
|
||||||
|
"ruleId": zod.string(),
|
||||||
|
"axis": zod.enum(['security', 'privacy']),
|
||||||
|
"severity": zod.enum(['critical', 'high', 'medium', 'low', 'info']),
|
||||||
|
"title": zod.string(),
|
||||||
|
"description": zod.string(),
|
||||||
|
"remediation": zod.string().nullish(),
|
||||||
|
"file": zod.string().nullish(),
|
||||||
|
"line": zod.number().nullish(),
|
||||||
|
"snippet": zod.string().nullish(),
|
||||||
|
"detectedBy": zod.enum(['static', 'ai'])
|
||||||
|
})),
|
||||||
|
"checkpoints": zod.array(zod.object({
|
||||||
|
"id": zod.string(),
|
||||||
|
"label": zod.string(),
|
||||||
|
"category": zod.string(),
|
||||||
|
"axis": zod.union([zod.literal('security'),zod.literal('privacy'),zod.literal(null)]).nullish(),
|
||||||
|
"severity": zod.union([zod.literal('critical'),zod.literal('high'),zod.literal('medium'),zod.literal('low'),zod.literal('info'),zod.literal(null)]).nullish(),
|
||||||
|
"status": zod.enum(['pass', 'flagged', 'skipped', 'error']),
|
||||||
|
"findingCount": zod.number(),
|
||||||
|
"scoreDelta": zod.number(),
|
||||||
|
"detectedBy": zod.union([zod.literal('static'),zod.literal('ai'),zod.literal(null)]).nullish()
|
||||||
|
}).describe('A single inspection step (Prüfschritt) with its partial assessment (Teilbewertung).')),
|
||||||
|
"checkCount": zod.number().describe('How often a skill with this exact fingerprint was scanned'),
|
||||||
|
"comparedScan": zod.union([zod.object({
|
||||||
|
"id": zod.number(),
|
||||||
|
"name": zod.string(),
|
||||||
|
"verdict": zod.enum(['pass', 'review', 'block']),
|
||||||
|
"riskScore": zod.number(),
|
||||||
|
"createdAt": zod.string()
|
||||||
|
}),zod.null()])
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary List configured AI providers
|
* @summary List configured AI providers
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue