Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 3 additions & 114 deletions scripts/build_web.py
Original file line number Diff line number Diff line change
@@ -1,124 +1,13 @@
from __future__ import annotations

import os
import shutil
import subprocess
import sys
import tomllib
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
WEB_DIR = ROOT / "web"
DIST_DIR = WEB_DIR / "dist"
NODE_MODULES = WEB_DIR / "node_modules"
STATIC_DIR = ROOT / "src" / "kimi_cli" / "web" / "static"

STRICT_VERSION = os.environ.get("KIMI_WEB_STRICT_VERSION", "").lower() in {"1", "true", "yes"}

REQUIRED_WEB_TYPE_FILES = (
NODE_MODULES / "vite" / "client.d.ts",
NODE_MODULES / "@types" / "node" / "index.d.ts",
)


def read_pyproject_version() -> str:
with (ROOT / "pyproject.toml").open("rb") as handle:
data = tomllib.load(handle)
return str(data["project"]["version"])


def find_version_in_dist(version: str) -> bool:
search_suffixes = {".js", ".css", ".html", ".map"}
version_with_prefix = f"v{version}"
found_plain = False

for path in DIST_DIR.rglob("*"):
if not path.is_file() or path.suffix not in search_suffixes:
continue
try:
content = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
if version_with_prefix in content:
return True
if version in content:
found_plain = True

return found_plain


def resolve_npm() -> str | None:
candidates = ["npm"]
if os.name == "nt":
candidates.extend(["npm.cmd", "npm.exe", "npm.bat"])
for candidate in candidates:
npm = shutil.which(candidate)
if npm:
return npm
return None


def run_npm(npm: str, args: list[str]) -> int:
try:
result = subprocess.run([npm, *args], check=False)
except FileNotFoundError:
print(
"npm not found or failed to execute. Install Node.js (npm) and ensure it is on PATH.",
file=sys.stderr,
)
return 1
return result.returncode


def has_required_web_type_files() -> bool:
return all(path.is_file() for path in REQUIRED_WEB_TYPE_FILES)
from kimi_cli.utils.web_build import build_web_ui


def main() -> int:
npm = resolve_npm()
if npm is None:
print("npm not found. Install Node.js (npm) to build the web UI.", file=sys.stderr)
return 1

expected_version = read_pyproject_version()
explicit_expected = os.environ.get("KIMI_WEB_EXPECT_VERSION")
if explicit_expected and explicit_expected != expected_version:
print(
f"web version mismatch: pyproject={expected_version}, expected={explicit_expected}",
file=sys.stderr,
)
return 1

needs_install = (not NODE_MODULES.exists()) or (not has_required_web_type_files())
if needs_install:
if NODE_MODULES.exists():
print("web dependencies are incomplete; reinstalling with devDependencies...")
returncode = run_npm(npm, ["--prefix", str(WEB_DIR), "ci", "--include=dev"])
if returncode != 0:
return returncode

returncode = run_npm(npm, ["--prefix", str(WEB_DIR), "run", "build"])
if returncode != 0:
return returncode

if not DIST_DIR.exists():
print("web/dist not found after build. Check the web build output.", file=sys.stderr)
return 1
if STRICT_VERSION and not find_version_in_dist(expected_version):
print(
f"web version not found in build output; expected version {expected_version}",
file=sys.stderr,
)
return 1

if STATIC_DIR.exists():
shutil.rmtree(STATIC_DIR)
STATIC_DIR.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(DIST_DIR, STATIC_DIR)

print(f"Synced web UI to {STATIC_DIR}")
return 0
return build_web_ui(Path(__file__).resolve().parents[1])


if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(main())
18 changes: 18 additions & 0 deletions src/kimi_cli/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,24 @@ def acp():
acp_main()


@cli.command(name="build-web")
def build_web(
root: Annotated[
Path,
typer.Option(
"--root",
help="Repository root containing web/ and pyproject.toml",
),
] = Path("."),
) -> None:
"""Build and sync the web UI into src/kimi_cli/web/static."""
from kimi_cli.utils.web_build import build_web_ui

code = build_web_ui(root.resolve())
if code != 0:
raise typer.Exit(code=code)


@cli.command(name="__web-worker", hidden=True)
def web_worker(session_id: str) -> None:
"""Run web worker subprocess (internal)."""
Expand Down
123 changes: 123 additions & 0 deletions src/kimi_cli/utils/web_build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
from __future__ import annotations

import os
import shutil
import subprocess
import sys
import tomllib
from pathlib import Path


def resolve_npm() -> str | None:
candidates = ["npm"]
if os.name == "nt":
candidates.extend(["npm.cmd", "npm.exe", "npm.bat"])
for candidate in candidates:
npm = shutil.which(candidate)
if npm:
return npm
return None


def run_npm(npm: str, args: list[str]) -> int:
try:
result = subprocess.run([npm, *args], check=False)
except FileNotFoundError:
print(
"npm not found or failed to execute. Install Node.js (npm) and ensure it is on PATH.",
file=sys.stderr,
)
return 1
return int(result.returncode)


