mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dbc062b1e | ||
|
|
ee8367e205 | ||
|
|
839a1fe815 | ||
|
|
c62cb903ab | ||
|
|
0bd76042d2 | ||
|
|
c286ba7903 | ||
|
|
234a3e43da |
No files matched your search
@@ -51,6 +51,9 @@
|
||||
import surfaces
|
||||
}
|
||||
|
||||
# Дополнительные сайты из постоянного тома сохраняются при обновлении шлюза.
|
||||
import /config/sites/*.caddy
|
||||
|
||||
# Любой хост, которому ask-эндпоинт разрешил сертификат.
|
||||
https:// {
|
||||
encode gzip zstd
|
||||
|
||||
@@ -66,14 +66,11 @@ def _category_for_path(*, context: TenantContext, raw_path: object) -> Knowledge
|
||||
for raw_name in raw_path:
|
||||
if not isinstance(raw_name, str) or not raw_name.strip():
|
||||
raise ValidationError({"categoryPath": t("ai.category_names_strings")})
|
||||
try:
|
||||
category = KnowledgeCategory.objects.get(
|
||||
organization_id=context.organization_id,
|
||||
parent_id=parent_id,
|
||||
name=raw_name.strip(),
|
||||
)
|
||||
except KnowledgeCategory.DoesNotExist as error:
|
||||
raise ValidationError({"categoryPath": t("ai.category_path_not_found")}) from error
|
||||
category, _ = KnowledgeCategory.objects.get_or_create(
|
||||
organization_id=context.organization_id,
|
||||
parent_id=parent_id,
|
||||
name=raw_name.strip(),
|
||||
)
|
||||
parent_id = category.id
|
||||
assert category is not None
|
||||
return category
|
||||
|
||||
@@ -65,7 +65,7 @@ class KnowledgeMetadataImportTests(TestCase):
|
||||
knowledge = Knowledge.objects.get(title="Acme support")
|
||||
self.assertEqual(knowledge.category_id, self.app.id)
|
||||
|
||||
def test_unknown_category_path_fails_per_document(self) -> None:
|
||||
def test_missing_category_is_created_under_existing_parent(self) -> None:
|
||||
response = self._import(
|
||||
[
|
||||
{
|
||||
@@ -78,15 +78,52 @@ class KnowledgeMetadataImportTests(TestCase):
|
||||
)
|
||||
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["created"], 1)
|
||||
self.assertEqual(len(payload["failed"]), 1)
|
||||
self.assertFalse(Knowledge.objects.filter(title="Unknown path").exists())
|
||||
self.assertFalse(
|
||||
KnowledgeCategory.objects.filter(
|
||||
organization=self.organization,
|
||||
name="Missing",
|
||||
).exists()
|
||||
self.assertEqual(payload["created"], 2)
|
||||
self.assertEqual(payload["failed"], [])
|
||||
category = KnowledgeCategory.objects.get(
|
||||
organization=self.organization, parent=self.products, name="Missing"
|
||||
)
|
||||
self.assertEqual(Knowledge.objects.get(title="Unknown path").category_id, category.id)
|
||||
|
||||
def test_new_tree_is_shared_and_repeat_import_does_not_duplicate_categories(self) -> None:
|
||||
documents = [
|
||||
{"title": title, "content": "Text", "categoryPath": [" New root ", "Child", "Leaf"]}
|
||||
for title in ["First", "Second"]
|
||||
]
|
||||
before = KnowledgeCategory.objects.count()
|
||||
self.assertEqual(self._import(documents).json()["created"], 2)
|
||||
self.assertEqual(self._import(documents).json()["unchanged"], 2)
|
||||
self.assertEqual(KnowledgeCategory.objects.count(), before + 3)
|
||||
first = Knowledge.objects.get(title="First")
|
||||
second = Knowledge.objects.get(title="Second")
|
||||
self.assertEqual(first.category_id, second.category_id)
|
||||
self.assertEqual(first.category.parent.parent.name, "New root")
|
||||
|
||||
def test_failed_document_rolls_back_new_categories_and_other_documents_import(self) -> None:
|
||||
response = self._import([
|
||||
{"title": "Invalid", "content": "Text", "description": [],
|
||||
"categoryPath": ["Rollback root", "Child"]},
|
||||
{"title": "Valid sibling", "content": "Text", "categoryPath": ["Kept root"]},
|
||||
])
|
||||
self.assertEqual(response.json()["created"], 1)
|
||||
self.assertEqual(len(response.json()["failed"]), 1)
|
||||
self.assertFalse(KnowledgeCategory.objects.filter(name="Rollback root").exists())
|
||||
self.assertFalse(Knowledge.objects.filter(title="Invalid").exists())
|
||||
|
||||
def test_invalid_path_rolls_back_preceding_levels(self) -> None:
|
||||
for path in [["Invalid root", " "], ["Invalid root", 42], []]:
|
||||
with self.subTest(path=path):
|
||||
response = self._import([{"title": "Invalid path", "content": "Text", "categoryPath": path}])
|
||||
self.assertEqual(len(response.json()["failed"]), 1)
|
||||
self.assertFalse(KnowledgeCategory.objects.filter(name="Invalid root").exists())
|
||||
|
||||
def test_existing_document_moves_to_new_category(self) -> None:
|
||||
self._import([{"title": "Moving", "content": "Text"}])
|
||||
response = self._import([
|
||||
{"title": "Moving", "content": "Text", "categoryPath": ["New destination"]}
|
||||
])
|
||||
self.assertEqual(response.json()["updated"], 1)
|
||||
self.assertEqual(Knowledge.objects.get(title="Moving").category.name, "New destination")
|
||||
|
||||
def test_omitted_metadata_preserves_category_and_agent_links(self) -> None:
|
||||
self._import(
|
||||
@@ -157,6 +194,7 @@ class KnowledgeImportPolicyTests(KnowledgePolicyTestBase):
|
||||
{
|
||||
"title": self.support_only.title,
|
||||
"content": "Attempted overwrite",
|
||||
"categoryPath": ["Forbidden category"],
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -167,3 +205,4 @@ class KnowledgeImportPolicyTests(KnowledgePolicyTestBase):
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.support_only.refresh_from_db()
|
||||
self.assertEqual(self.support_only.content, "")
|
||||
self.assertFalse(KnowledgeCategory.objects.filter(name="Forbidden category").exists())
|
||||
@@ -498,7 +498,7 @@ MESSAGES: dict[str, object] = {
|
||||
"notifications.type_dialog_new_message": "A new message in my conversation",
|
||||
"notifications.type_dialog_assigned": "A conversation was assigned to me",
|
||||
"notifications.type_dialog_waiting_long": "A conversation has been waiting a long time",
|
||||
"notifications.type_ai_stopped": "AI stopped by an error or a limit",
|
||||
"notifications.type_ai_stopped": "AI stopped by an error",
|
||||
"integrations.check_no_connection": "No connection: {error}",
|
||||
"integrations.check_api_key_missing": "The API key is not set",
|
||||
"integrations.check_base_url_missing": "Base URL is not set",
|
||||
|
||||
@@ -502,7 +502,7 @@ MESSAGES: dict[str, object] = {
|
||||
"notifications.type_dialog_new_message": "Новое сообщение в моём диалоге",
|
||||
"notifications.type_dialog_assigned": "Диалог назначили на меня",
|
||||
"notifications.type_dialog_waiting_long": "Диалог долго ждёт человека",
|
||||
"notifications.type_ai_stopped": "AI остановлен ошибкой или лимитом",
|
||||
"notifications.type_ai_stopped": "AI остановлен ошибкой",
|
||||
"integrations.check_no_connection": "Нет связи: {error}",
|
||||
"integrations.check_api_key_missing": "Не указан API-ключ",
|
||||
"integrations.check_base_url_missing": "Не указан Base URL",
|
||||
|
||||
@@ -16,79 +16,8 @@ import {
|
||||
} from "./model";
|
||||
import { t, tn } from "../../../i18n";
|
||||
import { readableSize } from "../../../shared/utils";
|
||||
|
||||
// Импорт YAML (дизайн-базлайн v2, кадр KB8): файл разбирается до применения —
|
||||
// видно, что создастся, что обновится и где ошибка пути. Импорт не создаёт
|
||||
// категории неявно, поэтому несуществующий categoryPath отклоняется отдельно.
|
||||
|
||||
type ImportAction = "create" | "update" | "error";
|
||||
|
||||
type ImportRow = {
|
||||
document: ParsedKnowledgeYaml["documents"][number];
|
||||
action: ImportAction;
|
||||
path: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
const ACTION_LABEL: Record<ImportAction, string> = {
|
||||
create: t("ai.create"),
|
||||
update: t("ai.update"),
|
||||
error: t("ai.error"),
|
||||
};
|
||||
|
||||
/** Разрешает путь категории в дерево организации: путь ищется по уровням. */
|
||||
function resolvePath(categories: KnowledgeCategory[], path: string[]): { ok: boolean; missing: string } {
|
||||
let parentId: number | null = null;
|
||||
for (const name of path) {
|
||||
const found: KnowledgeCategory | undefined = categories.find(
|
||||
(category) => category.parentId === parentId && category.name === name,
|
||||
);
|
||||
if (!found) return { ok: false, missing: name };
|
||||
parentId = found.id;
|
||||
}
|
||||
return { ok: true, missing: "" };
|
||||
}
|
||||
|
||||
function planRows(
|
||||
parsed: ParsedKnowledgeYaml,
|
||||
categories: KnowledgeCategory[],
|
||||
items: KnowledgeItem[],
|
||||
): ImportRow[] {
|
||||
const byTitle = new Map(items.map((item) => [item.title, item]));
|
||||
return parsed.documents.map((document) => {
|
||||
const existing = byTitle.get(document.title) ?? null;
|
||||
if (document.categoryPath) {
|
||||
const { ok, missing } = resolvePath(categories, document.categoryPath);
|
||||
if (!ok) {
|
||||
return {
|
||||
document,
|
||||
action: "error",
|
||||
path: document.categoryPath.join(" / "),
|
||||
note: t("ai.missing_category_path", { name: missing }),
|
||||
};
|
||||
}
|
||||
}
|
||||
const path = document.categoryPath ? document.categoryPath.join(" / ") : t("ai.not_given");
|
||||
if (!existing) {
|
||||
return {
|
||||
document,
|
||||
action: "create",
|
||||
path,
|
||||
note: document.categoryPath ? "" : t("ai.will_land_no_category"),
|
||||
};
|
||||
}
|
||||
const samePlace = document.categoryPath
|
||||
&& document.categoryPath[document.categoryPath.length - 1] === existing.category.name;
|
||||
return {
|
||||
document,
|
||||
action: "update",
|
||||
path,
|
||||
note: samePlace
|
||||
? t("ai.category_file_matches_current_one")
|
||||
: t("ai.title_matched_text_will_replaced"),
|
||||
};
|
||||
});
|
||||
}
|
||||
import { planRows } from "./knowledgeImportPlan";
|
||||
import { KnowledgeImportPreview } from "./KnowledgeImportPreview";
|
||||
|
||||
export function KnowledgeImportPage({
|
||||
canManage,
|
||||
@@ -218,44 +147,11 @@ export function KnowledgeImportPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsed && (
|
||||
<div className="knowledge-import-plan">
|
||||
<div className="knowledge-import-plan-head">
|
||||
<strong>{t("ai.what_will_happen")}</strong>
|
||||
<span>
|
||||
{counts.create > 0 && <small className="is-create">{t("ai.count_create", { count: counts.create })}</small>}
|
||||
{counts.update > 0 && <small className="is-update">{t("ai.count_update", { count: counts.update })}</small>}
|
||||
{counts.error > 0 && <small className="is-error">{t("ai.count_error", { count: counts.error })}</small>}
|
||||
</span>
|
||||
</div>
|
||||
<table className="knowledge-import-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("ai.document")}</th>
|
||||
<th>CATEGORYPATH</th>
|
||||
<th>{t("ai.action")}</th>
|
||||
<th>{t("ai.note")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr className={row.action === "error" ? "is-error" : ""} key={`${row.document.title}-${index}`}>
|
||||
<td><code>{row.document.title}</code></td>
|
||||
<td className="knowledge-import-path">{row.path}</td>
|
||||
<td><span className={`knowledge-import-action is-${row.action}`}>{ACTION_LABEL[row.action]}</span></td>
|
||||
<td className="knowledge-import-note">{row.note}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{parsed && <KnowledgeImportPreview rows={rows} counts={counts} />}
|
||||
|
||||
{parsed && (
|
||||
<div className="knowledge-import-actions">
|
||||
<span>{t("ai.import_never_creates_categories_implicitly")}<code>categoryPath</code>{" "}
|
||||
{t("ai.import_path_tail")}
|
||||
</span>
|
||||
<span>{t("ai.import_category_policy")}</span>
|
||||
<Button variant="secondary" disabled={busy} onClick={() => setRoute("knowledge")}>{t("common.cancel")}</Button>
|
||||
<Button variant="primary" disabled={busy || !canManage || importable.length === 0} onClick={() => void submit()}>
|
||||
{t("ai.import_documents", { count: tn("plural.documents", importable.length) })}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { t } from "../../../i18n";
|
||||
import { ACTION_LABEL, type ImportAction, type ImportRow } from "./knowledgeImportPlan";
|
||||
|
||||
export function KnowledgeImportPreview({ rows, counts }: {
|
||||
rows: ImportRow[];
|
||||
counts: Record<ImportAction, number>;
|
||||
}) {
|
||||
return (
|
||||
<div className="knowledge-import-plan">
|
||||
<div className="knowledge-import-plan-head">
|
||||
<strong>{t("ai.what_will_happen")}</strong>
|
||||
<span>
|
||||
{counts.create > 0 && <small className="is-create">{t("ai.count_create", { count: counts.create })}</small>}
|
||||
{counts.update > 0 && <small className="is-update">{t("ai.count_update", { count: counts.update })}</small>}
|
||||
{counts.error > 0 && <small className="is-error">{t("ai.count_error", { count: counts.error })}</small>}
|
||||
</span>
|
||||
</div>
|
||||
<table className="knowledge-import-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("ai.document")}</th>
|
||||
<th>CATEGORYPATH</th>
|
||||
<th>{t("ai.action")}</th>
|
||||
<th>{t("ai.note")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr className={row.action === "error" ? "is-error" : ""} key={`${row.document.title}-${index}`}>
|
||||
<td><code>{row.document.title}</code></td>
|
||||
<td className="knowledge-import-path">{row.path}</td>
|
||||
<td><span className={`knowledge-import-action is-${row.action}`}>{ACTION_LABEL[row.action]}</span></td>
|
||||
<td className="knowledge-import-note">{row.note}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { t } from "../../../i18n";
|
||||
import { planRows } from "./knowledgeImportPlan";
|
||||
import { parseKnowledgeYaml } from "./parseKnowledgeYaml";
|
||||
import type { KnowledgeCategory, KnowledgeItem } from "./types";
|
||||
|
||||
const categories: KnowledgeCategory[] = [
|
||||
{ id: 1, name: "Root", parentId: null, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
|
||||
{ id: 2, name: "Child", parentId: 1, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
|
||||
{ id: 3, name: "Other", parentId: null, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
|
||||
{ id: 4, name: "Child", parentId: 3, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
|
||||
];
|
||||
const existing: KnowledgeItem = {
|
||||
id: 10, title: "Existing", description: "", category: categories[1],
|
||||
isEnabled: true, attachments: [], agentsCount: 0, fragmentsCount: 0,
|
||||
createdAt: "", updatedAt: "",
|
||||
};
|
||||
|
||||
describe("knowledge import preview", () => {
|
||||
it("allows an entirely new category tree from YAML", () => {
|
||||
const parsed = parseKnowledgeYaml("documents:\n - title: New\n content: Text\n categoryPath: [New root, New child]");
|
||||
const [row] = planRows(parsed, [], []);
|
||||
expect(row.action).toBe("create");
|
||||
expect(row.path).toBe("New root / New child");
|
||||
expect(row.note).toBe(t("ai.import_category_creation"));
|
||||
});
|
||||
|
||||
it("allows missing descendants and updates existing documents", () => {
|
||||
const [row] = planRows({ documents: [
|
||||
{ title: existing.title, content: "Text", categoryPath: ["Root", "New child"] },
|
||||
] }, categories, [existing]);
|
||||
expect(row.action).toBe("update");
|
||||
expect(row.note).toBe(t("ai.import_category_creation"));
|
||||
});
|
||||
|
||||
it("compares the full path instead of only the leaf name", () => {
|
||||
const rows = planRows({ documents: [
|
||||
{ title: existing.title, content: "Text", categoryPath: ["Other", "Child"] },
|
||||
{ title: existing.title, content: "Text", categoryPath: ["Root", "Child"] },
|
||||
] }, categories, [existing]);
|
||||
expect(rows[0].note).toBe(t("ai.title_matched_text_will_replaced"));
|
||||
expect(rows[1].note).toBe(t("ai.category_file_matches_current_one"));
|
||||
});
|
||||
|
||||
it("keeps documents without a path importable", () => {
|
||||
const [row] = planRows({ documents: [{ title: "New", content: "Text" }] }, [], []);
|
||||
expect(row.action).toBe("create");
|
||||
expect(row.note).toBe(t("ai.will_land_no_category"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { t } from "../../../i18n";
|
||||
import type { KnowledgeCategory, KnowledgeItem } from "./model";
|
||||
import type { ParsedKnowledgeYaml } from "./parseKnowledgeYaml";
|
||||
|
||||
export type ImportAction = "create" | "update" | "error";
|
||||
|
||||
export type ImportRow = {
|
||||
document: ParsedKnowledgeYaml["documents"][number];
|
||||
action: ImportAction;
|
||||
path: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export const ACTION_LABEL: Record<ImportAction, string> = {
|
||||
create: t("ai.create"),
|
||||
update: t("ai.update"),
|
||||
error: t("ai.error"),
|
||||
};
|
||||
|
||||
/** Разрешает путь категории в дерево организации: путь ищется по уровням. */
|
||||
function resolvePath(categories: KnowledgeCategory[], path: string[]): number | null {
|
||||
let parentId: number | null = null;
|
||||
for (const name of path) {
|
||||
const found: KnowledgeCategory | undefined = categories.find(
|
||||
(category) => category.parentId === parentId && category.name === name,
|
||||
);
|
||||
if (!found) return null;
|
||||
parentId = found.id;
|
||||
}
|
||||
return parentId;
|
||||
}
|
||||
|
||||
export function planRows(
|
||||
parsed: ParsedKnowledgeYaml,
|
||||
categories: KnowledgeCategory[],
|
||||
items: KnowledgeItem[],
|
||||
): ImportRow[] {
|
||||
const byTitle = new Map(items.map((item) => [item.title, item]));
|
||||
return parsed.documents.map((document) => {
|
||||
const existing = byTitle.get(document.title) ?? null;
|
||||
const categoryId = document.categoryPath ? resolvePath(categories, document.categoryPath) : null;
|
||||
const categoryNote = document.categoryPath && categoryId === null ? t("ai.import_category_creation") : "";
|
||||
const path = document.categoryPath ? document.categoryPath.join(" / ") : t("ai.not_given");
|
||||
if (!existing) {
|
||||
return {
|
||||
document,
|
||||
action: "create",
|
||||
path,
|
||||
note: document.categoryPath ? categoryNote : t("ai.will_land_no_category"),
|
||||
};
|
||||
}
|
||||
const samePlace = categoryId !== null && categoryId === existing.category.id;
|
||||
return {
|
||||
document,
|
||||
action: "update",
|
||||
path,
|
||||
note: categoryNote || (samePlace
|
||||
? t("ai.category_file_matches_current_one")
|
||||
: t("ai.title_matched_text_will_replaced")),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -8,8 +8,7 @@ export type ParsedKnowledgeYaml = {
|
||||
// Парсит YAML импорта знаний (SPEC-CHATBALLS-0012, плоский формат ADR-CHATBALLS-0023):
|
||||
// { documents: [{ title, description?, content, categoryPath? }] }. Бросает
|
||||
// Error с человекочитаемым сообщением при невалидном YAML или структуре.
|
||||
// Путь категории разбирается, но не проверяется: несуществующий путь — это
|
||||
// замечание в предпросмотре импорта (кадр KB8), а не поломка файла.
|
||||
// Недостающие уровни пути сервер создаёт при применении импорта.
|
||||
export function parseKnowledgeYaml(text: string): ParsedKnowledgeYaml {
|
||||
let data: unknown;
|
||||
try {
|
||||
|
||||
@@ -2,8 +2,9 @@ import { Dropdown } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { SearchInput } from "../../shared/ui-controls";
|
||||
import { assigneeMenuItems, initials, SmallAvatar } from "./assigneeOptions";
|
||||
import { DialogLabels } from "./DialogLabels";
|
||||
import { NoteSection } from "./DialogNoteSection";
|
||||
import { assigneeMenuItems, SmallAvatar } from "./assigneeOptions";
|
||||
import { WaitingBlock } from "./WaitingBlock";
|
||||
import { PriorityBars } from "./DialogList";
|
||||
import { useEmployeeDirectory } from "./useEmployeeDirectory";
|
||||
@@ -11,17 +12,13 @@ import { statusFor } from "./data";
|
||||
import {
|
||||
agentColorOf,
|
||||
controlModeOf,
|
||||
createConversationLabel,
|
||||
fetchConversationLabels,
|
||||
groupColorOf,
|
||||
setConversationArchived,
|
||||
setConversationAssignee,
|
||||
setConversationGroup,
|
||||
setConversationLabels,
|
||||
setConversationNote,
|
||||
setConversationPriority,
|
||||
type ApiConversation,
|
||||
type ConversationLabelRef,
|
||||
type ConversationPriority,
|
||||
} from "./model";
|
||||
import type { EmployeeGroupRef } from "../../types";
|
||||
@@ -62,18 +59,11 @@ export function DialogControls({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [labels, setLabels] = useState<ConversationLabelRef[]>([]);
|
||||
const [newLabel, setNewLabel] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setNewLabel("");
|
||||
setErrorText("");
|
||||
}, [detail.id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConversationLabels().then(setLabels).catch(() => setLabels([]));
|
||||
}, []);
|
||||
|
||||
async function run(action: () => Promise<ApiConversation>) {
|
||||
setBusy(true);
|
||||
setErrorText("");
|
||||
@@ -86,30 +76,6 @@ export function DialogControls({
|
||||
}
|
||||
}
|
||||
|
||||
async function addLabel(id?: number) {
|
||||
let labelId = id;
|
||||
setBusy(true);
|
||||
setErrorText("");
|
||||
try {
|
||||
if (labelId === undefined) {
|
||||
const name = newLabel.trim();
|
||||
if (!name) return;
|
||||
const label = await createConversationLabel(name);
|
||||
setLabels((current) => (current.some((item) => item.id === label.id) ? current : [...current, label]));
|
||||
labelId = label.id;
|
||||
setNewLabel("");
|
||||
}
|
||||
const ids = [...new Set([...detail.labels.map((item) => item.id), labelId])];
|
||||
applyConversation(await setConversationLabels(detail.id, ids));
|
||||
} catch (error) {
|
||||
setErrorText(error instanceof Error ? error.message : t("conversations.could_not_add_label"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const assignedIds = new Set(detail.labels.map((item) => item.id));
|
||||
const availableLabels = labels.filter((item) => !assignedIds.has(item.id));
|
||||
const assignee = detail.assignedOperator;
|
||||
const assigneeHere = assignee ? directory.employees.some((employee) => employee.id === assignee.id && employee.online) : false;
|
||||
const assigneeLabel = assignee ? `${assignee.name}${viewerId != null && assignee.id === viewerId ? t("common.you_suffix") : ""}` : t("conversations.unassigned");
|
||||
@@ -205,57 +171,7 @@ export function DialogControls({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="ctx-label">{t("conversations.labels")}</label>
|
||||
<div className="ctx-labels">
|
||||
<Dropdown
|
||||
disabled={busy}
|
||||
trigger={["click"]}
|
||||
overlayClassName="app-dropdown is-wide ctx-labels-menu"
|
||||
menu={{
|
||||
items: [
|
||||
...availableLabels.map((label) => ({
|
||||
key: label.id,
|
||||
label: <button type="button" onClick={() => void addLabel(label.id)}><i className="ctx-dot is-square" style={{ background: label.color || "var(--n-5)" }} />{label.name}</button>,
|
||||
})),
|
||||
{
|
||||
key: "new",
|
||||
label: (
|
||||
<div className="ctx-new-label" onClick={(event) => event.stopPropagation()}>
|
||||
<input
|
||||
placeholder={t("conversations.new_label")}
|
||||
value={newLabel}
|
||||
onChange={(event) => setNewLabel(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void addLabel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" disabled={busy || !newLabel.trim()} onClick={() => void addLabel()}><Icon name="plus" size={13} /></button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<button type="button" className="ctx-add-label"><Icon name="plus" size={13} />{t("conversations.add")}</button>
|
||||
</Dropdown>
|
||||
{detail.labels.map((label) => (
|
||||
<b className="ctx-label-chip" key={label.id}>
|
||||
<i style={{ background: label.color || "var(--n-5)" }} />
|
||||
{label.name}
|
||||
<button
|
||||
aria-label={t("conversations.remove_label", { name: label.name })}
|
||||
disabled={busy}
|
||||
type="button"
|
||||
onClick={() => void run(() => setConversationLabels(detail.id, detail.labels.filter((item) => item.id !== label.id).map((item) => item.id)))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</b>
|
||||
))}
|
||||
</div>
|
||||
<DialogLabels detail={detail} busy={busy} setBusy={setBusy} setErrorText={setErrorText} applyConversation={applyConversation} run={run} />
|
||||
|
||||
<div className="ctx-meta-row">
|
||||
<span>{detail.waitingSince ? t("conversations.in_queue_since") : t("conversations.started")}</span>
|
||||
@@ -270,36 +186,6 @@ export function DialogControls({
|
||||
);
|
||||
}
|
||||
|
||||
function NoteSection({ detail, busy, onSave }: { detail: ApiConversation; busy: boolean; onSave: (note: string) => Promise<void> }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(detail.note);
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
setDraft(detail.note);
|
||||
}, [detail.id, detail.note]);
|
||||
|
||||
return (
|
||||
<section className="ctx-section is-note">
|
||||
<div className="ctx-section-head">
|
||||
<h4>{t("conversations.note")}</h4>
|
||||
<button type="button" aria-label={t("conversations.edit_note")} onClick={() => setEditing(true)}><Icon name="edit" size={14} /></button>
|
||||
</div>
|
||||
{editing ? (
|
||||
<div className="ctx-note is-editing">
|
||||
<textarea autoFocus disabled={busy} placeholder={t("conversations.internal_note_customer_does_not")} rows={3} value={draft} onChange={(event) => setDraft(event.target.value)} />
|
||||
<div className="ctx-note-actions">
|
||||
<button type="button" disabled={busy} onClick={() => { setDraft(detail.note); setEditing(false); }}>{t("common.cancel")}</button>
|
||||
<button type="button" className="primary" disabled={busy} onClick={() => void onSave(draft).then(() => setEditing(false))}>{t("common.save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`ctx-note ${detail.note ? "" : "is-empty"}`} onClick={() => setEditing(true)}>{detail.note || t("conversations.no_notes")}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// «сегодня, 17:02» · «вчера, 11:05» · «12 авг, 09:30»
|
||||
export function startedLabel(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
@@ -328,5 +214,3 @@ export function archiveConversationAction(
|
||||
});
|
||||
}
|
||||
|
||||
// Аватар 22px в поле «Ответственный»: фото сотрудника или инициалы.
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Dropdown } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../../i18n";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { createConversationLabel, fetchConversationLabels, setConversationLabels,
|
||||
type ApiConversation, type ConversationLabelRef } from "./model";
|
||||
import "./dialogLabels.css";
|
||||
|
||||
export function DialogLabels({ detail, busy, setBusy, setErrorText, applyConversation, run }: {
|
||||
detail: ApiConversation;
|
||||
busy: boolean;
|
||||
setBusy: (busy: boolean) => void;
|
||||
setErrorText: (error: string) => void;
|
||||
applyConversation: (updated: ApiConversation) => void;
|
||||
run: (action: () => Promise<ApiConversation>) => Promise<void>;
|
||||
}) {
|
||||
const [labels, setLabels] = useState<ConversationLabelRef[]>([]);
|
||||
const [newLabel, setNewLabel] = useState("");
|
||||
useEffect(() => { setNewLabel(""); }, [detail.id]);
|
||||
useEffect(() => {
|
||||
fetchConversationLabels().then(setLabels).catch((error) => {
|
||||
setErrorText(error instanceof Error ? error.message : t("conversations.could_not_add_label"));
|
||||
});
|
||||
}, [setErrorText]);
|
||||
|
||||
async function addLabel(id?: number) {
|
||||
if (busy) return;
|
||||
let labelId = id;
|
||||
setBusy(true);
|
||||
setErrorText("");
|
||||
try {
|
||||
if (labelId === undefined) {
|
||||
const name = newLabel.trim();
|
||||
if (!name) return;
|
||||
const label = await createConversationLabel(name);
|
||||
setLabels((current) => (current.some((item) => item.id === label.id) ? current : [...current, label]));
|
||||
labelId = label.id;
|
||||
setNewLabel("");
|
||||
}
|
||||
const ids = [...new Set([...detail.labels.map((item) => item.id), labelId])];
|
||||
applyConversation(await setConversationLabels(detail.id, ids));
|
||||
} catch (error) {
|
||||
setErrorText(error instanceof Error ? error.message : t("conversations.could_not_add_label"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const assignedIds = new Set(detail.labels.map((item) => item.id));
|
||||
const availableLabels = labels.filter((item) => !assignedIds.has(item.id));
|
||||
return (
|
||||
<>
|
||||
<label className="ctx-label">{t("conversations.labels")}</label>
|
||||
<div className="ctx-labels">
|
||||
<Dropdown
|
||||
disabled={busy}
|
||||
trigger={["click"]}
|
||||
overlayClassName="app-dropdown is-wide ctx-labels-menu"
|
||||
menu={{
|
||||
items: [
|
||||
...availableLabels.map((label) => ({
|
||||
key: label.id,
|
||||
label: <button type="button" onClick={() => void addLabel(label.id)}><i className="ctx-dot is-square" style={{ background: label.color || "var(--n-5)" }} />{label.name}</button>,
|
||||
})),
|
||||
{
|
||||
key: "new",
|
||||
label: (
|
||||
<div className="ctx-new-label" onClick={(event) => event.stopPropagation()}>
|
||||
<input
|
||||
aria-label={t("conversations.new_label")}
|
||||
disabled={busy}
|
||||
placeholder={t("conversations.new_label")}
|
||||
value={newLabel}
|
||||
onChange={(event) => setNewLabel(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
void addLabel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" aria-label={t("conversations.add")} disabled={busy || !newLabel.trim()} onClick={() => void addLabel()}><Icon name="plus" size={13} /></button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<button type="button" className="ctx-add-label"><Icon name="plus" size={13} />{t("conversations.add")}</button>
|
||||
</Dropdown>
|
||||
{detail.labels.map((label) => (
|
||||
<b className="ctx-label-chip" key={label.id}>
|
||||
<i style={{ background: label.color || "var(--n-5)" }} />
|
||||
{label.name}
|
||||
<button
|
||||
aria-label={t("conversations.remove_label", { name: label.name })}
|
||||
disabled={busy}
|
||||
type="button"
|
||||
onClick={() => void run(() => setConversationLabels(detail.id, detail.labels.filter((item) => item.id !== label.id).map((item) => item.id)))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</b>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../../i18n";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import type { ApiConversation } from "./model";
|
||||
|
||||
export function NoteSection({ detail, busy, onSave }: { detail: ApiConversation; busy: boolean; onSave: (note: string) => Promise<void> }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(detail.note);
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
setDraft(detail.note);
|
||||
}, [detail.id, detail.note]);
|
||||
|
||||
return (
|
||||
<section className="ctx-section is-note">
|
||||
<div className="ctx-section-head">
|
||||
<h4>{t("conversations.note")}</h4>
|
||||
<button type="button" aria-label={t("conversations.edit_note")} onClick={() => setEditing(true)}><Icon name="edit" size={14} /></button>
|
||||
</div>
|
||||
{editing ? (
|
||||
<div className="ctx-note is-editing">
|
||||
<textarea autoFocus disabled={busy} placeholder={t("conversations.internal_note_customer_does_not")} rows={3} value={draft} onChange={(event) => setDraft(event.target.value)} />
|
||||
<div className="ctx-note-actions">
|
||||
<button type="button" disabled={busy} onClick={() => { setDraft(detail.note); setEditing(false); }}>{t("common.cancel")}</button>
|
||||
<button type="button" className="primary" disabled={busy} onClick={() => void onSave(draft).then(() => setEditing(false))}>{t("common.save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`ctx-note ${detail.note ? "" : "is-empty"}`} onClick={() => setEditing(true)}>{detail.note || t("conversations.no_notes")}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/* Keep the inline add button from inheriting full-width menu item styles. */
|
||||
.app-dropdown.ctx-labels-menu .ctx-new-label button {
|
||||
width: 30px;
|
||||
min-height: 30px;
|
||||
padding: 0;
|
||||
color: #ffffff;
|
||||
background: var(--primary);
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export function HelpCenterApp() {
|
||||
);
|
||||
}
|
||||
if (!manifest || !themeReady) {
|
||||
return <main className="help-boot-loading"><Loader size={44} /></main>;
|
||||
return <main className="help-boot-loading"><Loader size={72} /></main>;
|
||||
}
|
||||
return route.articleSlug
|
||||
? <HelpArticle manifest={manifest} articleSlug={route.articleSlug} />
|
||||
|
||||
@@ -42,11 +42,17 @@ export function HelpLayout({
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
<footer className="help-footer">
|
||||
<a className="help-footer-brand" href={homeHref}>
|
||||
<span className="help-brand-mark"><LogoIcon /></span>
|
||||
<span>{portal.name}</span>
|
||||
</a>
|
||||
<span>{t("portals.knowledge_base_support")}</span>
|
||||
<div className="help-footer-row">
|
||||
<a className="help-footer-brand" href={homeHref}>
|
||||
<span className="help-brand-mark"><LogoIcon /></span>
|
||||
<span>{portal.name}</span>
|
||||
</a>
|
||||
<span>{t("portals.knowledge_base_support")}</span>
|
||||
</div>
|
||||
<span className="help-footer-powered">
|
||||
{t("portals.powered_by")}{" "}
|
||||
<a href="https://chatballs.ru" target="_blank" rel="noopener noreferrer">Chatballs</a>
|
||||
</span>
|
||||
</footer>
|
||||
{portal.webWidgetKey && <PortalWebWidget widgetKey={portal.webWidgetKey} />}
|
||||
</div>
|
||||
|
||||
@@ -153,17 +153,37 @@ html.help-center-document body {
|
||||
}
|
||||
|
||||
.help-footer {
|
||||
display: flex;
|
||||
display: grid;
|
||||
width: min(1024px, calc(100% - 48px));
|
||||
margin: 80px auto 0;
|
||||
padding: 34px 0 42px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
border-top: 1px solid var(--help-line-soft);
|
||||
color: var(--help-text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.help-footer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.help-footer-powered {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.help-footer-powered a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.help-footer-powered a:hover {
|
||||
color: var(--help-ink);
|
||||
}
|
||||
|
||||
.help-footer-brand {
|
||||
gap: 9px;
|
||||
font-size: 16px;
|
||||
@@ -256,8 +276,7 @@ html.help-center-document body {
|
||||
color: var(--help-text-muted);
|
||||
}
|
||||
|
||||
.help-fatal,
|
||||
.help-boot-loading {
|
||||
.help-fatal {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-content: center;
|
||||
|
||||
@@ -115,10 +115,14 @@
|
||||
}
|
||||
|
||||
.help-footer {
|
||||
gap: 14px;
|
||||
margin-top: 54px;
|
||||
}
|
||||
|
||||
.help-footer-row {
|
||||
display: grid;
|
||||
justify-content: start;
|
||||
gap: 12px;
|
||||
margin-top: 54px;
|
||||
}
|
||||
|
||||
.help-support-launcher {
|
||||
|
||||
@@ -352,10 +352,10 @@ export const en: Record<MessageKey, Message> = {
|
||||
"ai.how_agent_will_answer": "HOW THE AGENT WILL ANSWER",
|
||||
"ai.how_agent_will_answer_2": "How the agent will answer",
|
||||
"ai.how_should_speak": "How it should speak",
|
||||
"ai.import_category_policy": "Missing categories from the file are created automatically. Without a path, new documents go to “No category” and existing documents keep their category.",
|
||||
"ai.import_category_creation": "Missing categories will be created automatically.",
|
||||
"ai.import_documents": "Import {count}",
|
||||
"ai.import_finished": "Import finished",
|
||||
"ai.import_never_creates_categories_implicitly": "Import never creates categories implicitly: a document with a non-existent",
|
||||
"ai.import_path_tail": "is rejected on its own, the rest are applied. Without a path a document lands in “No category”.",
|
||||
"ai.index_up_date": "The index is up to date",
|
||||
"ai.indexing": "INDEXING",
|
||||
"ai.insert_link_into_text": "Insert a link into the text",
|
||||
@@ -381,7 +381,6 @@ export const en: Record<MessageKey, Message> = {
|
||||
"ai.list": "List",
|
||||
"ai.manage": "Manage",
|
||||
"ai.material_import": "Material import",
|
||||
"ai.missing_category_path": "there is no category “{name}” — import never creates categories. Create it or drop the last level of the path.",
|
||||
"ai.move": "Move",
|
||||
"ai.move_category": "Move to a category",
|
||||
"ai.name_group_all_takes_start": "A name and a group are all it takes to start. Instructions, knowledge and connections are set later on the card.",
|
||||
@@ -905,6 +904,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"portals.invalid_yaml": "Invalid YAML",
|
||||
"portals.ip_of_installation": "{value} · this installation's IP",
|
||||
"portals.knowledge_base_support": "Knowledge base and support",
|
||||
"portals.powered_by": "Powered by",
|
||||
"portals.language": "Language",
|
||||
"portals.language_2": "Language:",
|
||||
"portals.language_3": "LANGUAGE",
|
||||
@@ -1062,7 +1062,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"onboarding.s1.name": "AI provider",
|
||||
"onboarding.s1.time": "3 min",
|
||||
"onboarding.s1.title": "Connect an AI provider",
|
||||
"onboarding.s1.lead": "Without a provider the agent cannot answer anyone. The key is yours: spend, limits and tokens are all counted in the system.",
|
||||
"onboarding.s1.lead": "Without a provider the agent cannot answer anyone. The key is yours: you pay the provider at your own rate.",
|
||||
"onboarding.s1.path": "Settings → AI provider → Add provider",
|
||||
"onboarding.s1.do_1": "Open Settings in the sidebar and pick the AI provider section.",
|
||||
"onboarding.s1.do_2": "Click Add provider: OpenRouter, Custom (OpenAI-compatible) or a local model.",
|
||||
|
||||
@@ -353,10 +353,10 @@ export const ru = {
|
||||
"ai.how_agent_will_answer": "КАК ОТВЕТИТ АГЕНТ",
|
||||
"ai.how_agent_will_answer_2": "Как ответит агент",
|
||||
"ai.how_should_speak": "Как он должен говорить",
|
||||
"ai.import_category_policy": "Недостающие категории из файла создаются автоматически. Без указания пути новый документ попадает в «Без категории», существующий сохраняет свою категорию.",
|
||||
"ai.import_category_creation": "Недостающие категории будут созданы автоматически.",
|
||||
"ai.import_documents": "Импортировать {count}",
|
||||
"ai.import_finished": "Импорт завершён",
|
||||
"ai.import_never_creates_categories_implicitly": "Импорт не создаёт категории неявно: документ с несуществующим",
|
||||
"ai.import_path_tail": "отклоняется отдельно, остальные применяются. Без указания пути документ попадает в «Без категории».",
|
||||
"ai.index_up_date": "Индекс актуален",
|
||||
"ai.indexing": "ИНДЕКСАЦИЯ",
|
||||
"ai.insert_link_into_text": "Вставить ссылку в текст",
|
||||
@@ -382,7 +382,6 @@ export const ru = {
|
||||
"ai.list": "Список",
|
||||
"ai.manage": "Управлять",
|
||||
"ai.material_import": "Импорт материалов",
|
||||
"ai.missing_category_path": "категории «{name}» нет — импорт не создаёт категории. Создайте её или уберите последний уровень пути.",
|
||||
"ai.move": "Переместить",
|
||||
"ai.move_category": "Переместить в категорию",
|
||||
"ai.name_group_all_takes_start": "Имя и группа — всё, что нужно для старта. Инструкции, знания и подключения настраиваются потом на карточке.",
|
||||
@@ -906,6 +905,7 @@ export const ru = {
|
||||
"portals.invalid_yaml": "Невалидный YAML",
|
||||
"portals.ip_of_installation": "{value} · IP этой установки",
|
||||
"portals.knowledge_base_support": "База знаний и поддержка",
|
||||
"portals.powered_by": "Работает на",
|
||||
"portals.language": "Язык",
|
||||
"portals.language_2": "Язык:",
|
||||
"portals.language_3": "ЯЗЫК",
|
||||
@@ -1063,7 +1063,7 @@ export const ru = {
|
||||
"onboarding.s1.name": "AI-провайдер",
|
||||
"onboarding.s1.time": "3 мин",
|
||||
"onboarding.s1.title": "Подключите AI-провайдера",
|
||||
"onboarding.s1.lead": "Без провайдера агент не ответит ни одному клиенту. Ключ ваш: расходы, лимиты и токены считаются в системе.",
|
||||
"onboarding.s1.lead": "Без провайдера агент не ответит ни одному клиенту. Ключ ваш: расходы идут по вашему тарифу у провайдера.",
|
||||
"onboarding.s1.path": "Настройки → AI-провайдер → Добавить провайдера",
|
||||
"onboarding.s1.do_1": "В сайдбаре откройте «Настройки» и выберите раздел «AI-провайдер».",
|
||||
"onboarding.s1.do_2": "Нажмите «Добавить провайдера»: OpenRouter, Custom (OpenAI-compatible) или локальная модель.",
|
||||
|
||||
@@ -266,8 +266,7 @@
|
||||
.hub-brand-mark.has-logo {
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--n-7);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hub-brand-mark img {
|
||||
|
||||
@@ -29,10 +29,10 @@ function SurfaceApp() {
|
||||
}, [surface]);
|
||||
|
||||
if (surface === "loading") {
|
||||
return <main className="help-boot-loading"><Loader size={44} /></main>;
|
||||
return <main className="help-boot-loading"><Loader size={72} /></main>;
|
||||
}
|
||||
return surface === "help"
|
||||
? <Suspense fallback={<main className="help-boot-loading"><Loader size={44} /></main>}><HelpCenterApp /></Suspense>
|
||||
? <Suspense fallback={<main className="help-boot-loading"><Loader size={72} /></main>}><HelpCenterApp /></Suspense>
|
||||
: <App />;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,3 +15,17 @@
|
||||
color: var(--n-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Загрузчик портала до прихода манифеста и темы. Живёт в общем CSS, а не в
|
||||
чанке портала: чанк грузится лениво, и первый кадр иначе рисуется без
|
||||
стилей. Фиксированный слой на весь экран — центр не зависит от отступов
|
||||
и высоты документа. */
|
||||
.help-boot-loading {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0;
|
||||
background: var(--help-bg, var(--surface-page));
|
||||
color: var(--help-ink, var(--text-body));
|
||||
}
|
||||
@@ -108,7 +108,7 @@ export function LoadingState({ variant = "page" }: { variant?: "page" | "inline"
|
||||
}
|
||||
|
||||
export function LoadingScreen() {
|
||||
return <main className="state-screen"><div className="state-card"><LoadingState variant="inline" /></div></main>;
|
||||
return <main className="state-screen"><LogoSpinner size={56} /></main>;
|
||||
}
|
||||
|
||||
export function ErrorScreen({ retry }: { retry: () => void }) {
|
||||
|
||||
@@ -118,7 +118,7 @@ support.js — рантайм макетов
|
||||
- **KB5 Редактор знания** — тот же полноэкранный split, что PT7: слева заголовок + панель Markdown + монополе + статусная строка (знаки, ≈фрагменты, автосохранение); в центре предпросмотр «внутренний материал» и «Как ответит агент»; справа рейка 300px: категория, краткое описание, тумблер ответов, вложения (drop-зона + список), агенты (только чтение). Primary — «Сохранить и переиндексировать».
|
||||
- **KB6 Редактор · перетаскивание файла** — drop-оверлей «Отпустите, чтобы приложить к знанию» (файл = вложение + ссылка в тексте), в рейке загрузка с прогрессом, в шапке бейдж «Есть несохранённые правки».
|
||||
- **KB7 Категории** — дерево строками с ручкой перетаскивания, счётчиками, инлайн-переименованием, «+ вложенная»; удаление недоступно у системной и непустой категории (тайтл объясняет причину), справа карточка правил и предупреждение о непустой категории.
|
||||
- **KB8 Импорт YAML** — разбор до применения: файл, сводка «создать 4 / обновить 2 / ошибка 1», таблица документ · `categoryPath` · действие · замечание; ошибочная строка красная с текстом «категории нет — импорт не создаёт категории»; primary «Импортировать 6 документов».
|
||||
- **KB8 Импорт YAML** — разбор до применения: файл, сводка «создать 5 / обновить 2», таблица документ · `categoryPath` · действие · замечание; недостающие уровни категорий создаются автоматически с пояснением в замечании; primary «Импортировать 7 документов».
|
||||
- **KB9 Пустая категория** — «В категории «Обучение» пока нет знаний» + «Добавить знание» / «Импорт YAML»; счётчики не подменяются нулями из другой категории.
|
||||
|
||||
### Решения по базе знаний
|
||||
|
||||
@@ -580,9 +580,9 @@ a:hover{opacity:0.85;}
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<span style="flex:1;font-size:12px;line-height:1.5;color:var(--c-n4);max-width:640px;">Импорт не создаёт категории неявно: документ с несуществующим <code style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">categoryPath</code> отклоняется отдельно, остальные применяются. Без указания пути документ попадает в «Без категории».</span>
|
||||
<span style="flex:1;font-size:12px;line-height:1.5;color:var(--c-n4);max-width:640px;">Недостающие категории из файла создаются автоматически. Без указания пути новый документ попадает в «Без категории», существующий сохраняет свою категорию.</span>
|
||||
<button style="height:34px;padding:0 13px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;">Отмена</button>
|
||||
<button style="height:34px;padding:0 15px;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);">Импортировать 6 документов</button>
|
||||
<button style="height:34px;padding:0 15px;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);">Импортировать 7 документов</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -831,9 +831,8 @@ class Component extends DCLogic {
|
||||
}));
|
||||
|
||||
const importStats = [
|
||||
{ label: "создать 4", bg: "var(--c-ok-bg)", color: "var(--c-ok-text)" },
|
||||
{ label: "создать 5", bg: "var(--c-ok-bg)", color: "var(--c-ok-text)" },
|
||||
{ label: "обновить 2", bg: "var(--c-accent-bg)", color: "var(--c-accent-text)" },
|
||||
{ label: "ошибка 1", bg: "var(--c-err-bg)", color: "var(--c-err-text)" },
|
||||
];
|
||||
const IA = {
|
||||
new: { action: "создать", actBg: "var(--c-ok-bg)", actColor: "var(--c-ok-text)" },
|
||||
@@ -844,7 +843,7 @@ class Component extends DCLogic {
|
||||
{ title: "srok-poshiva", path: "Регламенты / Пошив", ...IA.upd, note: "совпал заголовок — заменим текст и пересоберём фрагменты" },
|
||||
{ title: "perenos-primerki", path: "Регламенты / Пошив", ...IA.new, note: "" },
|
||||
{ title: "price-2026", path: "Прайсы и оплата", ...IA.upd, note: "категория в файле совпадает с текущей" },
|
||||
{ title: "foxray-licensing", path: "Продукты / FoxRay / Лицензирование", ...IA.err, note: "категории «Лицензирование» нет — импорт не создаёт категории. Создайте её или уберите последний уровень пути.", err: true },
|
||||
{ title: "foxray-licensing", path: "Продукты / FoxRay / Лицензирование", ...IA.new, note: "Недостающие категории будут созданы автоматически." },
|
||||
{ title: "foxray-update", path: "Продукты / FoxRay", ...IA.new, note: "" },
|
||||
{ title: "uhod-za-shelkom", path: "не указана", ...IA.new, note: "попадёт в «Без категории»" },
|
||||
{ title: "script-otkaz", path: "Скрипты общения", ...IA.new, note: "" },
|
||||
|
||||
Reference in new issue
Block a user