diff --git a/services/api-server/src/api_server/ai_structure_service.py b/services/api-server/src/api_server/ai_structure_service.py
index d9213ab..f50d166 100644
--- a/services/api-server/src/api_server/ai_structure_service.py
+++ b/services/api-server/src/api_server/ai_structure_service.py
@@ -23,6 +23,8 @@ def prepare_ai_structure(
input_path: Path,
output_path: Path,
structure_only: bool = False,
+ display_input_path: str | None = None,
+ display_output_path: str | None = None,
) -> dict[str, Any]:
if not input_path.exists():
raise FileNotFoundError(f"Input path not found: {input_path}")
@@ -48,7 +50,17 @@ def prepare_ai_structure(
diagnostics.append("No 1C metadata/XML/BSL files or .cf/.cfe binaries were found.")
codex_root = output_path / _codex_folder_name(project_id)
- manifest = _manifest(project_id, input_path, output_path, codex_root, files, snapshot, normalized, diagnostics, binaries)
+ manifest = _manifest(
+ project_id,
+ display_input_path or str(input_path),
+ display_output_path or str(output_path),
+ _join_display_path(display_output_path, codex_root.name) if display_output_path else str(codex_root),
+ files,
+ snapshot,
+ normalized,
+ diagnostics,
+ binaries,
+ )
_write_json(output_path / "manifest.json", manifest)
_write_json(output_path / "source_inventory.json", {"files": files})
if snapshot is not None:
@@ -78,9 +90,9 @@ def _inventory(root: Path) -> list[dict[str, Any]]:
def _manifest(
project_id: str,
- input_path: Path,
- output_path: Path,
- codex_root: Path,
+ input_path: str,
+ output_path: str,
+ codex_root: str,
files: list[dict[str, Any]],
snapshot: SirSnapshot | None,
normalized: NormalizedProject | None,
@@ -90,10 +102,10 @@ def _manifest(
return {
"version": AI_STRUCTURE_VERSION,
"project_id": project_id,
- "input_path": str(input_path),
- "output_path": str(output_path),
- "codex_package_path": str(codex_root),
- "codex_package_folder": codex_root.name,
+ "input_path": input_path,
+ "output_path": output_path,
+ "codex_package_path": codex_root,
+ "codex_package_folder": Path(codex_root).name if not codex_root.startswith("\\\\") else codex_root.rstrip("\\").rsplit("\\", 1)[-1],
"status": "ready" if snapshot is not None or normalized is not None else "export_required",
"files_count": len(files),
"binary_1c_files": binaries,
@@ -135,6 +147,14 @@ def _codex_folder_name(project_id: str) -> str:
return f"codex-1c-context-{safe}"
+def _join_display_path(root: str | None, child: str) -> str:
+ if not root:
+ return child
+ separator = "\\" if root.startswith("\\\\") or "\\" in root else "/"
+ cleaned = root.rstrip("/\\")
+ return f"{cleaned}{separator}{child}"
+
+
def _write_codex_package(
root: Path,
input_path: Path,
diff --git a/services/api-server/src/api_server/html5_ai_structure.py b/services/api-server/src/api_server/html5_ai_structure.py
index 58c2761..947a45d 100644
--- a/services/api-server/src/api_server/html5_ai_structure.py
+++ b/services/api-server/src/api_server/html5_ai_structure.py
@@ -7,7 +7,13 @@ from urllib.parse import quote
from api_server.html5 import _page, _project_link, _topbar
-def render_html5_ai_structure_page(*, project_id: str, projects: Iterable[object], result: dict | None = None) -> str:
+def render_html5_ai_structure_page(
+ *,
+ project_id: str,
+ projects: Iterable[object],
+ result: dict | None = None,
+ saved_credentials: dict[str, str] | None = None,
+) -> str:
project_nav = "\n".join(_project_link(project, project_id) for project in projects)
return _page(
f"SFERA AI Structure - {project_id}",
@@ -24,7 +30,7 @@ def render_html5_ai_structure_page(*, project_id: str, projects: Iterable[object
Подготовка структуры
- {render_html5_ai_structure_form(project_id)}
+ {render_html5_ai_structure_form(project_id, saved_credentials=saved_credentials)}
Пути должны быть доступны серверу SFERA/API. Для docker-test используйте папку, смонтированную или доступную внутри контейнера; локальные диски Windows и закрытые SMB-папки без учетных данных сервер не увидит.
{render_html5_ai_structure_result(result)}
@@ -34,7 +40,11 @@ def render_html5_ai_structure_page(*, project_id: str, projects: Iterable[object
)
-def render_html5_ai_structure_form(project_id: str) -> str:
+def render_html5_ai_structure_form(project_id: str, *, saved_credentials: dict[str, str] | None = None) -> str:
+ saved_credentials = saved_credentials or {}
+ saved_username = str(saved_credentials.get("username") or "")
+ saved_domain = str(saved_credentials.get("domain") or "")
+ password_hint = "Пароль сохранен, оставьте пустым чтобы использовать его" if saved_credentials.get("password") else "Пароль SMB"
return f"""
"""
diff --git a/services/api-server/src/api_server/html5_ai_structure_controller.py b/services/api-server/src/api_server/html5_ai_structure_controller.py
index e581167..817cbdc 100644
--- a/services/api-server/src/api_server/html5_ai_structure_controller.py
+++ b/services/api-server/src/api_server/html5_ai_structure_controller.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Callable, Iterable
from pathlib import Path
from typing import Any
+from uuid import uuid4
from fastapi import HTTPException
@@ -12,14 +13,23 @@ from api_server.html5_ai_structure import (
render_html5_ai_structure_result,
)
from api_server.html5_forms import form_value
+from api_server.smb_paths import copy_local_tree_to_smb, copy_smb_tree_to_local, is_unc_path, remove_tree
+
+
+SmbCredentials = dict[str, str]
def html5_ai_structure_page(
*,
project_id: str,
project_summaries: Callable[[], Iterable[object]],
+ load_credentials: Callable[[str], SmbCredentials | None] | None = None,
) -> str:
- return render_html5_ai_structure_page(project_id=project_id, projects=project_summaries())
+ return render_html5_ai_structure_page(
+ project_id=project_id,
+ projects=project_summaries(),
+ saved_credentials=load_credentials(project_id) if load_credentials else None,
+ )
def html5_ai_structure_run(
@@ -27,6 +37,9 @@ def html5_ai_structure_run(
project_id: str,
form: dict[str, list[str]],
prepare: Callable[..., dict[str, Any]],
+ work_root: Path,
+ load_credentials: Callable[[str], SmbCredentials | None] | None = None,
+ save_credentials: Callable[[str, SmbCredentials], None] | None = None,
) -> str:
effective_project_id = form_value(form, "project_id") or project_id
input_path = form_value(form, "input_path")
@@ -35,12 +48,54 @@ def html5_ai_structure_run(
return render_html5_ai_structure_error("Заполните входную папку с .cf/.cfe или выгрузкой 1С.")
if not output_path:
return render_html5_ai_structure_error("Заполните папку результата.")
+ saved = load_credentials(project_id) if load_credentials else None
+ username = form_value(form, "smb_username") or (saved or {}).get("username", "")
+ password = form_value(form, "smb_password") or (saved or {}).get("password", "")
+ domain = form_value(form, "smb_domain") or (saved or {}).get("domain", "")
+ should_save = bool(form_value(form, "save_smb_credentials"))
+ uses_smb = is_unc_path(input_path) or is_unc_path(output_path)
+ if uses_smb and (not username or not password):
+ return render_html5_ai_structure_error("Для сетевого UNC-пути укажите логин и пароль SMB.")
+ if should_save and save_credentials and username and password:
+ save_credentials(project_id, {"username": username, "password": password, "domain": domain})
+
+ work_dir = work_root / f"{effective_project_id}-{uuid4().hex}"
try:
- result = prepare(project_id=effective_project_id, input_path=Path(input_path), output_path=Path(output_path))
+ work_dir.mkdir(parents=True, exist_ok=True)
+ local_input = work_dir / "input" if is_unc_path(input_path) else Path(input_path)
+ local_output = work_dir / "output" if is_unc_path(output_path) else Path(output_path)
+ if is_unc_path(input_path):
+ copy_smb_tree_to_local(
+ source=input_path,
+ target=local_input,
+ username=username,
+ password=password,
+ domain=domain or None,
+ )
+ result = prepare(
+ project_id=effective_project_id,
+ input_path=local_input,
+ output_path=local_output,
+ display_input_path=input_path,
+ display_output_path=output_path,
+ )
+ if is_unc_path(output_path):
+ copy_local_tree_to_smb(
+ source=local_output,
+ target=output_path,
+ username=username,
+ password=password,
+ domain=domain or None,
+ )
except FileNotFoundError as error:
return render_html5_ai_structure_error(str(error))
except PermissionError as error:
return render_html5_ai_structure_error(f"Нет доступа к папке: {error}")
except OSError as error:
return render_html5_ai_structure_error(f"Ошибка файловой системы: {error}")
+ except RuntimeError as error:
+ return render_html5_ai_structure_error(str(error))
+ finally:
+ if work_dir.exists():
+ remove_tree(work_dir, expected_parent=work_root)
return render_html5_ai_structure_result(result)
diff --git a/services/api-server/src/api_server/main.py b/services/api-server/src/api_server/main.py
index 795576c..1e6477a 100644
--- a/services/api-server/src/api_server/main.py
+++ b/services/api-server/src/api_server/main.py
@@ -284,6 +284,40 @@ _neo4j_user = os.environ.get("NEO4J_USER", "neo4j")
_neo4j_password = os.environ.get("NEO4J_PASSWORD", "password")
_EVENT_SUBSCRIPTION_KIND = getattr(NodeKind, "EVENT_SUBSCRIPTION", None)
+
+def _load_ai_structure_smb_credentials(project_id: str) -> dict[str, str] | None:
+ try:
+ payload = _storage.read_document("ai_structure_smb_credentials", project_id)
+ except FileNotFoundError:
+ return None
+ password_value = str(payload.get("password_b64") or "")
+ try:
+ password = base64.b64decode(password_value.encode("ascii")).decode("utf-8") if password_value else ""
+ except (ValueError, UnicodeDecodeError):
+ password = ""
+ username = str(payload.get("username") or "")
+ if not username and not password:
+ return None
+ return {
+ "username": username,
+ "domain": str(payload.get("domain") or ""),
+ "password": password,
+ }
+
+
+def _save_ai_structure_smb_credentials(project_id: str, credentials: dict[str, str]) -> None:
+ password = str(credentials.get("password") or "")
+ _storage.write_document(
+ "ai_structure_smb_credentials",
+ project_id,
+ {
+ "project_id": project_id,
+ "username": str(credentials.get("username") or ""),
+ "domain": str(credentials.get("domain") or ""),
+ "password_b64": base64.b64encode(password.encode("utf-8")).decode("ascii"),
+ },
+ )
+
_ACCESS_TARGET_KINDS = {
NodeKind.CATALOG,
NodeKind.DOCUMENT,
@@ -1585,7 +1619,11 @@ async def html5_project_access(project_id: str, profile: str | None = None) -> R
@app.get("/html5/projects/{project_id}/ai-structure")
async def html5_project_ai_structure(project_id: str) -> Response:
return _html5_response(
- _html5_ai_structure_page(project_id=project_id, project_summaries=_project_summaries)
+ _html5_ai_structure_page(
+ project_id=project_id,
+ project_summaries=_project_summaries,
+ load_credentials=_load_ai_structure_smb_credentials,
+ )
)
@@ -1597,6 +1635,9 @@ async def html5_project_ai_structure_run(project_id: str, request: Request) -> R
project_id=project_id,
form=form,
prepare=_prepare_ai_structure,
+ work_root=_storage.root / "ai_structure_work",
+ load_credentials=_load_ai_structure_smb_credentials,
+ save_credentials=_save_ai_structure_smb_credentials,
)
)
diff --git a/services/api-server/src/api_server/smb_paths.py b/services/api-server/src/api_server/smb_paths.py
new file mode 100644
index 0000000..146c09b
--- /dev/null
+++ b/services/api-server/src/api_server/smb_paths.py
@@ -0,0 +1,131 @@
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+from typing import Any
+
+
+def is_unc_path(path: str) -> bool:
+ return path.startswith("\\\\")
+
+
+def copy_smb_tree_to_local(
+ *,
+ source: str,
+ target: Path,
+ username: str,
+ password: str,
+ domain: str | None = None,
+) -> None:
+ smbclient = _smbclient()
+ server, _share, _relative = parse_unc_path(source)
+ _register_session(smbclient, server=server, username=username, password=password, domain=domain)
+ _copy_smb_directory(smbclient, source.rstrip("\\"), target)
+
+
+def copy_local_tree_to_smb(
+ *,
+ source: Path,
+ target: str,
+ username: str,
+ password: str,
+ domain: str | None = None,
+) -> None:
+ smbclient = _smbclient()
+ server, _share, _relative = parse_unc_path(target)
+ _register_session(smbclient, server=server, username=username, password=password, domain=domain)
+ remote_root = target.rstrip("\\")
+ _ensure_smb_directory(smbclient, remote_root)
+ if source.is_file():
+ _copy_local_file_to_smb(smbclient, source, f"{remote_root}\\{source.name}")
+ return
+ for path in sorted(source.rglob("*")):
+ relative = path.relative_to(source)
+ remote_relative = str(relative).replace("/", "\\")
+ remote = f"{remote_root}\\{remote_relative}"
+ if path.is_dir():
+ _ensure_smb_directory(smbclient, remote)
+ elif path.is_file():
+ _ensure_smb_directory(smbclient, remote.rsplit("\\", 1)[0])
+ _copy_local_file_to_smb(smbclient, path, remote)
+
+
+def parse_unc_path(path: str) -> tuple[str, str, str]:
+ parts = [part for part in path.strip("\\").split("\\") if part]
+ if len(parts) < 2:
+ raise ValueError("UNC путь должен содержать сервер и share: \\\\server\\share.")
+ server, share = parts[0], parts[1]
+ relative = "\\".join(parts[2:])
+ return server, share, relative
+
+
+def remove_tree(path: Path, *, expected_parent: Path) -> None:
+ resolved = path.resolve()
+ parent = expected_parent.resolve()
+ if resolved == parent or parent not in resolved.parents:
+ raise ValueError(f"Refusing to remove path outside work root: {resolved}")
+ if resolved.exists():
+ shutil.rmtree(resolved)
+
+
+def _smbclient() -> Any:
+ try:
+ import smbclient
+ except ImportError as error:
+ raise RuntimeError("SMB client dependency is not installed on the API server.") from error
+ return smbclient
+
+
+def _register_session(smbclient: Any, *, server: str, username: str, password: str, domain: str | None) -> None:
+ qualified_user = f"{domain}\\{username}" if domain else username
+ smbclient.register_session(server, username=qualified_user, password=password)
+
+
+def _copy_smb_directory(smbclient: Any, source: str, target: Path) -> None:
+ target.mkdir(parents=True, exist_ok=True)
+ for item in smbclient.scandir(source):
+ destination = target / item.name
+ child_source = f"{source}\\{item.name}"
+ try:
+ if item.is_dir():
+ _copy_smb_directory(smbclient, child_source, destination)
+ continue
+ except OSError:
+ continue
+ with smbclient.open_file(child_source, mode="rb") as remote_file:
+ with destination.open("wb") as local_file:
+ shutil.copyfileobj(remote_file, local_file, length=1024 * 1024)
+
+
+def _copy_local_file_to_smb(smbclient: Any, source: Path, target: str) -> None:
+ with source.open("rb") as local_file:
+ with smbclient.open_file(target, mode="wb") as remote_file:
+ shutil.copyfileobj(local_file, remote_file, length=1024 * 1024)
+
+
+def _ensure_smb_directory(smbclient: Any, path: str) -> None:
+ normalized = path.rstrip("\\")
+ try:
+ if smbclient.path.isdir(normalized):
+ return
+ except OSError:
+ pass
+ parent = _unc_parent_path(normalized)
+ if parent and parent != normalized:
+ _ensure_smb_directory(smbclient, parent)
+ try:
+ smbclient.mkdir(normalized)
+ except OSError:
+ if not smbclient.path.isdir(normalized):
+ raise
+
+
+def _unc_parent_path(path: str) -> str | None:
+ server, share, relative = parse_unc_path(path)
+ if not relative:
+ return None
+ parts = [part for part in relative.split("\\") if part]
+ if len(parts) <= 1:
+ return f"\\\\{server}\\{share}"
+ parent_relative = "\\".join(parts[:-1])
+ return f"\\\\{server}\\{share}\\{parent_relative}"
diff --git a/services/api-server/src/api_server/static/html5/html5.css b/services/api-server/src/api_server/static/html5/html5.css
index 85b112c..0e42953 100644
--- a/services/api-server/src/api_server/static/html5/html5.css
+++ b/services/api-server/src/api_server/static/html5/html5.css
@@ -13,7 +13,7 @@
.access-workspace{background:#f4f7fb}.access-layout{display:grid;grid-template-columns:300px minmax(0,1fr)340px;height:calc(100vh - 88px)}.access-main{border-top:0;border-bottom:0;overflow:auto}.access-nav,.access-side{overflow:auto}.tree-item[data-html5-access-profile-selected="true"]{background:#f8fbff;border-left:3px solid var(--brand);padding-left:9px}.access-empty{margin:16px;border:1px dashed #aeb8c6;background:#fff;padding:18px;color:#687385}.access-empty strong,.access-empty span{display:block}.access-empty strong{color:#1f2937}.access-profile,.access-plan,.access-result{border-bottom:1px solid var(--line);background:#fff}.access-summary{display:flex;gap:8px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--line);background:#f8fbff;color:#687385;font-size:12px;font-weight:800}.access-role-grid,.access-operations,.access-list{display:grid}.access-role-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.access-card{display:grid;gap:3px;min-width:0;padding:10px 12px;border-bottom:1px solid var(--line)}.access-role-grid .access-card:nth-child(odd){border-right:1px solid var(--line)}.access-card strong,.access-card small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.access-card small{color:var(--muted)}.access-plan-head{display:flex;gap:10px;align-items:center;justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--line);background:#fbfcfe}.access-plan-head form{margin:0}.access-actions{padding:12px}.access-warnings{margin:0;padding:0;list-style:none}.access-warnings li{padding:8px 12px;border-bottom:1px solid var(--line);color:var(--warn);font-weight:800}.access-json{margin:0;max-height:220px;overflow:auto;padding:12px;background:#fbfaf7;border-top:1px solid var(--line);font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap}.access-main .primary{background:var(--brand);border-color:var(--brand);color:#fff}
.access-builder{border-bottom:1px solid var(--line);background:#fff}.access-builder-form{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:12px;border-bottom:1px solid var(--line);background:#fbfcfe}.access-builder-form label{display:grid;gap:5px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase}.access-builder-form input,.access-builder-form textarea{min-width:0;border:1px solid var(--line);background:#fff;padding:0 8px;color:var(--text);font:13px/1.3 system-ui,-apple-system,Segoe UI,sans-serif;text-transform:none}.access-builder-form input{height:32px}.access-builder-form textarea{min-height:68px;padding:8px;resize:vertical}.access-builder-actions{grid-column:1/-1;display:flex;gap:8px;justify-content:flex-end}.access-builder-result{border-top:1px solid var(--line);background:#fff}
.access-card[hx-get]{cursor:pointer}.access-card[hx-get]:hover{background:#f8fbff}.access-user-detail{border-bottom:1px solid var(--line);background:#fff}
-.ai-structure-form{display:grid;grid-template-columns:1fr 1fr 220px auto;gap:10px;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fbfcfe}.ai-structure-form label{display:grid;gap:5px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase}.ai-structure-form input{height:32px;min-width:0;border:1px solid var(--line);background:#fff;padding:0 8px;color:var(--text);font:13px/1.3 system-ui,-apple-system,Segoe UI,sans-serif;text-transform:none}.ai-structure-result{background:#fff}
+.ai-structure-form{display:grid;grid-template-columns:1.4fr 1.4fr 180px 120px 180px 180px 120px auto;gap:10px;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fbfcfe}.ai-structure-form label{display:grid;gap:5px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase}.ai-structure-form input{height:32px;min-width:0;border:1px solid var(--line);background:#fff;padding:0 8px;color:var(--text);font:13px/1.3 system-ui,-apple-system,Segoe UI,sans-serif;text-transform:none}.ai-structure-form .checkbox-row{display:flex;align-items:center;gap:7px;height:32px}.ai-structure-form .checkbox-row input{width:16px;height:16px}.ai-structure-result{background:#fff}
@media(max-width:980px){.layout{grid-template-columns:1fr;height:auto}.tree,.inspector{max-height:320px}.editor{min-height:520px}.hero{display:block}.shell{padding:16px}}
@media(max-width:980px){.access-layout{grid-template-columns:1fr;height:auto}.access-nav,.access-side{max-height:360px}.access-role-grid{grid-template-columns:1fr}.access-role-grid .access-card:nth-child(odd){border-right:0}}
@media(max-width:980px){.access-builder-form{grid-template-columns:1fr}.access-builder-actions{justify-content:stretch}.access-builder-actions button{flex:1}}
diff --git a/services/api-server/tests/test_api.py b/services/api-server/tests/test_api.py
index 6b87b2b..6693415 100644
--- a/services/api-server/tests/test_api.py
+++ b/services/api-server/tests/test_api.py
@@ -1733,6 +1733,8 @@ def test_ai_structure_prepare_writes_ai_ready_package(tmp_path: Path):
"Структура для ИИ",
"192.168.220.200",
"Пути должны быть доступны серверу",
+ "smb_username",
+ "smb_password",
full_page=True,
)
@@ -1748,6 +1750,16 @@ def test_ai_structure_prepare_writes_ai_ready_package(tmp_path: Path):
)
assert_html5_response_contract(html5_missing, "ошибка", "Input path not found")
+ html5_smb_without_credentials = client.post(
+ "/html5/projects/ai-demo/ai-structure/run",
+ data={
+ "project_id": "ai-demo-html5",
+ "input_path": r"\\192.168.220.200\mst\1c\MARKA\CODEX\CF",
+ "output_path": r"\\192.168.220.200\mst\1c\MARKA\CODEX\CODEX",
+ },
+ )
+ assert_html5_response_contract(html5_smb_without_credentials, "ошибка", "логин и пароль SMB")
+
def test_ai_structure_prepare_reports_cf_cfe_export_required(tmp_path: Path):
source = tmp_path / "cf-source"