def build_web_ui(root: Path) -> int:
web_dir = root / "web"
dist_dir = web_dir / "dist"
node_modules = web_dir / "node_modules"
static_dir = root / "src" / "kimi_cli" / "web" / "static"

strict_version = os.environ.get("KIMI_WEB_STRICT_VERSION", "").lower() in {
"1",
"true",
"yes",
}

required_web_type_files = (
node_modules / "vite" / "client.d.ts",
node_modules / "@types" / "node" / "index.d.ts",
)

npm = resolve_npm()
if npm is None:
print("npm not found. Install Node.js (npm) to build the web UI.", file=sys.stderr)
return 1

pyproject = root / "pyproject.toml"
if not pyproject.exists():
print(f"pyproject.toml not found under {root}", file=sys.stderr)
return 1

with pyproject.open("rb") as handle:
project_data = tomllib.load(handle)
expected_version = str(project_data["project"]["version"])

explicit_expected = os.environ.get("KIMI_WEB_EXPECT_VERSION")
if explicit_expected and explicit_expected != expected_version:
print(
f"web version mismatch: pyproject={expected_version}, expected={explicit_expected}",
file=sys.stderr,
)
return 1

def has_required_web_type_files() -> bool:
return all(path.is_file() for path in required_web_type_files)

needs_install = (not node_modules.exists()) or (not has_required_web_type_files())
if needs_install:
if node_modules.exists():
print("web dependencies are incomplete; reinstalling with devDependencies...")
returncode = run_npm(npm, ["--prefix", str(web_dir), "ci", "--include=dev"])
if returncode != 0:
return returncode

returncode = run_npm(npm, ["--prefix", str(web_dir), "run", "build"])
if returncode != 0:
return returncode

if not dist_dir.exists():
print("web/dist not found after build. Check the web build output.", file=sys.stderr)
return 1

def find_version_in_dist(version: str) -> bool:
search_suffixes = {".js", ".css", ".html", ".map"}
version_with_prefix = f"v{version}"
found_plain = False

for path in dist_dir.rglob("*"):
if not path.is_file() or path.suffix not in search_suffixes:
continue
try:
content = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
if version_with_prefix in content:
return True
if version in content:
found_plain = True
return found_plain

if strict_version and not find_version_in_dist(expected_version):
print(
f"web version not found in build output; expected version {expected_version}",
file=sys.stderr,
)
return 1

if static_dir.exists():
shutil.rmtree(static_dir)
static_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(dist_dir, static_dir)

print(f"Synced web UI to {static_dir}")
return 0
9 changes: 9 additions & 0 deletions src/kimi_cli/vis/api/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ def _scan_session_dir(
except Exception:
logger.debug("Skipped malformed line in %s", wire_path)
continue
if parsed is None:
logger.debug("Skipped malformed line in %s", wire_path)
continue
if isinstance(parsed, WireFileMetadata):
continue
if parsed.message.type == "TurnBegin":
Expand Down Expand Up @@ -227,6 +230,9 @@ async def get_wire_events(work_dir_hash: str, session_id: str) -> dict[str, Any]
except Exception:
logger.debug("Skipped malformed line in %s", wire_path)
continue
if parsed is None:
logger.debug("Skipped malformed line in %s", wire_path)
continue
if isinstance(parsed, WireFileMetadata):
continue
events.append(
Expand Down Expand Up @@ -339,6 +345,9 @@ async def get_session_summary(work_dir_hash: str, session_id: str) -> dict[str,
except Exception:
logger.debug("Skipped malformed line in %s", wire_path)
continue
if parsed is None:
logger.debug("Skipped malformed line in %s", wire_path)
continue
if isinstance(parsed, WireFileMetadata):
continue

Expand Down
2 changes: 2 additions & 0 deletions src/kimi_cli/vis/api/statistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ def get_statistics() -> dict[str, Any]:
parsed = parse_wire_file_line(line)
except Exception:
continue
if parsed is None:
continue
if isinstance(parsed, WireFileMetadata):
continue

Expand Down
15 changes: 7 additions & 8 deletions src/kimi_cli/wire/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,15 @@ def parse_wire_file_metadata(line: str) -> WireFileMetadata | None:
return None


def parse_wire_file_line(line: str) -> WireFileMetadata | WireMessageRecord:
def parse_wire_file_line(line: str) -> WireFileMetadata | WireMessageRecord | None:
"""Parse a wire file line into metadata or a message record."""
metadata = parse_wire_file_metadata(line)
if metadata is not None:
return metadata
return WireMessageRecord.model_validate_json(line)
try:
return WireMessageRecord.model_validate_json(line)
except (ValidationError, ValueError):
return None


@dataclass(slots=True)
Expand Down Expand Up @@ -101,12 +104,8 @@ async def iter_records(self) -> AsyncIterator[WireMessageRecord]:
line = line.strip()
if not line:
continue
try:
parsed = parse_wire_file_line(line)
except Exception:
logger.exception(
"Failed to parse line in wire file {file}:", file=self.path
)
parsed = parse_wire_file_line(line)
if parsed is None:
continue
if isinstance(parsed, WireFileMetadata):
continue
Expand Down