A local-first private AI assistant for everyday use. Runs on-device models with encrypted P2P sync, and supports sharing chats publicly on ATProto.
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

wip: debuggin pi request error

fix: sending SSE events with typed events as openAI

fix: Update CLI model response rendering wrto new SSE response changes

fix: weabing more standards for model response so that its rendered on PI

- TODO: ofcourse Rust CLI repl rendering is broke, need to fix it next

wip: packaging pi

wip: packaging pi

feat: Calling pi from tiles as repl

- exposes a GET model_cache_path API for py server to load correct model

feat: added current model in config.toml + build script for pi

- having current model in config.toml allows pi to read it from there
and update its models.json

- build script for pi for DEV, so dev can run updated pi binary

feat: Added -p flag for PI repl + Daemon hot reloading

- PI repl will be under `-p` flag for now. `tiles -p` or `tiles run modelfile -p`
- FIX: On using different versions of Tiles, the bg daemon will also reload to the
correct version

feat: Able to hook into pi rpc , added /state command

- nextup steaming agent responses

madclaws 6d1dd6cf 4e036d96

+704 -198
+1 -1
Cargo.lock
··· 6550 6550 6551 6551 [[package]] 6552 6552 name = "tiles" 6553 - version = "0.4.7" 6553 + version = "0.4.8" 6554 6554 dependencies = [ 6555 6555 "anyhow", 6556 6556 "async-std",
+43
HACKING.md
··· 92 92 93 93 Now `tiles` should be available in PATH 94 94 95 + 96 + ### Development with PI 97 + 98 + [Pi](https://github.com/badlogic/pi-mono) is a minimal coding agent for agentic harness. Instead of providing harness by ourselves we will be leveraging Pi. 99 + 100 + 101 + Current approach on how we integrate Pi is, we pack the pi bun binary with the tiles installer and we switch to Pi repl from tiles cli, if harness is required. There are two ways we can do communicate with Pi, either via rpc mode or directly use the pi binary and get into the whole Pi ecosystem. 102 + 103 + For better maintainability and to be update with Pi, using rpc mode is the way. But as we are in experimental mode with Pi, for now we wont use rpc instead completely use Pi's repl and UI. So at this stage we use our own fork for tighter integration with Tiles system. But this can change later. So Pi will be available under a flag `tiles -p` or `tiles run -p <MODELFILE_PATH>` 104 + 105 + 106 + #### Setting up PI 107 + 108 + `git clone https://github.com/tilesprivacy/tiles-pi/tree/feat/integrate-w-tiles` 109 + 110 + `npm install` - for installing the deps 111 + 112 + ``` 113 + export TILES_PI_BUILD_ENV=debug # (other values: release) 114 + export TILES_PI_DEV_CONFIG_PATH=<TILES_REPO_PATH>/.tiles_dev/tiles 115 + ``` 116 + 117 + Set these env vars. `TILES_PI_BUILD_ENV` is used to find the correct config.toml file for Pi to read. tiles-pi rely on config.toml for user data directory, current model etc. At this point config.toml act as a shared memory for tiles-pi and tiles. For development we use `debug` value. If debug mode then it uses `TILES_PI_DEV_CONFIG_PATH`. So internally all the app-files, user-data etc are in a .tiles_dev folder at the root of project. Pi also creates it agent directory here under `.tiles_dev/tiles/data/pi/agent`. 118 + 119 + If mode is anything other than debug, then its release mode and the config.toml path is fixed, so need to worry about. Important thing to note is pi/agent directory will be in the tiles user data directory. 120 + 121 + To work with Pi we need to run Pi on a terminal and tiles inference py server on another, and tiles daemon shld also be running background. 122 + 123 + - Running Pi 124 + - From root of `tiles-pi` run `npm run build && ./pi-test.sh` 125 + - Running py server 126 + - From root of `tiles` run `just serve` 127 + - Running tiles daemon 128 + - First check if daemon is already running by `curl -X GET http://127.0.0.1:1729/`, if its returning tiles version, then daemon is running and its fine. 129 + 130 + - If above curl failed, then do `cargo run -- -x`. This will run tiles in non-repl mode, simultaneously running a deamon in background. 131 + 132 + Now these are running, u can jump into pi repl and do stuff with the model 133 + 134 + Later if we need to test the e2e integration in development, we need to build the tiles-pi binary and extract the artificats into `.tiles_dev/tiles/pi`. 135 + For that we can run `just build_w_pi`. 136 + 137 + 95 138 ## Additional Resources 96 139 97 140 - [Tiles Book](https://tiles.run/book)
+4
justfile
··· 32 32 ./pkg/build.sh 33 33 ./pkg/build_full.sh 34 34 35 + build_w_pi: 36 + ./scripts/build_with_pi_dev.sh 37 + 38 + 35 39 # runtiles: RUST_LOG=tiles=info,iroh=off cargo run
+1 -1
modelfiles/qwen
··· 1 1 FROM mlx-community/Qwen3.5-4B-MLX-4bit 2 - #FROM mlx-community/Qwen3.5-0.8B-8bit 2 + # FROM mlx-community/Qwen3.5-0.8B-8bit 3 3 # FROM mlx-community/Qwen3.5-0.8B-MLX-8bit 4 4 # FROM mlx-community/Qwen3-0.6B-4bit
pi-darwin-arm64.tar.gz

This is a binary file and will not be displayed.

+18
scripts/build_with_pi_dev.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + PI_TAR_DIR="/Users/tiles/Downloads" 5 + # cargo build 6 + 7 + # Build the pi binary 8 + 9 + # sh "${TILES_PI_DIR}/scripts/build-binaries.sh" --skip-deps --platform darwin-arm64 10 + 11 + rm -rf .tiles_dev/tiles/pi 12 + 13 + cp "${PI_TAR_DIR}/pi-darwin-arm64.tar.gz" ".tiles_dev/tiles/" 14 + 15 + cd .tiles_dev/tiles 16 + 17 + tar -xvf pi-darwin-arm64.tar.gz 18 +
+3
scripts/bundler.sh
··· 39 39 40 40 cp "${CLI_BIN_PATH}" "${DIST_DIR}/tmp/" 41 41 42 + # copying pi binary 43 + cp pi-darwin-arm64.tar.gz "${DIST_DIR}/tmp/" 44 + 42 45 # flushing this folder, else the final zip will have previous app-server zips too (#84) 43 46 rm -rf "${SERVER_DIR}/stack_export_prod" 44 47
+15
scripts/install.sh
··· 11 11 SERVER_DIR="/usr/local/share/tiles/server" # Python server folder 12 12 MODELFILE_DIR="/usr/local/share/tiles/modelfiles" # Modelfile server folder 13 13 14 + PI_DIR="/usr/local/share/tiles/pi" 15 + 14 16 TMPDIR="$(mktemp -d)" 15 17 OS=$(uname -s | tr '[:upper:]' '[:lower:]') 16 18 ARCH=$(uname -m) ··· 47 49 mkdir -p "${MODELFILE_DIR}" 48 50 49 51 cp -r "${TMPDIR}/modelfiles"/* "${MODELFILE_DIR}/" 52 + 53 + 54 + log "Installing PI artifacts ..." 55 + 56 + rm -rf "${PI_DIR}" 57 + 58 + mkdir -p "${PI_DIR}" 59 + 60 + cp -r "${TMPDIR}/pi-darwin-arm64.tar.gz" "${PI_DIR}/" 61 + 62 + tar -xvf "${PI_DIR}/pi-darwin-arm64.tar.gz" 63 + 64 + rm "${PI_DIR}/pi-darwin-arm64.tar.gz" 50 65 51 66 log "📦 Installing Python server to ${SERVER_DIR}..." 52 67 rm -rf "${SERVER_DIR}"
+12 -3
server/api.py
··· 2 2 import sys 3 3 from typing import Optional 4 4 5 - from fastapi import FastAPI, HTTPException 6 - from fastapi.responses import StreamingResponse 5 + from fastapi import FastAPI, HTTPException, Request 6 + from fastapi.responses import StreamingResponse, JSONResponse 7 + from fastapi.exceptions import RequestValidationError 7 8 from pydantic import BaseModel, Field 8 9 9 10 from . import runtime ··· 77 78 raise HTTPException(status_code=500, detail=str(e)) 78 79 79 80 81 + @app.exception_handler(RequestValidationError) 82 + async def validation_exception_handler(request: Request, exc: RequestValidationError): 83 + return JSONResponse( 84 + status_code=422, 85 + content={"detail": exc.errors()}, 86 + ) 87 + 88 + 80 89 @app.post("/v1/responses") 81 90 async def create_chat_response(request: ResponsesRequest): 82 91 """ ··· 87 96 return StreamingResponse( 88 97 runtime.backend.generate_response_chat_stream(request), 89 98 media_type="text/plain", 90 - headers={"Cache-Control": "no-cache"}, 99 + headers={"Cache-Control": "no-cache", "Content-Type": "text/event-stream"}, 91 100 ) 92 101 else: 93 102 return await runtime.backend.generate_response_chat(request)
+185 -49
server/backend/mlx.py
··· 4 4 import uuid 5 5 from collections.abc import AsyncGenerator 6 6 from pathlib import Path 7 - from fastapi import HTTPException 7 + from fastapi import HTTPException, requests 8 8 from openai_harmony import ( 9 9 Conversation, 10 10 DeveloperContent, ··· 13 13 Role, 14 14 SystemContent, 15 15 ) 16 - from openresponses_types import AssistantMessageItemParam, ReasoningEffortEnum 16 + from openresponses_types import ( 17 + AssistantMessageItemParam, 18 + ReasoningEffortEnum, 19 + SystemMessageItemParam, 20 + ) 17 21 from openresponses_types.types import ( 18 22 DeveloperMessageItemParam, 19 23 Error, ··· 33 37 ) 34 38 from .mlx_runner import MLXRunner 35 39 40 + import httpx 41 + 42 + client = httpx.AsyncClient() 43 + 36 44 logger = logging.getLogger("app") 37 45 38 46 from typing import Any, Dict, Iterator, List, Optional, Union ··· 65 73 finally: 66 74 _model_cache.clear() 67 75 68 - # Load new model 69 - if verbose: 70 - print(f"Loading model: {model_name}") 71 - 72 - logger.info(f"Loading model: {model_name}") 73 76 runner = MLXRunner(model_path_str, verbose=verbose) 74 77 runner.load_model() 75 78 ··· 280 283 user_input_content = request.input 281 284 else: 282 285 user_msg_item = request.input[-1] 283 - user_input_content = user_msg_item.content.root 286 + if isinstance(user_msg_item.content, list): 287 + user_input_content = user_msg_item.content[0].text 288 + else: 289 + user_input_content = user_msg_item.content.root 284 290 return user_input_content 285 291 286 292 293 + # TODO: Please refactor for Deus sake 287 294 async def generate_response_chat_stream( 288 295 request: ResponsesRequest, 289 296 ) -> AsyncGenerator[str, None]: 290 297 """Generate streaming chat responses for OpenResponses API.""" 291 298 model = request.model 292 299 created = int(time.time()) 293 - runner = get_or_load_model(model, None) 300 + response = await client.get( 301 + f"http://127.0.0.1:1729/model-cache-path?model_name={model}" 302 + ) 303 + 304 + model_cache_path = None 305 + if response.status_code == 200: 306 + model_cache_path = response.text 307 + else: 308 + raise HTTPException(status_code=500, detail="Model not found") 309 + 310 + runner = get_or_load_model(model, model_cache_path) 294 311 metrics = None 295 312 296 313 user_input_content = "" ··· 306 323 307 324 input_tokens = len(runner.tokenizer.encode(user_input_content)) # pyright: ignore 308 325 309 - # Initial chunk 326 + response_id = f"resp_{uuid.uuid4()}" 327 + message_id = f"msg_{uuid.uuid4()}" 328 + sequence_number = 0 329 + 310 330 initial_chunk = { 311 - "id": f"resp_{uuid.uuid4()}", 312 - "object": "response.chunk", 331 + "id": response_id, 332 + "object": "response", 313 333 "created_at": created, 314 334 "model": model, 315 335 "status": "in_progress", 316 336 "output": [ 317 337 { 318 338 "type": "message", 319 - "id": f"msg_{uuid.uuid4()}", 339 + "id": message_id, 320 340 "status": "in_progress", 321 341 "role": "assistant", 322 342 "content": [], 323 343 } 324 344 ], 325 - "usage": {"input_tokens": input_tokens, "output_tokens": 0}, 345 + "incomplete_details": {"reason": ""}, 346 + "previous_response_id": request.previous_response_id, 347 + "instructions": request.instructions, 348 + "temperature": request.temperature, 349 + "prompt_cache_key": request.prompt_cache, 350 + "safety_identifier": request.safety_identifier, 351 + "service_tier": request.service_tier, 352 + "background": request.background, 353 + "store": request.store, 354 + "max_tool_calls": request.max_tool_calls, 355 + "max_output_tokens": request.max_output_tokens, 356 + # input and output token details are 0, since we dont do cache now or 357 + # i dont know, how to do cache too 358 + "usage": { 359 + "input_tokens": input_tokens, 360 + "output_tokens": 0, 361 + "total_tokens": input_tokens, 362 + "input_tokens_details": 0, 363 + "output_tokens_details": 0, 364 + }, 365 + "reasoning": {"effort": "medium", "summary": "auto"}, 366 + "top_logprobs": request.top_logprobs, 367 + "frequency_penalty": 0, 368 + "presence_penalty": 0, 369 + "top_p": request.top_p, 370 + "text": {"format": {"type": "text"}, "verbosity": "low"}, 371 + "paralell_tool_calls": 0, 372 + "truncation": "disabled", 373 + "tool_choice": "auto", 374 + "tools": [{"name": "", "type": "function"}], 375 + "error": {"code": "", "message": ""}, 326 376 } 327 - yield f"data: {json.dumps(initial_chunk)}\n\n" 377 + event = { 378 + "type": "response.created", 379 + "sequence_number": sequence_number, 380 + "response": initial_chunk, 381 + } 382 + sequence_number += 1 383 + yield "event: response.created\n" 384 + yield f"data: {json.dumps(event)}\n\n" 328 385 329 386 accumulated_text = "" 330 387 answer_text = "" ··· 332 389 error = None 333 390 incomplete_details = None 334 391 has_answer_started: bool = False 392 + output_index = 0 393 + item_id = f"item_{uuid.uuid4()}" 394 + content_index = 0 335 395 try: 336 396 337 397 # TODO: Add the turn convo context for non-harmony models too ··· 365 425 accumulated_text += token 366 426 output_tokens += 1 # Each yield is one token 367 427 368 - chunk = { 369 - "id": f"resp_{uuid.uuid4()}", 370 - "object": "response.chunk", 371 - "created_at": created, 372 - "model": model, 373 - "status": "in_progress", 374 - "output": [ 375 - { 376 - "type": "message", 377 - "id": f"msg_{uuid.uuid4()}", 378 - "status": "in_progress", 379 - "role": "assistant", 380 - "content": [ 381 - { 382 - "type": "output_text", 383 - "text": token, 384 - "annotations": [], 385 - } 386 - ], 387 - } 388 - ], 389 - "usage": {"input_tokens": input_tokens, "output_tokens": output_tokens}, 428 + event_name = "" 429 + item_chunk = {} 430 + if sequence_number == 1: 431 + event_name = "response.output_item.added" 432 + item_chunk = { 433 + "type": "message", 434 + "id": message_id, 435 + "status": "in_progress", 436 + "role": "assistant", 437 + "content": [ 438 + { 439 + "type": "output_text", 440 + "text": token, 441 + "annotations": [], 442 + } 443 + ], 444 + } 445 + event = { 446 + "type": f"{event_name}", 447 + "sequence_number": sequence_number, 448 + "output_index": output_index, 449 + "item": item_chunk, 450 + } 451 + yield f"event: {event_name}\n" 452 + yield f"data: {json.dumps(event)}\n\n" 453 + 454 + event_name = "response.output_text.delta" 455 + event = { 456 + "type": f"{event_name}", 457 + "sequence_number": sequence_number, 458 + "output_index": output_index, 459 + "item_id": message_id, 460 + "delta": token, 461 + "content_index": content_index, 390 462 } 391 - yield f"data: {json.dumps(chunk)}\n\n" 463 + 464 + sequence_number += 1 465 + content_index += 1 466 + # print(event) 467 + yield f"event: {event_name}\n" 468 + yield f"data: {json.dumps(event)}\n\n" 392 469 393 470 except Exception as e: 394 471 error = {"message": str(e), "code": "500"} 395 472 incomplete_details = {"reason": "internal server error"} 396 473 474 + # TODO: fix error response acc to the standard 397 475 error_chunk = { 398 - "id": f"resp_{uuid.uuid4()}", 399 - "object": "response.chunk", 476 + "id": response_id, 477 + "object": "response", 400 478 "created_at": created, 401 479 "model": model, 402 480 "status": "failed", ··· 405 483 "output": [], 406 484 "usage": {"input_tokens": input_tokens, "output_tokens": output_tokens}, 407 485 } 408 - yield f"data: {json.dumps(error_chunk)}\n\n" 486 + event = {"type": "error", "sequence_number": sequence_number, "error": error} 487 + sequence_number += 1 488 + yield "event: error\n" 489 + yield f"data: {json.dumps(event)}\n\n" 409 490 return 410 491 411 492 # Final chunk ··· 413 494 # Build final chunk with accumulated text and store response for follow-ups 414 495 415 496 final_chunk = { 416 - "id": f"resp_{uuid.uuid4()}", 417 - "object": "response.chunk", 497 + "id": response_id, 498 + "object": "response", 418 499 "created_at": created, 419 500 "completed_at": completed_at, 420 501 "model": model, ··· 422 503 "output": [ 423 504 { 424 505 "type": "message", 425 - "id": f"msg_{uuid.uuid4()}", 506 + "id": message_id, 426 507 "status": "completed", 427 508 "role": "assistant", 428 509 "content": [ ··· 434 515 ], 435 516 } 436 517 ], 437 - "usage": {"input_tokens": input_tokens, "output_tokens": output_tokens}, 518 + "incomplete_details": {"reason": ""}, 519 + "previous_response_id": request.previous_response_id, 520 + "instructions": request.instructions, 521 + "temperature": request.temperature, 522 + "prompt_cache_key": request.prompt_cache, 523 + "safety_identifier": request.safety_identifier, 524 + "service_tier": request.service_tier, 525 + "background": request.background, 526 + "store": request.store, 527 + "max_tool_calls": request.max_tool_calls, 528 + "max_output_tokens": request.max_output_tokens, 529 + # input and output token details are 0, since we dont do cache now or 530 + # i dont know, how to do cache too 531 + "usage": { 532 + "input_tokens": input_tokens, 533 + "output_tokens": output_tokens, 534 + "total_tokens": input_tokens + output_tokens, 535 + "input_tokens_details": 0, 536 + "output_tokens_details": 0, 537 + }, 538 + "reasoning": {"effort": "medium", "summary": "auto"}, 539 + "top_logprobs": request.top_logprobs, 540 + "frequency_penalty": 0, 541 + "presence_penalty": 0, 542 + "top_p": request.top_p, 543 + "text": {"format": {"type": "text"}, "verbosity": "low"}, 544 + "paralell_tool_calls": 0, 545 + "truncation": "disabled", 546 + "tool_choice": "auto", 547 + "tools": [{"name": "", "type": "function"}], 548 + "error": {"code": "", "message": ""}, 438 549 } 439 550 440 551 # Store and return a typed ResponsesResponse for follow-ups ··· 458 569 usage={"input_tokens": input_tokens, "output_tokens": output_tokens}, 459 570 metrics=metrics_obj, 460 571 ) 461 - yield f"data: {json.dumps(final_chunk)}\n\n" 572 + output_done_event = { 573 + "type": "response.output_text.done", 574 + "sequence_number": sequence_number, 575 + "item_id": message_id, 576 + "output_index": output_index, 577 + "content_index": content_index, 578 + "text": answer_text, 579 + } 580 + yield "event: response.output_text.done\n" 581 + yield f"data: {json.dumps(output_done_event)}\n\n" 582 + event = { 583 + "type": "response.completed", 584 + "sequence_number": sequence_number, 585 + "response": final_chunk, 586 + } 587 + sequence_number += 1 588 + yield "event: response.completed\n" 589 + yield f"data: {json.dumps(event)}\n\n" 462 590 yield "data: [DONE]\n\n" 463 591 464 592 ··· 591 719 ) 592 720 ] 593 721 for item in convos: 722 + print(f"ITEM {item}") 594 723 match item: 595 724 case UserMessageItemParam(): 725 + content = "" 726 + if isinstance(item.content, list): 727 + content = item.content[0].text 728 + else: 729 + content = item.content.root 596 730 convo_list.append( 597 - Message.from_role_and_content( 598 - Role.USER, item.content.root 599 - ) # pyright: ignore 731 + Message.from_role_and_content(Role.USER, content) # pyright: ignore 600 732 ) 601 733 case DeveloperMessageItemParam(): 602 734 convo_list.append( ··· 612 744 Message.from_role_and_content( 613 745 Role.ASSISTANT, item.content.root 614 746 ) # pyright: ignore 747 + ) 748 + case SystemMessageItemParam(): 749 + convo_list.append( 750 + Message.from_role_and_content(Role.SYSTEM, item.content.root) 615 751 ) 616 752 case _: 617 753 raise TypeError("unknown type")
+1
server/pyproject.toml
··· 9 9 "mlx-lm==0.31.0", 10 10 "black==25.9.0", 11 11 "openai-harmony==0.0.8", 12 + "httpx==0.28.1", 12 13 "openresponses-types" 13 14 ] 14 15
+3 -1
server/uv.lock
··· 3 3 requires-python = "==3.13.*" 4 4 5 5 [options] 6 - exclude-newer = "2026-03-19T13:23:15.459975Z" 6 + exclude-newer = "2026-04-06T12:32:20.881602Z" 7 7 exclude-newer-span = "P10D" 8 8 9 9 [[package]] ··· 615 615 dependencies = [ 616 616 { name = "black" }, 617 617 { name = "fastapi" }, 618 + { name = "httpx" }, 618 619 { name = "mlx-lm" }, 619 620 { name = "openai-harmony" }, 620 621 { name = "openresponses-types" }, ··· 625 626 requires-dist = [ 626 627 { name = "black", specifier = "==25.9.0" }, 627 628 { name = "fastapi", specifier = "==0.119.0" }, 629 + { name = "httpx", specifier = "==0.28.1" }, 628 630 { name = "mlx-lm", specifier = "==0.31.0" }, 629 631 { name = "openai-harmony", specifier = "==0.0.8" }, 630 632 { name = "openresponses-types" },
+1 -1
tiles/Cargo.toml
··· 1 1 [package] 2 2 name = "tiles" 3 - version = "0.4.7" 3 + version = "0.4.8" 4 4 edition = "2024" 5 5 6 6 [dependencies]
+23 -27
tiles/src/commands/mod.rs
··· 16 16 use tiles::utils::installer::{UpdateInfo, get_update_info, try_update}; 17 17 use tiles::{core::health, runtime::RunArgs}; 18 18 19 - use tilekit::modelfile::parse_from_file; 20 19 pub use tilekit::optimize::optimize; 21 20 use toml::Table; 22 21 ··· 42 41 ▓▓ ▓▓▓▒ 43 42 ▓▓▓▓▓▓▓▓ 44 43 "#; 44 + 45 + // const FTUE_ASCII_ART_NEW: &str = r#" 46 + // ▃▅▆▆▇▇▇▇▆▇▇▇▆▆▆▆ 47 + // ░▅▆▆▇▆▇▇▇▇▇▇▇▆▆▆▆▇▇▇▇▆ 48 + // _▃▅▇▆▇▇▆▆▆▇▇▆▆▆▆▆▇▇▆▇▇▇▇▇▆▇▅ 49 + // ▃▆▇▆▇▇▇▆▆▆▆▆▅▆▇▆▇▇▇▇▇▆▆▆▆▆▃ 50 + // ▆▆▇▆▆▆▆▆▇▆▆▇▆▇▇▇▆▇▆▆▆▇▅ 51 + // ▂▆▆▇▇▇▇▇▇▇▇▇▆▆▆▇▇▇▇▇▇▇▇▁ 52 + // ▅▆▆▆▇▆▇▆▆▆▆▇▆▆▇▇▆▅ 53 + // ▆▇▇▇▇▇▆▇▇▅ 54 + // ▅▇▇▇▆▇▇▇▆ 55 + // ▆▆▇▇▇▇▇▇▆ 56 + // ▆▇▇▇▇▆▇▇▇ 57 + // ▆▇▇▇▆▆▆▆▇ 58 + // ▆▇▇▇▆▇▇▇▆ 59 + // ▂▇▇▇▆▇▇▆ 60 + // ▆▆▆▇▆▅ 61 + // ▁▆▇▆▅ 62 + // ▓▆▄ 63 + 64 + // "#; 45 65 const FTUE_REASSURANCE_LOCAL: &str = "On-device by default."; 46 66 // const FTUE_REASSURANCE_NO_CLOUD: &str = "Online models and identity optional."; 47 67 const FTUE_NICKNAME_PROMPT: &str = "Choose a username:"; ··· 56 76 const FTUE_CUSTOM_DATA_PROMPT: &str = "Use a custom data directory now? [y/N]"; 57 77 const FTUE_UPDATE_COMMAND: &str = "tiles update"; 58 78 59 - pub fn run_setup_for_ftue(run_args: &RunArgs) -> Result<()> { 79 + pub fn run_setup_for_ftue(_run_args: &RunArgs) -> Result<()> { 60 80 // initializes config directory 61 81 let config_provider = DefaultProvider; 62 82 config_provider.get_or_create_config_dir()?; ··· 77 97 setup_root_account(root_config.clone())?; 78 98 setup_default_user_data_dir(&config_provider)? 79 99 } else { 80 - print_runtime_context(run_args, &config_provider, &root_user_details)?; 100 + print_runtime_context(&config_provider, &root_user_details)?; 81 101 } 82 102 83 103 Ok(()) 84 104 } 85 105 86 106 fn print_runtime_context<T: ConfigProvider>( 87 - run_args: &RunArgs, 88 107 config_provider: &T, 89 108 root_user_details: &RootUser, 90 109 ) -> Result<()> { 91 - let model_name = get_configured_model_name(run_args.memory, config_provider)?; 92 110 let directory = config_provider 93 111 .get_user_data_dir() 94 112 .map(|path| path.display().to_string())?; ··· 101 119 102 120 println!("Account:"); 103 121 println!(" {} (DID: {})", nickname, root_user_details.id); 104 - println!("Model:"); 105 - println!(" {}", model_name); 106 122 println!("Directory:"); 107 123 println!(" {}", directory); 108 124 println!(); 109 125 Ok(()) 110 - } 111 - 112 - fn get_configured_model_name<T: ConfigProvider>( 113 - memory_mode: bool, 114 - config_provider: &T, 115 - ) -> Result<String> { 116 - let modelfile_path = if memory_mode { 117 - config_provider.get_lib_dir()?.join("modelfiles/mem-agent") 118 - } else { 119 - config_provider.get_lib_dir()?.join("modelfiles/gpt-oss") 120 - }; 121 - 122 - let modelfile_path_str = modelfile_path 123 - .to_str() 124 - .ok_or_else(|| anyhow!("Failed to parse modelfile path"))?; 125 - let modelfile = parse_from_file(modelfile_path_str) 126 - .map_err(|err| anyhow!("Failed to parse modelfile: {}", err))?; 127 - modelfile 128 - .from 129 - .ok_or_else(|| anyhow!("Missing FROM in modelfile {}", modelfile_path.display())) 130 126 } 131 127 132 128 fn setup_root_account(root_config: Table) -> Result<()> {
+73 -24
tiles/src/daemon.rs
··· 7 7 }; 8 8 9 9 use anyhow::{Result, anyhow}; 10 - use axum::{Router, extract::State, routing::get}; 10 + use axum::{ 11 + Router, 12 + extract::{Query, State}, 13 + http::StatusCode, 14 + routing::get, 15 + }; 16 + use axum_macros::debug_handler; 17 + use log::info; 11 18 use reqwest::Client; 19 + use semver::Version; 12 20 use std::fs::OpenOptions; 13 21 use std::sync::Mutex; 14 22 use tokio::sync::oneshot::{self, Receiver}; 15 23 16 - use crate::utils::config::{ConfigProvider, DefaultProvider}; 24 + use crate::utils::config::{ConfigProvider, DefaultProvider, get_model_cache}; 17 25 18 26 struct AppState { 19 27 pub shutdown_sender: Mutex<Option<oneshot::Sender<bool>>>, 28 + pub vsn: String, 20 29 } 21 30 22 - // #[derive(serde::Deserialize)] 23 - // pub struct SendParams { 24 - // ticket: String, 25 - // } 31 + #[derive(serde::Deserialize)] 32 + pub struct SendParams { 33 + model_name: String, 34 + } 26 35 36 + //TODO: Add a different PORT for development 37 + // We should update that in py server too for the daemon api calls 27 38 const DEFAULT_PORT: u32 = 1729; 28 39 pub async fn start_cmd(port: Option<u32>) -> Result<()> { 29 - if cfg!(debug_assertions) { 30 - start_server(port).await 31 - } else { 32 - start_daemon(port).await 33 - } 40 + start_daemon(port).await 34 41 } 35 42 36 43 pub async fn stop_cmd() -> Result<()> { 37 44 stop_server(None).await 38 45 } 39 - async fn root() -> &'static str { 40 - "Its me luttappi" 46 + async fn root(State(state): State<Arc<AppState>>) -> String { 47 + state.vsn.clone() 41 48 } 42 49 43 50 // allow zombie, since this process is expected to be 44 51 // running in background and have commands to stop if needed 45 52 #[allow(clippy::zombie_processes)] 46 53 async fn start_daemon(port: Option<u32>) -> Result<()> { 47 - if (ping(port).await).is_ok() { 48 - return Ok(()); 54 + if let Ok(daemon_current_vsn) = ping(port).await { 55 + let app_vsn = Version::parse(env!("CARGO_PKG_VERSION"))?; 56 + log::info!( 57 + "app version found {}, daemon version {}", 58 + app_vsn, 59 + daemon_current_vsn 60 + ); 61 + if app_vsn 62 + .cmp_precedence(&Version::parse(&daemon_current_vsn)?) 63 + .is_ne() 64 + { 65 + log::info!( 66 + "New app version found {}, hot reload the daemon {}", 67 + app_vsn, 68 + daemon_current_vsn 69 + ); 70 + stop_server(None).await?; 71 + log::info!("Stopped the current daemon server"); 72 + } else { 73 + return Ok(()); 74 + } 49 75 } 76 + 50 77 let data_dir = DefaultProvider.get_data_dir()?; 51 78 let stdout_log = OpenOptions::new() 52 79 .create(true) ··· 56 83 .create(true) 57 84 .append(true) 58 85 .open(data_dir.join("logs/daemon.err.log"))?; 59 - // let _process = Command::new("target/debug/tiles") 60 - let _process = Command::new("tiles") 86 + let base_command = if cfg!(debug_assertions) { 87 + "target/debug/tiles" 88 + } else { 89 + "tiles" 90 + }; 91 + let _process = Command::new(base_command) 61 92 .arg("daemon") 62 93 .stdin(Stdio::null()) 63 94 .stdout(Stdio::from(stdout_log)) ··· 75 106 76 107 let state = AppState { 77 108 shutdown_sender: Mutex::new(Some(shutdown_tx)), 109 + vsn: env!("CARGO_PKG_VERSION").to_owned(), 78 110 }; 111 + 79 112 let shared_state = Arc::new(state); 80 113 let app = Router::new() 81 114 .route("/", get(root)) 82 115 .route("/shutdown", get(shutdown)) 116 + .route("/model-cache-path", get(get_model_cache_path)) 83 117 .with_state(shared_state); 84 118 85 119 let addr = format!("127.0.0.1:{}", dyn_port); 86 120 let listener = tokio::net::TcpListener::bind(addr).await?; 87 121 88 - println!("Daemon server started at {}", dyn_port); 122 + info!("Daemon server started at {}", dyn_port); 89 123 let _ = axum::serve(listener, app) 90 124 .with_graceful_shutdown(shutdown_signal(shutdown_rx)) 91 125 .await; ··· 104 138 let _ = sender_real.send(true); 105 139 } 106 140 141 + #[debug_handler] 142 + async fn get_model_cache_path( 143 + State(_state): State<Arc<AppState>>, 144 + Query(params): Query<SendParams>, 145 + ) -> Result<String, StatusCode> { 146 + println!("getting model cache path"); 147 + if let Ok(model_path) = get_model_cache(&params.model_name) { 148 + Ok(model_path 149 + .to_str() 150 + .expect("Pathbuf to str failed") 151 + .to_owned()) 152 + } else { 153 + Err(StatusCode::NOT_FOUND) 154 + } 155 + } 156 + 107 157 // #[debug_handler] 108 158 // async fn send_ping(State(_state): State<Arc<AppState>>, Query(params): Query<SendParams>) { 109 159 // println!("Trying to send ping"); ··· 126 176 _ => Ok(()), 127 177 } 128 178 } 129 - pub async fn ping(port: Option<u32>) -> Result<(), String> { 179 + pub async fn ping(port: Option<u32>) -> anyhow::Result<String> { 130 180 let dyn_port = get_port(port); 131 181 let client = Client::new(); 132 182 let addr = format!("http://127.0.0.1:{}", dyn_port); 133 - 134 183 let res = client.get(addr).send().await; 135 184 136 185 match res { 137 - Err(err) => Err(format!("Pong failed: {:?}", err)), 138 - _ => Ok(()), 186 + Err(err) => Err(anyhow!(format!("Pong failed: {:?}", err))), 187 + Ok(resp) => resp.text().await.map_err(Into::into), 139 188 } 140 189 } 141 190 ··· 150 199 return Err(anyhow!(error)); 151 200 } 152 201 match ping(port).await { 153 - Ok(()) => return Ok(()), 202 + Ok(_) => return Ok(()), 154 203 Err(err) => { 155 204 retry_count -= 1; 156 - error = err; 205 + error = err.to_string(); 157 206 tokio::time::sleep(Duration::from_secs(2)).await; 158 207 } 159 208 }
+25 -6
tiles/src/main.rs
··· 9 9 network::{link, sync}, 10 10 }, 11 11 daemon::{start_cmd, start_server, stop_cmd}, 12 - runtime::{RunArgs, build_runtime}, 12 + runtime::{RunArgs, build_runtime, mlx::start_pi_rpc}, 13 13 utils::installer, 14 14 }; 15 15 ··· 77 77 /// The DID of the peer you want to sync 78 78 did: Option<String>, 79 79 }, 80 + Pi, 80 81 } 81 82 82 83 #[derive(Debug, Args)] ··· 95 96 // Don't go into the repl 96 97 #[arg(short = 'x', long)] 97 98 no_repl: bool, 99 + 100 + // Use PI repl instead of Tiles 101 + #[arg(short = 'p', long)] 102 + pi: bool, 98 103 } 99 104 100 105 #[derive(Debug, Args)] ··· 197 202 modelfile_path: None, 198 203 relay_count: cli.flags.relay_count, 199 204 memory: cli.flags.memory, 205 + pi: cli.flags.pi, 200 206 }; 201 207 202 208 commands::run_setup_for_ftue(&run_args) ··· 204 210 let _ = commands::try_app_update().await; 205 211 206 212 // trying to run the tiles daemon in background concurrently 207 - if !cfg!(debug_assertions) { 208 - tokio::spawn(async move { 209 - let _ = start_cmd(None).await; 210 - }); 211 - } 213 + // if !cfg!(debug_assertions) { 214 + let t = tokio::spawn(async move { 215 + let _ = start_cmd(None).await; 216 + }); 217 + t.await?; 218 + // } 212 219 core::init_account(&db_conn) 213 220 .inspect_err(|e| eprintln!("Tiles core init failed due to {:?}", e))?; 214 221 if !cli.flags.no_repl { ··· 225 232 modelfile_path, 226 233 relay_count: flags.relay_count, 227 234 memory: flags.memory, 235 + pi: flags.pi, 228 236 }; 237 + commands::run_setup_for_ftue(&run_args) 238 + .inspect_err(|e| eprintln!("Failed to setup Tiles due to {:?}", e))?; 239 + 240 + let t = tokio::spawn(async move { 241 + let _ = start_cmd(None).await; 242 + }); 243 + t.await?; 229 244 core::init_account(&db_conn) 230 245 .inspect_err(|e| eprintln!("Tiles core init failed due to {:?}", e))?; 231 246 commands::run(&runtime, run_args, &db_conn) ··· 280 295 } 281 296 }, 282 297 Some(Commands::Sync { did }) => sync(did).await?, 298 + Some(Commands::Pi) => { 299 + // blah 300 + start_pi_rpc()?; 301 + } 283 302 } 284 303 Ok(()) 285 304 }
+170 -79
tiles/src/runtime/mlx.rs
··· 2 2 use crate::core::chats::{Message, save_chat}; 3 3 use crate::core::storage::db::Dbconn; 4 4 use crate::runtime::RunArgs; 5 - use crate::utils::config::{ConfigProvider, DefaultProvider, get_memory_path, get_model_cache}; 5 + use crate::utils::config::{ 6 + ConfigProvider, DefaultProvider, get_memory_path, get_model_cache, update_current_model, 7 + }; 6 8 use crate::utils::hf_model_downloader::*; 7 9 use anyhow::{Context, Result, anyhow}; 8 10 use futures_util::StreamExt; ··· 18 20 use serde::{Deserialize, Serialize}; 19 21 use serde_json::{Value, json}; 20 22 use std::fs::OpenOptions; 23 + use std::io::{BufRead, BufReader, Write}; 21 24 use std::path::PathBuf; 22 - use std::process::Command; 23 - use std::process::Stdio; 25 + use std::process::{Child, Command}; 26 + use std::process::{ChildStdin, Stdio}; 24 27 use std::time::Duration; 25 28 use tilekit::modelfile::Modelfile; 26 29 use tilekit::modelfile::Role; ··· 51 54 52 55 impl MLXRuntime {} 53 56 54 - #[derive(Clone)] 57 + #[derive(Clone, Debug)] 55 58 pub struct ChatResponse { 56 59 // think: String, 57 60 pub reply: String, ··· 61 64 pub metrics: Option<BenchmarkMetrics>, 62 65 } 63 66 67 + #[derive(Serialize, Deserialize, Debug)] 68 + struct PiResponse { 69 + r#type: String, 70 + command: String, 71 + success: bool, 72 + data: Option<Value>, 73 + } 74 + 75 + #[derive(Serialize, Deserialize)] 76 + struct GetStateData { 77 + model: Value, 78 + #[serde(rename = "thinkingLevel")] 79 + thinking_level: String, 80 + #[serde(rename = "isStreaming")] 81 + is_streaming: bool, 82 + #[serde(rename = "sessionId")] 83 + session_id: String, 84 + } 64 85 impl Default for MLXRuntime { 65 86 fn default() -> Self { 66 87 Self::new() ··· 183 204 enum SlashCommand { 184 205 Continue, 185 206 Exit, 207 + State, 186 208 NotACommand, 187 209 } 188 210 ··· 193 215 show_help(modelname); 194 216 SlashCommand::Continue 195 217 } 218 + "state" => SlashCommand::State, 196 219 "bye" => SlashCommand::Exit, 197 220 "" => { 198 221 println!("Empty command. Type /help for available commands."); ··· 215 238 let _ = model_name; 216 239 217 240 println!("Available Commands:"); 241 + println!(" /state Show the current session state"); 218 242 println!(" /help Show this help message"); 219 243 println!(" /bye Exit the REPL"); 220 244 println!(); ··· 268 292 let mut prev_response_id: String = String::from(""); 269 293 270 294 let mut conversations: Vec<Message> = vec![]; 295 + 296 + let mut pi_process = start_pi_rpc()?; 297 + 298 + let pi_stdin = pi_process.stdin.as_mut().unwrap(); 271 299 loop { 272 300 let readline = editor.readline(">>> "); 273 301 let input = match readline { ··· 291 319 } 292 320 break; 293 321 } 322 + SlashCommand::State => { 323 + send_pi_command(pi_stdin, "e")?; 324 + } 294 325 SlashCommand::NotACommand => {} 295 326 } 296 327 ··· 305 336 tokens_per_second: 0.0, 306 337 total_latency_s: 0.0, 307 338 }; 308 - loop { 309 - if remaining_count > 0 { 310 - let chat_start = remaining_count == run_args.relay_count; 311 339 312 - match chat( 313 - &input, 314 - modelfile, 315 - chat_start, 316 - &python_code, 317 - &g_reply, 318 - run_args, 319 - &prev_response_id, 320 - &db_conn.chat, 321 - &current_user, 322 - &conversations, 323 - ) 324 - .await 325 - { 326 - Ok(response) => { 327 - if response.reply.is_empty() { 328 - if !response.code.is_empty() { 329 - python_code = response.code; 330 - } 331 - if let Some(metrics) = response.metrics { 332 - bench_metrics.update(metrics); 333 - } 334 - remaining_count -= 1; 335 - } else { 336 - g_reply = response.reply.clone(); 337 - if run_args.memory { 338 - println!("\n{}", response.reply.trim()); 339 - } else { 340 - prev_response_id = response.prev_response_id.clone(); 341 - println!("\n"); 342 - } 343 - conversations.push(Message { 344 - r#type: String::from("message"), 345 - role: Role::User, 346 - content: input, 347 - }); 348 - conversations.push(Message { 349 - r#type: String::from("message"), 350 - role: Role::Assistant, 351 - content: g_reply.clone(), 352 - }); 353 - 354 - save_chat(&db_conn.chat, &current_user, &g_reply, Some(&response))?; 355 - // Display benchmark metrics if available 356 - if let Some(metrics) = response.metrics { 357 - bench_metrics.update(metrics); 358 - println!( 359 - "{}", 360 - format!( 361 - "\n{} {:.1} tok/s | {} tokens | {:.0}s TTFT", 362 - "💡".yellow(), 363 - bench_metrics.total_tokens as f64 364 - / bench_metrics.total_latency_s, 365 - bench_metrics.total_tokens, 366 - bench_metrics.ttft_ms / 1000.0 367 - ) 368 - .dimmed() 369 - ); 370 - } 371 - 372 - break; 373 - } 374 - } 375 - Err(err) => { 376 - // if out of relay count, then clear the global_reply and ready for next fresh prompt 377 - println!("{:?}", err); 378 - g_reply.clear(); 379 - break; 380 - } 340 + let stdout = pi_process.stdout.take().expect("stdout"); 341 + let reader = BufReader::new(stdout); 342 + for line in reader.lines() { 343 + let line = line.unwrap(); 344 + if let Some(pi_response) = serde_json::from_str::<PiResponse>(&line)?.into() { 345 + if pi_response.command == "get_state" && pi_response.success { 346 + let data: GetStateData = serde_json::from_value(pi_response.data.unwrap())?; 347 + let render = format!( 348 + "Model: {}\n,thinking: {}\n, session_id: {}", 349 + data.model.get("name").unwrap(), 350 + data.thinking_level, 351 + data.session_id 352 + ); 353 + println!("{}", render); 354 + } else { 355 + println!("got line {}", line); 381 356 } 357 + break; 358 + } else { 359 + break; 382 360 } 383 361 } 384 - if g_reply.is_empty() { 385 - println!("\nNo reply, try another prompt"); 386 - } 362 + // loop { 363 + // if remaining_count > 0 { 364 + // let chat_start = remaining_count == run_args.relay_count; 365 + 366 + // match chat( 367 + // &input, 368 + // modelfile, 369 + // chat_start, 370 + // &python_code, 371 + // &g_reply, 372 + // run_args, 373 + // &prev_response_id, 374 + // &db_conn.chat, 375 + // &current_user, 376 + // &conversations, 377 + // ) 378 + // .await 379 + // { 380 + // Ok(response) => { 381 + // if response.reply.is_empty() { 382 + // if !response.code.is_empty() { 383 + // python_code = response.code; 384 + // } 385 + // if let Some(metrics) = response.metrics { 386 + // bench_metrics.update(metrics); 387 + // } 388 + // remaining_count -= 1; 389 + // } else { 390 + // g_reply = response.reply.clone(); 391 + // if run_args.memory { 392 + // println!("\n{}", response.reply.trim()); 393 + // } else { 394 + // prev_response_id = response.prev_response_id.clone(); 395 + // println!("\n"); 396 + // } 397 + // conversations.push(Message { 398 + // r#type: String::from("message"), 399 + // role: Role::User, 400 + // content: input, 401 + // }); 402 + // conversations.push(Message { 403 + // r#type: String::from("message"), 404 + // role: Role::Assistant, 405 + // content: g_reply.clone(), 406 + // }); 407 + 408 + // save_chat(&db_conn.chat, &current_user, &g_reply, Some(&response))?; 409 + // // Display benchmark metrics if available 410 + // if let Some(metrics) = response.metrics { 411 + // bench_metrics.update(metrics); 412 + // println!( 413 + // "{}", 414 + // format!( 415 + // "\n{} {:.1} tok/s | {} tokens | {:.0}s TTFT", 416 + // "💡".yellow(), 417 + // bench_metrics.total_tokens as f64 418 + // / bench_metrics.total_latency_s, 419 + // bench_metrics.total_tokens, 420 + // bench_metrics.ttft_ms / 1000.0 421 + // ) 422 + // .dimmed() 423 + // ); 424 + // } 425 + 426 + // break; 427 + // } 428 + // } 429 + // Err(err) => { 430 + // // if out of relay count, then clear the global_reply and ready for next fresh prompt 431 + // println!("{:?}", err); 432 + // g_reply.clear(); 433 + // break; 434 + // } 435 + // } 436 + // } 437 + // } 438 + // if g_reply.is_empty() { 439 + // println!("\nNo reply, try another prompt"); 440 + // } 387 441 } 388 442 Ok(()) 389 443 } ··· 699 753 Err(err) => Err(anyhow::anyhow!(format!("Download failed due to {:?}", err))), 700 754 } 701 755 } 756 + 757 + pub fn start_pi_rpc() -> Result<Child> { 758 + let mut pi_dir = DefaultProvider.get_lib_dir()?; 759 + let user_data_dir = DefaultProvider.get_user_data_dir()?; 760 + let pi_agent_dir = user_data_dir.join("pi/agent"); 761 + std::fs::create_dir_all(&pi_agent_dir).context("Failed to create pi_agent_dir")?; 762 + pi_dir = pi_dir.join("pi"); 763 + let pi_exec_path = pi_dir.join("pi"); 764 + let pi_process = Command::new(pi_exec_path) 765 + .arg("--mode") 766 + .arg("rpc") 767 + .arg("--no-session") 768 + .env("PI_CODING_AGENT_DIR", pi_agent_dir) 769 + .env("PI_OFFLINE", "true") 770 + .stdin(Stdio::piped()) 771 + .stdout(Stdio::piped()) 772 + .spawn() 773 + .expect("failed to PI"); 774 + 775 + Ok(pi_process) 776 + } 777 + 778 + fn read_events(process: Child) { 779 + for line in process.stdout.iter() {} 780 + } 781 + 782 + fn send_pi_command(pi_child_stdin: &mut ChildStdin, command: &str) -> Result<()> { 783 + let payload = json!({ 784 + "type": "get_state" 785 + }); 786 + 787 + let payload_str = format!("{}\n", serde_json::to_string(&payload)?); 788 + 789 + pi_child_stdin.write_all(payload_str.as_bytes()).unwrap(); 790 + pi_child_stdin.flush()?; 791 + Ok(()) 792 + }
+1
tiles/src/runtime/mod.rs
··· 9 9 pub modelfile_path: Option<String>, 10 10 pub relay_count: u32, 11 11 pub memory: bool, // Future flags go here 12 + pub pi: bool, 12 13 } 13 14 14 15 pub enum Runtime {
+125 -6
tiles/src/utils/config.rs
··· 14 14 /// - /server 15 15 /// - /models - Where the pre-downloaded models. 16 16 use anyhow::{Context, Result, anyhow}; 17 + use serde::{Deserialize, Serialize}; 17 18 use std::fs::File; 18 19 use std::path::PathBuf; 19 20 use std::str::FromStr; 20 21 use std::time::SystemTime; 21 22 use std::{env, fs}; 22 23 use toml::Table; 24 + 25 + #[derive(Serialize, Deserialize, Debug)] 26 + struct ModelConfig { 27 + pub current: String, 28 + } 29 + #[derive(Serialize, Deserialize, Debug)] 30 + struct RootUserConfig { 31 + id: String, 32 + nickname: String, 33 + } 34 + 35 + #[derive(Serialize, Deserialize, Debug)] 36 + struct DataConfig { 37 + path: String, 38 + } 39 + 40 + #[derive(Serialize, Deserialize, Debug)] 41 + struct RootConfig { 42 + #[serde(rename = "root-user")] 43 + pub root_user: Option<RootUserConfig>, 44 + pub data: Option<DataConfig>, 45 + pub model: Option<ModelConfig>, 46 + } 23 47 24 48 const MODEL_SUB_PATH: &str = "models/huggingface/hub"; 25 49 pub trait ConfigProvider { ··· 199 223 )) 200 224 } 201 225 226 + // TODO: This fn is very rigid and should be eventually replaced by 227 + // `get_or_create_root_config` 202 228 pub fn get_or_create_config() -> Result<Table> { 203 229 let tiles_config_dir = DefaultProvider.get_config_dir()?; 204 230 let config_toml_path = tiles_config_dir.join("config.toml"); ··· 225 251 } 226 252 } 227 253 254 + fn get_or_create_root_config() -> Result<RootConfig> { 255 + let tiles_config_dir = DefaultProvider.get_config_dir()?; 256 + let config_toml_path = tiles_config_dir.join("config.toml"); 257 + 258 + if config_toml_path 259 + .try_exists() 260 + .context("config.toml path doesn't exist")? 261 + { 262 + let config_str = fs::read_to_string(config_toml_path)?; 263 + Ok(toml::from_str(&config_str)?) 264 + } else { 265 + let init_table: RootConfig = toml::from_str( 266 + r#" 267 + [root-user] 268 + id = '' 269 + nickname = '' 270 + 271 + [data] 272 + path = '' 273 + "#, 274 + )?; 275 + fs::write(config_toml_path, toml::to_string(&init_table)?)?; 276 + Ok(init_table) 277 + } 278 + } 228 279 /// Saves the root config toml `Table` type 229 280 pub fn save_config(config: &Table) -> Result<()> { 230 281 let tiles_config_dir = DefaultProvider.get_config_dir()?; ··· 236 287 Ok(()) 237 288 } 238 289 290 + /// Saves the root config toml `RootConfig` type 291 + // #[warn(private_interfaces)] 292 + fn save_root_config(config: &RootConfig) -> Result<()> { 293 + let tiles_config_dir = DefaultProvider.get_config_dir()?; 294 + let config_path = tiles_config_dir.join("config.toml"); 295 + let tmp_path = tiles_config_dir.join("config.tmp.toml"); 296 + fs::write(&tmp_path, toml::to_string(config)?)?; 297 + fs::copy(&tmp_path, &config_path)?; 298 + fs::remove_file(tmp_path)?; 299 + Ok(()) 300 + } 239 301 /// Get the apt path where the model in the system 240 302 pub fn get_model_cache(model_name: &str) -> Result<PathBuf> { 241 303 let hf_model_dir = if model_name.starts_with("mlx-community/") { ··· 309 371 } 310 372 } 311 373 374 + pub fn update_current_model(model_name: &str) -> Result<()> { 375 + let mut root_config = get_or_create_root_config()?; 376 + // No toml file writes, if model is same 377 + if let Some(model_config) = &root_config.model 378 + && model_config.current == model_name 379 + { 380 + return Ok(()); 381 + } 382 + do_update_current_model(&mut root_config, model_name)?; 383 + save_root_config(&root_config) 384 + } 385 + 386 + fn do_update_current_model(config: &mut RootConfig, model_name: &str) -> Result<()> { 387 + if let Some(_model_config) = &config.model { 388 + let model_config_v2 = ModelConfig { 389 + current: model_name.to_owned(), 390 + }; 391 + config.model = Some(model_config_v2) 392 + } else { 393 + let model_config = ModelConfig { 394 + current: model_name.to_owned(), 395 + }; 396 + 397 + config.model = Some(model_config); 398 + } 399 + Ok(()) 400 + } 401 + 312 402 //TODO: Add more tests for config.toml 313 403 #[cfg(test)] 314 404 mod tests { 315 405 316 - // use super::*; 406 + use super::*; 407 + 408 + #[test] 409 + fn test_updating_current_model_first_time() { 410 + let mut config: RootConfig = toml::from_str( 411 + r#" 412 + [root-user] 413 + id = 'did:key:xyz' 414 + nickname = '' 415 + "#, 416 + ) 417 + .unwrap(); 317 418 318 - // #[test] 319 - // fn test_create_config_file() -> Result<()> { 320 - // let _config_table = get_or_create_config()?; 321 - // Ok(()) 322 - // } 419 + do_update_current_model(&mut config, "model_name").unwrap(); 420 + 421 + assert_eq!("model_name", config.model.unwrap().current); 422 + } 423 + 424 + #[test] 425 + fn test_updating_current_model_not_first_time() { 426 + let mut config: RootConfig = toml::from_str( 427 + r#" 428 + [root-user] 429 + id = 'did:key:xyz' 430 + nickname = '' 431 + 432 + [model] 433 + current = 'mlx-wahtever' 434 + "#, 435 + ) 436 + .unwrap(); 437 + 438 + do_update_current_model(&mut config, "model_name").unwrap(); 439 + 440 + assert_eq!("model_name", config.model.unwrap().current); 441 + } 323 442 }