diff --git a/apps/internal-ui/package.json b/apps/internal-ui/package.json index 756a028..29a1fd9 100644 --- a/apps/internal-ui/package.json +++ b/apps/internal-ui/package.json @@ -15,10 +15,12 @@ "@edevs/shared": "0.1.0", "@edevs/ui": "0.1.0", "antd": "^5.26.0", + "js-yaml": "^5.2.1", "react": "^19.1.0", "react-dom": "^19.1.0" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0" } diff --git a/apps/internal-ui/src/features/ai/detail/AiAgentInstructionsTab.tsx b/apps/internal-ui/src/features/ai/detail/AiAgentInstructionsTab.tsx index ba4645c..4633f42 100644 --- a/apps/internal-ui/src/features/ai/detail/AiAgentInstructionsTab.tsx +++ b/apps/internal-ui/src/features/ai/detail/AiAgentInstructionsTab.tsx @@ -1,6 +1,7 @@ import { EmptyState } from "../../../shared/ui"; import { DocCard } from "./DocCard"; import { DocCreateForm } from "./DocCreateForm"; +import { DocImportForm } from "./DocImportForm"; import { promptCategoryLabel, promptCategoryOptions, type PromptDoc } from "./model"; export function AiAgentInstructionsTab({ prompts, product, onChanged }: { prompts: PromptDoc[]; product: { code: string; name: string } | null; onChanged: () => void }) { @@ -21,6 +22,7 @@ export function AiAgentInstructionsTab({ prompts, product, onChanged }: { prompt /> ))} + ); } diff --git a/apps/internal-ui/src/features/ai/detail/AiAgentKnowledgeTab.tsx b/apps/internal-ui/src/features/ai/detail/AiAgentKnowledgeTab.tsx index bbb7ebd..1b7bbe0 100644 --- a/apps/internal-ui/src/features/ai/detail/AiAgentKnowledgeTab.tsx +++ b/apps/internal-ui/src/features/ai/detail/AiAgentKnowledgeTab.tsx @@ -1,6 +1,7 @@ import { EmptyState } from "../../../shared/ui"; import { DocCard } from "./DocCard"; import { DocCreateForm } from "./DocCreateForm"; +import { DocImportForm } from "./DocImportForm"; import { knowledgeCategoryLabel, knowledgeCategoryOptions, type KnowledgeDoc } from "./model"; export function AiAgentKnowledgeTab({ knowledge, channelName, product, onChanged }: { knowledge: KnowledgeDoc[]; channelName: string; product: { code: string; name: string } | null; onChanged: () => void }) { @@ -30,6 +31,7 @@ export function AiAgentKnowledgeTab({ knowledge, channelName, product, onChanged /> ))} + ); } diff --git a/apps/internal-ui/src/features/ai/detail/DocImportForm.tsx b/apps/internal-ui/src/features/ai/detail/DocImportForm.tsx new file mode 100644 index 0000000..e9a22f3 --- /dev/null +++ b/apps/internal-ui/src/features/ai/detail/DocImportForm.tsx @@ -0,0 +1,105 @@ +import { useState } from "react"; + +import { Button } from "../../../shared/ui-controls"; +import { importDocs, type DocKind, type ImportResult } from "./docApi"; +import { parseDocYaml } from "./parseDocYaml"; + +type DocImportFormProps = { + kind: DocKind; + onImported: () => void; +}; + +export function DocImportForm({ kind, onImported }: DocImportFormProps) { + const [open, setOpen] = useState(false); + const [preview, setPreview] = useState<{ count: number; product: string | null } | null>(null); + const [parsed, setParsed] = useState | null>(null); + const [result, setResult] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + function reset() { + setPreview(null); + setParsed(null); + setResult(null); + setError(null); + } + + async function onFile(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) return; + setError(null); + setResult(null); + try { + const text = await file.text(); + const data = parseDocYaml(text); + setParsed(data); + setPreview({ count: data.documents.length, product: data.product }); + } catch (caught) { + setParsed(null); + setPreview(null); + setError(caught instanceof Error ? caught.message : "Не удалось прочитать файл"); + } + } + + async function submit() { + if (!parsed) return; + setBusy(true); + setError(null); + try { + const report = await importDocs(kind, parsed.product, parsed.documents); + setResult(report); + setParsed(null); + setPreview(null); + onImported(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Не удалось импортировать"); + } finally { + setBusy(false); + } + } + + if (!open) { + return ( +
+ +
+ ); + } + + return ( +
+
+ +
+ {preview && ( +
+ К импорту: {preview.count} документ(ов){preview.product ? `, продукт «${preview.product}»` : ", глобально"}. +
+ )} + {result && ( +
+
+ Создано: {result.created} · Обновлено: {result.updated} · Без изменений: {result.unchanged} +
+ {result.failed.length > 0 && ( +
    + {result.failed.map((item, index) => ( +
  • {item.code ? `«${item.code}»: ` : ""}{item.detail}
  • + ))} +
+ )} +
+ )} + {error &&
{error}
} +
+ + +
+
+ ); +} diff --git a/apps/internal-ui/src/features/ai/detail/docApi.ts b/apps/internal-ui/src/features/ai/detail/docApi.ts index 704b715..dfcc196 100644 --- a/apps/internal-ui/src/features/ai/detail/docApi.ts +++ b/apps/internal-ui/src/features/ai/detail/docApi.ts @@ -59,6 +59,21 @@ export type CreateDocInput = { inclusionMode?: "MANDATORY" | "RETRIEVAL"; }; +export type ImportDocInput = { + code: string; + title: string; + category: string; + content: string; + inclusionMode?: "MANDATORY" | "RETRIEVAL"; +}; + +export type ImportResult = { + created: number; + updated: number; + unchanged: number; + failed: Array<{ code: string; detail: string }>; +}; + function slugCode(title: string): string { // SlugField хранит ASCII; кириллица отбрасывается → fallback на "doc". const slug = title @@ -75,3 +90,10 @@ export async function createDoc(kind: DocKind, input: CreateDocInput): Promise { + return api(`${base(kind)}/import/`, { + method: "POST", + body: JSON.stringify({ ...(product ? { product } : {}), documents }), + }); +} diff --git a/apps/internal-ui/src/features/ai/detail/parseDocYaml.test.ts b/apps/internal-ui/src/features/ai/detail/parseDocYaml.test.ts new file mode 100644 index 0000000..458946b --- /dev/null +++ b/apps/internal-ui/src/features/ai/detail/parseDocYaml.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { parseDocYaml } from "./parseDocYaml"; + +describe("parseDocYaml", () => { + it("parses a valid YAML into product + documents", () => { + const text = ` +product: foxray +documents: + - code: foxray-overview + title: Обзор продукта + category: OVERVIEW + inclusionMode: MANDATORY + content: | + Многострочный + текст + - code: foxray-faq + title: FAQ + category: FAQ + content: Вопрос? +`; + const result = parseDocYaml(text); + + expect(result.product).toBe("foxray"); + expect(result.documents).toHaveLength(2); + expect(result.documents[0]).toEqual({ code: "foxray-overview", title: "Обзор продукта", category: "OVERVIEW", content: "Многострочный\nтекст\n", inclusionMode: "MANDATORY" }); + expect(result.documents[1].inclusionMode).toBeUndefined(); + }); + + it("treats absent product as global (null)", () => { + const text = ` +documents: + - code: global-overview + title: Обзор + category: OVERVIEW + content: текст +`; + expect(parseDocYaml(text).product).toBeNull(); + }); + + it("throws on invalid YAML syntax", () => { + expect(() => parseDocYaml("product: foxray\n documents: [unclosed")).toThrow(/YAML/); + }); + + it("throws when documents is missing or empty", () => { + expect(() => parseDocYaml("product: foxray")).toThrow(/documents/); + expect(() => parseDocYaml("documents: []")).toThrow(/documents/); + }); + + it("throws when a required field is empty", () => { + const text = ` +documents: + - code: "" + title: Обзор + category: OVERVIEW + content: текст +`; + expect(() => parseDocYaml(text)).toThrow(/code/); + }); + + it("throws on an invalid inclusionMode", () => { + const text = ` +documents: + - code: foxray-overview + title: Обзор + category: OVERVIEW + inclusionMode: MAYBE + content: текст +`; + expect(() => parseDocYaml(text)).toThrow(/inclusionMode/); + }); +}); diff --git a/apps/internal-ui/src/features/ai/detail/parseDocYaml.ts b/apps/internal-ui/src/features/ai/detail/parseDocYaml.ts new file mode 100644 index 0000000..e9f578b --- /dev/null +++ b/apps/internal-ui/src/features/ai/detail/parseDocYaml.ts @@ -0,0 +1,60 @@ +import { load as parseYaml } from "js-yaml"; + +import type { ImportDocInput } from "./docApi"; + +export type ParsedDocYaml = { + product: string | null; + documents: ImportDocInput[]; +}; + +const REQUIRED_FIELDS = ["code", "title", "category", "content"] as const; + +// Парсит YAML импорта знаний/инструкций и структурно валидирует. +// Бросает Error с человекочитаемым сообщением при невалидном YAML или структуре. +export function parseDocYaml(text: string): ParsedDocYaml { + let data: unknown; + try { + data = parseYaml(text); + } catch (error) { + throw new Error(error instanceof Error ? `Невалидный YAML: ${error.message}` : "Невалидный YAML"); + } + if (!data || typeof data !== "object") { + throw new Error("Ожидается объект верхнего уровня с ключом documents"); + } + const root = data as { product?: unknown; documents?: unknown }; + const product = root.product === undefined || root.product === null ? null : String(root.product).trim(); + if (product && !/^[a-z0-9-]+$/i.test(product)) { + throw new Error(`product должен быть ASCII-slug, получено: «${product}»`); + } + if (!Array.isArray(root.documents) || root.documents.length === 0) { + throw new Error("documents должен быть непустым списком"); + } + const documents: ImportDocInput[] = []; + root.documents.forEach((item, index) => { + if (!item || typeof item !== "object") { + throw new Error(`Документ #${index + 1}: должен быть объектом`); + } + const record = item as Record; + for (const field of REQUIRED_FIELDS) { + const value = record[field]; + if (value === undefined || value === null || String(value).trim() === "") { + throw new Error(`Документ #${index + 1}: обязательное поле «${field}» пустое`); + } + } + const doc: ImportDocInput = { + code: String(record.code).trim(), + title: String(record.title).trim(), + category: String(record.category).trim(), + content: String(record.content), + }; + if (record.inclusionMode !== undefined && record.inclusionMode !== null) { + const inclusion = String(record.inclusionMode).trim(); + if (inclusion !== "MANDATORY" && inclusion !== "RETRIEVAL") { + throw new Error(`Документ #${index + 1}: inclusionMode должен быть MANDATORY или RETRIEVAL`); + } + doc.inclusionMode = inclusion; + } + documents.push(doc); + }); + return { product: product || null, documents }; +} diff --git a/apps/internal-ui/src/features/ai/detail/styles.css b/apps/internal-ui/src/features/ai/detail/styles.css index cf786c5..d141511 100644 --- a/apps/internal-ui/src/features/ai/detail/styles.css +++ b/apps/internal-ui/src/features/ai/detail/styles.css @@ -611,3 +611,21 @@ border-color: #cf1322; } +/* --- Doc import (YAML) --- */ +.ai-doc-import-preview, +.ai-doc-import-summary { + font-size: 13px; + color: #262626; +} +.ai-doc-import-report { + display: flex; + flex-direction: column; + gap: 8px; +} +.ai-doc-import-failed { + margin: 0; + padding-left: 18px; + color: #cf1322; + font-size: 12px; +} + diff --git a/package-lock.json b/package-lock.json index de79f03..d4d4c1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,10 +48,12 @@ "@edevs/shared": "0.1.0", "@edevs/ui": "0.1.0", "antd": "^5.26.0", + "js-yaml": "^5.2.1", "react": "^19.1.0", "react-dom": "^19.1.0" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0" } @@ -1596,6 +1598,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -1858,6 +1867,12 @@ "react-dom": ">=16.0.0" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2192,6 +2207,28 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz", + "integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",