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.
- datn_cli-0.2.2/PKG-INFO +79 -0
- datn_cli-0.2.2/README.md +64 -0
- datn_cli-0.2.2/datn_cli/__init__.py +3 -0
- datn_cli-0.2.2/datn_cli/__main__.py +264 -0
- datn_cli-0.2.2/datn_cli/compose.py +139 -0
- datn_cli-0.2.2/datn_cli/config.py +137 -0
- datn_cli-0.2.2/datn_cli/doctor.py +223 -0
- datn_cli-0.2.2/datn_cli/embed_detect.py +88 -0
- datn_cli-0.2.2/datn_cli/templates/docker-compose.dist.yml.j2 +124 -0
- datn_cli-0.2.2/datn_cli/wizard.py +190 -0
- datn_cli-0.2.2/datn_cli.egg-info/PKG-INFO +79 -0
- datn_cli-0.2.2/datn_cli.egg-info/SOURCES.txt +16 -0
- datn_cli-0.2.2/datn_cli.egg-info/dependency_links.txt +1 -0
- datn_cli-0.2.2/datn_cli.egg-info/entry_points.txt +2 -0
- datn_cli-0.2.2/datn_cli.egg-info/requires.txt +4 -0
- datn_cli-0.2.2/datn_cli.egg-info/top_level.txt +1 -0
- datn_cli-0.2.2/pyproject.toml +33 -0
- datn_cli-0.2.2/setup.cfg +4 -0
datn_cli-0.2.2/PKG-INFO
ADDED
|
@@ -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).
|
datn_cli-0.2.2/README.md
ADDED
|
@@ -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,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()
|