aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
-rw-r--r--.github/agents/commit.agent.md2
-rw-r--r--.github/instructions/commit.instructions.md2
-rw-r--r--.github/workflows/check.yml2
-rw-r--r--.github/workflows/issue-checker.yml84
-rw-r--r--.github/workflows/prek.yml52
-rw-r--r--.github/workflows/test.yml69
-rw-r--r--.gitignore2
-rw-r--r--.pre-commit-config.yaml50
-rw-r--r--CNAME2
-rw-r--r--README.md16
-rw-r--r--pyproject.toml9
-rw-r--r--src-tauri/Cargo.toml5
-rw-r--r--src-tauri/icons/icon.svg2
-rw-r--r--src-tauri/src/core/account_storage.rs2
-rw-r--r--src-tauri/src/core/assistant.rs694
-rw-r--r--src-tauri/src/core/auth.rs7
-rw-r--r--src-tauri/src/core/config.rs40
-rw-r--r--src-tauri/src/core/downloader.rs33
-rw-r--r--src-tauri/src/core/fabric.rs18
-rw-r--r--src-tauri/src/core/forge.rs84
-rw-r--r--src-tauri/src/core/java.rs100
-rw-r--r--src-tauri/src/core/manifest.rs9
-rw-r--r--src-tauri/src/core/maven.rs5
-rw-r--r--src-tauri/src/core/mod.rs1
-rw-r--r--src-tauri/src/core/version_merge.rs2
-rw-r--r--src-tauri/src/main.rs170
-rw-r--r--src-tauri/src/utils/mod.rs2
-rw-r--r--src-tauri/tauri.conf.json4
-rw-r--r--ui/package.json6
-rw-r--r--ui/pnpm-lock.yaml47
-rw-r--r--ui/public/vite.svg2
-rw-r--r--ui/src/App.svelte21
-rw-r--r--ui/src/assets/svelte.svg2
-rw-r--r--ui/src/components/AssistantView.svelte436
-rw-r--r--ui/src/components/ConfigEditorModal.svelte369
-rw-r--r--ui/src/components/CustomSelect.svelte43
-rw-r--r--ui/src/components/SettingsView.svelte386
-rw-r--r--ui/src/components/Sidebar.svelte5
-rw-r--r--ui/src/components/VersionsView.svelte1
-rw-r--r--ui/src/stores/assistant.svelte.ts166
-rw-r--r--ui/src/stores/logs.svelte.ts8
-rw-r--r--ui/src/stores/settings.svelte.ts167
-rw-r--r--ui/src/types/index.ts28
-rw-r--r--ui/tsconfig.app.json1
-rw-r--r--uv.lock32
45 files changed, 2907 insertions, 281 deletions
diff --git a/.github/agents/commit.agent.md b/.github/agents/commit.agent.md
index 13372e5..7187402 100644
--- a/.github/agents/commit.agent.md
+++ b/.github/agents/commit.agent.md
@@ -257,4 +257,4 @@ git commit --amend -m "<new message>"
## References
-- Commit spec: <a>.github/references/git/conventional-commit.md</a> \ No newline at end of file
+- Commit spec: <a>.github/references/git/conventional-commit.md</a>
diff --git a/.github/instructions/commit.instructions.md b/.github/instructions/commit.instructions.md
index cc29e9c..f01f080 100644
--- a/.github/instructions/commit.instructions.md
+++ b/.github/instructions/commit.instructions.md
@@ -35,4 +35,4 @@ When user requests commit help → Follow <a>.github/agents/commit.agent.md</a>
## User Triggers
-"create commit", "commit message", "conventional commit" \ No newline at end of file
+"create commit", "commit message", "conventional commit"
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 1280ad7..ba8ce54 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -40,6 +40,6 @@ jobs:
- run: pnpm lint
working-directory: ui
- - run: pnpm format --check
+ - run: pnpm format
working-directory: ui
diff --git a/.github/workflows/issue-checker.yml b/.github/workflows/issue-checker.yml
deleted file mode 100644
index a598488..0000000
--- a/.github/workflows/issue-checker.yml
+++ /dev/null
@@ -1,84 +0,0 @@
-name: Issue Checker
-
-on:
- issues:
- types: [opened, edited]
- pull_request_target:
- types: [opened, edited]
-
-permissions:
- issues: write
- pull-requests: write
-
-jobs:
- check:
- runs-on: ubuntu-latest
- steps:
- - name: Check and Label Issues
- uses: actions/github-script@v7
- with:
- script: |
- const issue = context.payload.issue || context.payload.pull_request;
- if (!issue) return;
-
- const body = issue.body || '';
- const title = issue.title || '';
- const isIssue = !!context.payload.issue;
- const issueNumber = issue.number;
-
- // Skip if "I have not read carefully" is checked - handled by issue-checkbox-checker.yml
- const hasNotReadChecked =
- body.includes('- [x] I have not read carefully') ||
- body.includes('- [X] I have not read carefully') ||
- body.includes('- [x] 我未仔细阅读') ||
- body.includes('- [X] 我未仔细阅读');
-
- if (hasNotReadChecked) {
- // Let issue-checkbox-checker handle this
- return;
- }
-
- const labels = [];
-
- // Platform labels
- if (body.match(/Windows|windows/i)) labels.push('platform: windows');
- if (body.match(/macOS|macos|Mac/i)) labels.push('platform: macos');
- if (body.match(/Linux|linux|Ubuntu|Debian|Arch|Fedora/i)) labels.push('platform: linux');
-
- // Mod loader labels
- if (body.match(/Fabric/i)) labels.push('mod-loader: fabric');
- if (body.match(/Forge/i)) labels.push('mod-loader: forge');
-
- // Java related
- if (body.match(/Java|java|JRE|JDK/i)) labels.push('java');
-
- // Authentication issues
- if (body.match(/login|authentication|Microsoft|Xbox|account/i)) labels.push('authentication');
-
- // Download issues
- if (body.match(/download|downloading|assets|libraries/i)) labels.push('download');
-
- // Launch issues
- if (body.match(/launch|start|won't start|crash|crashed/i)) labels.push('launch');
-
- // UI issues
- if (body.match(/UI|interface|display|rendering|visual/i)) labels.push('ui');
-
- // Performance issues
- if (body.match(/slow|performance|lag|freeze|hang/i)) labels.push('performance');
-
- // Check for unclear titles
- if (isIssue && (title.length < 10 ||
- title.match(/^(help|问题|stuck|卡住|error|错误|bug)$/i))) {
- labels.push('needs-clarification');
- }
-
- // Apply labels
- if (labels.length > 0) {
- await github.rest.issues.addLabels({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- labels: labels
- });
- }
diff --git a/.github/workflows/prek.yml b/.github/workflows/prek.yml
new file mode 100644
index 0000000..b49447d
--- /dev/null
+++ b/.github/workflows/prek.yml
@@ -0,0 +1,52 @@
+name: Prek Checks
+
+on:
+ push:
+ branches: ["main", "dev"]
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ prek:
+ runs-on: ubuntu-latest
+ if: "!contains(github.event.head_commit.message, '[skip ci]')"
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ fetch-depth: 0
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Install system dependencies
+ if: runner.os == 'Linux'
+ run: |
+ sudo apt-get update || true
+ sudo apt-get install -y \
+ libwebkit2gtk-4.1-dev \
+ build-essential \
+ libssl-dev \
+ libgtk-3-dev \
+ libayatana-appindicator3-dev \
+ librsvg2-dev \
+ pkg-config
+
+ - name: Run prek (auto-fix)
+ id: prek
+ uses: j178/prek-action@v1
+ continue-on-error: true
+ with:
+ all_files: true
+
+ - name: Commit fixes
+ if: steps.prek.outcome == 'failure'
+ uses: stefanzweifel/git-auto-commit-action@v5
+ with:
+ commit_message: "chore: apply prek auto-fixes [skip ci]"
+ commit_user_name: "hydroroll-bot"
+ commit_user_email: "bot@hydroroll.team"
+ skip_dirty_check: true
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 5440cd8..8bf6d2f 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -45,8 +45,8 @@ jobs:
- name: Install Dependencies (Ubuntu)
if: runner.os == 'Linux' && !matrix.wayland
run: |
- sudo apt-get update
- sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
+ sudo apt-get update || true
+ sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libfuse2
- name: Install Dependencies (Arch Linux)
if: matrix.wayland
@@ -65,6 +65,27 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
+ - name: Install Node.js
+ if: github.event_name == 'workflow_dispatch'
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Install pnpm
+ if: github.event_name == 'workflow_dispatch'
+ uses: pnpm/action-setup@v2
+ with:
+ version: 9
+
+ - name: Install Frontend Dependencies
+ if: github.event_name == 'workflow_dispatch'
+ working-directory: ./ui
+ run: pnpm install
+
+ - name: Install Tauri CLI
+ if: github.event_name == 'workflow_dispatch'
+ run: cargo install tauri-cli
+
- name: Rust Cache
uses: swatinem/rust-cache@v2
with:
@@ -74,6 +95,48 @@ jobs:
working-directory: ./src-tauri
run: cargo test --verbose
- - name: Build (Dev)
+ - name: Build Rust Only (Push/PR)
+ if: github.event_name != 'workflow_dispatch'
working-directory: ./src-tauri
run: cargo build --verbose
+
+ - name: Build App (Debug)
+ if: github.event_name == 'workflow_dispatch'
+ run: cargo tauri build --debug
+
+ - name: Get Short SHA
+ if: github.event_name == 'workflow_dispatch'
+ id: slug
+ run: echo "sha8=$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_OUTPUT
+
+ - name: Upload Artifact (Linux)
+ if: runner.os == 'Linux' && github.event_name == 'workflow_dispatch'
+ uses: actions/upload-artifact@v4
+ with:
+ name: dropout-linux-${{ matrix.wayland && 'arch' || 'ubuntu' }}-${{ steps.slug.outputs.sha8 }}
+ path: |
+ src-tauri/target/debug/bundle/appimage/*.AppImage
+ src-tauri/target/debug/bundle/deb/*.deb
+ src-tauri/target/debug/dropout
+ retention-days: 5
+
+ - name: Upload Artifact (Windows)
+ if: runner.os == 'Windows' && github.event_name == 'workflow_dispatch'
+ uses: actions/upload-artifact@v4
+ with:
+ name: dropout-windows-${{ steps.slug.outputs.sha8 }}
+ path: |
+ src-tauri/target/debug/bundle/msi/*.msi
+ src-tauri/target/debug/bundle/nsis/*.exe
+ src-tauri/target/debug/dropout.exe
+ retention-days: 5
+
+ - name: Upload Artifact (macOS)
+ if: runner.os == 'macOS' && github.event_name == 'workflow_dispatch'
+ uses: actions/upload-artifact@v4
+ with:
+ name: dropout-macos-${{ steps.slug.outputs.sha8 }}
+ path: |
+ src-tauri/target/debug/bundle/dmg/*.dmg
+ src-tauri/target/debug/bundle/macos/DropOut.app
+ retention-days: 5
diff --git a/.gitignore b/.gitignore
index 1c6830d..4d4229e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,4 +24,4 @@ node_modules/
# Python Build
dist/
-__pycache__/ \ No newline at end of file
+__pycache__/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 0929a81..5a5ed67 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,49 +1,37 @@
+ci:
+ autofix_prs: true
+ autofix_commit_msg: "[pre-commit.ci] auto fixes from pre-commit hooks [skip ci]"
+
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v6.0.0
+ rev: v5.0.0
hooks:
- - id: check-ast
- - id: check-case-conflict
- id: check-json
+ exclude: ^ui/tsconfig.*\.json$
- id: check-toml
- id: check-yaml
+ - id: check-case-conflict
- id: fix-byte-order-marker
+ - id: end-of-file-fixer
+ - id: check-merge-conflict
+ - id: detect-private-key
+ - id: check-ast
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.14.13
+ rev: v0.1.6
hooks:
- - id: ruff-check
- types_or: [ python, pyi ]
+ - id: ruff
args: [ --fix ]
- id: ruff-format
- types_or: [ python, pyi ]
- - repo: local
+ - repo: https://github.com/doublify/pre-commit-rust
+ rev: v1.0
hooks:
- - id: cargo-fmt
- name: cargo fmt
- entry: bash -c 'cd src-tauri && cargo fmt --all'
- language: system
+ - id: fmt
+ args: ["--check", "--manifest-path", "src-tauri/Cargo.toml", "--"]
files: ^src-tauri/.*\.rs$
pass_filenames: false
-
- - id: cargo-clippy
- name: cargo clippy
- entry: bash -c 'cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings'
- language: system
+ - id: clippy
+ args: ["--manifest-path", "src-tauri/Cargo.toml", "--", "-D", "warnings"]
files: ^src-tauri/.*\.rs$
pass_filenames: false
-
- - id: oxlint
- name: oxlint (svelte/ts/js)
- entry: pnpm --dir ui run lint:fix
- language: system
- files: ^ui/
- pass_filenames: false
-
- - id: oxfmt
- name: oxfmt (format)
- entry: pnpm --dir ui run format
- language: system
- files: ^ui/
- pass_filenames: false \ No newline at end of file
diff --git a/CNAME b/CNAME
index ed64ffa..b35b671 100644
--- a/CNAME
+++ b/CNAME
@@ -1 +1 @@
-dropout.hydroroll.team \ No newline at end of file
+dropout.hydroroll.team
diff --git a/README.md b/README.md
index d702fe9..337816f 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,13 @@
-# DropOut
+# Drop*O*ut
+
+[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FHsiangNianian%2FDropOut.svg?type=small)](https://app.fossa.com/projects/git%2Bgithub.com%2FHsiangNianian%2FDropOut?ref=badge_small)
+[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit)
+[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/HsiangNianian/DropOut/main.svg)](https://results.pre-commit.ci/latest/github/HsiangNianian/DropOut/main)
+[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
+[![CodeQL Advanced](https://github.com/HsiangNianian/DropOut/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/HsiangNianian/DropOut/actions/workflows/codeql.yml)
+[![Dependabot Updates](https://github.com/HsiangNianian/DropOut/actions/workflows/dependabot/dependabot-updates/badge.svg)](https://github.com/HsiangNianian/DropOut/actions/workflows/dependabot/dependabot-updates)
+[![Release](https://github.com/HsiangNianian/DropOut/actions/workflows/release.yml/badge.svg)](https://github.com/HsiangNianian/DropOut/actions/workflows/release.yml)
+[![Test & Build](https://github.com/HsiangNianian/DropOut/actions/workflows/test.yml/badge.svg)](https://github.com/HsiangNianian/DropOut/actions/workflows/test.yml)
DropOut is a modern, minimalist, and efficient Minecraft launcher built with the latest web and system technologies. It leverages **Tauri v2** to deliver a lightweight application with a robust **Rust** backend and a reactive **Svelte 5** frontend.
@@ -100,4 +109,9 @@ Contributions are welcome! Please feel free to submit a Pull Request.
## License
+[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FHsiangNianian%2FDropOut.svg?type=shield&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2FHsiangNianian%2FDropOut?ref=badge_shield&issueType=license)
+[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FHsiangNianian%2FDropOut.svg?type=shield&issueType=security)](https://app.fossa.com/projects/git%2Bgithub.com%2FHsiangNianian%2FDropOut?ref=badge_shield&issueType=security)
+
+[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FHsiangNianian%2FDropOut.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2FHsiangNianian%2FDropOut?ref=badge_large)
+
Distributed under the MIT License. See `LICENSE` for more information.
diff --git a/pyproject.toml b/pyproject.toml
index 2aec266..d49374f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,9 +4,7 @@ dynamic = ["version"]
description = "DropOut is a modern, minimalist, and efficient Minecraft launcher built with the latest web and system technologies. It leverages Tauri v2 to deliver a lightweight application with a robust Rust backend and a reactive Svelte 5 frontend."
readme = "README.md"
requires-python = ">=3.10"
-dependencies = [
- "pre-commit>=4.5.1",
-]
+dependencies = []
[build-system]
requires = ["hatchling"]
@@ -18,3 +16,8 @@ path = "_version.py"
[tool.hatch.build.targets.wheel]
packages = ["."]
only-include = ["_version.py"]
+
+[dependency-groups]
+dev = [
+ "prek>=0.2.28",
+]
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 97529a1..9fe91b7 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "dropout"
-version = "0.1.23"
+version = "0.1.25"
edition = "2021"
authors = ["HsiangNianian"]
description = "The DropOut Minecraft Game Launcher"
@@ -27,7 +27,7 @@ flate2 = "1.0"
tar = "0.4"
dirs = "5.0"
serde_urlencoded = "0.7.1"
-tauri-plugin-dialog = "2.5.0"
+tauri-plugin-dialog = "2.6.0"
tauri-plugin-fs = "2.4.5"
[build-dependencies]
@@ -45,4 +45,3 @@ section = "games"
assets = [
["target/release/dropout", "usr/bin/", "755"],
]
-
diff --git a/src-tauri/icons/icon.svg b/src-tauri/icons/icon.svg
index d8b0ed7..0baf00f 100644
--- a/src-tauri/icons/icon.svg
+++ b/src-tauri/icons/icon.svg
@@ -47,4 +47,4 @@
<!-- Layer 3: Output - x=412 -->
<circle cx="412" cy="256" r="30" fill="#7289da" stroke="#ffffff" stroke-width="4"/>
-</svg> \ No newline at end of file
+</svg>
diff --git a/src-tauri/src/core/account_storage.rs b/src-tauri/src/core/account_storage.rs
index 569df7b..8998206 100644
--- a/src-tauri/src/core/account_storage.rs
+++ b/src-tauri/src/core/account_storage.rs
@@ -138,6 +138,7 @@ impl AccountStorage {
}
}
+ #[allow(dead_code)]
pub fn set_active_account(&self, uuid: &str) -> Result<(), String> {
let mut store = self.load();
if store.accounts.iter().any(|a| a.id() == uuid) {
@@ -148,6 +149,7 @@ impl AccountStorage {
}
}
+ #[allow(dead_code)]
pub fn get_all_accounts(&self) -> Vec<StoredAccount> {
self.load().accounts
}
diff --git a/src-tauri/src/core/assistant.rs b/src-tauri/src/core/assistant.rs
new file mode 100644
index 0000000..9a8f7bf
--- /dev/null
+++ b/src-tauri/src/core/assistant.rs
@@ -0,0 +1,694 @@
+use super::config::AssistantConfig;
+use futures::StreamExt;
+use serde::{Deserialize, Serialize};
+use std::collections::VecDeque;
+use std::sync::{Arc, Mutex};
+use tauri::{Emitter, Window};
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Message {
+ pub role: String,
+ pub content: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct OllamaChatRequest {
+ pub model: String,
+ pub messages: Vec<Message>,
+ pub stream: bool,
+}
+
+#[derive(Debug, Deserialize)]
+#[allow(dead_code)]
+pub struct OllamaChatResponse {
+ pub model: String,
+ pub created_at: String,
+ pub message: Message,
+ pub done: bool,
+}
+
+// Ollama model list response structures
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct OllamaModelDetails {
+ pub format: Option<String>,
+ pub family: Option<String>,
+ pub parameter_size: Option<String>,
+ pub quantization_level: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct OllamaModel {
+ pub name: String,
+ pub modified_at: Option<String>,
+ pub size: Option<u64>,
+ pub digest: Option<String>,
+ pub details: Option<OllamaModelDetails>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct OllamaTagsResponse {
+ pub models: Vec<OllamaModel>,
+}
+
+// Simplified model info for frontend
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ModelInfo {
+ pub id: String,
+ pub name: String,
+ pub size: Option<String>,
+ pub details: Option<String>,
+}
+
+#[derive(Debug, Serialize)]
+pub struct OpenAIChatRequest {
+ pub model: String,
+ pub messages: Vec<Message>,
+ pub stream: bool,
+}
+
+#[derive(Debug, Deserialize)]
+#[allow(dead_code)]
+pub struct OpenAIChoice {
+ pub index: i32,
+ pub message: Message,
+ pub finish_reason: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+#[allow(dead_code)]
+pub struct OpenAIChatResponse {
+ pub id: String,
+ pub object: String,
+ pub created: i64,
+ pub model: String,
+ pub choices: Vec<OpenAIChoice>,
+}
+
+// OpenAI models list response
+#[derive(Debug, Deserialize)]
+#[allow(dead_code)]
+pub struct OpenAIModelData {
+ pub id: String,
+ pub object: String,
+ pub created: Option<i64>,
+ pub owned_by: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+#[allow(dead_code)]
+pub struct OpenAIModelsResponse {
+ pub object: String,
+ pub data: Vec<OpenAIModelData>,
+}
+
+// Streaming response structures
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct GenerationStats {
+ pub total_duration: u64,
+ pub load_duration: u64,
+ pub prompt_eval_count: u64,
+ pub prompt_eval_duration: u64,
+ pub eval_count: u64,
+ pub eval_duration: u64,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct StreamChunk {
+ pub content: String,
+ pub done: bool,
+ pub stats: Option<GenerationStats>,
+}
+
+// Ollama streaming response (each line is a JSON object)
+#[derive(Debug, Deserialize)]
+#[allow(dead_code)]
+pub struct OllamaStreamResponse {
+ pub model: Option<String>,
+ pub created_at: Option<String>,
+ pub message: Option<Message>,
+ pub done: bool,
+ pub total_duration: Option<u64>,
+ pub load_duration: Option<u64>,
+ pub prompt_eval_count: Option<u64>,
+ pub prompt_eval_duration: Option<u64>,
+ pub eval_count: Option<u64>,
+ pub eval_duration: Option<u64>,
+}
+
+// OpenAI streaming response
+#[derive(Debug, Deserialize)]
+#[allow(dead_code)]
+pub struct OpenAIStreamDelta {
+ pub role: Option<String>,
+ pub content: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+#[allow(dead_code)]
+pub struct OpenAIStreamChoice {
+ pub index: i32,
+ pub delta: OpenAIStreamDelta,
+ pub finish_reason: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+#[allow(dead_code)]
+pub struct OpenAIStreamResponse {
+ pub id: Option<String>,
+ pub object: Option<String>,
+ pub created: Option<i64>,
+ pub model: Option<String>,
+ pub choices: Vec<OpenAIStreamChoice>,
+}
+
+#[derive(Clone)]
+pub struct GameAssistant {
+ client: reqwest::Client,
+ pub log_buffer: VecDeque<String>,
+ pub max_log_lines: usize,
+}
+
+impl GameAssistant {
+ pub fn new() -> Self {
+ Self {
+ client: reqwest::Client::new(),
+ log_buffer: VecDeque::new(),
+ max_log_lines: 100,
+ }
+ }
+
+ pub fn add_log(&mut self, line: String) {
+ if self.log_buffer.len() >= self.max_log_lines {
+ self.log_buffer.pop_front();
+ }
+ self.log_buffer.push_back(line);
+ }
+
+ pub fn get_log_context(&self) -> String {
+ self.log_buffer
+ .iter()
+ .cloned()
+ .collect::<Vec<_>>()
+ .join("\n")
+ }
+
+ pub async fn check_health(&self, config: &AssistantConfig) -> bool {
+ if config.llm_provider == "ollama" {
+ match self
+ .client
+ .get(format!("{}/api/tags", config.ollama_endpoint))
+ .send()
+ .await
+ {
+ Ok(res) => res.status().is_success(),
+ Err(_) => false,
+ }
+ } else if config.llm_provider == "openai" {
+ // For OpenAI, just check if API key is set
+ config.openai_api_key.is_some() && !config.openai_api_key.as_ref().unwrap().is_empty()
+ } else {
+ false
+ }
+ }
+
+ pub async fn chat(
+ &self,
+ mut messages: Vec<Message>,
+ config: &AssistantConfig,
+ ) -> Result<Message, String> {
+ // Inject system prompt and log context
+ if !messages.iter().any(|m| m.role == "system") {
+ let context = self.get_log_context();
+ let mut system_content = config.system_prompt.clone();
+
+ // Add language instruction if not auto
+ if config.response_language != "auto" {
+ system_content = format!("{}\n\nIMPORTANT: Respond in {}. Do not include Pinyin or English translations unless explicitly requested.", system_content, config.response_language);
+ }
+
+ // Add log context if available
+ if !context.is_empty() {
+ system_content = format!(
+ "{}\n\nRecent game logs:\n```\n{}\n```",
+ system_content, context
+ );
+ }
+
+ messages.insert(
+ 0,
+ Message {
+ role: "system".to_string(),
+ content: system_content,
+ },
+ );
+ }
+
+ if config.llm_provider == "ollama" {
+ self.chat_ollama(messages, config).await
+ } else if config.llm_provider == "openai" {
+ self.chat_openai(messages, config).await
+ } else {
+ Err(format!("Unknown LLM provider: {}", config.llm_provider))
+ }
+ }
+
+ async fn chat_ollama(
+ &self,
+ messages: Vec<Message>,
+ config: &AssistantConfig,
+ ) -> Result<Message, String> {
+ let request = OllamaChatRequest {
+ model: config.ollama_model.clone(),
+ messages,
+ stream: false,
+ };
+
+ let response = self
+ .client
+ .post(format!("{}/api/chat", config.ollama_endpoint))
+ .json(&request)
+ .send()
+ .await
+ .map_err(|e| format!("Ollama request failed: {}", e))?;
+
+ if !response.status().is_success() {
+ return Err(format!("Ollama API returned error: {}", response.status()));
+ }
+
+ let chat_response: OllamaChatResponse = response
+ .json()
+ .await
+ .map_err(|e| format!("Failed to parse Ollama response: {}", e))?;
+
+ Ok(chat_response.message)
+ }
+
+ async fn chat_openai(
+ &self,
+ messages: Vec<Message>,
+ config: &AssistantConfig,
+ ) -> Result<Message, String> {
+ let api_key = config
+ .openai_api_key
+ .as_ref()
+ .ok_or("OpenAI API key not configured")?;
+
+ let request = OpenAIChatRequest {
+ model: config.openai_model.clone(),
+ messages,
+ stream: false,
+ };
+
+ let response = self
+ .client
+ .post(format!("{}/chat/completions", config.openai_endpoint))
+ .header("Authorization", format!("Bearer {}", api_key))
+ .header("Content-Type", "application/json")
+ .json(&request)
+ .send()
+ .await
+ .map_err(|e| format!("OpenAI request failed: {}", e))?;
+
+ if !response.status().is_success() {
+ let status = response.status();
+ let error_text = response.text().await.unwrap_or_default();
+ return Err(format!("OpenAI API error ({}): {}", status, error_text));
+ }
+
+ let chat_response: OpenAIChatResponse = response
+ .json()
+ .await
+ .map_err(|e| format!("Failed to parse OpenAI response: {}", e))?;
+
+ chat_response
+ .choices
+ .into_iter()
+ .next()
+ .map(|c| c.message)
+ .ok_or_else(|| "No response from OpenAI".to_string())
+ }
+
+ pub async fn list_ollama_models(&self, endpoint: &str) -> Result<Vec<ModelInfo>, String> {
+ let response = self
+ .client
+ .get(format!("{}/api/tags", endpoint))
+ .send()
+ .await
+ .map_err(|e| format!("Failed to connect to Ollama: {}", e))?;
+
+ if !response.status().is_success() {
+ return Err(format!("Ollama API error: {}", response.status()));
+ }
+
+ let tags_response: OllamaTagsResponse = response
+ .json()
+ .await
+ .map_err(|e| format!("Failed to parse Ollama response: {}", e))?;
+
+ let models: Vec<ModelInfo> = tags_response
+ .models
+ .into_iter()
+ .map(|m| {
+ let size_str = m.size.map(format_size);
+ let details_str = m.details.map(|d| {
+ let mut parts = Vec::new();
+ if let Some(family) = d.family {
+ parts.push(family);
+ }
+ if let Some(params) = d.parameter_size {
+ parts.push(params);
+ }
+ if let Some(quant) = d.quantization_level {
+ parts.push(quant);
+ }
+ parts.join(" / ")
+ });
+
+ ModelInfo {
+ id: m.name.clone(),
+ name: m.name,
+ size: size_str,
+ details: details_str,
+ }
+ })
+ .collect();
+
+ Ok(models)
+ }
+
+ pub async fn list_openai_models(
+ &self,
+ config: &AssistantConfig,
+ ) -> Result<Vec<ModelInfo>, String> {
+ let api_key = config
+ .openai_api_key
+ .as_ref()
+ .ok_or("OpenAI API key not configured")?;
+
+ let response = self
+ .client
+ .get(format!("{}/models", config.openai_endpoint))
+ .header("Authorization", format!("Bearer {}", api_key))
+ .send()
+ .await
+ .map_err(|e| format!("Failed to connect to OpenAI: {}", e))?;
+
+ if !response.status().is_success() {
+ let status = response.status();
+ let error_text = response.text().await.unwrap_or_default();
+ return Err(format!("OpenAI API error ({}): {}", status, error_text));
+ }
+
+ let models_response: OpenAIModelsResponse = response
+ .json()
+ .await
+ .map_err(|e| format!("Failed to parse OpenAI response: {}", e))?;
+
+ // Filter to only show chat models (gpt-*)
+ let models: Vec<ModelInfo> = models_response
+ .data
+ .into_iter()
+ .filter(|m| {
+ m.id.starts_with("gpt-") || m.id.starts_with("o1") || m.id.contains("turbo")
+ })
+ .map(|m| ModelInfo {
+ id: m.id.clone(),
+ name: m.id,
+ size: None,
+ details: m.owned_by,
+ })
+ .collect();
+
+ Ok(models)
+ }
+
+ // Streaming chat methods
+ pub async fn chat_stream(
+ &self,
+ mut messages: Vec<Message>,
+ config: &AssistantConfig,
+ window: &Window,
+ ) -> Result<String, String> {
+ // Inject system prompt and log context
+ if !messages.iter().any(|m| m.role == "system") {
+ let context = self.get_log_context();
+ let mut system_content = config.system_prompt.clone();
+
+ if config.response_language != "auto" {
+ system_content = format!("{}\n\nIMPORTANT: Respond in {}. Do not include Pinyin or English translations unless explicitly requested.", system_content, config.response_language);
+ }
+
+ if !context.is_empty() {
+ system_content = format!(
+ "{}\n\nRecent game logs:\n```\n{}\n```",
+ system_content, context
+ );
+ }
+
+ messages.insert(
+ 0,
+ Message {
+ role: "system".to_string(),
+ content: system_content,
+ },
+ );
+ }
+
+ if config.llm_provider == "ollama" {
+ self.chat_stream_ollama(messages, config, window).await
+ } else if config.llm_provider == "openai" {
+ self.chat_stream_openai(messages, config, window).await
+ } else {
+ Err(format!("Unknown LLM provider: {}", config.llm_provider))
+ }
+ }
+
+ async fn chat_stream_ollama(
+ &self,
+ messages: Vec<Message>,
+ config: &AssistantConfig,
+ window: &Window,
+ ) -> Result<String, String> {
+ let request = OllamaChatRequest {
+ model: config.ollama_model.clone(),
+ messages,
+ stream: true,
+ };
+
+ let response = self
+ .client
+ .post(format!("{}/api/chat", config.ollama_endpoint))
+ .json(&request)
+ .send()
+ .await
+ .map_err(|e| format!("Ollama request failed: {}", e))?;
+
+ if !response.status().is_success() {
+ return Err(format!("Ollama API returned error: {}", response.status()));
+ }
+
+ let mut full_content = String::new();
+ let mut stream = response.bytes_stream();
+
+ while let Some(chunk_result) = stream.next().await {
+ match chunk_result {
+ Ok(chunk) => {
+ let text = String::from_utf8_lossy(&chunk);
+ // Ollama returns newline-delimited JSON
+ for line in text.lines() {
+ if line.trim().is_empty() {
+ continue;
+ }
+ if let Ok(stream_response) =
+ serde_json::from_str::<OllamaStreamResponse>(line)
+ {
+ if let Some(msg) = stream_response.message {
+ full_content.push_str(&msg.content);
+ let _ = window.emit(
+ "assistant-stream",
+ StreamChunk {
+ content: msg.content,
+ done: stream_response.done,
+ stats: None,
+ },
+ );
+ }
+ if stream_response.done {
+ let stats = if let (
+ Some(total),
+ Some(load),
+ Some(prompt_cnt),
+ Some(prompt_dur),
+ Some(eval_cnt),
+ Some(eval_dur),
+ ) = (
+ stream_response.total_duration,
+ stream_response.load_duration,
+ stream_response.prompt_eval_count,
+ stream_response.prompt_eval_duration,
+ stream_response.eval_count,
+ stream_response.eval_duration,
+ ) {
+ Some(GenerationStats {
+ total_duration: total,
+ load_duration: load,
+ prompt_eval_count: prompt_cnt,
+ prompt_eval_duration: prompt_dur,
+ eval_count: eval_cnt,
+ eval_duration: eval_dur,
+ })
+ } else {
+ None
+ };
+
+ let _ = window.emit(
+ "assistant-stream",
+ StreamChunk {
+ content: String::new(),
+ done: true,
+ stats,
+ },
+ );
+ }
+ }
+ }
+ }
+ Err(e) => {
+ return Err(format!("Stream error: {}", e));
+ }
+ }
+ }
+
+ Ok(full_content)
+ }
+
+ async fn chat_stream_openai(
+ &self,
+ messages: Vec<Message>,
+ config: &AssistantConfig,
+ window: &Window,
+ ) -> Result<String, String> {
+ let api_key = config
+ .openai_api_key
+ .as_ref()
+ .ok_or("OpenAI API key not configured")?;
+
+ let request = OpenAIChatRequest {
+ model: config.openai_model.clone(),
+ messages,
+ stream: true,
+ };
+
+ let response = self
+ .client
+ .post(format!("{}/chat/completions", config.openai_endpoint))
+ .header("Authorization", format!("Bearer {}", api_key))
+ .header("Content-Type", "application/json")
+ .json(&request)
+ .send()
+ .await
+ .map_err(|e| format!("OpenAI request failed: {}", e))?;
+
+ if !response.status().is_success() {
+ let status = response.status();
+ let error_text = response.text().await.unwrap_or_default();
+ return Err(format!("OpenAI API error ({}): {}", status, error_text));
+ }
+
+ let mut full_content = String::new();
+ let mut stream = response.bytes_stream();
+ let mut buffer = String::new();
+
+ while let Some(chunk_result) = stream.next().await {
+ match chunk_result {
+ Ok(chunk) => {
+ buffer.push_str(&String::from_utf8_lossy(&chunk));
+
+ // Process complete lines
+ while let Some(pos) = buffer.find('\n') {
+ let line = buffer[..pos].to_string();
+ buffer = buffer[pos + 1..].to_string();
+
+ let line = line.trim();
+ if line.is_empty() || line == "data: [DONE]" {
+ if line == "data: [DONE]" {
+ let _ = window.emit(
+ "assistant-stream",
+ StreamChunk {
+ content: String::new(),
+ done: true,
+ stats: None,
+ },
+ );
+ }
+ continue;
+ }
+
+ if let Some(data) = line.strip_prefix("data: ") {
+ if let Ok(stream_response) =
+ serde_json::from_str::<OpenAIStreamResponse>(data)
+ {
+ if let Some(choice) = stream_response.choices.first() {
+ if let Some(content) = &choice.delta.content {
+ full_content.push_str(content);
+ let _ = window.emit(
+ "assistant-stream",
+ StreamChunk {
+ content: content.clone(),
+ done: false,
+ stats: None,
+ },
+ );
+ }
+ if choice.finish_reason.is_some() {
+ let _ = window.emit(
+ "assistant-stream",
+ StreamChunk {
+ content: String::new(),
+ done: true,
+ stats: None,
+ },
+ );
+ }
+ }
+ }
+ }
+ }
+ }
+ Err(e) => {
+ return Err(format!("Stream error: {}", e));
+ }
+ }
+ }
+
+ Ok(full_content)
+ }
+}
+
+fn format_size(bytes: u64) -> String {
+ const KB: u64 = 1024;
+ const MB: u64 = KB * 1024;
+ const GB: u64 = MB * 1024;
+
+ if bytes >= GB {
+ format!("{:.1} GB", bytes as f64 / GB as f64)
+ } else if bytes >= MB {
+ format!("{:.1} MB", bytes as f64 / MB as f64)
+ } else if bytes >= KB {
+ format!("{:.1} KB", bytes as f64 / KB as f64)
+ } else {
+ format!("{} B", bytes)
+ }
+}
+
+pub struct AssistantState {
+ pub assistant: Arc<Mutex<GameAssistant>>,
+}
+
+impl AssistantState {
+ pub fn new() -> Self {
+ Self {
+ assistant: Arc::new(Mutex::new(GameAssistant::new())),
+ }
+ }
+}
diff --git a/src-tauri/src/core/auth.rs b/src-tauri/src/core/auth.rs
index 5f01a58..ac5904c 100644
--- a/src-tauri/src/core/auth.rs
+++ b/src-tauri/src/core/auth.rs
@@ -136,6 +136,7 @@ pub async fn refresh_microsoft_token(refresh_token: &str) -> Result<TokenRespons
}
/// Check if a Microsoft account token is expired or about to expire
+#[allow(dead_code)]
pub fn is_token_expired(expires_at: i64) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -430,17 +431,21 @@ pub async fn fetch_profile(mc_access_token: &str) -> Result<MinecraftProfile, St
// 7. Check Game Ownership
#[derive(Debug, Serialize, Deserialize)]
+#[allow(dead_code)]
pub struct Entitlement {
pub name: String,
}
#[derive(Debug, Serialize, Deserialize)]
+#[allow(dead_code)]
pub struct EntitlementsResponse {
pub items: Vec<Entitlement>,
pub signature: Option<String>,
- pub keyId: Option<String>,
+ #[serde(rename = "keyId")]
+ pub key_id: Option<String>,
}
+#[allow(dead_code)]
pub async fn check_ownership(mc_access_token: &str) -> Result<bool, String> {
let client = get_client();
let url = "https://api.minecraftservices.com/entitlements/mcstore";
diff --git a/src-tauri/src/core/config.rs b/src-tauri/src/core/config.rs
index 43c8145..4c4acad 100644
--- a/src-tauri/src/core/config.rs
+++ b/src-tauri/src/core/config.rs
@@ -6,6 +6,44 @@ use tauri::{AppHandle, Manager};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
+pub struct AssistantConfig {
+ pub enabled: bool,
+ pub llm_provider: String, // "ollama" or "openai"
+ // Ollama settings
+ pub ollama_endpoint: String,
+ pub ollama_model: String,
+ // OpenAI settings
+ pub openai_api_key: Option<String>,
+ pub openai_endpoint: String,
+ pub openai_model: String,
+ // Common settings
+ pub system_prompt: String,
+ pub response_language: String,
+ // TTS settings
+ pub tts_enabled: bool,
+ pub tts_provider: String, // "disabled", "piper", "edge"
+}
+
+impl Default for AssistantConfig {
+ fn default() -> Self {
+ Self {
+ enabled: true,
+ llm_provider: "ollama".to_string(),
+ ollama_endpoint: "http://localhost:11434".to_string(),
+ ollama_model: "llama3".to_string(),
+ openai_api_key: None,
+ openai_endpoint: "https://api.openai.com/v1".to_string(),
+ openai_model: "gpt-3.5-turbo".to_string(),
+ system_prompt: "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice.".to_string(),
+ response_language: "auto".to_string(),
+ tts_enabled: false,
+ tts_provider: "disabled".to_string(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(default)]
pub struct LauncherConfig {
pub min_memory: u32, // in MB
pub max_memory: u32, // in MB
@@ -20,6 +58,7 @@ pub struct LauncherConfig {
pub theme: String,
pub log_upload_service: String, // "paste.rs" or "pastebin.com"
pub pastebin_api_key: Option<String>,
+ pub assistant: AssistantConfig,
}
impl Default for LauncherConfig {
@@ -38,6 +77,7 @@ impl Default for LauncherConfig {
theme: "dark".to_string(),
log_upload_service: "paste.rs".to_string(),
pastebin_api_key: None,
+ assistant: AssistantConfig::default(),
}
}
}
diff --git a/src-tauri/src/core/downloader.rs b/src-tauri/src/core/downloader.rs
index bf6334f..9c6b7f0 100644
--- a/src-tauri/src/core/downloader.rs
+++ b/src-tauri/src/core/downloader.rs
@@ -111,9 +111,8 @@ impl DownloadQueue {
/// Remove a completed or cancelled download
pub fn remove(&mut self, major_version: u32, image_type: &str) {
- self.pending_downloads.retain(|d| {
- !(d.major_version == major_version && d.image_type == image_type)
- });
+ self.pending_downloads
+ .retain(|d| !(d.major_version == major_version && d.image_type == image_type));
}
}
@@ -174,7 +173,8 @@ pub async fn download_with_resume(
let content = tokio::fs::read_to_string(&meta_path)
.await
.map_err(|e| e.to_string())?;
- serde_json::from_str(&content).unwrap_or_else(|_| create_new_metadata(url, &file_name, total_size, checksum))
+ serde_json::from_str(&content)
+ .unwrap_or_else(|_| create_new_metadata(url, &file_name, total_size, checksum))
} else {
create_new_metadata(url, &file_name, total_size, checksum)
};
@@ -191,6 +191,7 @@ pub async fn download_with_resume(
.create(true)
.write(true)
.read(true)
+ .truncate(false)
.open(&part_path)
.await
.map_err(|e| format!("Failed to open part file: {}", e))?;
@@ -220,9 +221,7 @@ pub async fn download_with_resume(
let segment_end = segment.end;
let app_handle = app_handle.clone();
let file_name = file_name.clone();
- let total_size = total_size;
let last_progress_bytes = last_progress_bytes.clone();
- let start_time = start_time.clone();
let handle = tokio::spawn(async move {
let _permit = semaphore.acquire().await.unwrap();
@@ -240,7 +239,9 @@ pub async fn download_with_resume(
.await
.map_err(|e| format!("Request failed: {}", e))?;
- if !response.status().is_success() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
+ if !response.status().is_success()
+ && response.status() != reqwest::StatusCode::PARTIAL_CONTENT
+ {
return Err(format!("Server returned error: {}", response.status()));
}
@@ -319,7 +320,8 @@ pub async fn download_with_resume(
if e.contains("cancelled") {
// Save progress for resume
metadata.downloaded_bytes = progress.load(Ordering::Relaxed);
- let meta_content = serde_json::to_string_pretty(&metadata).map_err(|e| e.to_string())?;
+ let meta_content =
+ serde_json::to_string_pretty(&metadata).map_err(|e| e.to_string())?;
tokio::fs::write(&meta_path, meta_content).await.ok();
return Err(e);
}
@@ -357,7 +359,7 @@ pub async fn download_with_resume(
let data = tokio::fs::read(&part_path)
.await
.map_err(|e| format!("Failed to read file for verification: {}", e))?;
-
+
if !verify_checksum(&data, Some(expected), None) {
// Checksum failed, delete files and retry
tokio::fs::remove_file(&part_path).await.ok();
@@ -378,7 +380,12 @@ pub async fn download_with_resume(
}
/// Create new download metadata with segments
-fn create_new_metadata(url: &str, file_name: &str, total_size: u64, checksum: Option<&str>) -> DownloadMetadata {
+fn create_new_metadata(
+ url: &str,
+ file_name: &str,
+ total_size: u64,
+ checksum: Option<&str>,
+) -> DownloadMetadata {
let segment_count = get_segment_count(total_size);
let segment_size = total_size / segment_count as u64;
let mut segments = Vec::new();
@@ -559,11 +566,7 @@ pub async fn download_files(
if task.sha256.is_some() || task.sha1.is_some() {
if let Ok(data) = tokio::fs::read(&task.path).await {
- if verify_checksum(
- &data,
- task.sha256.as_deref(),
- task.sha1.as_deref(),
- ) {
+ if verify_checksum(&data, task.sha256.as_deref(), task.sha1.as_deref()) {
// Already valid, skip download
let skipped_size = tokio::fs::metadata(&task.path)
.await
diff --git a/src-tauri/src/core/fabric.rs b/src-tauri/src/core/fabric.rs
index 3e4d50d..32790c7 100644
--- a/src-tauri/src/core/fabric.rs
+++ b/src-tauri/src/core/fabric.rs
@@ -67,13 +67,11 @@ pub struct FabricLibrary {
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(untagged)]
pub enum FabricMainClass {
- Structured {
- client: String,
- server: String,
- },
+ Structured { client: String, server: String },
Simple(String),
}
+#[allow(dead_code)]
impl FabricMainClass {
pub fn client(&self) -> &str {
match self {
@@ -81,7 +79,7 @@ impl FabricMainClass {
FabricMainClass::Simple(s) => s,
}
}
-
+
pub fn server(&self) -> &str {
match self {
FabricMainClass::Structured { server, .. } => server,
@@ -200,7 +198,7 @@ pub fn generate_version_id(game_version: &str, loader_version: &str) -> String {
/// # Returns
/// Information about the installed version.
pub async fn install_fabric(
- game_dir: &PathBuf,
+ game_dir: &std::path::Path,
game_version: &str,
loader_version: &str,
) -> Result<InstalledFabricVersion, Box<dyn Error + Send + Sync>> {
@@ -240,7 +238,11 @@ pub async fn install_fabric(
///
/// # Returns
/// `true` if the version JSON exists, `false` otherwise.
-pub fn is_fabric_installed(game_dir: &PathBuf, game_version: &str, loader_version: &str) -> bool {
+pub fn is_fabric_installed(
+ game_dir: &std::path::Path,
+ game_version: &str,
+ loader_version: &str,
+) -> bool {
let version_id = generate_version_id(game_version, loader_version);
let json_path = game_dir
.join("versions")
@@ -257,7 +259,7 @@ pub fn is_fabric_installed(game_dir: &PathBuf, game_version: &str, loader_versio
/// # Returns
/// A list of installed Fabric version IDs.
pub async fn list_installed_fabric_versions(
- game_dir: &PathBuf,
+ game_dir: &std::path::Path,
) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> {
let versions_dir = game_dir.join("versions");
let mut installed = Vec::new();
diff --git a/src-tauri/src/core/forge.rs b/src-tauri/src/core/forge.rs
index e69b296..0528b76 100644
--- a/src-tauri/src/core/forge.rs
+++ b/src-tauri/src/core/forge.rs
@@ -9,6 +9,8 @@
use serde::{Deserialize, Serialize};
use std::error::Error;
+#[cfg(target_os = "windows")]
+use std::os::windows::process::CommandExt;
use std::path::PathBuf;
const FORGE_PROMOTIONS_URL: &str =
@@ -43,6 +45,7 @@ pub struct InstalledForgeVersion {
/// Forge installer manifest structure (from version.json inside installer JAR)
#[derive(Debug, Deserialize)]
+#[allow(dead_code)]
struct ForgeInstallerManifest {
id: Option<String>,
#[serde(rename = "inheritsFrom")]
@@ -183,30 +186,30 @@ async fn fetch_forge_installer_manifest(
forge_version: &str,
) -> Result<ForgeInstallerManifest, Box<dyn Error + Send + Sync>> {
let forge_full = format!("{}-{}", game_version, forge_version);
-
+
// Download the installer JAR to extract version.json
let installer_url = format!(
"{}net/minecraftforge/forge/{}/forge-{}-installer.jar",
FORGE_MAVEN_URL, forge_full, forge_full
);
-
+
println!("Fetching Forge installer from: {}", installer_url);
-
+
let response = reqwest::get(&installer_url).await?;
if !response.status().is_success() {
return Err(format!("Failed to download Forge installer: {}", response.status()).into());
}
-
+
let bytes = response.bytes().await?;
-
+
// Extract version.json from the JAR (which is a ZIP file)
let cursor = std::io::Cursor::new(bytes.as_ref());
let mut archive = zip::ZipArchive::new(cursor)?;
-
+
// Look for version.json in the archive
let version_json = archive.by_name("version.json")?;
let manifest: ForgeInstallerManifest = serde_json::from_reader(version_json)?;
-
+
Ok(manifest)
}
@@ -224,7 +227,7 @@ async fn fetch_forge_installer_manifest(
/// # Returns
/// Information about the installed version.
pub async fn install_forge(
- game_dir: &PathBuf,
+ game_dir: &std::path::Path,
game_version: &str,
forge_version: &str,
) -> Result<InstalledForgeVersion, Box<dyn Error + Send + Sync>> {
@@ -234,7 +237,8 @@ pub async fn install_forge(
let manifest = fetch_forge_installer_manifest(game_version, forge_version).await?;
// Create version JSON from the manifest
- let version_json = create_forge_version_json_from_manifest(game_version, forge_version, &manifest)?;
+ let version_json =
+ create_forge_version_json_from_manifest(game_version, forge_version, &manifest)?;
// Create the version directory
let version_dir = game_dir.join("versions").join(&version_id);
@@ -275,42 +279,46 @@ pub async fn run_forge_installer(
"{}net/minecraftforge/forge/{}-{}/forge-{}-{}-installer.jar",
FORGE_MAVEN_URL, game_version, forge_version, game_version, forge_version
);
-
+
let installer_path = game_dir.join("forge-installer.jar");
-
+
// Download installer
let client = reqwest::Client::new();
let response = client.get(&installer_url).send().await?;
-
+
if !response.status().is_success() {
return Err(format!("Failed to download Forge installer: {}", response.status()).into());
}
-
+
let bytes = response.bytes().await?;
tokio::fs::write(&installer_path, &bytes).await?;
-
+
// Run the installer in headless mode
// The installer accepts --installClient <path> to install to a specific directory
- let output = tokio::process::Command::new(java_path)
- .arg("-jar")
+ let mut cmd = tokio::process::Command::new(java_path);
+ cmd.arg("-jar")
.arg(&installer_path)
.arg("--installClient")
- .arg(game_dir)
- .output()
- .await?;
-
+ .arg(game_dir);
+
+ #[cfg(target_os = "windows")]
+ cmd.creation_flags(0x08000000);
+
+ let output = cmd.output().await?;
+
// Clean up installer
let _ = tokio::fs::remove_file(&installer_path).await;
-
+
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
return Err(format!(
"Forge installer failed:\nstdout: {}\nstderr: {}",
stdout, stderr
- ).into());
+ )
+ .into());
}
-
+
Ok(())
}
@@ -332,13 +340,14 @@ fn create_forge_version_json_from_manifest(
});
// Convert libraries to JSON format, preserving download info
- let lib_entries: Vec<serde_json::Value> = manifest.libraries
+ let lib_entries: Vec<serde_json::Value> = manifest
+ .libraries
.iter()
.map(|lib| {
let mut entry = serde_json::json!({
"name": lib.name
});
-
+
// Add URL if present
if let Some(url) = &lib.url {
entry["url"] = serde_json::Value::String(url.clone());
@@ -346,19 +355,22 @@ fn create_forge_version_json_from_manifest(
// Default to Forge Maven for Forge libraries
entry["url"] = serde_json::Value::String(FORGE_MAVEN_URL.to_string());
}
-
+
// Add downloads if present
if let Some(downloads) = &lib.downloads {
if let Some(artifact) = &downloads.artifact {
let mut artifact_json = serde_json::Map::new();
if let Some(path) = &artifact.path {
- artifact_json.insert("path".to_string(), serde_json::Value::String(path.clone()));
+ artifact_json
+ .insert("path".to_string(), serde_json::Value::String(path.clone()));
}
if let Some(url) = &artifact.url {
- artifact_json.insert("url".to_string(), serde_json::Value::String(url.clone()));
+ artifact_json
+ .insert("url".to_string(), serde_json::Value::String(url.clone()));
}
if let Some(sha1) = &artifact.sha1 {
- artifact_json.insert("sha1".to_string(), serde_json::Value::String(sha1.clone()));
+ artifact_json
+ .insert("sha1".to_string(), serde_json::Value::String(sha1.clone()));
}
if !artifact_json.is_empty() {
entry["downloads"] = serde_json::json!({
@@ -367,7 +379,7 @@ fn create_forge_version_json_from_manifest(
}
}
}
-
+
entry
})
.collect();
@@ -377,7 +389,7 @@ fn create_forge_version_json_from_manifest(
"game": [],
"jvm": []
});
-
+
if let Some(args) = &manifest.arguments {
if let Some(game_args) = &args.game {
arguments["game"] = serde_json::Value::Array(game_args.clone());
@@ -461,7 +473,12 @@ fn is_modern_forge(game_version: &str) -> bool {
///
/// # Returns
/// `true` if the version JSON exists, `false` otherwise.
-pub fn is_forge_installed(game_dir: &PathBuf, game_version: &str, forge_version: &str) -> bool {
+#[allow(dead_code)]
+pub fn is_forge_installed(
+ game_dir: &std::path::Path,
+ game_version: &str,
+ forge_version: &str,
+) -> bool {
let version_id = generate_version_id(game_version, forge_version);
let json_path = game_dir
.join("versions")
@@ -477,8 +494,9 @@ pub fn is_forge_installed(game_dir: &PathBuf, game_version: &str, forge_version:
///
/// # Returns
/// A list of installed Forge version IDs.
+#[allow(dead_code)]
pub async fn list_installed_forge_versions(
- game_dir: &PathBuf,
+ game_dir: &std::path::Path,
) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> {
let versions_dir = game_dir.join("versions");
let mut installed = Vec::new();
diff --git a/src-tauri/src/core/java.rs b/src-tauri/src/core/java.rs
index 8341138..1d57d21 100644
--- a/src-tauri/src/core/java.rs
+++ b/src-tauri/src/core/java.rs
@@ -1,16 +1,30 @@
use serde::{Deserialize, Serialize};
+#[cfg(target_os = "windows")]
+use std::os::windows::process::CommandExt;
use std::path::PathBuf;
use std::process::Command;
use tauri::AppHandle;
use tauri::Emitter;
use tauri::Manager;
-use crate::core::downloader::{self, JavaDownloadProgress, DownloadQueue, PendingJavaDownload};
+use crate::core::downloader::{self, DownloadQueue, JavaDownloadProgress, PendingJavaDownload};
use crate::utils::zip;
const ADOPTIUM_API_BASE: &str = "https://api.adoptium.net/v3";
const CACHE_DURATION_SECS: u64 = 24 * 60 * 60; // 24 hours
+/// Helper to strip UNC prefix on Windows (\\?\)
+fn strip_unc_prefix(path: PathBuf) -> PathBuf {
+ #[cfg(target_os = "windows")]
+ {
+ let s = path.to_string_lossy().to_string();
+ if s.starts_with(r"\\?\") {
+ return PathBuf::from(&s[4..]);
+ }
+ }
+ path
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JavaInstallation {
pub path: String,
@@ -58,7 +72,7 @@ pub struct JavaReleaseInfo {
}
/// Java catalog containing all available versions
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct JavaCatalog {
pub releases: Vec<JavaReleaseInfo>,
pub available_major_versions: Vec<u32>,
@@ -66,17 +80,6 @@ pub struct JavaCatalog {
pub cached_at: u64,
}
-impl Default for JavaCatalog {
- fn default() -> Self {
- Self {
- releases: Vec::new(),
- available_major_versions: Vec::new(),
- lts_versions: Vec::new(),
- cached_at: 0,
- }
- }
-}
-
/// Adoptium `/v3/assets/latest/{version}/hotspot` API response structures
#[derive(Debug, Clone, Deserialize)]
pub struct AdoptiumAsset {
@@ -86,6 +89,7 @@ pub struct AdoptiumAsset {
}
#[derive(Debug, Clone, Deserialize)]
+#[allow(dead_code)]
pub struct AdoptiumBinary {
pub os: String,
pub architecture: String,
@@ -104,6 +108,7 @@ pub struct AdoptiumPackage {
}
#[derive(Debug, Clone, Deserialize)]
+#[allow(dead_code)]
pub struct AdoptiumVersionData {
pub major: u32,
pub minor: u32,
@@ -114,6 +119,7 @@ pub struct AdoptiumVersionData {
/// Adoptium available releases response
#[derive(Debug, Clone, Deserialize)]
+#[allow(dead_code)]
pub struct AvailableReleases {
pub available_releases: Vec<u32>,
pub available_lts_releases: Vec<u32>,
@@ -231,6 +237,7 @@ pub fn save_catalog_cache(app_handle: &AppHandle, catalog: &JavaCatalog) -> Resu
}
/// Clear Java catalog cache
+#[allow(dead_code)]
pub fn clear_catalog_cache(app_handle: &AppHandle) -> Result<(), String> {
let cache_path = get_catalog_cache_path(app_handle);
if cache_path.exists() {
@@ -240,7 +247,10 @@ pub fn clear_catalog_cache(app_handle: &AppHandle) -> Result<(), String> {
}
/// Fetch complete Java catalog from Adoptium API with platform availability check
-pub async fn fetch_java_catalog(app_handle: &AppHandle, force_refresh: bool) -> Result<JavaCatalog, String> {
+pub async fn fetch_java_catalog(
+ app_handle: &AppHandle,
+ force_refresh: bool,
+) -> Result<JavaCatalog, String> {
// Check cache first unless force refresh
if !force_refresh {
if let Some(cached) = load_cached_catalog(app_handle) {
@@ -294,7 +304,9 @@ pub async fn fetch_java_catalog(app_handle: &AppHandle, force_refresh: bool) ->
file_size: asset.binary.package.size,
checksum: asset.binary.package.checksum,
download_url: asset.binary.package.link,
- is_lts: available.available_lts_releases.contains(major_version),
+ is_lts: available
+ .available_lts_releases
+ .contains(major_version),
is_available: true,
architecture: asset.binary.architecture.clone(),
});
@@ -547,7 +559,11 @@ pub async fn download_and_install_java(
// Linux/Windows: jdk-xxx/bin/java
let java_home = version_dir.join(&top_level_dir);
let java_bin = if cfg!(target_os = "macos") {
- java_home.join("Contents").join("Home").join("bin").join("java")
+ java_home
+ .join("Contents")
+ .join("Home")
+ .join("bin")
+ .join("java")
} else if cfg!(windows) {
java_home.join("bin").join("java.exe")
} else {
@@ -561,6 +577,10 @@ pub async fn download_and_install_java(
));
}
+ // Resolve symlinks and strip UNC prefix to ensure clean path
+ let java_bin = std::fs::canonicalize(&java_bin).map_err(|e| e.to_string())?;
+ let java_bin = strip_unc_prefix(java_bin);
+
// 9. Verify installation
let installation = check_java_installation(&java_bin)
.ok_or_else(|| "Failed to verify Java installation".to_string())?;
@@ -634,16 +654,22 @@ fn get_java_candidates() -> Vec<PathBuf> {
let mut candidates = Vec::new();
// Check PATH first
- if let Ok(output) = Command::new(if cfg!(windows) { "where" } else { "which" })
- .arg("java")
- .output()
- {
+ let mut cmd = Command::new(if cfg!(windows) { "where" } else { "which" });
+ cmd.arg("java");
+ #[cfg(target_os = "windows")]
+ cmd.creation_flags(0x08000000);
+
+ if let Ok(output) = cmd.output() {
if output.status.success() {
let paths = String::from_utf8_lossy(&output.stdout);
for line in paths.lines() {
let path = PathBuf::from(line.trim());
if path.exists() {
- candidates.push(path);
+ // Resolve symlinks (important for Windows javapath wrapper)
+ let resolved = std::fs::canonicalize(&path).unwrap_or(path);
+ // Strip UNC prefix if present to keep paths clean
+ let final_path = strip_unc_prefix(resolved);
+ candidates.push(final_path);
}
}
}
@@ -786,7 +812,12 @@ fn get_java_candidates() -> Vec<PathBuf> {
/// Check a specific Java installation and get its version info
fn check_java_installation(path: &PathBuf) -> Option<JavaInstallation> {
- let output = Command::new(path).arg("-version").output().ok()?;
+ let mut cmd = Command::new(path);
+ cmd.arg("-version");
+ #[cfg(target_os = "windows")]
+ cmd.creation_flags(0x08000000);
+
+ let output = cmd.output().ok()?;
// Java outputs version info to stderr
let version_output = String::from_utf8_lossy(&output.stderr);
@@ -885,14 +916,15 @@ pub fn detect_all_java_installations(app_handle: &AppHandle) -> Vec<JavaInstalla
installations
}
-//// Find the java executable in a directory using a limited-depth search
+/// Find the java executable in a directory using a limited-depth search
fn find_java_executable(dir: &PathBuf) -> Option<PathBuf> {
let bin_name = if cfg!(windows) { "java.exe" } else { "java" };
// Directly look in the bin directory
let direct_bin = dir.join("bin").join(bin_name);
if direct_bin.exists() {
- return Some(direct_bin);
+ let resolved = std::fs::canonicalize(&direct_bin).unwrap_or(direct_bin);
+ return Some(strip_unc_prefix(resolved));
}
// macOS: Contents/Home/bin/java
@@ -912,13 +944,18 @@ fn find_java_executable(dir: &PathBuf) -> Option<PathBuf> {
// Try direct bin path
let nested_bin = path.join("bin").join(bin_name);
if nested_bin.exists() {
- return Some(nested_bin);
+ let resolved = std::fs::canonicalize(&nested_bin).unwrap_or(nested_bin);
+ return Some(strip_unc_prefix(resolved));
}
// macOS: nested/Contents/Home/bin/java
#[cfg(target_os = "macos")]
{
- let macos_nested = path.join("Contents").join("Home").join("bin").join(bin_name);
+ let macos_nested = path
+ .join("Contents")
+ .join("Home")
+ .join("bin")
+ .join(bin_name);
if macos_nested.exists() {
return Some(macos_nested);
}
@@ -931,7 +968,9 @@ fn find_java_executable(dir: &PathBuf) -> Option<PathBuf> {
}
/// Resume pending Java downloads from queue
-pub async fn resume_pending_downloads(app_handle: &AppHandle) -> Result<Vec<JavaInstallation>, String> {
+pub async fn resume_pending_downloads(
+ app_handle: &AppHandle,
+) -> Result<Vec<JavaInstallation>, String> {
let queue = DownloadQueue::load(app_handle);
let mut installed = Vec::new();
@@ -978,7 +1017,12 @@ pub fn get_pending_downloads(app_handle: &AppHandle) -> Vec<PendingJavaDownload>
}
/// Clear a specific pending download
-pub fn clear_pending_download(app_handle: &AppHandle, major_version: u32, image_type: &str) -> Result<(), String> {
+#[allow(dead_code)]
+pub fn clear_pending_download(
+ app_handle: &AppHandle,
+ major_version: u32,
+ image_type: &str,
+) -> Result<(), String> {
let mut queue = DownloadQueue::load(app_handle);
queue.remove(major_version, image_type);
queue.save(app_handle)
diff --git a/src-tauri/src/core/manifest.rs b/src-tauri/src/core/manifest.rs
index bae87c9..d92ae58 100644
--- a/src-tauri/src/core/manifest.rs
+++ b/src-tauri/src/core/manifest.rs
@@ -45,7 +45,7 @@ pub async fn fetch_version_manifest() -> Result<VersionManifest, Box<dyn Error +
/// # Returns
/// The parsed `GameVersion` if found, or an error if not found.
pub async fn load_local_version(
- game_dir: &PathBuf,
+ game_dir: &std::path::Path,
version_id: &str,
) -> Result<GameVersion, Box<dyn Error + Send + Sync>> {
let json_path = game_dir
@@ -102,7 +102,7 @@ pub async fn fetch_vanilla_version(
/// # Returns
/// A fully resolved `GameVersion` ready for launching.
pub async fn load_version(
- game_dir: &PathBuf,
+ game_dir: &std::path::Path,
version_id: &str,
) -> Result<GameVersion, Box<dyn Error + Send + Sync>> {
// Try loading from local first
@@ -138,7 +138,7 @@ pub async fn load_version(
/// # Returns
/// The path where the JSON was saved.
pub async fn save_local_version(
- game_dir: &PathBuf,
+ game_dir: &std::path::Path,
version: &GameVersion,
) -> Result<PathBuf, Box<dyn Error + Send + Sync>> {
let version_dir = game_dir.join("versions").join(&version.id);
@@ -158,8 +158,9 @@ pub async fn save_local_version(
///
/// # Returns
/// A list of version IDs found in the versions directory.
+#[allow(dead_code)]
pub async fn list_local_versions(
- game_dir: &PathBuf,
+ game_dir: &std::path::Path,
) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> {
let versions_dir = game_dir.join("versions");
let mut versions = Vec::new();
diff --git a/src-tauri/src/core/maven.rs b/src-tauri/src/core/maven.rs
index 8c89768..760e68b 100644
--- a/src-tauri/src/core/maven.rs
+++ b/src-tauri/src/core/maven.rs
@@ -8,6 +8,7 @@
use std::path::PathBuf;
/// Known Maven repository URLs for mod loaders
+#[allow(dead_code)]
pub const MAVEN_CENTRAL: &str = "https://repo1.maven.org/maven2/";
pub const FABRIC_MAVEN: &str = "https://maven.fabricmc.net/";
pub const FORGE_MAVEN: &str = "https://maven.minecraftforge.net/";
@@ -114,7 +115,7 @@ impl MavenCoordinate {
///
/// # Returns
/// The full path where the library should be stored
- pub fn to_local_path(&self, libraries_dir: &PathBuf) -> PathBuf {
+ pub fn to_local_path(&self, libraries_dir: &std::path::Path) -> PathBuf {
let rel_path = self.to_path();
libraries_dir.join(rel_path.replace('/', std::path::MAIN_SEPARATOR_STR))
}
@@ -183,7 +184,7 @@ pub fn resolve_library_url(
///
/// # Returns
/// The path where the library should be stored
-pub fn get_library_path(name: &str, libraries_dir: &PathBuf) -> Option<PathBuf> {
+pub fn get_library_path(name: &str, libraries_dir: &std::path::Path) -> Option<PathBuf> {
let coord = MavenCoordinate::parse(name)?;
Some(coord.to_local_path(libraries_dir))
}
diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs
index 3c09a76..7ad6ef9 100644
--- a/src-tauri/src/core/mod.rs
+++ b/src-tauri/src/core/mod.rs
@@ -1,4 +1,5 @@
pub mod account_storage;
+pub mod assistant;
pub mod auth;
pub mod config;
pub mod downloader;
diff --git a/src-tauri/src/core/version_merge.rs b/src-tauri/src/core/version_merge.rs
index fe6b3cd..098d271 100644
--- a/src-tauri/src/core/version_merge.rs
+++ b/src-tauri/src/core/version_merge.rs
@@ -101,6 +101,7 @@ fn merge_json_arrays(
///
/// # Returns
/// `true` if the version has an `inheritsFrom` field that needs resolution.
+#[allow(dead_code)]
pub fn needs_inheritance_resolution(version: &GameVersion) -> bool {
version.inherits_from.is_some()
}
@@ -116,6 +117,7 @@ pub fn needs_inheritance_resolution(version: &GameVersion) -> bool {
///
/// # Returns
/// A fully merged `GameVersion` with all inheritance resolved.
+#[allow(dead_code)]
pub async fn resolve_inheritance<F, Fut>(
version: GameVersion,
version_loader: F,
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index b59ae31..5ccbe96 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -1,12 +1,12 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+use serde::Serialize;
use std::process::Stdio;
use std::sync::Mutex;
use tauri::{Emitter, Manager, State, Window}; // Added Emitter
use tokio::io::{AsyncBufReadExt, BufReader};
-use tokio::process::Command;
-use serde::Serialize; // Added Serialize
+use tokio::process::Command; // Added Serialize
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
@@ -67,6 +67,7 @@ async fn start_game(
window: Window,
auth_state: State<'_, core::auth::AccountState>,
config_state: State<'_, core::config::ConfigState>,
+ assistant_state: State<'_, core::assistant::AssistantState>,
version_id: String,
) -> Result<String, String> {
emit_log!(
@@ -83,10 +84,7 @@ async fn start_game(
.clone()
.ok_or("No active account found. Please login first.")?;
- emit_log!(
- window,
- format!("Account found: {}", account.username())
- );
+ emit_log!(window, format!("Account found: {}", account.username()));
let config = config_state.config.lock().unwrap().clone();
emit_log!(window, format!("Java path: {}", config.java_path));
@@ -119,10 +117,11 @@ async fn start_game(
// First, load the local version to get the original inheritsFrom value
// (before merge clears it)
- let original_inherits_from = match core::manifest::load_local_version(&game_dir, &version_id).await {
- Ok(local_version) => local_version.inherits_from.clone(),
- Err(_) => None,
- };
+ let original_inherits_from =
+ match core::manifest::load_local_version(&game_dir, &version_id).await {
+ Ok(local_version) => local_version.inherits_from.clone(),
+ Err(_) => None,
+ };
let version_details = core::manifest::load_version(&game_dir, &version_id)
.await
@@ -138,8 +137,7 @@ async fn start_game(
// Determine the actual minecraft version for client.jar
// (for modded versions, this is the parent vanilla version)
- let minecraft_version = original_inherits_from
- .unwrap_or_else(|| version_id.clone());
+ let minecraft_version = original_inherits_from.unwrap_or_else(|| version_id.clone());
// 2. Prepare download tasks
emit_log!(window, "Preparing download tasks...".to_string());
@@ -525,7 +523,7 @@ async fn start_game(
);
// Debug: Log arguments (only first few to avoid spam)
if args.len() > 10 {
- emit_log!(window, format!("First 10 args: {:?}", &args[..10]));
+ emit_log!(window, format!("Java Args: {:?}", &args));
}
// Spawn the process
@@ -573,9 +571,11 @@ async fn start_game(
);
let window_rx = window.clone();
+ let assistant_arc = assistant_state.assistant.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
+ assistant_arc.lock().unwrap().add_log(line.clone());
let _ = window_rx.emit("game-stdout", line);
}
// Emit log when stdout stream ends (game closing)
@@ -583,10 +583,12 @@ async fn start_game(
});
let window_rx_err = window.clone();
+ let assistant_arc_err = assistant_state.assistant.clone();
let window_exit = window.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stderr).lines();
while let Ok(Some(line)) = reader.next_line().await {
+ assistant_arc_err.lock().unwrap().add_log(line.clone());
let _ = window_rx_err.emit("game-stderr", line);
}
// Emit log when stderr stream ends
@@ -700,10 +702,18 @@ async fn check_version_installed(window: Window, version_id: String) -> Result<b
// For modded versions, check the parent vanilla version
let minecraft_version = if version_id.starts_with("fabric-loader-") {
// Format: fabric-loader-X.X.X-1.20.4
- version_id.split('-').last().unwrap_or(&version_id).to_string()
+ version_id
+ .split('-')
+ .next_back()
+ .unwrap_or(&version_id)
+ .to_string()
} else if version_id.contains("-forge-") {
// Format: 1.20.4-forge-49.0.38
- version_id.split("-forge-").next().unwrap_or(&version_id).to_string()
+ version_id
+ .split("-forge-")
+ .next()
+ .unwrap_or(&version_id)
+ .to_string()
} else {
version_id.clone()
};
@@ -749,21 +759,24 @@ async fn install_version(
);
// First, try to fetch the vanilla version from Mojang and save it locally
- let version_details = match core::manifest::load_local_version(&game_dir, &version_id).await {
+ let _version_details = match core::manifest::load_local_version(&game_dir, &version_id).await {
Ok(v) => v,
Err(_) => {
// Not found locally, fetch from Mojang
- emit_log!(window, format!("Fetching version {} from Mojang...", version_id));
+ emit_log!(
+ window,
+ format!("Fetching version {} from Mojang...", version_id)
+ );
let fetched = core::manifest::fetch_vanilla_version(&version_id)
.await
.map_err(|e| e.to_string())?;
-
+
// Save the version JSON locally
emit_log!(window, format!("Saving version JSON..."));
core::manifest::save_local_version(&game_dir, &fetched)
.await
.map_err(|e| e.to_string())?;
-
+
fetched
}
};
@@ -1056,6 +1069,38 @@ async fn save_settings(
}
#[tauri::command]
+async fn get_config_path(state: State<'_, core::config::ConfigState>) -> Result<String, String> {
+ Ok(state.file_path.to_string_lossy().to_string())
+}
+
+#[tauri::command]
+async fn read_raw_config(state: State<'_, core::config::ConfigState>) -> Result<String, String> {
+ tokio::fs::read_to_string(&state.file_path)
+ .await
+ .map_err(|e| e.to_string())
+}
+
+#[tauri::command]
+async fn save_raw_config(
+ state: State<'_, core::config::ConfigState>,
+ content: String,
+) -> Result<(), String> {
+ // Validate JSON
+ let new_config: core::config::LauncherConfig =
+ serde_json::from_str(&content).map_err(|e| format!("Invalid JSON: {}", e))?;
+
+ // Save to file
+ tokio::fs::write(&state.file_path, &content)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ // Update in-memory state
+ *state.config.lock().unwrap() = new_config;
+
+ Ok(())
+}
+
+#[tauri::command]
async fn start_microsoft_login() -> Result<core::auth::DeviceCodeResponse, String> {
core::auth::start_device_flow().await
}
@@ -1166,7 +1211,9 @@ async fn refresh_account(
/// Detect Java installations on the system
#[tauri::command]
-async fn detect_java(app_handle: tauri::AppHandle) -> Result<Vec<core::java::JavaInstallation>, String> {
+async fn detect_java(
+ app_handle: tauri::AppHandle,
+) -> Result<Vec<core::java::JavaInstallation>, String> {
Ok(core::java::detect_all_java_installations(&app_handle))
}
@@ -1484,11 +1531,13 @@ async fn install_forge(
config.java_path.clone()
} else {
// Try to find a suitable Java installation
- let javas = core::java::detect_all_java_installations(&app_handle);
+ let javas = core::java::detect_all_java_installations(app_handle);
if let Some(java) = javas.first() {
java.path.clone()
} else {
- return Err("No Java installation found. Please configure Java in settings.".to_string());
+ return Err(
+ "No Java installation found. Please configure Java in settings.".to_string(),
+ );
}
};
let java_path = std::path::PathBuf::from(&java_path_str);
@@ -1500,7 +1549,10 @@ async fn install_forge(
.await
.map_err(|e| format!("Forge installer failed: {}", e))?;
- emit_log!(window, "Forge installer completed, creating version profile...".to_string());
+ emit_log!(
+ window,
+ "Forge installer completed, creating version profile...".to_string()
+ );
// Now create the version JSON
let result = core::forge::install_forge(&game_dir, &game_version, &forge_version)
@@ -1547,7 +1599,7 @@ async fn get_github_releases() -> Result<Vec<GithubRelease>, String> {
r["name"].as_str(),
r["published_at"].as_str(),
r["body"].as_str(),
- r["html_url"].as_str()
+ r["html_url"].as_str(),
) {
result.push(GithubRelease {
tag_name: tag.to_string(),
@@ -1589,8 +1641,7 @@ async fn upload_to_pastebin(
match service.as_str() {
"pastebin.com" => {
- let api_key = api_key
- .ok_or("Pastebin API Key not configured in settings")?;
+ let api_key = api_key.ok_or("Pastebin API Key not configured in settings")?;
let res = client
.post("https://pastebin.com/api/api_post.php")
@@ -1636,6 +1687,60 @@ async fn upload_to_pastebin(
}
}
+#[tauri::command]
+async fn assistant_check_health(
+ assistant_state: State<'_, core::assistant::AssistantState>,
+ config_state: State<'_, core::config::ConfigState>,
+) -> Result<bool, String> {
+ let assistant = assistant_state.assistant.lock().unwrap().clone();
+ let config = config_state.config.lock().unwrap().clone();
+ Ok(assistant.check_health(&config.assistant).await)
+}
+
+#[tauri::command]
+async fn assistant_chat(
+ assistant_state: State<'_, core::assistant::AssistantState>,
+ config_state: State<'_, core::config::ConfigState>,
+ messages: Vec<core::assistant::Message>,
+) -> Result<core::assistant::Message, String> {
+ let assistant = assistant_state.assistant.lock().unwrap().clone();
+ let config = config_state.config.lock().unwrap().clone();
+ assistant.chat(messages, &config.assistant).await
+}
+
+#[tauri::command]
+async fn list_ollama_models(
+ assistant_state: State<'_, core::assistant::AssistantState>,
+ endpoint: String,
+) -> Result<Vec<core::assistant::ModelInfo>, String> {
+ let assistant = assistant_state.assistant.lock().unwrap().clone();
+ assistant.list_ollama_models(&endpoint).await
+}
+
+#[tauri::command]
+async fn list_openai_models(
+ assistant_state: State<'_, core::assistant::AssistantState>,
+ config_state: State<'_, core::config::ConfigState>,
+) -> Result<Vec<core::assistant::ModelInfo>, String> {
+ let assistant = assistant_state.assistant.lock().unwrap().clone();
+ let config = config_state.config.lock().unwrap().clone();
+ assistant.list_openai_models(&config.assistant).await
+}
+
+#[tauri::command]
+async fn assistant_chat_stream(
+ window: tauri::Window,
+ assistant_state: State<'_, core::assistant::AssistantState>,
+ config_state: State<'_, core::config::ConfigState>,
+ messages: Vec<core::assistant::Message>,
+) -> Result<String, String> {
+ let assistant = assistant_state.assistant.lock().unwrap().clone();
+ let config = config_state.config.lock().unwrap().clone();
+ assistant
+ .chat_stream(messages, &config.assistant, &window)
+ .await
+}
+
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
@@ -1643,6 +1748,7 @@ fn main() {
.plugin(tauri_plugin_shell::init())
.manage(core::auth::AccountState::new())
.manage(MsRefreshTokenState::new())
+ .manage(core::assistant::AssistantState::new())
.setup(|app| {
let config_state = core::config::ConfigState::new(app.handle());
app.manage(config_state);
@@ -1666,7 +1772,7 @@ fn main() {
}
// Check for pending Java downloads and notify frontend
- let pending = core::java::get_pending_downloads(&app.app_handle());
+ let pending = core::java::get_pending_downloads(app.app_handle());
if !pending.is_empty() {
println!("[Startup] Found {} pending Java download(s)", pending.len());
let _ = app.emit("pending-java-downloads", pending.len());
@@ -1685,6 +1791,9 @@ fn main() {
logout,
get_settings,
save_settings,
+ get_config_path,
+ read_raw_config,
+ save_raw_config,
start_microsoft_login,
complete_microsoft_login,
refresh_account,
@@ -1711,7 +1820,12 @@ fn main() {
get_forge_versions_for_game,
install_forge,
get_github_releases,
- upload_to_pastebin
+ upload_to_pastebin,
+ assistant_check_health,
+ assistant_chat,
+ assistant_chat_stream,
+ list_ollama_models,
+ list_openai_models
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs
index c0aed36..00b9087 100644
--- a/src-tauri/src/utils/mod.rs
+++ b/src-tauri/src/utils/mod.rs
@@ -1,6 +1,7 @@
pub mod zip;
// File system related utility functions
+#[allow(dead_code)]
pub mod file_utils {
use std::fs;
use std::io::{self, Write};
@@ -16,6 +17,7 @@ pub mod file_utils {
}
// Configuration parsing utilities
+#[allow(dead_code)]
pub mod config_parser {
use std::collections::HashMap;
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 060a871..e10c258 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,6 +1,6 @@
{
"productName": "dropout",
- "version": "0.1.23",
+ "version": "0.1.25",
"identifier": "com.dropout.launcher",
"build": {
"beforeDevCommand": "pnpm -C ../ui dev",
@@ -20,7 +20,7 @@
}
],
"security": {
- "csp": null,
+ "csp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https: ws: wss:;",
"capabilities": ["default"]
}
},
diff --git a/ui/package.json b/ui/package.json
index 82f8db3..008fcfb 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -14,18 +14,20 @@
},
"dependencies": {
"@tauri-apps/api": "^2.9.1",
- "@tauri-apps/plugin-dialog": "^2.5.0",
+ "@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-shell": "^2.3.4",
"lucide-svelte": "^0.562.0",
"marked": "^17.0.1",
- "node-emoji": "^2.2.0"
+ "node-emoji": "^2.2.0",
+ "prismjs": "^1.30.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.18",
"@tsconfig/svelte": "^5.0.6",
"@types/node": "^24.10.1",
+ "@types/prismjs": "^1.26.5",
"autoprefixer": "^10.4.23",
"oxfmt": "^0.24.0",
"oxlint": "^1.39.0",
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index 390862c..465b682 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -15,8 +15,8 @@ importers:
specifier: ^2.9.1
version: 2.9.1
'@tauri-apps/plugin-dialog':
- specifier: ^2.5.0
- version: 2.5.0
+ specifier: ^2.6.0
+ version: 2.6.0
'@tauri-apps/plugin-fs':
specifier: ^2.4.5
version: 2.4.5
@@ -32,6 +32,9 @@ importers:
node-emoji:
specifier: ^2.2.0
version: 2.2.0
+ prismjs:
+ specifier: ^1.30.0
+ version: 1.30.0
devDependencies:
'@sveltejs/vite-plugin-svelte':
specifier: ^6.2.1
@@ -45,6 +48,9 @@ importers:
'@types/node':
specifier: ^24.10.1
version: 24.10.7
+ '@types/prismjs':
+ specifier: ^1.26.5
+ version: 1.26.5
autoprefixer:
specifier: ^10.4.23
version: 10.4.23(postcss@8.5.6)
@@ -124,21 +130,25 @@ packages:
resolution: {integrity: sha512-ItPDOPoQ0wLj/s8osc5ch57uUcA1Wk8r0YdO8vLRpXA3UNg7KPOm1vdbkIZRRiSUphZcuX5ioOEetEK8H7RlTw==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@oxfmt/linux-arm64-musl@0.24.0':
resolution: {integrity: sha512-JkQO3WnQjQTJONx8nxdgVBfl6BBFfpp9bKhChYhWeakwJdr7QPOAWJ/v3FGZfr0TbqINwnNR74aVZayDDRyXEA==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@oxfmt/linux-x64-gnu@0.24.0':
resolution: {integrity: sha512-N/SXlFO+2kak5gMt0oxApi0WXQDhwA0PShR0UbkY0PwtHjfSiDqJSOumyNqgQVoroKr1GNnoRmUqjZIz6DKIcw==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@oxfmt/linux-x64-musl@0.24.0':
resolution: {integrity: sha512-WM0pek5YDCQf50XQ7GLCE9sMBCMPW/NPAEPH/Hx6Qyir37lEsP4rUmSECo/QFNTU6KBc9NnsviAyJruWPpCMXw==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@oxfmt/win32-arm64@0.24.0':
resolution: {integrity: sha512-vFCseli1KWtwdHrVlT/jWfZ8jP8oYpnPPEjI23mPLW8K/6GEJmmvy0PZP5NpWUFNTzX0lqie58XnrATJYAe9Xw==}
@@ -164,21 +174,25 @@ packages:
resolution: {integrity: sha512-qocBkvS2V6rH0t9AT3DfQunMnj3xkM7srs5/Ycj2j5ZqMoaWd/FxHNVJDFP++35roKSvsRJoS0mtA8/77jqm6Q==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@oxlint/linux-arm64-musl@1.39.0':
resolution: {integrity: sha512-arZzAc1PPcz9epvGBBCMHICeyQloKtHX3eoOe62B3Dskn7gf6Q14wnDHr1r9Vp4vtcBATNq6HlKV14smdlC/qA==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@oxlint/linux-x64-gnu@1.39.0':
resolution: {integrity: sha512-ZVt5qsECpuNprdWxAPpDBwoixr1VTcZ4qAEQA2l/wmFyVPDYFD3oBY/SWACNnWBddMrswjTg9O8ALxYWoEpmXw==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@oxlint/linux-x64-musl@1.39.0':
resolution: {integrity: sha512-pB0hlGyKPbxr9NMIV783lD6cWL3MpaqnZRM9MWni4yBdHPTKyFNYdg5hGD0Bwg+UP4S2rOevq/+OO9x9Bi7E6g==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@oxlint/win32-arm64@1.39.0':
resolution: {integrity: sha512-Gg2SFaJohI9+tIQVKXlPw3FsPQFi/eCSWiCgwPtPn5uzQxHRTeQEZKuluz1fuzR5U70TXubb2liZi4Dgl8LJQA==}
@@ -225,24 +239,28 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-beta.50':
resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rolldown/binding-linux-x64-gnu@1.0.0-beta.50':
resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-beta.50':
resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-beta.50':
resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==}
@@ -338,24 +356,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@@ -393,8 +415,8 @@ packages:
'@tauri-apps/api@2.9.1':
resolution: {integrity: sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==}
- '@tauri-apps/plugin-dialog@2.5.0':
- resolution: {integrity: sha512-I0R0ygwRd9AN8Wj5GnzCogOlqu2+OWAtBd0zEC4+kQCI32fRowIyuhPCBoUv4h/lQt2bM39kHlxPHD5vDcFjiA==}
+ '@tauri-apps/plugin-dialog@2.6.0':
+ resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
'@tauri-apps/plugin-fs@2.4.5':
resolution: {integrity: sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==}
@@ -414,6 +436,9 @@ packages:
'@types/node@24.10.7':
resolution: {integrity: sha512-+054pVMzVTmRQV8BhpGv3UyfZ2Llgl8rdpDTon+cUH9+na0ncBVXj3wTUKh14+Kiz18ziM3b4ikpP5/Pc0rQEQ==}
+ '@types/prismjs@1.26.5':
+ resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
+
acorn@8.15.0:
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
engines: {node: '>=0.4.0'}
@@ -551,24 +576,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@@ -650,6 +679,10 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
+ prismjs@1.30.0:
+ resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
+ engines: {node: '>=6'}
+
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@@ -1005,7 +1038,7 @@ snapshots:
'@tauri-apps/api@2.9.1': {}
- '@tauri-apps/plugin-dialog@2.5.0':
+ '@tauri-apps/plugin-dialog@2.6.0':
dependencies:
'@tauri-apps/api': 2.9.1
@@ -1030,6 +1063,8 @@ snapshots:
dependencies:
undici-types: 7.16.0
+ '@types/prismjs@1.26.5': {}
+
acorn@8.15.0: {}
aria-query@5.3.2: {}
@@ -1217,6 +1252,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ prismjs@1.30.0: {}
+
readdirp@4.1.2: {}
rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1):
diff --git a/ui/public/vite.svg b/ui/public/vite.svg
index e7b8dfb..ee9fada 100644
--- a/ui/public/vite.svg
+++ b/ui/public/vite.svg
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg> \ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
diff --git a/ui/src/App.svelte b/ui/src/App.svelte
index 760a15f..2b78892 100644
--- a/ui/src/App.svelte
+++ b/ui/src/App.svelte
@@ -10,6 +10,7 @@
import LoginModal from "./components/LoginModal.svelte";
import ParticleBackground from "./components/ParticleBackground.svelte";
import SettingsView from "./components/SettingsView.svelte";
+ import AssistantView from "./components/AssistantView.svelte";
import Sidebar from "./components/Sidebar.svelte";
import StatusToast from "./components/StatusToast.svelte";
import VersionsView from "./components/VersionsView.svelte";
@@ -18,6 +19,7 @@
import { gameState } from "./stores/game.svelte";
import { settingsState } from "./stores/settings.svelte";
import { uiState } from "./stores/ui.svelte";
+ import { logsState } from "./stores/logs.svelte";
import { convertFileSrc } from "@tauri-apps/api/core";
let mouseX = $state(0);
@@ -29,24 +31,19 @@
}
onMount(async () => {
+ // ENFORCE DARK MODE: Always add 'dark' class and attribute
+ document.documentElement.classList.add('dark');
+ document.documentElement.setAttribute('data-theme', 'dark');
+ document.documentElement.classList.remove('light');
+
authState.checkAccount();
await settingsState.loadSettings();
+ logsState.init();
await settingsState.detectJava();
gameState.loadVersions();
getVersion().then((v) => (uiState.appVersion = v));
window.addEventListener("mousemove", handleMouseMove);
});
-
- $effect(() => {
- // ENFORCE DARK MODE: Always add 'dark' class and attribute
- // This combined with the @variant dark in app.css ensures dark mode is always active
- // regardless of system preference settings.
- document.documentElement.classList.add('dark');
- document.documentElement.setAttribute('data-theme', 'dark');
-
- // Ensure 'light' class is never present
- document.documentElement.classList.remove('light');
- });
onDestroy(() => {
if (typeof window !== 'undefined')
@@ -120,6 +117,8 @@
<VersionsView />
{:else if uiState.currentView === "settings"}
<SettingsView />
+ {:else if uiState.currentView === "guide"}
+ <AssistantView />
{/if}
</div>
diff --git a/ui/src/assets/svelte.svg b/ui/src/assets/svelte.svg
index c5e0848..8c056ce 100644
--- a/ui/src/assets/svelte.svg
+++ b/ui/src/assets/svelte.svg
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg> \ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
diff --git a/ui/src/components/AssistantView.svelte b/ui/src/components/AssistantView.svelte
new file mode 100644
index 0000000..54509a5
--- /dev/null
+++ b/ui/src/components/AssistantView.svelte
@@ -0,0 +1,436 @@
+<script lang="ts">
+ import { assistantState } from '../stores/assistant.svelte';
+ import { settingsState } from '../stores/settings.svelte';
+ import { Send, Bot, RefreshCw, Trash2, AlertTriangle, Settings, Brain, ChevronDown } from 'lucide-svelte';
+ import { uiState } from '../stores/ui.svelte';
+ import { marked } from 'marked';
+ import { onMount } from 'svelte';
+
+ let input = $state('');
+ let messagesContainer: HTMLDivElement | undefined = undefined;
+
+ function parseMessageContent(content: string) {
+ if (!content) return { thinking: null, content: '', isThinking: false };
+
+ // Support both <thinking> and <think> (DeepSeek uses <think>)
+ let startTag = '<thinking>';
+ let endTag = '</thinking>';
+ let startIndex = content.indexOf(startTag);
+
+ if (startIndex === -1) {
+ startTag = '<think>';
+ endTag = '</think>';
+ startIndex = content.indexOf(startTag);
+ }
+
+ // Also check for encoded tags if they weren't decoded properly
+ if (startIndex === -1) {
+ startTag = '\u003cthink\u003e';
+ endTag = '\u003c/think\u003e';
+ startIndex = content.indexOf(startTag);
+ }
+
+ if (startIndex !== -1) {
+ const endIndex = content.indexOf(endTag, startIndex);
+
+ if (endIndex !== -1) {
+ // Completed thinking block
+ // We extract the thinking part and keep the rest (before and after)
+ const before = content.substring(0, startIndex);
+ const thinking = content.substring(startIndex + startTag.length, endIndex).trim();
+ const after = content.substring(endIndex + endTag.length);
+
+ return {
+ thinking,
+ content: (before + after).trim(),
+ isThinking: false
+ };
+ } else {
+ // Incomplete thinking block (still streaming)
+ const before = content.substring(0, startIndex);
+ const thinking = content.substring(startIndex + startTag.length).trim();
+
+ return {
+ thinking,
+ content: before.trim(),
+ isThinking: true
+ };
+ }
+ }
+
+ return { thinking: null, content, isThinking: false };
+ }
+
+ function renderMarkdown(content: string): string {
+ if (!content) return '';
+ try {
+ // marked.parse returns string synchronously when async is false (default)
+ return marked(content, { breaks: true, gfm: true }) as string;
+ } catch {
+ return content;
+ }
+ }
+
+ function scrollToBottom() {
+ if (messagesContainer) {
+ setTimeout(() => {
+ if (messagesContainer) {
+ messagesContainer.scrollTop = messagesContainer.scrollHeight;
+ }
+ }, 0);
+ }
+ }
+
+ onMount(() => {
+ assistantState.init();
+ });
+
+ // Scroll to bottom when messages change
+ $effect(() => {
+ // Access reactive state
+ const _len = assistantState.messages.length;
+ const _processing = assistantState.isProcessing;
+ // Scroll on next tick
+ if (_len > 0 || _processing) {
+ scrollToBottom();
+ }
+ });
+
+ async function handleSubmit() {
+ if (!input.trim() || assistantState.isProcessing) return;
+ const text = input;
+ input = '';
+ const provider = settingsState.settings.assistant.llm_provider;
+ const endpoint = provider === 'ollama'
+ ? settingsState.settings.assistant.ollama_endpoint
+ : settingsState.settings.assistant.openai_endpoint;
+ await assistantState.sendMessage(
+ text,
+ settingsState.settings.assistant.enabled,
+ provider,
+ endpoint
+ );
+ }
+
+ function handleKeydown(e: KeyboardEvent) {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ }
+
+ function getProviderName(): string {
+ const provider = settingsState.settings.assistant.llm_provider;
+ if (provider === 'ollama') {
+ return `Ollama (${settingsState.settings.assistant.ollama_model})`;
+ } else if (provider === 'openai') {
+ return `OpenAI (${settingsState.settings.assistant.openai_model})`;
+ }
+ return provider;
+ }
+
+ function getProviderHelpText(): string {
+ const provider = settingsState.settings.assistant.llm_provider;
+ if (provider === 'ollama') {
+ return `Please ensure Ollama is installed and running at ${settingsState.settings.assistant.ollama_endpoint}.`;
+ } else if (provider === 'openai') {
+ return "Please check your OpenAI API key in Settings > AI Assistant.";
+ }
+ return "";
+ }
+</script>
+
+<div class="h-full w-full flex flex-col gap-4 p-4 lg:p-8 animate-in fade-in zoom-in-95 duration-300">
+ <div class="flex items-center justify-between mb-2">
+ <div class="flex items-center gap-3">
+ <div class="p-2 bg-indigo-500/20 rounded-lg text-indigo-400">
+ <Bot size={24} />
+ </div>
+ <div>
+ <h2 class="text-2xl font-bold">Game Assistant</h2>
+ <p class="text-zinc-400 text-sm">Powered by {getProviderName()}</p>
+ </div>
+ </div>
+
+ <div class="flex items-center gap-2">
+ {#if !settingsState.settings.assistant.enabled}
+ <div class="flex items-center gap-2 px-3 py-1.5 bg-zinc-500/10 text-zinc-400 rounded-full text-xs font-medium border border-zinc-500/20">
+ <AlertTriangle size={14} />
+ <span>Disabled</span>
+ </div>
+ {:else if !assistantState.isProviderHealthy}
+ <div class="flex items-center gap-2 px-3 py-1.5 bg-red-500/10 text-red-400 rounded-full text-xs font-medium border border-red-500/20">
+ <AlertTriangle size={14} />
+ <span>Offline</span>
+ </div>
+ {:else}
+ <div class="flex items-center gap-2 px-3 py-1.5 bg-emerald-500/10 text-emerald-400 rounded-full text-xs font-medium border border-emerald-500/20">
+ <div class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
+ <span>Online</span>
+ </div>
+ {/if}
+
+ <button
+ onclick={() => assistantState.checkHealth()}
+ class="p-2 hover:bg-white/5 rounded-lg text-zinc-400 hover:text-white transition-colors"
+ title="Check Connection"
+ >
+ <RefreshCw size={18} class={assistantState.isProcessing ? "animate-spin" : ""} />
+ </button>
+
+ <button
+ onclick={() => assistantState.clearHistory()}
+ class="p-2 hover:bg-white/5 rounded-lg text-zinc-400 hover:text-white transition-colors"
+ title="Clear History"
+ >
+ <Trash2 size={18} />
+ </button>
+
+ <button
+ onclick={() => uiState.setView('settings')}
+ class="p-2 hover:bg-white/5 rounded-lg text-zinc-400 hover:text-white transition-colors"
+ title="Settings"
+ >
+ <Settings size={18} />
+ </button>
+ </div>
+ </div>
+
+ <!-- Chat Area -->
+ <div class="flex-1 bg-black/20 border border-white/5 rounded-xl overflow-hidden flex flex-col relative">
+ {#if assistantState.messages.length === 0}
+ <div class="absolute inset-0 flex flex-col items-center justify-center text-zinc-500 gap-4 p-8 text-center">
+ <Bot size={48} class="opacity-20" />
+ <div class="max-w-md">
+ <p class="text-lg font-medium text-zinc-300 mb-2">How can I help you today?</p>
+ <p class="text-sm opacity-70">I can analyze your game logs, diagnose crashes, or explain mod features.</p>
+ </div>
+ {#if !settingsState.settings.assistant.enabled}
+ <div class="bg-zinc-500/10 border border-zinc-500/20 rounded-lg p-4 text-sm text-zinc-400 mt-4 max-w-sm">
+ Assistant is disabled. Enable it in <button onclick={() => uiState.setView('settings')} class="text-indigo-400 hover:underline">Settings > AI Assistant</button>.
+ </div>
+ {:else if !assistantState.isProviderHealthy}
+ <div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-sm text-red-400 mt-4 max-w-sm">
+ {getProviderHelpText()}
+ </div>
+ {/if}
+ </div>
+ {/if}
+
+ <div
+ bind:this={messagesContainer}
+ class="flex-1 overflow-y-auto p-4 space-y-4 scroll-smooth"
+ >
+ {#each assistantState.messages as msg, idx}
+ <div class="flex gap-3 {msg.role === 'user' ? 'justify-end' : 'justify-start'}">
+ {#if msg.role === 'assistant'}
+ <div class="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center text-indigo-400 shrink-0 mt-1">
+ <Bot size={16} />
+ </div>
+ {/if}
+
+ <div class="max-w-[80%] p-3 rounded-2xl {msg.role === 'user' ? 'bg-indigo-600 text-white rounded-tr-none' : 'bg-zinc-800/50 text-zinc-200 rounded-tl-none border border-white/5'}">
+ {#if msg.role === 'user'}
+ <div class="break-words whitespace-pre-wrap">
+ {msg.content}
+ </div>
+ {:else}
+ {@const parsed = parseMessageContent(msg.content)}
+
+ <!-- Thinking Block -->
+ {#if parsed.thinking}
+ <div class="mb-3 max-w-full overflow-hidden">
+ <details class="group" open={parsed.isThinking}>
+ <summary class="list-none cursor-pointer flex items-center gap-2 text-zinc-500 hover:text-zinc-300 transition-colors text-xs font-medium select-none bg-black/20 p-2 rounded-lg border border-white/5 w-fit mb-2 outline-none">
+ <Brain size={14} />
+ <span>Thinking Process</span>
+ <ChevronDown size={14} class="transition-transform duration-200 group-open:rotate-180" />
+ </summary>
+ <div class="pl-3 border-l-2 border-zinc-700 text-zinc-500 text-xs italic leading-relaxed whitespace-pre-wrap font-mono max-h-96 overflow-y-auto custom-scrollbar bg-black/10 p-2 rounded-r-md">
+ {parsed.thinking}
+ {#if parsed.isThinking}
+ <span class="inline-block w-1.5 h-3 bg-zinc-500 ml-1 animate-pulse align-middle"></span>
+ {/if}
+ </div>
+ </details>
+ </div>
+ {/if}
+
+ <!-- Markdown rendered content for assistant -->
+ <div class="markdown-content prose prose-invert prose-sm max-w-none">
+ {#if parsed.content}
+ {@html renderMarkdown(parsed.content)}
+ {:else if assistantState.isProcessing && idx === assistantState.messages.length - 1 && !parsed.isThinking}
+ <span class="inline-flex items-center gap-1">
+ <span class="w-2 h-2 bg-zinc-400 rounded-full animate-pulse"></span>
+ <span class="w-2 h-2 bg-zinc-400 rounded-full animate-pulse" style="animation-delay: 0.2s"></span>
+ <span class="w-2 h-2 bg-zinc-400 rounded-full animate-pulse" style="animation-delay: 0.4s"></span>
+ </span>
+ {/if}
+ </div>
+
+ <!-- Generation Stats -->
+ {#if msg.stats}
+ <div class="mt-3 pt-3 border-t border-white/5 text-[10px] text-zinc-500 font-mono flex flex-wrap gap-x-4 gap-y-1 opacity-70 hover:opacity-100 transition-opacity select-none">
+ <div class="flex gap-1" title="Tokens generated">
+ <span>Eval:</span>
+ <span class="text-zinc-400">{msg.stats.eval_count} tokens</span>
+ </div>
+ <div class="flex gap-1" title="Total duration">
+ <span>Time:</span>
+ <span class="text-zinc-400">{(msg.stats.total_duration / 1e9).toFixed(2)}s</span>
+ </div>
+ {#if msg.stats.eval_duration > 0}
+ <div class="flex gap-1" title="Generation speed">
+ <span>Speed:</span>
+ <span class="text-zinc-400">{(msg.stats.eval_count / (msg.stats.eval_duration / 1e9)).toFixed(1)} t/s</span>
+ </div>
+ {/if}
+ </div>
+ {/if}
+ {/if}
+ </div>
+ </div>
+ {/each}
+ </div>
+
+ <!-- Input Area -->
+ <div class="p-4 bg-zinc-900/50 border-t border-white/5">
+ <div class="relative">
+ <textarea
+ bind:value={input}
+ onkeydown={handleKeydown}
+ placeholder={settingsState.settings.assistant.enabled ? "Ask about your game..." : "Assistant is disabled..."}
+ class="w-full bg-black/20 border border-white/10 rounded-xl py-3 pl-4 pr-12 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/50 resize-none h-[52px] max-h-32 transition-all text-white disabled:opacity-50"
+ disabled={assistantState.isProcessing || !settingsState.settings.assistant.enabled}
+ ></textarea>
+
+ <button
+ onclick={handleSubmit}
+ disabled={!input.trim() || assistantState.isProcessing || !settingsState.settings.assistant.enabled}
+ class="absolute right-2 top-2 p-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:hover:bg-indigo-600 text-white rounded-lg transition-colors"
+ >
+ <Send size={16} />
+ </button>
+ </div>
+ </div>
+ </div>
+</div>
+
+<style>
+ /* Markdown content styles */
+ .markdown-content :global(p) {
+ margin-bottom: 0.5rem;
+ }
+
+ .markdown-content :global(p:last-child) {
+ margin-bottom: 0;
+ }
+
+ .markdown-content :global(pre) {
+ background-color: rgba(0, 0, 0, 0.4);
+ border-radius: 0.5rem;
+ padding: 0.75rem;
+ overflow-x: auto;
+ margin: 0.5rem 0;
+ }
+
+ .markdown-content :global(code) {
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
+ font-size: 0.85em;
+ }
+
+ .markdown-content :global(pre code) {
+ background: none;
+ padding: 0;
+ }
+
+ .markdown-content :global(:not(pre) > code) {
+ background-color: rgba(0, 0, 0, 0.3);
+ padding: 0.15rem 0.4rem;
+ border-radius: 0.25rem;
+ }
+
+ .markdown-content :global(ul),
+ .markdown-content :global(ol) {
+ margin: 0.5rem 0;
+ padding-left: 1.5rem;
+ }
+
+ .markdown-content :global(li) {
+ margin: 0.25rem 0;
+ }
+
+ .markdown-content :global(blockquote) {
+ border-left: 3px solid rgba(99, 102, 241, 0.5);
+ padding-left: 1rem;
+ margin: 0.5rem 0;
+ color: rgba(255, 255, 255, 0.7);
+ }
+
+ .markdown-content :global(h1),
+ .markdown-content :global(h2),
+ .markdown-content :global(h3),
+ .markdown-content :global(h4) {
+ font-weight: 600;
+ margin: 0.75rem 0 0.5rem 0;
+ }
+
+ .markdown-content :global(h1) {
+ font-size: 1.25rem;
+ }
+
+ .markdown-content :global(h2) {
+ font-size: 1.125rem;
+ }
+
+ .markdown-content :global(h3) {
+ font-size: 1rem;
+ }
+
+ .markdown-content :global(a) {
+ color: rgb(129, 140, 248);
+ text-decoration: underline;
+ }
+
+ .markdown-content :global(a:hover) {
+ color: rgb(165, 180, 252);
+ }
+
+ .markdown-content :global(table) {
+ border-collapse: collapse;
+ margin: 0.5rem 0;
+ width: 100%;
+ }
+
+ .markdown-content :global(th),
+ .markdown-content :global(td) {
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ padding: 0.5rem;
+ text-align: left;
+ }
+
+ .markdown-content :global(th) {
+ background-color: rgba(0, 0, 0, 0.3);
+ font-weight: 600;
+ }
+
+ .markdown-content :global(hr) {
+ border: none;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ margin: 1rem 0;
+ }
+
+ .markdown-content :global(img) {
+ max-width: 100%;
+ border-radius: 0.5rem;
+ }
+
+ .markdown-content :global(strong) {
+ font-weight: 600;
+ }
+
+ .markdown-content :global(em) {
+ font-style: italic;
+ }
+</style>
diff --git a/ui/src/components/ConfigEditorModal.svelte b/ui/src/components/ConfigEditorModal.svelte
new file mode 100644
index 0000000..dd866ee
--- /dev/null
+++ b/ui/src/components/ConfigEditorModal.svelte
@@ -0,0 +1,369 @@
+<script lang="ts">
+ import { settingsState } from "../stores/settings.svelte";
+ import { Save, X, FileJson, AlertCircle, Undo, Redo, Settings } from "lucide-svelte";
+ import Prism from 'prismjs';
+ import 'prismjs/components/prism-json';
+ import 'prismjs/themes/prism-tomorrow.css';
+
+ let content = $state(settingsState.rawConfigContent);
+ let isSaving = $state(false);
+ let localError = $state("");
+
+ let textareaRef: HTMLTextAreaElement | undefined = $state();
+ let preRef: HTMLPreElement | undefined = $state();
+ let lineNumbersRef: HTMLDivElement | undefined = $state();
+
+ // Textarea attributes that TypeScript doesn't recognize but are valid HTML
+ const textareaAttrs = {
+ autocorrect: "off",
+ autocapitalize: "off"
+ } as Record<string, string>;
+
+ // History State
+ let history = $state([settingsState.rawConfigContent]);
+ let historyIndex = $state(0);
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
+
+ // Editor Settings
+ let showLineNumbers = $state(localStorage.getItem('editor_showLineNumbers') !== 'false');
+ let showStatusBar = $state(localStorage.getItem('editor_showStatusBar') !== 'false');
+ let showSettings = $state(false);
+
+ // Cursor Status
+ let cursorLine = $state(1);
+ let cursorCol = $state(1);
+
+ let lines = $derived(content.split('\n'));
+
+ $effect(() => {
+ localStorage.setItem('editor_showLineNumbers', String(showLineNumbers));
+ localStorage.setItem('editor_showStatusBar', String(showStatusBar));
+ });
+
+ // Cleanup timer on destroy
+ $effect(() => {
+ return () => {
+ if (debounceTimer) clearTimeout(debounceTimer);
+ };
+ });
+
+ // Initial validation
+ $effect(() => {
+ validate(content);
+ });
+
+ function validate(text: string) {
+ try {
+ JSON.parse(text);
+ localError = "";
+ } catch (e: any) {
+ localError = e.message;
+ }
+ }
+
+ function pushHistory(newContent: string, immediate = false) {
+ if (debounceTimer) clearTimeout(debounceTimer);
+
+ const commit = () => {
+ if (newContent === history[historyIndex]) return;
+ const next = history.slice(0, historyIndex + 1);
+ next.push(newContent);
+ history = next;
+ historyIndex = next.length - 1;
+ };
+
+ if (immediate) {
+ commit();
+ } else {
+ debounceTimer = setTimeout(commit, 500);
+ }
+ }
+
+ function handleUndo() {
+ if (historyIndex > 0) {
+ historyIndex--;
+ content = history[historyIndex];
+ validate(content);
+ }
+ }
+
+ function handleRedo() {
+ if (historyIndex < history.length - 1) {
+ historyIndex++;
+ content = history[historyIndex];
+ validate(content);
+ }
+ }
+
+ function updateCursor() {
+ if (!textareaRef) return;
+ const pos = textareaRef.selectionStart;
+ const text = textareaRef.value.substring(0, pos);
+ const lines = text.split('\n');
+ cursorLine = lines.length;
+ cursorCol = lines[lines.length - 1].length + 1;
+ }
+
+ function handleInput(e: Event) {
+ const target = e.target as HTMLTextAreaElement;
+ content = target.value;
+ validate(content);
+ pushHistory(content);
+ updateCursor();
+ }
+
+ function handleScroll() {
+ if (textareaRef) {
+ if (preRef) {
+ preRef.scrollTop = textareaRef.scrollTop;
+ preRef.scrollLeft = textareaRef.scrollLeft;
+ }
+ if (lineNumbersRef) {
+ lineNumbersRef.scrollTop = textareaRef.scrollTop;
+ }
+ }
+ }
+
+ let highlightedCode = $derived(
+ Prism.highlight(content, Prism.languages.json, 'json') + '\n'
+ );
+
+ async function handleSave(close = false) {
+ if (localError) return;
+ isSaving = true;
+ await settingsState.saveRawConfig(content, close);
+ isSaving = false;
+ }
+
+ function handleKeydown(e: KeyboardEvent) {
+ // Save
+ if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ handleSave(false); // Keep open on shortcut save
+ }
+ // Undo
+ else if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
+ e.preventDefault();
+ handleUndo();
+ }
+ // Redo (Ctrl+Shift+Z or Ctrl+Y)
+ else if (
+ (e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey) ||
+ (e.key === 'y' && (e.ctrlKey || e.metaKey))
+ ) {
+ e.preventDefault();
+ handleRedo();
+ }
+ // Close
+ else if (e.key === 'Escape') {
+ settingsState.closeConfigEditor();
+ }
+ // Tab
+ else if (e.key === 'Tab') {
+ e.preventDefault();
+ const target = e.target as HTMLTextAreaElement;
+ const start = target.selectionStart;
+ const end = target.selectionEnd;
+
+ pushHistory(content, true);
+
+ const newContent = content.substring(0, start) + " " + content.substring(end);
+ content = newContent;
+
+ pushHistory(content, true);
+
+ setTimeout(() => {
+ target.selectionStart = target.selectionEnd = start + 2;
+ updateCursor();
+ }, 0);
+ validate(content);
+ }
+ }
+</script>
+
+<div class="fixed inset-0 z-[100] flex items-center justify-center backdrop-blur-sm bg-black/70 animate-in fade-in duration-200">
+ <div
+ class="bg-[#1d1f21] rounded-xl border border-zinc-700 shadow-2xl w-[900px] max-w-[95vw] h-[85vh] flex flex-col overflow-hidden"
+ role="dialog"
+ aria-modal="true"
+ >
+ <!-- Header -->
+ <div class="flex items-center justify-between p-4 border-b border-zinc-700 bg-[#1d1f21] z-20 relative">
+ <div class="flex items-center gap-3">
+ <div class="p-2 bg-indigo-500/20 rounded-lg text-indigo-400">
+ <FileJson size={20} />
+ </div>
+ <div class="flex flex-col">
+ <h3 class="text-lg font-bold text-white leading-none">Configuration Editor</h3>
+ <span class="text-[10px] text-zinc-500 font-mono mt-1 break-all">{settingsState.configFilePath}</span>
+ </div>
+ </div>
+ <div class="flex items-center gap-2">
+ <!-- Undo/Redo Buttons -->
+ <div class="flex items-center bg-zinc-800 rounded-lg p-0.5 mr-2 border border-zinc-700">
+ <button
+ onclick={handleUndo}
+ disabled={historyIndex === 0}
+ class="p-1.5 text-zinc-400 hover:text-white hover:bg-zinc-700 rounded disabled:opacity-30 disabled:hover:bg-transparent transition-colors"
+ title="Undo (Ctrl+Z)"
+ >
+ <Undo size={16} />
+ </button>
+ <button
+ onclick={handleRedo}
+ disabled={historyIndex === history.length - 1}
+ class="p-1.5 text-zinc-400 hover:text-white hover:bg-zinc-700 rounded disabled:opacity-30 disabled:hover:bg-transparent transition-colors"
+ title="Redo (Ctrl+Y)"
+ >
+ <Redo size={16} />
+ </button>
+ </div>
+
+ <!-- Settings Toggle -->
+ <div class="relative">
+ <button
+ onclick={() => showSettings = !showSettings}
+ class="text-zinc-400 hover:text-white transition-colors p-2 hover:bg-white/5 rounded-lg {showSettings ? 'bg-white/10 text-white' : ''}"
+ title="Editor Settings"
+ >
+ <Settings size={20} />
+ </button>
+
+ {#if showSettings}
+ <div class="absolute right-0 top-full mt-2 w-48 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl p-2 z-50 flex flex-col gap-1">
+ <label class="flex items-center gap-2 p-2 hover:bg-white/5 rounded cursor-pointer">
+ <input type="checkbox" bind:checked={showLineNumbers} class="rounded border-zinc-600 bg-zinc-900 text-indigo-500 focus:ring-indigo-500/50" />
+ <span class="text-sm text-zinc-300">Line Numbers</span>
+ </label>
+ <label class="flex items-center gap-2 p-2 hover:bg-white/5 rounded cursor-pointer">
+ <input type="checkbox" bind:checked={showStatusBar} class="rounded border-zinc-600 bg-zinc-900 text-indigo-500 focus:ring-indigo-500/50" />
+ <span class="text-sm text-zinc-300">Cursor Status</span>
+ </label>
+ </div>
+ {/if}
+ </div>
+
+ <button
+ onclick={() => settingsState.closeConfigEditor()}
+ class="text-zinc-400 hover:text-white transition-colors p-2 hover:bg-white/5 rounded-lg"
+ title="Close (Esc)"
+ >
+ <X size={20} />
+ </button>
+ </div>
+ </div>
+
+ <!-- Error Banner -->
+ {#if localError || settingsState.configEditorError}
+ <div class="bg-red-500/10 border-b border-red-500/20 p-3 flex items-start gap-3 animate-in slide-in-from-top-2 z-10 relative">
+ <AlertCircle size={16} class="text-red-400 mt-0.5 shrink-0" />
+ <p class="text-xs text-red-300 font-mono whitespace-pre-wrap">{localError || settingsState.configEditorError}</p>
+ </div>
+ {/if}
+
+ <!-- Editor Body (Flex row for line numbers + code) -->
+ <div class="flex-1 flex overflow-hidden relative bg-[#1d1f21]">
+ <!-- Line Numbers -->
+ {#if showLineNumbers}
+ <div
+ bind:this={lineNumbersRef}
+ class="pt-4 pb-4 pr-3 pl-2 text-right text-zinc-600 bg-[#1d1f21] border-r border-zinc-700/50 font-mono select-none overflow-hidden min-w-[3rem]"
+ aria-hidden="true"
+ >
+ {#each lines as _, i}
+ <div class="leading-[20px] text-[13px]">{i + 1}</div>
+ {/each}
+ </div>
+ {/if}
+
+ <!-- Code Area -->
+ <div class="flex-1 relative overflow-hidden group">
+ <!-- Highlighted Code (Background) -->
+ <pre
+ bind:this={preRef}
+ aria-hidden="true"
+ class="absolute inset-0 w-full h-full p-4 m-0 bg-transparent pointer-events-none overflow-hidden whitespace-pre font-mono text-sm leading-relaxed"
+ ><code class="language-json">{@html highlightedCode}</code></pre>
+
+ <!-- Textarea (Foreground) -->
+ <textarea
+ bind:this={textareaRef}
+ bind:value={content}
+ oninput={handleInput}
+ onkeydown={handleKeydown}
+ onscroll={handleScroll}
+ onmouseup={updateCursor}
+ onkeyup={updateCursor}
+ onclick={() => showSettings = false}
+ class="absolute inset-0 w-full h-full p-4 bg-transparent text-transparent caret-white font-mono text-sm leading-relaxed resize-none focus:outline-none whitespace-pre overflow-auto z-10 selection:bg-indigo-500/30"
+ spellcheck="false"
+ {...textareaAttrs}
+ ></textarea>
+ </div>
+ </div>
+
+ <!-- Footer -->
+ <div class="p-3 border-t border-zinc-700 bg-[#1d1f21] flex justify-between items-center z-20 relative">
+ <div class="text-xs text-zinc-500 flex gap-4 items-center">
+ {#if showStatusBar}
+ <div class="flex gap-3 font-mono border-r border-zinc-700 pr-4 mr-1">
+ <span>Ln {cursorLine}</span>
+ <span>Col {cursorCol}</span>
+ </div>
+ {/if}
+ <span class="hidden sm:inline"><span class="bg-white/10 px-1.5 py-0.5 rounded text-zinc-300">Ctrl+S</span> save</span>
+ </div>
+ <div class="flex gap-3">
+ <button
+ onclick={() => settingsState.closeConfigEditor()}
+ class="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg text-sm font-medium transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onclick={() => handleSave(false)}
+ disabled={isSaving || !!localError}
+ class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
+ title={localError ? "Fix errors before saving" : "Save changes"}
+ >
+ {#if isSaving}
+ <div class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
+ Saving...
+ {:else}
+ <Save size={16} />
+ Save
+ {/if}
+ </button>
+ </div>
+ </div>
+ </div>
+</div>
+
+<style>
+ /* Ensure exact font match */
+ pre, textarea {
+ font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
+ font-size: 13px !important;
+ line-height: 20px !important;
+ letter-spacing: 0px !important;
+ tab-size: 2;
+ }
+
+ /* Hide scrollbar for pre but keep it functional for textarea */
+ pre::-webkit-scrollbar {
+ display: none;
+ }
+
+ /* Override Prism background and font weights for alignment */
+ :global(pre[class*="language-"]), :global(code[class*="language-"]) {
+ background: transparent !important;
+ text-shadow: none !important;
+ box-shadow: none !important;
+ }
+
+ /* CRITICAL: Force normal weight to match textarea */
+ :global(.token) {
+ font-weight: normal !important;
+ font-style: normal !important;
+ }
+</style>
diff --git a/ui/src/components/CustomSelect.svelte b/ui/src/components/CustomSelect.svelte
index 2e89c75..0767471 100644
--- a/ui/src/components/CustomSelect.svelte
+++ b/ui/src/components/CustomSelect.svelte
@@ -13,6 +13,7 @@
placeholder?: string;
disabled?: boolean;
class?: string;
+ allowCustom?: boolean; // New prop to allow custom input
onchange?: (value: string) => void;
}
@@ -22,17 +23,25 @@
placeholder = "Select...",
disabled = false,
class: className = "",
+ allowCustom = false,
onchange
}: Props = $props();
let isOpen = $state(false);
let containerRef: HTMLDivElement;
+ let customInput = $state(""); // State for custom input
let selectedOption = $derived(options.find(o => o.value === value));
+ // Display label: if option exists use its label, otherwise if custom is allowed use raw value, else placeholder
+ let displayLabel = $derived(selectedOption ? selectedOption.label : (allowCustom && value ? value : placeholder));
function toggle() {
if (!disabled) {
isOpen = !isOpen;
+ // When opening, if current value is custom (not in options), pre-fill input
+ if (isOpen && allowCustom && !selectedOption) {
+ customInput = value;
+ }
}
}
@@ -43,6 +52,13 @@
onchange?.(option.value);
}
+ function handleCustomSubmit() {
+ if (!customInput.trim()) return;
+ value = customInput.trim();
+ isOpen = false;
+ onchange?.(value);
+ }
+
function handleKeydown(e: KeyboardEvent) {
if (disabled) return;
@@ -98,8 +114,8 @@
transition-colors cursor-pointer outline-none
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-zinc-700"
>
- <span class="truncate {!selectedOption ? 'text-zinc-500' : ''}">
- {selectedOption?.label || placeholder}
+ <span class="truncate {(!selectedOption && !value) ? 'text-zinc-500' : ''}">
+ {displayLabel}
</span>
<ChevronDown
size={14}
@@ -111,8 +127,29 @@
{#if isOpen}
<div
class="absolute z-50 w-full mt-1 py-1 bg-zinc-900 border border-zinc-700 rounded-md shadow-xl
- max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150"
+ max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 flex flex-col"
>
+ {#if allowCustom}
+ <div class="px-2 py-2 border-b border-zinc-700/50 mb-1">
+ <div class="flex gap-2">
+ <input
+ type="text"
+ bind:value={customInput}
+ placeholder="Custom value..."
+ class="flex-1 bg-black/30 border border-zinc-700 rounded px-2 py-1 text-xs text-white focus:border-indigo-500 outline-none"
+ onkeydown={(e) => e.key === 'Enter' && handleCustomSubmit()}
+ onclick={(e) => e.stopPropagation()}
+ />
+ <button
+ onclick={(e) => { e.stopPropagation(); handleCustomSubmit(); }}
+ class="px-2 py-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded text-xs transition-colors"
+ >
+ Set
+ </button>
+ </div>
+ </div>
+ {/if}
+
{#each options as option}
<button
type="button"
diff --git a/ui/src/components/SettingsView.svelte b/ui/src/components/SettingsView.svelte
index 76d441b..4de18b3 100644
--- a/ui/src/components/SettingsView.svelte
+++ b/ui/src/components/SettingsView.svelte
@@ -2,6 +2,9 @@
import { open } from "@tauri-apps/plugin-dialog";
import { settingsState } from "../stores/settings.svelte";
import CustomSelect from "./CustomSelect.svelte";
+ import ConfigEditorModal from "./ConfigEditorModal.svelte";
+ import { onMount } from "svelte";
+ import { RefreshCw, FileJson } from "lucide-svelte";
// Use convertFileSrc directly from settingsState.backgroundUrl for cleaner approach
// or use the imported one if passing raw path.
@@ -17,6 +20,84 @@
{ value: "pastebin.com", label: "pastebin.com (Requires API Key)" }
];
+ const llmProviderOptions = [
+ { value: "ollama", label: "Ollama (Local)" },
+ { value: "openai", label: "OpenAI (Remote)" }
+ ];
+
+ const languageOptions = [
+ { value: "auto", label: "Auto (Match User)" },
+ { value: "English", label: "English" },
+ { value: "Chinese", label: "中文" },
+ { value: "Japanese", label: "日本語" },
+ { value: "Korean", label: "한국어" },
+ { value: "Spanish", label: "Español" },
+ { value: "French", label: "Français" },
+ { value: "German", label: "Deutsch" },
+ { value: "Russian", label: "Русский" },
+ ];
+
+ const ttsProviderOptions = [
+ { value: "disabled", label: "Disabled" },
+ { value: "piper", label: "Piper TTS (Local)" },
+ { value: "edge", label: "Edge TTS (Online)" },
+ ];
+
+ const personas = [
+ {
+ value: "default",
+ label: "Minecraft Expert (Default)",
+ prompt: "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice."
+ },
+ {
+ value: "technical",
+ label: "Technical Debugger",
+ prompt: "You are a technical support specialist for Minecraft. Focus strictly on analyzing logs, identifying crash causes, and providing technical solutions. Be precise and avoid conversational filler."
+ },
+ {
+ value: "concise",
+ label: "Concise Helper",
+ prompt: "You are a direct and concise assistant. Provide answers in as few words as possible while remaining accurate. Use bullet points for lists."
+ },
+ {
+ value: "explain",
+ label: "Teacher / Explainer",
+ prompt: "You are a patient teacher. Explain Minecraft concepts, redstone mechanics, and mod features in simple, easy-to-understand terms suitable for beginners."
+ },
+ {
+ value: "pirate",
+ label: "Pirate Captain",
+ prompt: "You are a salty Minecraft Pirate Captain! Yarr! Speak like a pirate while helping the crew (the user) with their blocky adventures. Use terms like 'matey', 'landlubber', and 'treasure'."
+ }
+ ];
+
+ let selectedPersona = $state("");
+
+ function applyPersona(value: string) {
+ const persona = personas.find(p => p.value === value);
+ if (persona) {
+ settingsState.settings.assistant.system_prompt = persona.prompt;
+ selectedPersona = value; // Keep selected to show what's active
+ }
+ }
+
+ function resetSystemPrompt() {
+ const defaultPersona = personas.find(p => p.value === "default");
+ if (defaultPersona) {
+ settingsState.settings.assistant.system_prompt = defaultPersona.prompt;
+ selectedPersona = "default";
+ }
+ }
+
+ // Load models when assistant settings are shown
+ function loadModelsForProvider() {
+ if (settingsState.settings.assistant.llm_provider === "ollama") {
+ settingsState.loadOllamaModels();
+ } else if (settingsState.settings.assistant.llm_provider === "openai") {
+ settingsState.loadOpenaiModels();
+ }
+ }
+
async function selectBackground() {
try {
const selected = await open({
@@ -47,6 +128,15 @@
<div class="h-full flex flex-col p-6 overflow-hidden">
<div class="flex items-center justify-between mb-6">
<h2 class="text-3xl font-black bg-clip-text text-transparent bg-gradient-to-r dark:from-white dark:to-white/60 from-gray-900 to-gray-600">Settings</h2>
+
+ <button
+ onclick={() => settingsState.openConfigEditor()}
+ class="p-2 hover:bg-white/10 rounded-lg text-zinc-400 hover:text-white transition-colors flex items-center gap-2 text-sm border border-transparent hover:border-white/5"
+ title="Open Settings JSON"
+ >
+ <FileJson size={18} />
+ <span class="hidden sm:inline">Open JSON</span>
+ </button>
</div>
<div class="flex-1 overflow-y-auto pr-2 space-y-6 custom-scrollbar pb-10">
@@ -341,6 +431,298 @@
</div>
</div>
+ <!-- AI Assistant -->
+ <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
+ <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <rect x="3" y="11" width="18" height="10" rx="2"/>
+ <circle cx="12" cy="5" r="2"/>
+ <path d="M12 7v4"/>
+ <circle cx="8" cy="16" r="1" fill="currentColor"/>
+ <circle cx="16" cy="16" r="1" fill="currentColor"/>
+ </svg>
+ AI Assistant
+ </h3>
+ <div class="space-y-6">
+ <!-- Enable/Disable -->
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="assistant-enabled-label">Enable Assistant</h4>
+ <p class="text-xs dark:text-white/40 text-black/50 mt-1">Toggle the AI assistant feature on or off.</p>
+ </div>
+ <button
+ aria-labelledby="assistant-enabled-label"
+ onclick={() => { settingsState.settings.assistant.enabled = !settingsState.settings.assistant.enabled; settingsState.saveSettings(); }}
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.assistant.enabled ? 'bg-indigo-500' : 'dark:bg-white/10 bg-black/10'}"
+ >
+ <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.assistant.enabled ? 'translate-x-5' : 'translate-x-0'}"></div>
+ </button>
+ </div>
+
+ {#if settingsState.settings.assistant.enabled}
+ <!-- LLM Provider Section -->
+ <div class="pt-4 border-t dark:border-white/5 border-black/5">
+ <h4 class="text-xs font-bold uppercase tracking-widest text-white/30 mb-4">Language Model</h4>
+
+ <div class="space-y-4">
+ <div>
+ <label for="llm-provider" class="block text-sm font-medium text-white/70 mb-2">Provider</label>
+ <CustomSelect
+ options={llmProviderOptions}
+ bind:value={settingsState.settings.assistant.llm_provider}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full"
+ />
+ </div>
+
+ {#if settingsState.settings.assistant.llm_provider === 'ollama'}
+ <!-- Ollama Settings -->
+ <div class="pl-4 border-l-2 border-indigo-500/30 space-y-4">
+ <div>
+ <label for="ollama-endpoint" class="block text-sm font-medium text-white/70 mb-2">API Endpoint</label>
+ <div class="flex gap-2">
+ <input
+ id="ollama-endpoint"
+ type="text"
+ bind:value={settingsState.settings.assistant.ollama_endpoint}
+ placeholder="http://localhost:11434"
+ class="bg-black/40 text-white flex-1 px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
+ />
+ <button
+ onclick={() => settingsState.loadOllamaModels()}
+ disabled={settingsState.isLoadingOllamaModels}
+ class="px-4 py-2 bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white rounded-xl border border-white/5 transition-colors whitespace-nowrap text-sm font-medium flex items-center gap-2"
+ title="Refresh models"
+ >
+ <RefreshCw size={14} class={settingsState.isLoadingOllamaModels ? "animate-spin" : ""} />
+ <span class="hidden sm:inline">Refresh</span>
+ </button>
+ </div>
+ <p class="text-xs text-white/30 mt-2">
+ Default: http://localhost:11434. Make sure Ollama is running.
+ </p>
+ </div>
+
+ <div>
+ <div class="flex items-center justify-between mb-2">
+ <label for="ollama-model" class="block text-sm font-medium text-white/70">Model</label>
+ {#if settingsState.ollamaModels.length > 0}
+ <span class="text-[10px] text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full">
+ {settingsState.ollamaModels.length} installed
+ </span>
+ {/if}
+ </div>
+
+ {#if settingsState.isLoadingOllamaModels}
+ <div class="bg-black/40 text-white/50 w-full px-4 py-3 rounded-xl border border-white/10 text-sm flex items-center gap-2">
+ <RefreshCw size={14} class="animate-spin" />
+ Loading models...
+ </div>
+ {:else if settingsState.ollamaModelsError}
+ <div class="bg-red-500/10 text-red-400 w-full px-4 py-3 rounded-xl border border-red-500/20 text-sm">
+ {settingsState.ollamaModelsError}
+ </div>
+ <CustomSelect
+ options={settingsState.currentModelOptions}
+ bind:value={settingsState.settings.assistant.ollama_model}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full mt-2"
+ allowCustom={true}
+ />
+ {:else if settingsState.ollamaModels.length === 0}
+ <div class="bg-amber-500/10 text-amber-400 w-full px-4 py-3 rounded-xl border border-amber-500/20 text-sm">
+ No models found. Click Refresh to load installed models.
+ </div>
+ <CustomSelect
+ options={settingsState.currentModelOptions}
+ bind:value={settingsState.settings.assistant.ollama_model}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full mt-2"
+ allowCustom={true}
+ />
+ {:else}
+ <CustomSelect
+ options={settingsState.currentModelOptions}
+ bind:value={settingsState.settings.assistant.ollama_model}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full"
+ allowCustom={true}
+ />
+ {/if}
+
+ <p class="text-xs text-white/30 mt-2">
+ Run <code class="bg-black/30 px-1 rounded">ollama pull {'<model>'}</code> to download new models. Or type a custom model name above.
+ </p>
+ </div>
+ </div>
+ {:else if settingsState.settings.assistant.llm_provider === 'openai'}
+ <!-- OpenAI Settings -->
+ <div class="pl-4 border-l-2 border-emerald-500/30 space-y-4">
+ <div>
+ <label for="openai-key" class="block text-sm font-medium text-white/70 mb-2">API Key</label>
+ <div class="flex gap-2">
+ <input
+ id="openai-key"
+ type="password"
+ bind:value={settingsState.settings.assistant.openai_api_key}
+ placeholder="sk-..."
+ class="bg-black/40 text-white flex-1 px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
+ />
+ <button
+ onclick={() => settingsState.loadOpenaiModels()}
+ disabled={settingsState.isLoadingOpenaiModels || !settingsState.settings.assistant.openai_api_key}
+ class="px-4 py-2 bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white rounded-xl border border-white/5 transition-colors whitespace-nowrap text-sm font-medium flex items-center gap-2"
+ title="Refresh models"
+ >
+ <RefreshCw size={14} class={settingsState.isLoadingOpenaiModels ? "animate-spin" : ""} />
+ <span class="hidden sm:inline">Load</span>
+ </button>
+ </div>
+ <p class="text-xs text-white/30 mt-2">
+ Get your API key from <a href="https://platform.openai.com/api-keys" target="_blank" class="text-indigo-400 hover:underline">OpenAI Dashboard</a>.
+ </p>
+ </div>
+
+ <div>
+ <label for="openai-endpoint" class="block text-sm font-medium text-white/70 mb-2">API Endpoint</label>
+ <input
+ id="openai-endpoint"
+ type="text"
+ bind:value={settingsState.settings.assistant.openai_endpoint}
+ placeholder="https://api.openai.com/v1"
+ class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
+ />
+ <p class="text-xs text-white/30 mt-2">
+ Use custom endpoint for Azure OpenAI or other compatible APIs.
+ </p>
+ </div>
+
+ <div>
+ <div class="flex items-center justify-between mb-2">
+ <label for="openai-model" class="block text-sm font-medium text-white/70">Model</label>
+ {#if settingsState.openaiModels.length > 0}
+ <span class="text-[10px] text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full">
+ {settingsState.openaiModels.length} available
+ </span>
+ {/if}
+ </div>
+
+ {#if settingsState.isLoadingOpenaiModels}
+ <div class="bg-black/40 text-white/50 w-full px-4 py-3 rounded-xl border border-white/10 text-sm flex items-center gap-2">
+ <RefreshCw size={14} class="animate-spin" />
+ Loading models...
+ </div>
+ {:else if settingsState.openaiModelsError}
+ <div class="bg-red-500/10 text-red-400 w-full px-4 py-3 rounded-xl border border-red-500/20 text-sm mb-2">
+ {settingsState.openaiModelsError}
+ </div>
+ <CustomSelect
+ options={settingsState.currentModelOptions}
+ bind:value={settingsState.settings.assistant.openai_model}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full"
+ allowCustom={true}
+ />
+ {:else}
+ <CustomSelect
+ options={settingsState.currentModelOptions}
+ bind:value={settingsState.settings.assistant.openai_model}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full"
+ allowCustom={true}
+ />
+ {/if}
+ </div>
+ </div>
+ {/if}
+ </div>
+ </div>
+
+ <!-- Response Settings -->
+ <div class="pt-4 border-t dark:border-white/5 border-black/5">
+ <h4 class="text-xs font-bold uppercase tracking-widest text-white/30 mb-4">Response Settings</h4>
+
+ <div class="space-y-4">
+ <div>
+ <label for="response-lang" class="block text-sm font-medium text-white/70 mb-2">Response Language</label>
+ <CustomSelect
+ options={languageOptions}
+ bind:value={settingsState.settings.assistant.response_language}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full"
+ />
+ </div>
+
+ <div>
+ <div class="flex items-center justify-between mb-2">
+ <label for="system-prompt" class="block text-sm font-medium text-white/70">System Prompt</label>
+ <button
+ onclick={resetSystemPrompt}
+ class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors flex items-center gap-1 opacity-80 hover:opacity-100"
+ title="Reset to default prompt"
+ >
+ <RefreshCw size={10} />
+ Reset
+ </button>
+ </div>
+
+ <div class="mb-3">
+ <CustomSelect
+ options={personas.map(p => ({ value: p.value, label: p.label }))}
+ bind:value={selectedPersona}
+ placeholder="Load a preset persona..."
+ onchange={applyPersona}
+ class="w-full"
+ />
+ </div>
+
+ <textarea
+ id="system-prompt"
+ bind:value={settingsState.settings.assistant.system_prompt}
+ oninput={() => selectedPersona = ""}
+ rows="4"
+ placeholder="You are a helpful Minecraft expert assistant..."
+ class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none text-sm transition-colors resize-none"
+ ></textarea>
+ <p class="text-xs text-white/30 mt-2">
+ Customize how the assistant behaves and responds.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <!-- TTS Settings -->
+ <div class="pt-4 border-t dark:border-white/5 border-black/5">
+ <h4 class="text-xs font-bold uppercase tracking-widest text-white/30 mb-4">Text-to-Speech (Coming Soon)</h4>
+
+ <div class="space-y-4 opacity-50 pointer-events-none">
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium dark:text-white/90 text-black/80">Enable TTS</h4>
+ <p class="text-xs dark:text-white/40 text-black/50 mt-1">Read assistant responses aloud.</p>
+ </div>
+ <button
+ disabled
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none dark:bg-white/10 bg-black/10"
+ >
+ <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out translate-x-0"></div>
+ </button>
+ </div>
+
+ <div>
+ <label class="block text-sm font-medium text-white/70 mb-2">TTS Provider</label>
+ <CustomSelect
+ options={ttsProviderOptions}
+ value="disabled"
+ class="w-full"
+ />
+ </div>
+ </div>
+ </div>
+ {/if}
+ </div>
+ </div>
+
<div class="pt-4 flex justify-end">
<button
onclick={() => settingsState.saveSettings()}
@@ -352,6 +734,10 @@
</div>
</div>
+{#if settingsState.showConfigEditor}
+ <ConfigEditorModal />
+{/if}
+
<!-- Java Download Modal -->
{#if settingsState.showJavaDownloadModal}
<div class="fixed inset-0 z-[100] flex items-center justify-center backdrop-blur-sm bg-black/70">
diff --git a/ui/src/components/Sidebar.svelte b/ui/src/components/Sidebar.svelte
index 1d7cc16..3d36f89 100644
--- a/ui/src/components/Sidebar.svelte
+++ b/ui/src/components/Sidebar.svelte
@@ -1,6 +1,6 @@
<script lang="ts">
import { uiState } from '../stores/ui.svelte';
- import { Home, Package, Settings } from 'lucide-svelte';
+ import { Home, Package, Settings, Bot } from 'lucide-svelte';
</script>
<aside
@@ -57,7 +57,7 @@
<!-- Navigation -->
<nav class="flex-1 w-full flex flex-col gap-1 px-3">
<!-- Nav Item Helper -->
- {#snippet navItem(view, Icon, label)}
+ {#snippet navItem(view: any, Icon: any, label: string)}
<button
class="group flex items-center lg:gap-3 justify-center lg:justify-start w-full px-0 lg:px-4 py-2.5 rounded-sm transition-all duration-200 relative
{uiState.currentView === view
@@ -77,6 +77,7 @@
{@render navItem('home', Home, 'Overview')}
{@render navItem('versions', Package, 'Versions')}
+ {@render navItem('guide', Bot, 'Assistant')}
{@render navItem('settings', Settings, 'Settings')}
</nav>
diff --git a/ui/src/components/VersionsView.svelte b/ui/src/components/VersionsView.svelte
index ce354b9..063c28d 100644
--- a/ui/src/components/VersionsView.svelte
+++ b/ui/src/components/VersionsView.svelte
@@ -236,4 +236,3 @@
</div>
</div>
</div>
-
diff --git a/ui/src/stores/assistant.svelte.ts b/ui/src/stores/assistant.svelte.ts
new file mode 100644
index 0000000..a3f47ea
--- /dev/null
+++ b/ui/src/stores/assistant.svelte.ts
@@ -0,0 +1,166 @@
+import { invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+
+export interface GenerationStats {
+ total_duration: number;
+ load_duration: number;
+ prompt_eval_count: number;
+ prompt_eval_duration: number;
+ eval_count: number;
+ eval_duration: number;
+}
+
+export interface Message {
+ role: "user" | "assistant" | "system";
+ content: string;
+ stats?: GenerationStats;
+}
+
+interface StreamChunk {
+ content: string;
+ done: boolean;
+ stats?: GenerationStats;
+}
+
+// Module-level state using $state
+let messages = $state<Message[]>([]);
+let isProcessing = $state(false);
+let isProviderHealthy = $state(false);
+let streamingContent = "";
+let initialized = false;
+let streamUnlisten: UnlistenFn | null = null;
+
+async function init() {
+ if (initialized) return;
+ initialized = true;
+ await checkHealth();
+}
+
+async function checkHealth() {
+ try {
+ isProviderHealthy = await invoke("assistant_check_health");
+ } catch (e) {
+ console.error("Failed to check provider health:", e);
+ isProviderHealthy = false;
+ }
+}
+
+function finishStreaming() {
+ isProcessing = false;
+ streamingContent = "";
+ if (streamUnlisten) {
+ streamUnlisten();
+ streamUnlisten = null;
+ }
+}
+
+async function sendMessage(
+ content: string,
+ isEnabled: boolean,
+ provider: string,
+ endpoint: string,
+) {
+ if (!content.trim()) return;
+ if (!isEnabled) {
+ messages = [
+ ...messages,
+ {
+ role: "assistant",
+ content: "Assistant is disabled. Enable it in Settings > AI Assistant.",
+ },
+ ];
+ return;
+ }
+
+ // Add user message
+ messages = [...messages, { role: "user", content }];
+ isProcessing = true;
+ streamingContent = "";
+
+ // Add empty assistant message for streaming
+ messages = [...messages, { role: "assistant", content: "" }];
+
+ try {
+ // Set up stream listener
+ streamUnlisten = await listen<StreamChunk>("assistant-stream", (event) => {
+ const chunk = event.payload;
+
+ if (chunk.content) {
+ streamingContent += chunk.content;
+ // Update the last message (assistant's response)
+ const lastIdx = messages.length - 1;
+ if (lastIdx >= 0 && messages[lastIdx].role === "assistant") {
+ messages[lastIdx] = {
+ ...messages[lastIdx],
+ content: streamingContent,
+ };
+ // Trigger reactivity
+ messages = [...messages];
+ }
+ }
+
+ if (chunk.done) {
+ if (chunk.stats) {
+ const lastIdx = messages.length - 1;
+ if (lastIdx >= 0 && messages[lastIdx].role === "assistant") {
+ messages[lastIdx] = {
+ ...messages[lastIdx],
+ stats: chunk.stats,
+ };
+ messages = [...messages];
+ }
+ }
+ finishStreaming();
+ }
+ });
+
+ // Start streaming chat
+ await invoke<string>("assistant_chat_stream", {
+ messages: messages.slice(0, -1), // Exclude the empty assistant message
+ });
+ } catch (e) {
+ console.error("Failed to send message:", e);
+ const errorMessage = e instanceof Error ? e.message : String(e);
+
+ let helpText = "";
+ if (provider === "ollama") {
+ helpText = `\n\nPlease ensure Ollama is running at ${endpoint}.`;
+ } else if (provider === "openai") {
+ helpText = "\n\nPlease check your OpenAI API key in Settings.";
+ }
+
+ // Update the last message with error
+ const lastIdx = messages.length - 1;
+ if (lastIdx >= 0 && messages[lastIdx].role === "assistant") {
+ messages[lastIdx] = {
+ role: "assistant",
+ content: `Error: ${errorMessage}${helpText}`,
+ };
+ messages = [...messages];
+ }
+
+ finishStreaming();
+ }
+}
+
+function clearHistory() {
+ messages = [];
+ streamingContent = "";
+}
+
+// Export as an object with getters for reactive access
+export const assistantState = {
+ get messages() {
+ return messages;
+ },
+ get isProcessing() {
+ return isProcessing;
+ },
+ get isProviderHealthy() {
+ return isProviderHealthy;
+ },
+ init,
+ checkHealth,
+ sendMessage,
+ clearHistory,
+};
diff --git a/ui/src/stores/logs.svelte.ts b/ui/src/stores/logs.svelte.ts
index 5df9abc..c9d4acc 100644
--- a/ui/src/stores/logs.svelte.ts
+++ b/ui/src/stores/logs.svelte.ts
@@ -39,7 +39,6 @@ export class LogsState {
constructor() {
this.addLog("info", "Launcher", "Logs initialized");
- this.setupListeners();
}
addLog(level: LogEntry["level"], source: string, message: string) {
@@ -95,7 +94,12 @@ export class LogsState {
.join("\n");
}
- private async setupListeners() {
+ private initialized = false;
+
+ async init() {
+ if (this.initialized) return;
+ this.initialized = true;
+
// General Launcher Logs
await listen<string>("launcher-log", (e) => {
this.addLog("info", "Launcher", e.payload);
diff --git a/ui/src/stores/settings.svelte.ts b/ui/src/stores/settings.svelte.ts
index 12e4a1c..8a90736 100644
--- a/ui/src/stores/settings.svelte.ts
+++ b/ui/src/stores/settings.svelte.ts
@@ -8,6 +8,7 @@ import type {
JavaInstallation,
JavaReleaseInfo,
LauncherConfig,
+ ModelInfo,
PendingJavaDownload,
} from "../types";
import { uiState } from "./ui.svelte";
@@ -27,6 +28,20 @@ export class SettingsState {
custom_background_path: undefined,
log_upload_service: "paste.rs",
pastebin_api_key: undefined,
+ assistant: {
+ enabled: true,
+ llm_provider: "ollama",
+ ollama_endpoint: "http://localhost:11434",
+ ollama_model: "llama3",
+ openai_api_key: undefined,
+ openai_endpoint: "https://api.openai.com/v1",
+ openai_model: "gpt-3.5-turbo",
+ system_prompt:
+ "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice.",
+ response_language: "auto",
+ tts_enabled: false,
+ tts_provider: "disabled",
+ },
});
// Convert background path to proper asset URL
@@ -62,9 +77,58 @@ export class SettingsState {
// Pending downloads
pendingDownloads = $state<PendingJavaDownload[]>([]);
+ // AI Model lists
+ ollamaModels = $state<ModelInfo[]>([]);
+ openaiModels = $state<ModelInfo[]>([]);
+ isLoadingOllamaModels = $state(false);
+ isLoadingOpenaiModels = $state(false);
+ ollamaModelsError = $state("");
+ openaiModelsError = $state("");
+
+ // Config Editor state
+ showConfigEditor = $state(false);
+ rawConfigContent = $state("");
+ configFilePath = $state("");
+ configEditorError = $state("");
+
// Event listener cleanup
private progressUnlisten: UnlistenFn | null = null;
+ async openConfigEditor() {
+ this.configEditorError = "";
+ try {
+ const path = await invoke<string>("get_config_path");
+ const content = await invoke<string>("read_raw_config");
+ this.configFilePath = path;
+ this.rawConfigContent = content;
+ this.showConfigEditor = true;
+ } catch (e) {
+ console.error("Failed to open config editor:", e);
+ uiState.setStatus(`Failed to open config: ${e}`);
+ }
+ }
+
+ async saveRawConfig(content: string, closeAfterSave = true) {
+ try {
+ await invoke("save_raw_config", { content });
+ // Reload settings to ensure UI is in sync
+ await this.loadSettings();
+ if (closeAfterSave) {
+ this.showConfigEditor = false;
+ }
+ uiState.setStatus("Configuration saved successfully!");
+ } catch (e) {
+ console.error("Failed to save config:", e);
+ this.configEditorError = String(e);
+ }
+ }
+
+ closeConfigEditor() {
+ this.showConfigEditor = false;
+ this.rawConfigContent = "";
+ this.configEditorError = "";
+ }
+
// Computed: filtered releases based on selection
get filteredReleases(): JavaReleaseInfo[] {
if (!this.javaCatalog) return [];
@@ -389,6 +453,109 @@ export class SettingsState {
get availableJavaVersions(): number[] {
return this.availableMajorVersions;
}
+
+ // AI Model loading methods
+ async loadOllamaModels() {
+ this.isLoadingOllamaModels = true;
+ this.ollamaModelsError = "";
+
+ try {
+ const models = await invoke<ModelInfo[]>("list_ollama_models", {
+ endpoint: this.settings.assistant.ollama_endpoint,
+ });
+ this.ollamaModels = models;
+
+ // If no model is selected or selected model isn't available, select the first one
+ if (models.length > 0) {
+ const currentModel = this.settings.assistant.ollama_model;
+ const modelExists = models.some((m) => m.id === currentModel);
+ if (!modelExists) {
+ this.settings.assistant.ollama_model = models[0].id;
+ }
+ }
+ } catch (e) {
+ console.error("Failed to load Ollama models:", e);
+ this.ollamaModelsError = String(e);
+ this.ollamaModels = [];
+ } finally {
+ this.isLoadingOllamaModels = false;
+ }
+ }
+
+ async loadOpenaiModels() {
+ if (!this.settings.assistant.openai_api_key) {
+ this.openaiModelsError = "API key required";
+ this.openaiModels = [];
+ return;
+ }
+
+ this.isLoadingOpenaiModels = true;
+ this.openaiModelsError = "";
+
+ try {
+ const models = await invoke<ModelInfo[]>("list_openai_models");
+ this.openaiModels = models;
+
+ // If no model is selected or selected model isn't available, select the first one
+ if (models.length > 0) {
+ const currentModel = this.settings.assistant.openai_model;
+ const modelExists = models.some((m) => m.id === currentModel);
+ if (!modelExists) {
+ this.settings.assistant.openai_model = models[0].id;
+ }
+ }
+ } catch (e) {
+ console.error("Failed to load OpenAI models:", e);
+ this.openaiModelsError = String(e);
+ this.openaiModels = [];
+ } finally {
+ this.isLoadingOpenaiModels = false;
+ }
+ }
+
+ // Computed: get model options for current provider
+ get currentModelOptions(): { value: string; label: string; details?: string }[] {
+ const provider = this.settings.assistant.llm_provider;
+
+ if (provider === "ollama") {
+ if (this.ollamaModels.length === 0) {
+ // Return fallback options if no models loaded
+ return [
+ { value: "llama3", label: "Llama 3" },
+ { value: "llama3.1", label: "Llama 3.1" },
+ { value: "llama3.2", label: "Llama 3.2" },
+ { value: "mistral", label: "Mistral" },
+ { value: "gemma2", label: "Gemma 2" },
+ { value: "qwen2.5", label: "Qwen 2.5" },
+ { value: "phi3", label: "Phi-3" },
+ { value: "codellama", label: "Code Llama" },
+ ];
+ }
+ return this.ollamaModels.map((m) => ({
+ value: m.id,
+ label: m.name,
+ details: m.size ? `${m.size}${m.details ? ` - ${m.details}` : ""}` : m.details,
+ }));
+ } else if (provider === "openai") {
+ if (this.openaiModels.length === 0) {
+ // Return fallback options if no models loaded
+ return [
+ { value: "gpt-4o", label: "GPT-4o" },
+ { value: "gpt-4o-mini", label: "GPT-4o Mini" },
+ { value: "gpt-4-turbo", label: "GPT-4 Turbo" },
+ { value: "gpt-4", label: "GPT-4" },
+ { value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" },
+ ];
+ }
+ return this.openaiModels.map((m) => ({
+ value: m.id,
+ label: m.name,
+ details: m.details,
+ }));
+ }
+
+ return [];
+ }
}
export const settingsState = new SettingsState();
diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts
index 83e7f9e..6471869 100644
--- a/ui/src/types/index.ts
+++ b/ui/src/types/index.ts
@@ -1,4 +1,4 @@
-export type ViewType = "home" | "versions" | "settings";
+export type ViewType = "home" | "versions" | "settings" | "guide";
export interface Version {
id: string;
@@ -26,6 +26,31 @@ export interface DeviceCodeResponse {
message?: string;
}
+export interface AssistantConfig {
+ enabled: boolean;
+ llm_provider: "ollama" | "openai";
+ // Ollama settings
+ ollama_endpoint: string;
+ ollama_model: string;
+ // OpenAI settings
+ openai_api_key?: string;
+ openai_endpoint: string;
+ openai_model: string;
+ // Common settings
+ system_prompt: string;
+ response_language: string;
+ // TTS settings
+ tts_enabled: boolean;
+ tts_provider: string;
+}
+
+export interface ModelInfo {
+ id: string;
+ name: string;
+ size?: string;
+ details?: string;
+}
+
export interface LauncherConfig {
min_memory: number;
max_memory: number;
@@ -40,6 +65,7 @@ export interface LauncherConfig {
theme: string;
log_upload_service: "paste.rs" | "pastebin.com";
pastebin_api_key?: string;
+ assistant: AssistantConfig;
}
export interface JavaInstallation {
diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json
index 31c18cf..addb46d 100644
--- a/ui/tsconfig.app.json
+++ b/ui/tsconfig.app.json
@@ -5,6 +5,7 @@
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
+ "moduleResolution": "bundler",
"types": ["svelte", "vite/client"],
"noEmit": true,
/**
diff --git a/uv.lock b/uv.lock
index b7a6bec..15d682d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -27,9 +27,17 @@ dependencies = [
{ name = "pre-commit" },
]
+[package.dev-dependencies]
+dev = [
+ { name = "prek" },
+]
+
[package.metadata]
requires-dist = [{ name = "pre-commit", specifier = ">=4.5.1" }]
+[package.metadata.requires-dev]
+dev = [{ name = "prek", specifier = ">=0.2.28" }]
+
[[package]]
name = "filelock"
version = "3.20.3"
@@ -83,6 +91,30 @@ wheels = [
]
[[package]]
+name = "prek"
+version = "0.2.28"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/54/d7/dda8a6b7819bdb9d1f54fa046911e80974d862bacba75a3539b43a9bb858/prek-0.2.28.tar.gz", hash = "sha256:ac54f58cad26e617a5c5459b705ff1cbaaa41640db03d8d35e39645aca1b82cf", size = 283945 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/8c/2e18867e31d06dfa4974bf31c4e597dc1d2b3671b3c04d85f1f6a32ebc74/prek-0.2.28-py3-none-linux_armv6l.whl", hash = "sha256:1705c0bd035379cb5f1d03c19481821363d72d7923303fe8c84fd8cc7c6c3318", size = 4802811 },
+ { url = "https://files.pythonhosted.org/packages/26/fa/6c6d0b0d8b2f21301da2bb3441f22232ed5a8cba1b63eeb18244d2192a2e/prek-0.2.28-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:67677c08767c278335b31ebcbf00c1c73e14eadd99a0d8537dfb43c54482673a", size = 4904156 },
+ { url = "https://files.pythonhosted.org/packages/fc/5a/aa071ef1c2e6c3f58b50d9138676c96dd6de2323a44e1a3e56e18d25c382/prek-0.2.28-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ffac92215058ea6ba954a8e3f978dcd2a5e89922a318fcb7035fb9c0ab4de395", size = 4630803 },
+ { url = "https://files.pythonhosted.org/packages/77/dc/66498e805a0bb17820de0c3575d75b202c66045a9bfeeff9305d9bedd126/prek-0.2.28-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:413c3da1c9b252b3fd5113f4a04c2dead3c793b0ec56fc55294bb797ba7ca056", size = 4826037 },
+ { url = "https://files.pythonhosted.org/packages/27/ad/99cccc9283c7b34cd92356fcb301a2b1c25a8b65dc34b86c671b0f8e29d8/prek-0.2.28-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e99639bb68b70704e7b3f7f1a51cb23527c4dbd4b8a01dfccaa70f26f8f6c58b", size = 4723658 },
+ { url = "https://files.pythonhosted.org/packages/53/13/ce3edc2dda7b65485612e08ab038b8dd1ef7b10a94b0193f527b19a5e246/prek-0.2.28-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33c4a1b2a8a76581476ae327d6d4f1b0af6af42a90d436e21e45c72cb1081b81", size = 5044611 },
+ { url = "https://files.pythonhosted.org/packages/48/47/6405d7ad7959d9b57d56fec9a1b4b2e00abeb955084dd45d100fb50a8377/prek-0.2.28-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6236dda18152fc56b9f802ce2914fbb2d19f9891595d552228c7894004b3332f", size = 5511371 },
+ { url = "https://files.pythonhosted.org/packages/92/cc/108c227fae40268ece36b80e5649037f1a816518e9b6d585d128b263df79/prek-0.2.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6506709d9a52ee431d48b249b8b5fb93597a9511eb260ee85d868750425057f", size = 5099352 },
+ { url = "https://files.pythonhosted.org/packages/12/d6/156ad3996d3a078a1bc2c0839b8681634216a494dcb298b8751beb28b327/prek-0.2.28-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7cc920c12613440c105a767dc19acf048e8a05342ba38b48450d673bea33bd62", size = 4834340 },
+ { url = "https://files.pythonhosted.org/packages/f4/06/c632d4c4bb9c63d25bcc26149f99c715206a40e414fb6b80e7f800ae2e2d/prek-0.2.28-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:322a1922e6d2fcb2a4c487e01b2613856dc3206269bdc317ad28770704159e63", size = 4844870 },
+ { url = "https://files.pythonhosted.org/packages/ba/03/763f62d292399ee962e2583e7bc3fd2f8ee2609813c89cc10ec89a39204c/prek-0.2.28-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:a07baefe3562371368135eac3c8b9cdb831bac17b83cb1b6a8f5688050918e6c", size = 4709011 },
+ { url = "https://files.pythonhosted.org/packages/e1/62/49397d1a5c2aaf5e7a8c0644be901ee97934a8a2cac0052652d01b7c6585/prek-0.2.28-py3-none-musllinux_1_1_i686.whl", hash = "sha256:17e95cab33067365028ffc1d4ab6c80c6c150f88e352d7c64bdc15e0570778f6", size = 4928435 },
+ { url = "https://files.pythonhosted.org/packages/5e/e8/8ec73b5bb3fb9d5daf77f181cc46c541bd476075c7613f9b4c9c953925cc/prek-0.2.28-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:bc5272e2e8d81438a3cd2785c3b14b6e73dcb8e61b8a2b7ce6e693d57f7181ac", size = 5209880 },
+ { url = "https://files.pythonhosted.org/packages/f3/52/a784a52bf72619bdfaafdbb6c964bda404b377213e7dc786f9ad6024501c/prek-0.2.28-py3-none-win32.whl", hash = "sha256:74657a1663191fb5f09f15873704c2d2640095ce56277d36860bbd08ba7eea94", size = 4622786 },
+ { url = "https://files.pythonhosted.org/packages/d0/b8/ec6aafefeb05ef3a0bfcc044d036890f2b732b90ed1426acbf1e33289a44/prek-0.2.28-py3-none-win_amd64.whl", hash = "sha256:c350046d623362db65e2be1455ef1c5a96ea476e235445752fa946a705f1c1c9", size = 5316389 },
+ { url = "https://files.pythonhosted.org/packages/df/3f/9d4aba92cb9199cad0b056de8292a78dcca1dc1f6a6a34550799f19bde3d/prek-0.2.28-py3-none-win_arm64.whl", hash = "sha256:81db6ba7e5cf1d5ceec7d3b04e01aded32b8db8f1238ad812ac6ebc0bd35f141", size = 4974627 },
+]
+
+[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }