datn-cli 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
datn_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """datn-cli — one-command distribution wrapper hiding docker compose."""
2
+
3
+ __version__ = "0.2.2"
datn_cli/__main__.py ADDED
@@ -0,0 +1,264 @@
1
+ """datn — CLI che giấu docker compose cho hệ multi-agent assistant."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ import subprocess
6
+ import webbrowser
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.prompt import Confirm, Prompt
11
+
12
+ from . import __version__
13
+ from . import compose as cm
14
+ from . import config as cfg
15
+ from . import doctor as dr
16
+ from . import wizard
17
+
18
+ console = Console()
19
+ app = typer.Typer(
20
+ help="datn — one-command distribution (ẩn docker compose).",
21
+ no_args_is_help=True,
22
+ add_completion=False,
23
+ )
24
+ config_app = typer.Typer(help="Cấu hình lại sau khi đã chạy.", no_args_is_help=False)
25
+ app.add_typer(config_app, name="config")
26
+
27
+
28
+ def _image_tag() -> str:
29
+ return cfg.read_lock().get("image_tag", cfg.DEFAULT_IMAGE_TAG)
30
+
31
+
32
+ # ── init ─────────────────────────────────────────────────────────────────────
33
+ @app.command()
34
+ def init():
35
+ """Wizard cấu hình LLM + embedding → ghi ~/.datn/.env + provider.lock."""
36
+ # Nếu đã cấu hình trước đó (re-init), cần biết volumes có tồn tại không để
37
+ # guard đổi dim hoạt động → đảm bảo Docker chạy. Lần đầu (chưa config) thì
38
+ # bỏ qua, không bắt user bật Docker chỉ để nhập key.
39
+ volumes = False
40
+ if cfg.is_configured():
41
+ if dr.ensure_docker(auto_start=True):
42
+ volumes = cm.volumes_exist()
43
+ else:
44
+ console.print(
45
+ "[yellow]⚠ Docker không chạy — bỏ qua kiểm tra volume. "
46
+ "Nếu đổi embedding dim, hãy chạy `datn reset` thủ công.[/yellow]"
47
+ )
48
+ wizard.run_init(image_tag=cfg.DEFAULT_IMAGE_TAG, volumes_exist=volumes)
49
+
50
+
51
+ # ── up ───────────────────────────────────────────────────────────────────────
52
+ @app.command()
53
+ def up():
54
+ """Pull images + khởi động toàn bộ stack + chờ healthy."""
55
+ if not cfg.is_configured():
56
+ console.print("[red]Chưa cấu hình. Chạy [bold]datn init[/bold] trước.[/red]")
57
+ raise typer.Exit(1)
58
+
59
+ if not dr.run_doctor(require_config=True):
60
+ console.print("[red]✗ doctor phát hiện lỗi blocking. Sửa rồi chạy lại.[/red]")
61
+ raise typer.Exit(1)
62
+
63
+ tag = _image_tag()
64
+ cm.render_compose(tag)
65
+ try:
66
+ cm.pull()
67
+ cm.up()
68
+ except subprocess.CalledProcessError as e:
69
+ console.print(
70
+ f"[bold red]✗ docker compose lỗi (exit {e.returncode}).[/bold red]\n"
71
+ f" Xem lỗi docker cụ thể ở ngay phía trên. Nguyên nhân thường gặp:\n"
72
+ f" • Port bận (port is already allocated) → đóng app/stack khác chiếm port; chạy [cyan]datn doctor[/cyan]\n"
73
+ f" • Image tag '{tag}' chưa có trên Docker Hub / mạng lỗi → kiểm tra [cyan]docker pull {cfg.BACKEND_IMAGE}:{tag}[/cyan]\n"
74
+ f" • Container crash → xem [cyan]datn logs[/cyan]"
75
+ )
76
+ raise typer.Exit(1)
77
+ if cm.wait_healthy():
78
+ console.print(f"\n[bold green]✓ Hệ thống sẵn sàng![/bold green] → {cfg.WEB_URL}")
79
+ console.print("[dim]Mở bằng: datn open[/dim]")
80
+ else:
81
+ console.print("[red]Một số service chưa healthy. Kiểm tra: datn logs[/red]")
82
+ raise typer.Exit(1)
83
+
84
+
85
+ # ── down / open / logs ───────────────────────────────────────────────────────
86
+ @app.command()
87
+ def down():
88
+ """Dừng stack (giữ data)."""
89
+ cm.down()
90
+ console.print("[green]✓ Đã dừng (data được giữ).[/green]")
91
+
92
+
93
+ @app.command()
94
+ def open(): # noqa: A001 — tên lệnh user-facing
95
+ """Mở web UI trên trình duyệt."""
96
+ console.print(f"→ Mở {cfg.WEB_URL}")
97
+ webbrowser.open(cfg.WEB_URL)
98
+
99
+
100
+ @app.command()
101
+ def logs(service: str = typer.Argument(None, help="Tên service (vd agent-api). Bỏ trống = tất cả.")):
102
+ """Xem logs realtime."""
103
+ cm.logs(service)
104
+
105
+
106
+ # ── doctor ───────────────────────────────────────────────────────────────────
107
+ @app.command()
108
+ def doctor():
109
+ """Kiểm tra môi trường (Docker, ports, config, dim...)."""
110
+ require = cfg.is_configured()
111
+ ok = dr.run_doctor(require_config=require)
112
+ raise typer.Exit(0 if ok else 1)
113
+
114
+
115
+ # ── reset / update / uninstall ───────────────────────────────────────────────
116
+ @app.command()
117
+ def reset():
118
+ """Xoá toàn bộ data (volumes) — bắt buộc khi đổi embedding dim."""
119
+ if not Confirm.ask(
120
+ "[bold red]Xoá toàn bộ data RAG + chat history?[/bold red]", default=False
121
+ ):
122
+ console.print("Đã huỷ.")
123
+ return
124
+ cm.reset()
125
+ console.print("[green]✓ Đã xoá volumes. Chạy datn init + datn up để bắt đầu lại.[/green]")
126
+
127
+
128
+ @app.command()
129
+ def update(tag: str = typer.Option(None, "--tag", help="Image tag mới (vd v0.2.0).")):
130
+ """Đổi image tag → re-render + pull + up lại."""
131
+ lock = cfg.read_lock()
132
+ if tag:
133
+ lock["image_tag"] = tag
134
+ cfg.write_lock(lock)
135
+ new_tag = lock.get("image_tag", cfg.DEFAULT_IMAGE_TAG)
136
+ cm.render_compose(new_tag)
137
+ try:
138
+ cm.pull()
139
+ cm.up()
140
+ except subprocess.CalledProcessError as e:
141
+ console.print(f"[bold red]✗ docker compose lỗi (exit {e.returncode}). Tag '{new_tag}' đã push chưa?[/bold red]")
142
+ raise typer.Exit(1)
143
+ console.print(f"[green]✓ Đã cập nhật lên tag '{new_tag}'.[/green]")
144
+
145
+
146
+ @app.command()
147
+ def uninstall():
148
+ """down -v + xoá ~/.datn/ (giữ images đã pull)."""
149
+ if not Confirm.ask(
150
+ "[bold red]Gỡ datn: xoá data + cấu hình ~/.datn?[/bold red]", default=False
151
+ ):
152
+ console.print("Đã huỷ.")
153
+ return
154
+ try:
155
+ cm.reset()
156
+ except Exception:
157
+ pass
158
+ shutil.rmtree(cfg.datn_home(), ignore_errors=True)
159
+ console.print("[green]✓ Đã gỡ. (Images Docker vẫn còn — xoá tay nếu muốn.)[/green]")
160
+
161
+
162
+ @app.command()
163
+ def version():
164
+ """In version CLI."""
165
+ console.print(f"datn-cli {__version__}")
166
+
167
+
168
+ # ── config sub-app ───────────────────────────────────────────────────────────
169
+ def _after_env_change(restart: bool = True):
170
+ if restart and dr.docker_ready():
171
+ cm.restart_backend()
172
+
173
+
174
+ @config_app.callback(invoke_without_command=True)
175
+ def config_main(ctx: typer.Context):
176
+ """Không tham số → menu interactive."""
177
+ if ctx.invoked_subcommand is not None:
178
+ return
179
+ if not cfg.is_configured():
180
+ console.print("[red]Chưa có config. Chạy [bold]datn init[/bold].[/red]")
181
+ raise typer.Exit(1)
182
+ choice = Prompt.ask(
183
+ "Sửa mục nào?",
184
+ choices=["llm", "embedding", "tavily", "serpapi"],
185
+ default="llm",
186
+ )
187
+ {"llm": config_llm, "embedding": config_embedding,
188
+ "tavily": config_tavily, "serpapi": config_serpapi}[choice]()
189
+
190
+
191
+ @config_app.command("llm")
192
+ def config_llm():
193
+ """Đổi LLM provider/model/key → restart backend."""
194
+ env = cfg.read_env()
195
+ env.update(wizard.prompt_llm())
196
+ cfg.write_env(env)
197
+ lock = cfg.read_lock()
198
+ lock["llm_provider"] = env["LLM_PROVIDER"]
199
+ cfg.write_lock(lock)
200
+ console.print("[green]✓ Đã cập nhật LLM.[/green]")
201
+ _after_env_change()
202
+
203
+
204
+ @config_app.command("embedding")
205
+ def config_embedding():
206
+ """Đổi embedding → detect dim; nếu đổi dim + có data → bắt reset."""
207
+ try:
208
+ new = wizard.prompt_embedding()
209
+ except wizard.DimensionDetectError as e:
210
+ console.print(f"[red]✗ {e}[/red]")
211
+ raise typer.Exit(1)
212
+
213
+ lock = cfg.read_lock()
214
+ old_dim = lock.get("embedding_dim")
215
+ new_dim = int(new["EMBEDDING_DIM"])
216
+ if old_dim is not None and old_dim != new_dim and cm.volumes_exist():
217
+ console.print(
218
+ f"[bold red]✗ Đổi dim {old_dim}→{new_dim} sẽ vỡ collection. "
219
+ f"Chạy datn reset trước.[/bold red]"
220
+ )
221
+ raise typer.Exit(1)
222
+
223
+ env = cfg.read_env()
224
+ env.update(new)
225
+ cfg.write_env(env)
226
+ lock["embedding_provider"] = new["EMBEDDING_PROVIDER"]
227
+ lock["embedding_dim"] = new_dim
228
+ cfg.write_lock(lock)
229
+ console.print("[green]✓ Đã cập nhật embedding.[/green]")
230
+ _after_env_change()
231
+
232
+
233
+ @config_app.command("tavily")
234
+ def config_tavily():
235
+ """Đổi Tavily API key."""
236
+ env = cfg.read_env()
237
+ env["TAVILY_API_KEY"] = Prompt.ask("Tavily API Key", default="", show_default=False)
238
+ cfg.write_env(env)
239
+ console.print("[green]✓ Đã cập nhật Tavily.[/green]")
240
+ _after_env_change()
241
+
242
+
243
+ @config_app.command("serpapi")
244
+ def config_serpapi():
245
+ """Đổi SerpApi API key."""
246
+ env = cfg.read_env()
247
+ env["SERPAPI_API_KEY"] = Prompt.ask("SerpApi API Key", default="", show_default=False)
248
+ cfg.write_env(env)
249
+ console.print("[green]✓ Đã cập nhật SerpApi.[/green]")
250
+ _after_env_change()
251
+
252
+
253
+ @config_app.command("set")
254
+ def config_set(key: str = typer.Argument(...), value: str = typer.Argument(...)):
255
+ """Sửa nhanh 1 biến .env (cho người rành env)."""
256
+ env = cfg.read_env()
257
+ env[key] = value
258
+ cfg.write_env(env)
259
+ console.print(f"[green]✓ {key} = {value}[/green]")
260
+ _after_env_change()
261
+
262
+
263
+ if __name__ == "__main__":
264
+ app()
datn_cli/compose.py ADDED
@@ -0,0 +1,139 @@
1
+ """Render compose template + bọc các lệnh docker compose."""
2
+ from __future__ import annotations
3
+
4
+ import platform
5
+ import subprocess
6
+ import time
7
+ from importlib import resources
8
+
9
+ import httpx
10
+ from jinja2 import Template
11
+ from rich.console import Console
12
+
13
+ from . import config as cfg
14
+
15
+ console = Console()
16
+
17
+ _SYSTEM = platform.system()
18
+
19
+
20
+ # ── Render template ──────────────────────────────────────────────────────────
21
+ def _needs_host_gateway() -> bool:
22
+ """Linux + selfhost URL trỏ host.docker.internal → cần extra_hosts."""
23
+ if _SYSTEM != "Linux":
24
+ return False
25
+ env = cfg.read_env()
26
+ urls = (env.get("LLM_BASE_URL", ""), env.get("EMBEDDING_BASE_URL", ""))
27
+ return any("host.docker.internal" in u for u in urls)
28
+
29
+
30
+ def render_compose(image_tag: str) -> None:
31
+ """Render docker-compose.yml vào ~/.datn/ từ template .j2."""
32
+ tpl_text = (
33
+ resources.files("datn_cli.templates")
34
+ .joinpath("docker-compose.dist.yml.j2")
35
+ .read_text(encoding="utf-8")
36
+ )
37
+ rendered = Template(tpl_text).render(
38
+ backend_image=cfg.BACKEND_IMAGE,
39
+ frontend_image=cfg.FRONTEND_IMAGE,
40
+ image_tag=image_tag,
41
+ env_file=str(cfg.env_path()),
42
+ needs_host_gateway=_needs_host_gateway(),
43
+ )
44
+ cfg.compose_path().write_text(rendered, encoding="utf-8")
45
+
46
+
47
+ # ── docker compose wrappers ──────────────────────────────────────────────────
48
+ def _compose(*args: str, check: bool = True, capture: bool = False) -> subprocess.CompletedProcess:
49
+ # -p datn: pin project name → volume luôn có prefix 'datn_' (deterministic,
50
+ # không phụ thuộc thư mục chứa compose file). volumes_exist() dựa vào điều này.
51
+ cmd = ["docker", "compose", "-p", cfg.PROJECT_NAME, "-f", str(cfg.compose_path()), *args]
52
+ return subprocess.run(cmd, check=check, capture_output=capture, text=True)
53
+
54
+
55
+ def volumes_exist() -> bool:
56
+ """Có volume của project 'datn' nào đang tồn tại không (để guard đổi dim).
57
+
58
+ Vì _compose pin -p datn, volume thực tế có dạng 'datn_datn_<name>'. Lọc theo
59
+ prefix project để không nhầm với volume của dự án khác.
60
+ """
61
+ try:
62
+ r = subprocess.run(
63
+ ["docker", "volume", "ls", "--format", "{{.Name}}"],
64
+ capture_output=True, text=True, timeout=10,
65
+ )
66
+ prefix = f"{cfg.PROJECT_NAME}_"
67
+ return any(v.startswith(prefix) for v in r.stdout.split())
68
+ except Exception:
69
+ return False
70
+
71
+
72
+ def pull() -> None:
73
+ console.print("[cyan]Pulling images từ Docker Hub...[/cyan]")
74
+ console.print("[dim]⚠ Lần đầu có thể mất vài phút (~2-4GB).[/dim]")
75
+ _compose("pull")
76
+
77
+
78
+ def up() -> None:
79
+ console.print("[cyan]Khởi động services...[/cyan]")
80
+ _compose("up", "-d")
81
+
82
+
83
+ def down() -> None:
84
+ _compose("down")
85
+
86
+
87
+ def reset() -> None:
88
+ """down -v — xoá volumes (mất data RAG + chat history)."""
89
+ _compose("down", "-v")
90
+
91
+
92
+ def logs(service: str | None = None) -> None:
93
+ args = ["logs", "-f"]
94
+ if service:
95
+ args.append(service)
96
+ _compose(*args, check=False)
97
+
98
+
99
+ # ── Health polling ───────────────────────────────────────────────────────────
100
+ def wait_healthy(timeout: int = 120) -> bool:
101
+ """Poll agent-api `/` + qdrant readiness tới khi healthy hoặc hết giờ."""
102
+ console.print("[cyan]Chờ services healthy...[/cyan]")
103
+ # Poll /health (200) — KHÔNG dùng "/" vì api.py không có route đó (trả 404,
104
+ # sẽ false-positive). /health do api.py expose riêng cho mục đích này.
105
+ api_url = f"http://localhost:{cfg.PORTS['agent-api']}/health"
106
+ qdrant_url = f"http://localhost:{cfg.PORTS['qdrant']}/readyz"
107
+
108
+ deadline = time.time() + timeout
109
+ api_ok = qdrant_ok = False
110
+ while time.time() < deadline:
111
+ if not qdrant_ok:
112
+ try:
113
+ qdrant_ok = httpx.get(qdrant_url, timeout=3).status_code == 200
114
+ if qdrant_ok:
115
+ console.print("[green]✓[/green] qdrant")
116
+ except Exception:
117
+ pass
118
+ if not api_ok:
119
+ try:
120
+ api_ok = httpx.get(api_url, timeout=3).status_code == 200
121
+ if api_ok:
122
+ console.print("[green]✓[/green] agent-api")
123
+ except Exception:
124
+ pass
125
+ if api_ok and qdrant_ok:
126
+ return True
127
+ time.sleep(3)
128
+
129
+ if not qdrant_ok:
130
+ console.print("[red]✗ qdrant không healthy. Xem: datn logs qdrant[/red]")
131
+ if not api_ok:
132
+ console.print("[red]✗ agent-api không healthy. Xem: datn logs agent-api[/red]")
133
+ return False
134
+
135
+
136
+ def restart_backend() -> None:
137
+ """Restart agent-api + mcp-server để nạp .env mới (không mất data)."""
138
+ console.print("[cyan]Restart backend để nạp cấu hình mới...[/cyan]")
139
+ _compose("up", "-d", "--force-recreate", "agent-api", "mcp-server")
datn_cli/config.py ADDED
@@ -0,0 +1,137 @@
1
+ """Đường dẫn + đọc/ghi ~/.datn/.env và provider.lock."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import stat
7
+ from pathlib import Path
8
+
9
+ # Image tag mặc định — phải khớp tag đã push lên Docker Hub (xem notes.md mục C/F).
10
+ DEFAULT_IMAGE_TAG = "latest"
11
+
12
+ # Compose project name — pin để volume naming deterministic (xem compose.py).
13
+ PROJECT_NAME = "datn"
14
+
15
+ # Docker Hub images
16
+ BACKEND_IMAGE = "ngoquan0904/datn-backend"
17
+ FRONTEND_IMAGE = "ngoquan0904/datn-frontend"
18
+
19
+ # Cổng dùng trên host (để doctor kiểm tra + in URL)
20
+ PORTS = {
21
+ "frontend": 5173,
22
+ "agent-api": 5000,
23
+ "mcp-server": 8000,
24
+ "postgres": 5432,
25
+ "qdrant": 6333,
26
+ "minio": 9000,
27
+ "minio-console": 9001,
28
+ }
29
+
30
+ WEB_URL = f"http://localhost:{PORTS['frontend']}"
31
+
32
+ # Field bắt buộc trong .env để hệ thống chạy
33
+ REQUIRED_ENV_FIELDS = [
34
+ "LLM_PROVIDER",
35
+ "LLM_MODEL",
36
+ "EMBEDDING_PROVIDER",
37
+ "EMBEDDING_MODEL",
38
+ "EMBEDDING_DIM",
39
+ ]
40
+
41
+
42
+ def datn_home() -> Path:
43
+ """Thư mục cấu hình ~/.datn (tạo nếu chưa có)."""
44
+ home = Path(os.path.expanduser("~")) / ".datn"
45
+ home.mkdir(parents=True, exist_ok=True)
46
+ return home
47
+
48
+
49
+ def env_path() -> Path:
50
+ return datn_home() / ".env"
51
+
52
+
53
+ def lock_path() -> Path:
54
+ return datn_home() / "provider.lock"
55
+
56
+
57
+ def compose_path() -> Path:
58
+ return datn_home() / "docker-compose.yml"
59
+
60
+
61
+ # ── .env IO ─────────────────────────────────────────────────────────────────
62
+ def read_env() -> dict[str, str]:
63
+ """Đọc .env thành dict (bỏ qua comment/dòng trống)."""
64
+ path = env_path()
65
+ if not path.exists():
66
+ return {}
67
+ data: dict[str, str] = {}
68
+ for line in path.read_text(encoding="utf-8").splitlines():
69
+ line = line.strip()
70
+ if not line or line.startswith("#") or "=" not in line:
71
+ continue
72
+ key, _, val = line.partition("=")
73
+ data[key.strip()] = val.strip()
74
+ return data
75
+
76
+
77
+ def write_env(data: dict[str, str]) -> None:
78
+ """Ghi .env theo thứ tự khối + chmod 600 (chỉ owner đọc — không lộ key)."""
79
+ order = [
80
+ ("# === LLM ===", None),
81
+ (None, "LLM_PROVIDER"),
82
+ (None, "LLM_API_KEY"),
83
+ (None, "LLM_MODEL"),
84
+ (None, "LLM_BASE_URL"),
85
+ (None, "LLM_DISABLE_THINKING"),
86
+ ("# === EMBEDDING ===", None),
87
+ (None, "EMBEDDING_PROVIDER"),
88
+ (None, "EMBEDDING_API_KEY"),
89
+ (None, "EMBEDDING_MODEL"),
90
+ (None, "EMBEDDING_BASE_URL"),
91
+ (None, "EMBEDDING_DIM"),
92
+ # Sub-agent keys (Tavily/SerpApi/Unsplash) KHÔNG ghi mặc định — đã bake trong
93
+ # image. Chỉ xuất hiện nếu user set tay (datn config set) → vào nhánh "extra".
94
+ ]
95
+ lines: list[str] = []
96
+ written: set[str] = set()
97
+ for comment, key in order:
98
+ if comment:
99
+ if lines:
100
+ lines.append("")
101
+ lines.append(comment)
102
+ elif key is not None:
103
+ lines.append(f"{key}={data.get(key, '')}")
104
+ written.add(key)
105
+ # Giữ lại field lạ (nếu user thêm tay) ở cuối
106
+ extra = [k for k in data if k not in written]
107
+ if extra:
108
+ lines.append("")
109
+ lines.append("# === Extra ===")
110
+ for k in extra:
111
+ lines.append(f"{k}={data[k]}")
112
+
113
+ path = env_path()
114
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
115
+ try:
116
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 600
117
+ except OSError:
118
+ pass # Windows có thể không hỗ trợ chmod đầy đủ — bỏ qua
119
+
120
+
121
+ # ── provider.lock IO ─────────────────────────────────────────────────────────
122
+ def read_lock() -> dict:
123
+ path = lock_path()
124
+ if not path.exists():
125
+ return {}
126
+ try:
127
+ return json.loads(path.read_text(encoding="utf-8"))
128
+ except json.JSONDecodeError:
129
+ return {}
130
+
131
+
132
+ def write_lock(data: dict) -> None:
133
+ lock_path().write_text(json.dumps(data, indent=2), encoding="utf-8")
134
+
135
+
136
+ def is_configured() -> bool:
137
+ return env_path().exists()
datn_cli/doctor.py ADDED
@@ -0,0 +1,223 @@
1
+ """`datn doctor` — kiểm tra môi trường trước khi `up`.
2
+
3
+ Thứ tự: dừng ở lỗi blocking, tiếp tục với warning. Tự khởi động Docker nếu
4
+ daemon chưa chạy (Mac/Linux/Windows).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import platform
9
+ import shutil
10
+ import socket
11
+ import subprocess
12
+ import time
13
+
14
+ from rich.console import Console
15
+
16
+ from . import config as cfg
17
+
18
+ console = Console()
19
+
20
+ _SYSTEM = platform.system() # "Linux" | "Darwin" | "Windows"
21
+
22
+
23
+ # ── Docker daemon ────────────────────────────────────────────────────────────
24
+ def docker_ready() -> bool:
25
+ try:
26
+ r = subprocess.run(
27
+ ["docker", "info"],
28
+ capture_output=True,
29
+ timeout=10,
30
+ )
31
+ return r.returncode == 0
32
+ except (FileNotFoundError, subprocess.TimeoutExpired):
33
+ return False
34
+
35
+
36
+ def docker_installed() -> bool:
37
+ return shutil.which("docker") is not None
38
+
39
+
40
+ def _start_docker_daemon() -> None:
41
+ """Khởi động Docker theo platform (không chờ — caller poll riêng)."""
42
+ try:
43
+ if _SYSTEM == "Darwin":
44
+ subprocess.Popen(["open", "-a", "Docker"])
45
+ elif _SYSTEM == "Linux":
46
+ # sudo -n: non-interactive. Nếu cần password mà không có TTY → fail ngay
47
+ # thay vì treo 30s. Khi đó in hướng dẫn thủ công.
48
+ r = subprocess.run(
49
+ ["sudo", "-n", "systemctl", "start", "docker"],
50
+ capture_output=True, timeout=15,
51
+ )
52
+ if r.returncode != 0:
53
+ console.print(
54
+ " [yellow]Không tự start được (cần quyền). Chạy tay:[/yellow] "
55
+ "[cyan]sudo systemctl start docker[/cyan]"
56
+ )
57
+ elif _SYSTEM == "Windows":
58
+ subprocess.Popen(
59
+ ['C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe'],
60
+ shell=True,
61
+ )
62
+ except Exception:
63
+ pass
64
+
65
+
66
+ def ensure_docker(auto_start: bool = True) -> bool:
67
+ """Đảm bảo Docker chạy. Tự start + chờ tối đa 60s nếu cần."""
68
+ if docker_ready():
69
+ return True
70
+ if not docker_installed():
71
+ console.print("[bold red]✗ Docker chưa cài.[/bold red]")
72
+ if _SYSTEM == "Linux":
73
+ console.print(" Cài: [cyan]curl -fsSL https://get.docker.com | sh[/cyan]")
74
+ else:
75
+ console.print(" Tải Docker Desktop: [cyan]https://www.docker.com/products/docker-desktop/[/cyan]")
76
+ return False
77
+ if not auto_start:
78
+ console.print("[red]✗ Docker chưa chạy.[/red]")
79
+ return False
80
+
81
+ console.print("[yellow]⚠ Docker chưa chạy — đang khởi động...[/yellow]")
82
+ _start_docker_daemon()
83
+ for _ in range(30): # 30 × 2s = 60s
84
+ if docker_ready():
85
+ console.print("[green]✓ Docker sẵn sàng[/green]")
86
+ return True
87
+ time.sleep(2)
88
+ console.print("[red]✗ Docker không khởi động được sau 60s. Mở Docker Desktop thủ công.[/red]")
89
+ return False
90
+
91
+
92
+ # ── Linux docker group ───────────────────────────────────────────────────────
93
+ def _check_docker_group() -> tuple[bool, str]:
94
+ if _SYSTEM != "Linux":
95
+ return True, ""
96
+ try:
97
+ groups = subprocess.run(["id", "-nG"], capture_output=True, text=True, timeout=5).stdout
98
+ if "docker" in groups.split():
99
+ return True, ""
100
+ return False, (
101
+ "User chưa thuộc nhóm 'docker'. Chạy:\n"
102
+ " [cyan]sudo usermod -aG docker $USER[/cyan]\n"
103
+ " rồi [bold]đăng xuất + đăng nhập lại[/bold] (group mới có hiệu lực)."
104
+ )
105
+ except Exception:
106
+ return True, ""
107
+
108
+
109
+ # ── Disk / RAM ───────────────────────────────────────────────────────────────
110
+ def _check_disk() -> tuple[bool, str]:
111
+ try:
112
+ free_gb = shutil.disk_usage("/").free / (1024 ** 3)
113
+ if free_gb < 5:
114
+ return False, f"Chỉ còn {free_gb:.1f}GB trống (cần ~5GB cho images)."
115
+ return True, ""
116
+ except Exception:
117
+ return True, ""
118
+
119
+
120
+ def _check_ram() -> tuple[bool, str]:
121
+ # Đọc /proc/meminfo trên Linux; bỏ qua trên Mac/Windows (không có psutil dep).
122
+ if _SYSTEM != "Linux":
123
+ return True, ""
124
+ try:
125
+ with open("/proc/meminfo") as f:
126
+ for line in f:
127
+ if line.startswith("MemAvailable:"):
128
+ kb = int(line.split()[1])
129
+ gb = kb / (1024 ** 2)
130
+ if gb < 2:
131
+ return False, f"RAM khả dụng {gb:.1f}GB (<2GB) — Qdrant+Postgres có thể nặng."
132
+ return True, ""
133
+ except Exception:
134
+ pass
135
+ return True, ""
136
+
137
+
138
+ # ── Ports ────────────────────────────────────────────────────────────────────
139
+ def _port_in_use(port: int) -> bool:
140
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
141
+ s.settimeout(0.5)
142
+ return s.connect_ex(("127.0.0.1", port)) == 0
143
+
144
+
145
+ def _check_ports() -> tuple[bool, str]:
146
+ busy = [name + f":{p}" for name, p in cfg.PORTS.items() if _port_in_use(p)]
147
+ if busy:
148
+ return False, "Cổng đang bận: " + ", ".join(busy) + " (đóng app chiếm cổng hoặc đổi)."
149
+ return True, ""
150
+
151
+
152
+ # ── .env / dim ───────────────────────────────────────────────────────────────
153
+ def _check_env() -> tuple[bool, str]:
154
+ if not cfg.env_path().exists():
155
+ return False, "Chưa có ~/.datn/.env. Chạy [bold]datn init[/bold] trước."
156
+ data = cfg.read_env()
157
+ missing = [k for k in cfg.REQUIRED_ENV_FIELDS if not data.get(k)]
158
+ if missing:
159
+ return False, "Thiếu field bắt buộc: " + ", ".join(missing) + ". Chạy [bold]datn init[/bold]."
160
+ return True, ""
161
+
162
+
163
+ def _check_dim_lock() -> tuple[bool, str]:
164
+ data = cfg.read_env()
165
+ lock = cfg.read_lock()
166
+ env_dim = data.get("EMBEDDING_DIM")
167
+ lock_dim = lock.get("embedding_dim")
168
+ if env_dim and lock_dim is not None and str(lock_dim) != str(env_dim):
169
+ return False, (
170
+ f"embedding_dim lệch: .env={env_dim} vs lock={lock_dim}. "
171
+ "Chạy [bold]datn reset[/bold] rồi [bold]datn init[/bold]."
172
+ )
173
+ return True, ""
174
+
175
+
176
+ def _check_wsl() -> tuple[bool, str]:
177
+ if _SYSTEM != "Windows":
178
+ return True, ""
179
+ try:
180
+ r = subprocess.run(["wsl", "--status"], capture_output=True, timeout=10)
181
+ if r.returncode != 0:
182
+ return False, "WSL2 chưa sẵn sàng. Chạy [cyan]wsl --install[/cyan] (cần reboot)."
183
+ return True, ""
184
+ except Exception:
185
+ return False, "Không kiểm tra được WSL. Docker Desktop cần WSL2."
186
+
187
+
188
+ # ── Orchestration ────────────────────────────────────────────────────────────
189
+ def run_doctor(require_config: bool = True, auto_start_docker: bool = True) -> bool:
190
+ """Chạy toàn bộ check. Trả True nếu không có lỗi blocking."""
191
+ console.print("[bold]=== datn doctor ===[/bold]")
192
+ ok = True
193
+
194
+ # 1. Docker (blocking, có auto-start)
195
+ if ensure_docker(auto_start=auto_start_docker):
196
+ console.print("[green]✓[/green] Docker daemon")
197
+ else:
198
+ return False # không có Docker thì các check sau vô nghĩa
199
+
200
+ # 2-N: warning/blocking checks
201
+ checks = [
202
+ ("docker group", _check_docker_group(), True),
203
+ ("disk space", _check_disk(), False),
204
+ ("RAM", _check_ram(), False),
205
+ ("ports", _check_ports(), False),
206
+ ("WSL2", _check_wsl(), True),
207
+ ]
208
+ if require_config:
209
+ checks += [
210
+ ("config (.env)", _check_env(), True),
211
+ ("embedding dim lock", _check_dim_lock(), True),
212
+ ]
213
+
214
+ for name, (passed, msg), blocking in checks:
215
+ if passed:
216
+ console.print(f"[green]✓[/green] {name}")
217
+ elif blocking:
218
+ console.print(f"[bold red]✗[/bold red] {name}: {msg}")
219
+ ok = False
220
+ else:
221
+ console.print(f"[yellow]⚠[/yellow] {name}: {msg}")
222
+
223
+ return ok
@@ -0,0 +1,88 @@
1
+ """Xác định embedding dimension.
2
+
3
+ - Provider có model phổ biến (openai/gemini/openrouter) → tra bảng tĩnh.
4
+ - selfhost → gửi 1 test embed call (OpenAI schema) đo len vector thực tế.
5
+
6
+ KHÔNG import backend code (CLI là package độc lập). Bảng dim được nhân bản
7
+ từ infrastructure/model.py:get_embedding_dimension — giữ đồng bộ thủ công.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import httpx
12
+
13
+ # Mirror của infrastructure/model.py — cập nhật cùng lúc nếu backend đổi.
14
+ _DIMENSION_MAP = {
15
+ "text-embedding-3-small": 1536,
16
+ "text-embedding-3-large": 3072,
17
+ "text-embedding-ada-002": 1536,
18
+ "google/gemini-embedding-001": 3072,
19
+ "embedding-001": 3072,
20
+ "openai/text-embedding-3-small": 1536,
21
+ "openai/text-embedding-3-large": 3072,
22
+ "openai/text-embedding-ada-002": 1536,
23
+ }
24
+
25
+
26
+ class DimensionDetectError(RuntimeError):
27
+ """Không xác định được dim (endpoint sai / key sai / schema lạ)."""
28
+
29
+
30
+ def known_dimension(model: str) -> int | None:
31
+ """Tra dim từ bảng tĩnh. None nếu không biết (vd model selfhost lạ)."""
32
+ if model in _DIMENSION_MAP:
33
+ return _DIMENSION_MAP[model]
34
+ for key, dim in _DIMENSION_MAP.items():
35
+ if key in model:
36
+ return dim
37
+ return None
38
+
39
+
40
+ def detect_selfhost_dimension(base_url: str, model: str, api_key: str = "") -> int:
41
+ """Gửi test embed call tới endpoint OpenAI-compatible, đo dim.
42
+
43
+ Raise DimensionDetectError với thông điệp rõ ràng nếu thất bại.
44
+ """
45
+ url = base_url.rstrip("/") + "/embeddings"
46
+ headers = {"Content-Type": "application/json"}
47
+ if api_key:
48
+ headers["Authorization"] = f"Bearer {api_key}"
49
+
50
+ try:
51
+ resp = httpx.post(
52
+ url,
53
+ headers=headers,
54
+ json={"model": model, "input": "test"},
55
+ timeout=30.0,
56
+ )
57
+ except httpx.ConnectError as e:
58
+ raise DimensionDetectError(
59
+ f"Không kết nối được tới {url}. Kiểm tra base_url + endpoint đang chạy.\n ({e})"
60
+ )
61
+ except httpx.TimeoutException:
62
+ raise DimensionDetectError(f"Timeout khi gọi {url} (30s). Endpoint phản hồi quá chậm?")
63
+
64
+ if resp.status_code == 401:
65
+ raise DimensionDetectError(f"401 Unauthorized — sai API key cho {url}.")
66
+ if resp.status_code == 404:
67
+ raise DimensionDetectError(
68
+ f"404 Not Found — {url} không tồn tại. base_url có đúng dạng "
69
+ f"'http://host:port/v1' không?"
70
+ )
71
+ if resp.status_code >= 400:
72
+ raise DimensionDetectError(
73
+ f"HTTP {resp.status_code} từ {url}: {resp.text[:200]}"
74
+ )
75
+
76
+ try:
77
+ data = resp.json()
78
+ embedding = data["data"][0]["embedding"]
79
+ except (ValueError, KeyError, IndexError, TypeError):
80
+ raise DimensionDetectError(
81
+ f"Response không đúng OpenAI schema (cần data[0].embedding). "
82
+ f"Nhận: {resp.text[:200]}"
83
+ )
84
+
85
+ if not isinstance(embedding, list) or not embedding:
86
+ raise DimensionDetectError("embedding rỗng hoặc không phải list.")
87
+
88
+ return len(embedding)
@@ -0,0 +1,124 @@
1
+ # Generated by datn-cli — KHÔNG sửa tay (chạy `datn config`/`datn update` để đổi).
2
+ # Compose dist: pull images từ Docker Hub, KHÔNG bind-mount, KHÔNG --reload.
3
+ services:
4
+ agent-api:
5
+ image: {{ backend_image }}:{{ image_tag }}
6
+ container_name: datn-agent-api
7
+ ports:
8
+ - "5000:8000"
9
+ command: uvicorn api:app --host 0.0.0.0 --port 8000
10
+ environment:
11
+ POSTGRES_HOST: postgres
12
+ POSTGRES_PORT: "5432"
13
+ POSTGRES_USER: postgres
14
+ POSTGRES_PASSWORD: postgres
15
+ POSTGRES_DB: chat_history
16
+ QDRANT_HOST: qdrant
17
+ QDRANT_PORT: "6333"
18
+ MINIO_HOST: minio
19
+ MINIO_PORT: "9000"
20
+ MINIO_ACCESS_KEY: minioadmin
21
+ MINIO_SECRET_KEY: minioadmin
22
+ STORAGE_BUCKET: documents
23
+ COLLECTION_NAME: documents
24
+ MCP_SERVER_URL: http://mcp-server:8000/sse
25
+ env_file:
26
+ - {{ env_file }}
27
+ {%- if needs_host_gateway %}
28
+ extra_hosts:
29
+ - "host.docker.internal:host-gateway"
30
+ {%- endif %}
31
+ depends_on:
32
+ postgres:
33
+ condition: service_healthy
34
+ qdrant:
35
+ condition: service_started
36
+ minio:
37
+ condition: service_started
38
+ restart: always
39
+
40
+ mcp-server:
41
+ image: {{ backend_image }}:{{ image_tag }}
42
+ container_name: datn-mcp-server
43
+ ports:
44
+ - "8000:8000"
45
+ command: python -m mcp_server.mcp_server
46
+ environment:
47
+ POSTGRES_HOST: postgres
48
+ POSTGRES_PORT: "5432"
49
+ POSTGRES_USER: postgres
50
+ POSTGRES_PASSWORD: postgres
51
+ POSTGRES_DB: chat_history
52
+ QDRANT_HOST: qdrant
53
+ QDRANT_PORT: "6333"
54
+ MINIO_HOST: minio
55
+ MINIO_PORT: "9000"
56
+ MINIO_ACCESS_KEY: minioadmin
57
+ MINIO_SECRET_KEY: minioadmin
58
+ STORAGE_BUCKET: documents
59
+ COLLECTION_NAME: documents
60
+ env_file:
61
+ - {{ env_file }}
62
+ {%- if needs_host_gateway %}
63
+ extra_hosts:
64
+ - "host.docker.internal:host-gateway"
65
+ {%- endif %}
66
+ depends_on:
67
+ postgres:
68
+ condition: service_healthy
69
+ restart: always
70
+
71
+ frontend:
72
+ image: {{ frontend_image }}:{{ image_tag }}
73
+ container_name: datn-frontend
74
+ ports:
75
+ - "5173:80"
76
+ depends_on:
77
+ - agent-api
78
+ restart: always
79
+
80
+ postgres:
81
+ image: postgres:16-alpine
82
+ container_name: datn-postgres
83
+ ports:
84
+ - "5432:5432"
85
+ environment:
86
+ POSTGRES_USER: postgres
87
+ POSTGRES_PASSWORD: postgres
88
+ POSTGRES_DB: chat_history
89
+ volumes:
90
+ - datn_postgres_data:/var/lib/postgresql/data
91
+ healthcheck:
92
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
93
+ interval: 5s
94
+ timeout: 5s
95
+ retries: 5
96
+ restart: always
97
+
98
+ qdrant:
99
+ image: qdrant/qdrant:v1.15.1
100
+ container_name: datn-qdrant
101
+ ports:
102
+ - "6333:6333"
103
+ volumes:
104
+ - datn_qdrant_data:/qdrant/storage
105
+ restart: always
106
+
107
+ minio:
108
+ image: quay.io/minio/minio
109
+ container_name: datn-minio
110
+ ports:
111
+ - "9000:9000"
112
+ - "9001:9001"
113
+ environment:
114
+ MINIO_ROOT_USER: minioadmin
115
+ MINIO_ROOT_PASSWORD: minioadmin
116
+ command: server /data --console-address ":9001"
117
+ volumes:
118
+ - datn_minio_data:/data
119
+ restart: always
120
+
121
+ volumes:
122
+ datn_postgres_data:
123
+ datn_qdrant_data:
124
+ datn_minio_data:
datn_cli/wizard.py ADDED
@@ -0,0 +1,190 @@
1
+ """Wizard `datn init` + reconfigure `datn config`.
2
+
3
+ Ghi ~/.datn/.env (chmod 600) + provider.lock. Embedding dim tự detect — KHÔNG
4
+ hỏi user nhập tay. localhost trong selfhost URL được auto-replace để dùng được
5
+ từ trong container.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from rich.console import Console
12
+ from rich.prompt import Confirm, Prompt
13
+
14
+ from . import config as cfg
15
+ from .embed_detect import (
16
+ DimensionDetectError,
17
+ detect_selfhost_dimension,
18
+ known_dimension,
19
+ )
20
+
21
+ console = Console()
22
+
23
+ LLM_PROVIDERS = ["openai", "openrouter", "gemini", "selfhost"]
24
+ EMBEDDING_PROVIDERS = ["openai", "gemini", "openrouter", "selfhost"]
25
+
26
+ # Gợi ý model mặc định theo provider
27
+ _LLM_DEFAULTS = {
28
+ "openai": "gpt-4o-mini",
29
+ "openrouter": "openai/gpt-4o-mini",
30
+ "gemini": "gemini-2.5-flash",
31
+ "selfhost": "",
32
+ }
33
+ _EMBEDDING_DEFAULTS = {
34
+ "openai": "text-embedding-3-small",
35
+ "gemini": "google/gemini-embedding-001",
36
+ "openrouter": "google/gemini-embedding-001",
37
+ "selfhost": "",
38
+ }
39
+
40
+
41
+ def _fix_localhost(url: str) -> str:
42
+ """localhost/127.0.0.1 → host.docker.internal (để container gọi được host)."""
43
+ if not url:
44
+ return url
45
+ new = re.sub(r"\b(localhost|127\.0\.0\.1)\b", "host.docker.internal", url)
46
+ if new != url:
47
+ console.print(
48
+ f" [yellow]↪ Đổi '{url}' → '{new}' (để chạy được từ trong container)[/yellow]"
49
+ )
50
+ return new
51
+
52
+
53
+ # ── LLM section ──────────────────────────────────────────────────────────────
54
+ def prompt_llm() -> dict[str, str]:
55
+ console.print("\n[bold cyan]── LLM Provider ──[/bold cyan]")
56
+ provider = Prompt.ask(" Provider", choices=LLM_PROVIDERS, default="openai")
57
+
58
+ if provider == "selfhost":
59
+ base_url = ""
60
+ while not base_url:
61
+ base_url = _fix_localhost(Prompt.ask(" Base URL (vd http://host:8000/v1)").strip())
62
+ if not base_url:
63
+ console.print(" [red]Base URL bắt buộc cho selfhost.[/red]")
64
+ api_key = Prompt.ask(" API Key (Enter nếu không cần)", default="", show_default=False)
65
+ model = ""
66
+ while not model:
67
+ model = Prompt.ask(" Model").strip()
68
+ # Model reasoning (Qwen/VNPT/DeepSeek-distill) cần tắt thinking để xuất
69
+ # đúng XML structured — nếu không, chuỗi suy luận rò vào output làm hỏng flow.
70
+ disable_thinking = Confirm.ask(
71
+ " Model dạng reasoning (Qwen/VNPT...) — tắt chế độ thinking?", default=False
72
+ )
73
+ else:
74
+ base_url = ""
75
+ api_key = Prompt.ask(" API Key", password=True)
76
+ model = Prompt.ask(" Model", default=_LLM_DEFAULTS[provider])
77
+ disable_thinking = False
78
+
79
+ return {
80
+ "LLM_PROVIDER": provider,
81
+ "LLM_API_KEY": api_key,
82
+ "LLM_MODEL": model,
83
+ "LLM_BASE_URL": base_url,
84
+ "LLM_DISABLE_THINKING": "true" if disable_thinking else "",
85
+ }
86
+
87
+
88
+ # ── Embedding section (dim auto-detect) ──────────────────────────────────────
89
+ def prompt_embedding() -> dict[str, str]:
90
+ console.print("\n[bold cyan]── Embedding Provider ──[/bold cyan]")
91
+ provider = Prompt.ask(" Provider", choices=EMBEDDING_PROVIDERS, default="openai")
92
+
93
+ if provider == "selfhost":
94
+ base_url = _fix_localhost(Prompt.ask(" Base URL (vd http://host:8080/v1)"))
95
+ api_key = Prompt.ask(" API Key (Enter nếu không cần)", default="", show_default=False)
96
+ model = Prompt.ask(" Model")
97
+ console.print(" [dim]→ Đang kiểm tra endpoint + đo dimension...[/dim]")
98
+ dim = detect_selfhost_dimension(base_url, model, api_key) # raise nếu lỗi
99
+ console.print(f" [green]✓ Kết nối OK — dim = {dim}[/green]")
100
+ else:
101
+ base_url = ""
102
+ api_key = Prompt.ask(" API Key", password=True)
103
+ model = Prompt.ask(" Model", default=_EMBEDDING_DEFAULTS[provider])
104
+ dim = known_dimension(model)
105
+ if dim is None:
106
+ console.print(
107
+ f" [yellow]⚠ Không rõ dim của '{model}', mặc định 1536. "
108
+ f"Nếu sai, embedding sẽ vỡ collection.[/yellow]"
109
+ )
110
+ dim = 1536
111
+ else:
112
+ console.print(f" [green]✓ dim = {dim}[/green]")
113
+
114
+ return {
115
+ "EMBEDDING_PROVIDER": provider,
116
+ "EMBEDDING_API_KEY": api_key,
117
+ "EMBEDDING_MODEL": model,
118
+ "EMBEDDING_BASE_URL": base_url,
119
+ "EMBEDDING_DIM": str(dim),
120
+ }
121
+
122
+
123
+ # ── Optional sub-agents ──────────────────────────────────────────────────────
124
+ def prompt_optional() -> dict[str, str]:
125
+ # Tavily/SerpApi/Unsplash đã bake sẵn trong image (chủ dự án chia sẻ) → user
126
+ # KHÔNG cần nhập. Không ghi vào .env (rỗng sẽ override key bake). Ai muốn dùng
127
+ # key riêng: `datn config set TAVILY_API_KEY <key>`.
128
+ console.print(
129
+ "\n[dim]News/Travel/Slide đã có key dùng chung sẵn — bỏ qua. "
130
+ "Dùng key riêng? `datn config set TAVILY_API_KEY <key>`.[/dim]"
131
+ )
132
+ return {}
133
+
134
+
135
+ # ── Orchestration ────────────────────────────────────────────────────────────
136
+ def run_init(image_tag: str, volumes_exist: bool) -> bool:
137
+ """Chạy wizard đầy đủ. Trả False nếu user huỷ / bị guard chặn."""
138
+ console.print("[bold]=== datn init ===[/bold]")
139
+
140
+ if cfg.is_configured():
141
+ if not Confirm.ask(
142
+ "[yellow]Config đã tồn tại (~/.datn/.env). Ghi đè?[/yellow]", default=False
143
+ ):
144
+ console.print("Đã huỷ.")
145
+ return False
146
+
147
+ old_lock = cfg.read_lock()
148
+
149
+ try:
150
+ llm = prompt_llm()
151
+ embedding = prompt_embedding()
152
+ except DimensionDetectError as e:
153
+ console.print(f"\n[bold red]✗ Lỗi cấu hình embedding:[/bold red]\n {e}")
154
+ console.print("[red]Không ghi .env. Sửa lại endpoint/key rồi chạy `datn init` lại.[/red]")
155
+ return False
156
+
157
+ optional = prompt_optional()
158
+
159
+ new_dim = int(embedding["EMBEDDING_DIM"])
160
+ old_dim = old_lock.get("embedding_dim")
161
+
162
+ # Guard đổi dim: dim mới ≠ dim cũ VÀ volumes đã có data → chặn, bắt reset
163
+ if old_dim is not None and old_dim != new_dim and volumes_exist:
164
+ console.print(
165
+ f"\n[bold red]✗ Đổi embedding dimension {old_dim} → {new_dim} "
166
+ f"sẽ vỡ Qdrant collection![/bold red]"
167
+ )
168
+ console.print(
169
+ "[red]Data RAG hiện tại dùng dim cũ. Chạy [bold]datn reset[/bold] "
170
+ "(xoá data) trước, rồi [bold]datn init[/bold] lại.[/red]"
171
+ )
172
+ return False
173
+
174
+ env_data = {**llm, **embedding, **optional}
175
+ cfg.write_env(env_data)
176
+ cfg.write_lock({
177
+ "llm_provider": llm["LLM_PROVIDER"],
178
+ "embedding_provider": embedding["EMBEDDING_PROVIDER"],
179
+ "embedding_dim": new_dim,
180
+ "image_tag": image_tag,
181
+ })
182
+
183
+ console.print(f"\n[green]✓ Đã ghi {cfg.env_path()} (chmod 600)[/green]")
184
+ console.print(f"[green]✓ Đã ghi {cfg.lock_path()}[/green]")
185
+ console.print(
186
+ "\n[dim]💡 Gmail/Calendar: cấu hình trong Settings sau khi `datn up` "
187
+ "(upload client_secret.json + Authorize).[/dim]"
188
+ )
189
+ console.print("\n[bold]Tiếp theo:[/bold] datn up")
190
+ return True
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: datn-cli
3
+ Version: 0.2.2
4
+ Summary: One-command distribution for the datn multi-agent assistant (hides docker compose).
5
+ Author: ngoquan0904
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ngoquan0904/datn-cli
8
+ Keywords: cli,docker,compose,ai-assistant,distribution
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: typer>=0.12
12
+ Requires-Dist: rich>=13.0
13
+ Requires-Dist: httpx>=0.27
14
+ Requires-Dist: jinja2>=3.1
15
+
16
+ # datn-cli
17
+
18
+ Cài hệ trợ lý đa tác tử **datn** bằng 1 lệnh — CLI ẩn docker compose. Không cần source code.
19
+
20
+ ## Cài đặt (1 lệnh)
21
+
22
+ **macOS / Linux:**
23
+ ```bash
24
+ curl -fsSL https://raw.githubusercontent.com/ngoquan0904/datn-cli/main/install.sh | sh
25
+ ```
26
+
27
+ **Windows (PowerShell):**
28
+ ```powershell
29
+ irm https://raw.githubusercontent.com/ngoquan0904/datn-cli/main/install.ps1 | iex
30
+ ```
31
+
32
+ Script tự cài: Python 3.9+, pipx, Docker (Linux) / WSL2+Docker Desktop (Windows), rồi `datn-cli`.
33
+
34
+ ## Chạy
35
+
36
+ ```bash
37
+ datn init # nhập API key cho LLM + embedding (chọn provider)
38
+ datn up # pull images + khởi động + chờ healthy (lần đầu mất vài phút)
39
+ datn open # mở http://localhost:5173
40
+ ```
41
+
42
+ Trong `datn init` bạn chỉ cần **nhập API key**. Embedding dimension tự dò.
43
+ Gmail/Calendar cấu hình sau trong **Settings** của web UI (upload `client_secret.json`).
44
+
45
+ ## Lệnh hay dùng
46
+
47
+ | Lệnh | Việc |
48
+ |---|---|
49
+ | `datn doctor` | Kiểm tra Docker, ports, cấu hình |
50
+ | `datn logs agent-api` | Xem log 1 service |
51
+ | `datn down` | Dừng (giữ data) |
52
+ | `datn config llm` | Đổi LLM provider/model/key |
53
+ | `datn config embedding` | Đổi embedding (đổi dim → cần `datn reset`) |
54
+ | `datn update --tag vX.Y.Z` | Cập nhật phiên bản image |
55
+ | `datn reset` | Xoá data (RAG + chat history) |
56
+ | `datn uninstall` | Gỡ data + cấu hình |
57
+
58
+ ## Self-host LLM/Embedding
59
+
60
+ Chọn provider `selfhost` trong `datn init`, nhập `base_url` (OpenAI-compatible, vd vLLM/llama.cpp/Ollama).
61
+ `localhost`/`127.0.0.1` tự đổi sang `host.docker.internal` để container gọi được host.
62
+
63
+ ## Troubleshooting
64
+
65
+ | Triệu chứng | Cách xử lý |
66
+ |---|---|
67
+ | **Docker chưa cài** (macOS) | Tải Docker Desktop: https://www.docker.com/products/docker-desktop/ — script không tự cài được file .dmg |
68
+ | **`permission denied` Docker (Linux)** | Đăng xuất + đăng nhập lại 1 lần (đã thêm bạn vào nhóm `docker`). Kiểm tra: `datn doctor` |
69
+ | **WSL2 chưa có (Windows)** | Chạy lại `install.ps1` sau khi **khởi động lại máy** (script đã chạy `wsl --install`) |
70
+ | **Port bận** (5000/8000/5173/5432/6333/9000) | `datn doctor` báo port + tiến trình. Đóng app chiếm port hoặc dừng stack cũ |
71
+ | **Đĩa đầy khi pull** | Cần ~5GB trống cho images. Dọn ổ rồi `datn up` lại |
72
+ | **Đổi embedding → lỗi dim** | `datn reset` (xoá data) → `datn init` → `datn up`. Đổi dim làm vỡ Qdrant collection |
73
+ | **News/Travel báo "chưa cấu hình"** | Thiếu Tavily/SerpApi key — chạy `datn config tavily` / `datn config serpapi` (optional) |
74
+ | **Image pull lỗi / tag không tồn tại** | Kiểm tra tag trong `~/.datn/provider.lock`; thử `datn update --tag latest` |
75
+
76
+ ## Yêu cầu hệ thống
77
+ - RAM ≥ 4GB (Qdrant + Postgres + backend).
78
+ - Đĩa trống ≥ 5GB (images ~2-4GB).
79
+ - Kết nối Internet (pull images + gọi LLM API).
@@ -0,0 +1,13 @@
1
+ datn_cli/__init__.py,sha256=GQlHfc1MXNBzFbuhv7YG1b8fpb1imqfconHH4Lwj9r8,98
2
+ datn_cli/__main__.py,sha256=LqTaFVA_VlN__pEvm9Gq9T-lriqp0J43jNWZZZwgiC4,9842
3
+ datn_cli/compose.py,sha256=0Cc7IUyVh473SRgcB7Z4rWsA6y4TqC9ssi6s4_fkYpk,5234
4
+ datn_cli/config.py,sha256=MM2E85RLevqyoiUpirOvd6O7RmAn5ctQfqbb5WLA9iM,4257
5
+ datn_cli/doctor.py,sha256=v53StkCufA8ivnSb7okXBRPUaJrQnDBnNVv6brPu46Y,8708
6
+ datn_cli/embed_detect.py,sha256=bIm_oPoO4LxEXlA2gUveAsfBUi6Aq3hwwGAWiqw14sI,3167
7
+ datn_cli/wizard.py,sha256=6utheFz00zN3ZroGUHFIsFoVN_yvSh1kQF9MXSfHhD4,7736
8
+ datn_cli/templates/docker-compose.dist.yml.j2,sha256=H8UULedNAslFQIHuBtu5ZaMUyBlAOIFHgLmkBrdHBOQ,3143
9
+ datn_cli-0.2.2.dist-info/METADATA,sha256=i1qBa60GynElLTt8DSnySaudkfie_ojQ6GihkNperQA,3440
10
+ datn_cli-0.2.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ datn_cli-0.2.2.dist-info/entry_points.txt,sha256=jrKnCK2Ta7w2AOHRYgJrwiGxLIGLOp3CW5MhiNkFHPM,47
12
+ datn_cli-0.2.2.dist-info/top_level.txt,sha256=g0FBgLb8HtVXr8ofSMjFiKDel5_ygI0Mk5jcWd6clP4,9
13
+ datn_cli-0.2.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ datn = datn_cli.__main__:app
@@ -0,0 +1 @@
1
+ datn_cli