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 +3 -0
- datn_cli/__main__.py +264 -0
- datn_cli/compose.py +139 -0
- datn_cli/config.py +137 -0
- datn_cli/doctor.py +223 -0
- datn_cli/embed_detect.py +88 -0
- datn_cli/templates/docker-compose.dist.yml.j2 +124 -0
- datn_cli/wizard.py +190 -0
- datn_cli-0.2.2.dist-info/METADATA +79 -0
- datn_cli-0.2.2.dist-info/RECORD +13 -0
- datn_cli-0.2.2.dist-info/WHEEL +5 -0
- datn_cli-0.2.2.dist-info/entry_points.txt +2 -0
- datn_cli-0.2.2.dist-info/top_level.txt +1 -0
datn_cli/__init__.py
ADDED
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
|
datn_cli/embed_detect.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
datn_cli
|