datn-cli 0.2.2__tar.gz

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.
@@ -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,64 @@
1
+ # datn-cli
2
+
3
+ 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.
4
+
5
+ ## Cài đặt (1 lệnh)
6
+
7
+ **macOS / Linux:**
8
+ ```bash
9
+ curl -fsSL https://raw.githubusercontent.com/ngoquan0904/datn-cli/main/install.sh | sh
10
+ ```
11
+
12
+ **Windows (PowerShell):**
13
+ ```powershell
14
+ irm https://raw.githubusercontent.com/ngoquan0904/datn-cli/main/install.ps1 | iex
15
+ ```
16
+
17
+ Script tự cài: Python 3.9+, pipx, Docker (Linux) / WSL2+Docker Desktop (Windows), rồi `datn-cli`.
18
+
19
+ ## Chạy
20
+
21
+ ```bash
22
+ datn init # nhập API key cho LLM + embedding (chọn provider)
23
+ datn up # pull images + khởi động + chờ healthy (lần đầu mất vài phút)
24
+ datn open # mở http://localhost:5173
25
+ ```
26
+
27
+ Trong `datn init` bạn chỉ cần **nhập API key**. Embedding dimension tự dò.
28
+ Gmail/Calendar cấu hình sau trong **Settings** của web UI (upload `client_secret.json`).
29
+
30
+ ## Lệnh hay dùng
31
+
32
+ | Lệnh | Việc |
33
+ |---|---|
34
+ | `datn doctor` | Kiểm tra Docker, ports, cấu hình |
35
+ | `datn logs agent-api` | Xem log 1 service |
36
+ | `datn down` | Dừng (giữ data) |
37
+ | `datn config llm` | Đổi LLM provider/model/key |
38
+ | `datn config embedding` | Đổi embedding (đổi dim → cần `datn reset`) |
39
+ | `datn update --tag vX.Y.Z` | Cập nhật phiên bản image |
40
+ | `datn reset` | Xoá data (RAG + chat history) |
41
+ | `datn uninstall` | Gỡ data + cấu hình |
42
+
43
+ ## Self-host LLM/Embedding
44
+
45
+ Chọn provider `selfhost` trong `datn init`, nhập `base_url` (OpenAI-compatible, vd vLLM/llama.cpp/Ollama).
46
+ `localhost`/`127.0.0.1` tự đổi sang `host.docker.internal` để container gọi được host.
47
+
48
+ ## Troubleshooting
49
+
50
+ | Triệu chứng | Cách xử lý |
51
+ |---|---|
52
+ | **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 |
53
+ | **`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` |
54
+ | **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`) |
55
+ | **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ũ |
56
+ | **Đĩa đầy khi pull** | Cần ~5GB trống cho images. Dọn ổ rồi `datn up` lại |
57
+ | **Đổi embedding → lỗi dim** | `datn reset` (xoá data) → `datn init` → `datn up`. Đổi dim làm vỡ Qdrant collection |
58
+ | **News/Travel báo "chưa cấu hình"** | Thiếu Tavily/SerpApi key — chạy `datn config tavily` / `datn config serpapi` (optional) |
59
+ | **Image pull lỗi / tag không tồn tại** | Kiểm tra tag trong `~/.datn/provider.lock`; thử `datn update --tag latest` |
60
+
61
+ ## Yêu cầu hệ thống
62
+ - RAM ≥ 4GB (Qdrant + Postgres + backend).
63
+ - Đĩa trống ≥ 5GB (images ~2-4GB).
64
+ - Kết nối Internet (pull images + gọi LLM API).
@@ -0,0 +1,3 @@
1
+ """datn-cli — one-command distribution wrapper hiding docker compose."""
2
+
3
+ __version__ = "0.2.2"
@@ -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()
@@ -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")
@@ -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()