Commit 41f78a0d authored by Siddique's avatar Siddique
Browse files

Added Export button, Fixed Docker file.

parent 08a9295f
# Optional host ports
FRONTEND_PORT=5173
BACKEND_PORT=8000
LOCAL_LLM_PORT=8080
# Bundled llama.cpp model configuration
LOCAL_LLM_MODEL_DIR=./models
LOCAL_LLM_MODEL_FILE=gemma-4-E4B-it-Q4_K_M.gguf
LOCAL_LLM_MODEL_NAME=local-gguf-model
# Optional Academic Cloud key. Leave blank when it is not used.
CHAT_AI_API_KEY=
# Used only with docker-compose.host-llm.yml
HOST_LLM_BASE_URL=http://host.docker.internal:8080/v1
HOST_LLM_MODEL=local-model
......@@ -50,12 +50,22 @@ You also need access to at least one supported LLM provider.
### Bundled local LLM on CPU
Place this model file in `models/`:
Copy the example Docker configuration:
```powershell
Copy-Item .env.example .env
```
On macOS/Linux, use `cp .env.example .env`.
Place your GGUF model in `models/`. The default configuration expects:
```text
gemma-4-E4B-it-Q4_K_M.gguf
```
To use another GGUF file, change `LOCAL_LLM_MODEL_FILE` in the root `.env`. Model weights are intentionally ignored by Git and must be downloaded separately by each user.
Then start the stack:
```sh
......@@ -69,6 +79,7 @@ docker compose -f docker-compose.yml -f docker-compose.cuda.yml up --build
```
An NVIDIA GPU, compatible drivers, and the NVIDIA Container Toolkit are required.
The CUDA mode uses the same `LOCAL_LLM_MODEL_FILE` configuration as CPU mode.
### LLM server running on the host
......@@ -83,6 +94,10 @@ $env:HOST_LLM_BASE_URL = "http://host.docker.internal:8080/v1"
$env:HOST_LLM_MODEL = "your-model-name"
```
The host LLM must expose an OpenAI-compatible `/v1` API. `host.docker.internal` is configured for Docker Desktop and Linux Docker Engine.
This override disables the bundled llama.cpp container. It can also be used when the deployment will use only Academic Cloud or **Use my OpenAI key**, because those providers do not require a local GGUF server.
After startup:
- Frontend: `http://localhost:5173`
......@@ -90,6 +105,19 @@ After startup:
- Interactive API documentation: `http://localhost:8000/docs`
Docker stores uploaded files and the SQLite database in the named `asyst-backend-data` volume.
The frontend is built as static production assets and served by Nginx. Nginx proxies `/api` requests to the backend inside the Compose network, so the browser does not depend on Docker-internal hostnames.
To stop the application while retaining its data:
```sh
docker compose down
```
To also permanently delete uploaded files and the SQLite database:
```sh
docker compose down --volumes
```
## Run Locally
......@@ -167,6 +195,8 @@ npm run build
Do not commit API keys, local `.env` files, SQLite databases, uploaded student files, virtual environments, dependency directories, build output, or model weights. The included `.gitignore` excludes these files.
When **Use my OpenAI key** is selected, the key is held in the frontend component state and sent in the grading-start request. The backend wraps the request value as a secret and uses it to construct the OpenAI-compatible client for that grading job. It is not written to SQLite, uploaded files, browser local storage, or API responses. It still travels in the HTTP request and remains temporarily in process memory, so HTTPS and a dedicated secret-management design would be required for a production deployment exposed beyond a trusted local environment.
Before the first GitLab commit, verify the repository contents with:
```sh
......
......@@ -12,6 +12,7 @@ class GradingExampleRequest(BaseModel):
question: str
answer: str
score: str
max_points: str
class GradingStartRequest(BaseModel):
......
......@@ -108,6 +108,7 @@ def _coerce_grading_example(example: Any) -> GradingExample:
question = _example_value(example, "question")
answer = _example_value(example, "answer")
score_text = _example_value(example, "score")
max_points_text = _example_value(example, "max_points")
if not question:
raise ValueError("Each grading example needs a question")
......@@ -115,16 +116,27 @@ def _coerce_grading_example(example: Any) -> GradingExample:
raise ValueError("Each grading example needs a student answer")
if not score_text:
raise ValueError("Each grading example needs an awarded score")
if not max_points_text:
raise ValueError("Each grading example needs maximum points")
try:
score = float(score_text.replace(",", "."))
except ValueError as exc:
raise ValueError("Example score must be a number") from exc
try:
max_points = float(max_points_text.replace(",", "."))
except ValueError as exc:
raise ValueError("Example maximum points must be a number") from exc
if score < 0:
raise ValueError("Example score must be greater than or equal to 0")
if max_points <= 0:
raise ValueError("Example maximum points must be greater than 0")
if score > max_points:
raise ValueError("Example awarded score cannot exceed its maximum points")
return GradingExample(question=question, answer=answer, score=score)
return GradingExample(question=question, answer=answer, score=score, max_points=max_points)
def _example_value(example: Any, key: str) -> str:
......
......@@ -12,6 +12,7 @@ class GradingExample:
question: str
answer: str
score: float
max_points: float
@dataclass
......
......@@ -36,7 +36,7 @@ def valid_scores_text(max_points: float) -> str:
def _base_system() -> str:
return (
"Du bist ein erfahrener Korrektor fuer kurze studentische Antworten in der Informatik.\n"
"Du bist ein erfahrener Korrektor fuer kurze studentische Antworten.\n"
"Bewerte ausschliesslich die fachliche Korrektheit.\n"
"Ignoriere Rechtschreibung, Grammatik, Stil und Zeichensetzung.\n"
"Bewerte nur belegte fachliche Inhalte.\n"
......@@ -97,6 +97,7 @@ Studentische Antwort:
{example.answer}
<<<END_BEISPIEL_ANTWORT>>>
Maximale Punktzahl: {_format_score(example.max_points)}
Punktzahl: {_format_score(example.score)}"""
)
return "\n\n".join(chunks)
......
......@@ -132,6 +132,7 @@ def test_start_grading_accepts_prompt_options(monkeypatch) -> None:
"question": "What is RAM?",
"answer": "RAM stores active program data.",
"score": "2",
"max_points": "2",
}
],
),
......@@ -142,3 +143,4 @@ def test_start_grading_accepts_prompt_options(monkeypatch) -> None:
assert len(captured["examples"]) == 1
assert captured["examples"][0].question == "What is RAM?"
assert captured["examples"][0].score == "2"
assert captured["examples"][0].max_points == "2"
......@@ -161,8 +161,8 @@ def test_p3_prompt_includes_scored_examples() -> None:
student_answer="RAM ist schneller Speicher.",
max_points=2.0,
examples=(
GradingExample(question="Was ist RAM?", answer="RAM speichert aktive Daten.", score=2.0),
GradingExample(question="Was ist RAM?", answer="RAM ist eine Festplatte.", score=0.0),
GradingExample(question="Was ist RAM?", answer="RAM speichert aktive Daten.", score=2.0, max_points=2.0),
GradingExample(question="Was ist RAM?", answer="RAM ist eine Festplatte.", score=0.0, max_points=2.0),
),
)
......@@ -170,6 +170,7 @@ def test_p3_prompt_includes_scored_examples() -> None:
assert "Bewertungsbeispiel 1" in content
assert "RAM speichert aktive Daten." in content
assert "Maximale Punktzahl: 2.0" in content
assert "Punktzahl: 2.0" in content
assert "Bewertungsbeispiel 2" in content
assert "RAM ist eine Festplatte." in content
......@@ -184,7 +185,7 @@ def test_include_feedback_option_works_for_example_guided_prompt() -> None:
max_points=2.0,
include_feedback=True,
examples=(
GradingExample(question="Was ist RAM?", answer="RAM speichert aktive Daten.", score=2.0),
GradingExample(question="Was ist RAM?", answer="RAM speichert aktive Daten.", score=2.0, max_points=2.0),
),
)
......@@ -207,10 +208,10 @@ def test_p3_validation_rejects_missing_or_invalid_examples() -> None:
strategy="p3_true_one_shot",
custom_grading_rules=None,
examples=[
{"question": "Q", "answer": "A", "score": "1"},
{"question": "Q", "answer": "A", "score": "1"},
{"question": "Q", "answer": "A", "score": "1"},
{"question": "Q", "answer": "A", "score": "1"},
{"question": "Q", "answer": "A", "score": "1", "max_points": "2"},
{"question": "Q", "answer": "A", "score": "1", "max_points": "2"},
{"question": "Q", "answer": "A", "score": "1", "max_points": "2"},
{"question": "Q", "answer": "A", "score": "1", "max_points": "2"},
],
)
......@@ -218,14 +219,28 @@ def test_p3_validation_rejects_missing_or_invalid_examples() -> None:
validate_prompt_run_options(
strategy="p3_true_one_shot",
custom_grading_rules=None,
examples=[{"question": "Q", "answer": " ", "score": "1"}],
examples=[{"question": "Q", "answer": " ", "score": "1", "max_points": "2"}],
)
with pytest.raises(ValueError, match="must be a number"):
validate_prompt_run_options(
strategy="p3_true_one_shot",
custom_grading_rules=None,
examples=[{"question": "Q", "answer": "A", "score": "full"}],
examples=[{"question": "Q", "answer": "A", "score": "full", "max_points": "2"}],
)
with pytest.raises(ValueError, match="needs maximum points"):
validate_prompt_run_options(
strategy="p3_true_one_shot",
custom_grading_rules=None,
examples=[{"question": "Q", "answer": "A", "score": "1"}],
)
with pytest.raises(ValueError, match="cannot exceed"):
validate_prompt_run_options(
strategy="p3_true_one_shot",
custom_grading_rules=None,
examples=[{"question": "Q", "answer": "A", "score": "3", "max_points": "2"}],
)
......
......@@ -2,3 +2,5 @@ node_modules/
dist/
.vite/
*.log
.env
.env.*
FROM node:22-alpine
FROM node:22-alpine AS build
WORKDIR /app
......@@ -7,6 +7,17 @@ RUN npm ci
COPY . .
EXPOSE 5173
ARG VITE_API_BASE_URL=/api
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=10s --timeout=3s --retries=6 \
CMD wget -q -O - http://127.0.0.1/health >/dev/null || exit 1
server {
listen 80;
server_name _;
client_max_body_size 50m;
location = /health {
access_log off;
add_header Content-Type text/plain;
return 200 "ok\n";
}
location /api/ {
proxy_pass http://backend:8000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location / {
try_files $uri $uri/ /index.html;
}
}
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { MemoryRouter, Route, Routes } from "react-router-dom"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import BackendStudentsOverviewPage from "./BackendStudentsOverviewPage"
import { downloadMoodleExport, getRunStudents } from "../services/sessionsApi"
vi.mock("../services/sessionsApi", () => ({
deleteRun: vi.fn(),
downloadMoodleExport: vi.fn(),
getRunStudents: vi.fn()
}))
const mockedGetRunStudents = vi.mocked(getRunStudents)
const mockedDownloadMoodleExport = vi.mocked(downloadMoodleExport)
function renderPage() {
return render(
<MemoryRouter initialEntries={["/overview-live/run-1/students"]}>
<Routes>
<Route path="/overview-live/:runId/students" element={<BackendStudentsOverviewPage />} />
</Routes>
</MemoryRouter>
)
}
describe("BackendStudentsOverviewPage", () => {
afterEach(cleanup)
beforeEach(() => {
vi.clearAllMocks()
mockedGetRunStudents.mockResolvedValue({
run_id: "run-1",
items: [],
pagination: {
page: 1,
page_size: 500,
total: 0
}
})
mockedDownloadMoodleExport.mockResolvedValue()
})
it("shows the Overview heading and exports the current run", async () => {
renderPage()
expect(screen.getByRole("heading", { name: "Overview" })).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: /Export results/ }))
await waitFor(() => expect(mockedDownloadMoodleExport).toHaveBeenCalledWith("run-1"))
})
it("shows a useful export error", async () => {
mockedDownloadMoodleExport.mockRejectedValue(new Error("Complete all reviews before export."))
renderPage()
fireEvent.click(screen.getByRole("button", { name: /Export results/ }))
expect(await screen.findByText("Complete all reviews before export.")).toBeInTheDocument()
})
})
......@@ -2,7 +2,12 @@ import { useEffect, useState } from "react"
import { useNavigate, useParams } from "react-router-dom"
import AppFrame from "../components/AppFrame"
import { deleteRun, getRunStudents, type RunStudentRow } from "../services/sessionsApi"
import {
deleteRun,
downloadMoodleExport,
getRunStudents,
type RunStudentRow
} from "../services/sessionsApi"
function BackendStudentsOverviewPage() {
const { runId } = useParams<{ runId: string }>()
......@@ -11,6 +16,7 @@ function BackendStudentsOverviewPage() {
const [students, setStudents] = useState<RunStudentRow[]>([])
const [error, setError] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [isExporting, setIsExporting] = useState(false)
useEffect(() => {
if (!runId) return
......@@ -65,14 +71,26 @@ function BackendStudentsOverviewPage() {
}
}
const handleExport = async () => {
setIsExporting(true)
setError(null)
try {
await downloadMoodleExport(runId)
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to export results")
} finally {
setIsExporting(false)
}
}
return (
<AppFrame>
<section className="space-y-6">
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="brand-font text-4xl font-extrabold tracking-tight">Student Overview (New)</h1>
<h1 className="brand-font text-4xl font-extrabold tracking-tight">Overview</h1>
<p className="mt-1 text-sm text-[var(--on-surface-variant)]">
Backend-powered student summary with review completion and scores.
Student results, review completion, and final scores.
</p>
</div>
......@@ -84,6 +102,15 @@ function BackendStudentsOverviewPage() {
>
Back to Question Overview
</button>
<button
type="button"
onClick={handleExport}
disabled={isExporting}
className="custom-gradient-primary inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="material-symbols-outlined !text-base">download</span>
{isExporting ? "Exporting..." : "Export results"}
</button>
<button
type="button"
onClick={handleDeleteRun}
......
......@@ -120,6 +120,9 @@ describe("UploadPage grading method drawer", () => {
fireEvent.change(screen.getByLabelText("Example 1 awarded score"), {
target: { value: "2" }
})
fireEvent.change(screen.getByLabelText("Example 1 maximum points"), {
target: { value: "2" }
})
fireEvent.change(screen.getByLabelText("Example 2 question"), {
target: { value: "What is RAM?" }
})
......@@ -129,6 +132,9 @@ describe("UploadPage grading method drawer", () => {
fireEvent.change(screen.getByLabelText("Example 2 awarded score"), {
target: { value: "0" }
})
fireEvent.change(screen.getByLabelText("Example 2 maximum points"), {
target: { value: "2" }
})
fireEvent.click(screen.getByRole("button", { name: "Apply settings" }))
fireEvent.click(screen.getByRole("button", { name: /Upload & Start Grading/ }))
......@@ -142,12 +148,14 @@ describe("UploadPage grading method drawer", () => {
{
question: "What is RAM?",
answer: "RAM stores active program data.",
score: "2"
score: "2",
max_points: "2"
},
{
question: "What is RAM?",
answer: "RAM is permanent disk storage.",
score: "0"
score: "0",
max_points: "2"
}
]
})
......
......@@ -32,6 +32,7 @@ interface GradingExampleDraft {
question: string
answer: string
score: string
maxPoints: string
}
const GRADING_METHODS: Array<{
......@@ -68,9 +69,9 @@ const GRADING_METHODS: Array<{
function createExampleDrafts(): GradingExampleDraft[] {
return [
{ question: "", answer: "", score: "" },
{ question: "", answer: "", score: "" },
{ question: "", answer: "", score: "" }
{ question: "", answer: "", score: "", maxPoints: "" },
{ question: "", answer: "", score: "", maxPoints: "" },
{ question: "", answer: "", score: "", maxPoints: "" }
]
}
......@@ -171,14 +172,22 @@ function UploadPage() {
for (let index = 0; index < activeExampleDrafts.length; index += 1) {
const example = activeExampleDrafts[index]
if (!example.question.trim() || !example.answer.trim() || !example.score.trim()) {
setDrawerError(`Complete question, answer, and score for example ${index + 1}.`)
if (!example.question.trim() || !example.answer.trim() || !example.score.trim() || !example.maxPoints.trim()) {
setDrawerError(`Complete question, answer, awarded score, and maximum points for example ${index + 1}.`)
return false
}
if (!isValidScoreText(example.score)) {
setDrawerError(`Enter a valid non-negative score for example ${index + 1}.`)
return false
}
if (!isValidScoreText(example.maxPoints) || Number(example.maxPoints.trim().replace(",", ".")) <= 0) {
setDrawerError(`Enter maximum points greater than 0 for example ${index + 1}.`)
return false
}
if (Number(example.score.trim().replace(",", ".")) > Number(example.maxPoints.trim().replace(",", "."))) {
setDrawerError(`The awarded score cannot exceed maximum points for example ${index + 1}.`)
return false
}
}
setDrawerError(null)
......@@ -198,7 +207,8 @@ function UploadPage() {
options.examples = activeExampleDrafts.map(example => ({
question: example.question.trim(),
answer: example.answer.trim(),
score: example.score.trim()
score: example.score.trim(),
max_points: example.maxPoints.trim()
}))
}
return options
......@@ -883,6 +893,17 @@ function UploadPage() {
className="min-h-20 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-normal text-[var(--on-surface)] outline-none transition-colors focus:border-[var(--primary)]"
/>
</label>
<label className="block space-y-1 text-xs font-bold text-[var(--on-primary-fixed-variant)]">
<span>Example {index + 1} maximum points</span>
<input
type="text"
inputMode="decimal"
value={example.maxPoints}
onChange={event => updateExampleDraft(index, "maxPoints", event.target.value)}
placeholder="e.g. 2"
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-normal text-[var(--on-surface)] outline-none transition-colors focus:border-[var(--primary)]"
/>
</label>
<label className="block space-y-1 text-xs font-bold text-[var(--on-primary-fixed-variant)]">
<span>Example {index + 1} awarded score</span>
<input
......
......@@ -44,6 +44,7 @@ export interface StartGradingRunOptions {
question: string
answer: string
score: string
max_points: string
}>
llm_profile?: LlmProfile
provider?: string
......@@ -389,6 +390,27 @@ export async function getRunStudents(
return (await response.json()) as RunStudentsResponse
}
export async function downloadMoodleExport(runId: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/runs/${encodeURIComponent(runId)}/export/moodle`)
if (!response.ok) {
const message = await parseError(response)
throw new Error(`Unable to export results: ${message}`)
}
const disposition = response.headers.get("Content-Disposition") ?? ""
const filenameMatch = disposition.match(/filename="?([^";]+)"?/i)
const filename = filenameMatch?.[1] ?? `moodle_export_${runId}.csv`
const objectUrl = URL.createObjectURL(await response.blob())
const link = document.createElement("a")
link.href = objectUrl
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(objectUrl)
}
export async function getQuestionAnswers(
runId: string,
questionId: number,
......
......@@ -11,7 +11,9 @@ services:
NVIDIA_DRIVER_CAPABILITIES: compute,utility
command:
- -m
- /models/gemma-4-E4B-it-Q4_K_M.gguf
- /models/${LOCAL_LLM_MODEL_FILE:-gemma-4-E4B-it-Q4_K_M.gguf}
- --alias
- ${LOCAL_LLM_MODEL_NAME:-local-gguf-model}
- --host
- 0.0.0.0
- --port
......
......@@ -5,12 +5,9 @@ services:
LOCAL_LLM_BASE_URL: ${HOST_LLM_BASE_URL:-http://host.docker.internal:8080/v1}
LOCAL_LLM_MODEL: ${HOST_LLM_MODEL:-gemma-4-E4B-it-Q4_K_M.gguf}
LLM_MODEL: ${HOST_LLM_MODEL:-gemma-4-E4B-it-Q4_K_M.gguf}
extra_hosts:
- host.docker.internal:host-gateway
local-llm:
image: busybox:1.36
command:
- sh
- -c
- while true; do sleep 3600; done
ports: []
volumes: []
profiles:
- bundled-local-disabled
......@@ -3,53 +3,66 @@ services:
build:
context: ./asyst-backend
ports:
- "8000:8000"
- "${BACKEND_PORT:-8000}:8000"
environment:
CORS_ORIGINS: http://localhost:5173,http://127.0.0.1:5173
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173,http://127.0.0.1:5173}
DATABASE_URL: sqlite:////data/asyst.db
UPLOAD_DIR: /data/uploads
LLM_PROVIDER: openai_compatible
LLM_BASE_URL: http://local-llm:8080/v1
LLM_MODEL: gemma-4-E4B-it-GGUF
LLM_MODEL: ${LOCAL_LLM_MODEL_NAME:-local-gguf-model}
LLM_PROMPT_STRATEGY: p1_reference_zero_shot
LLM_MAX_TOKENS: "1024"
LLM_TEMPERATURE: "0"
LLM_TIMEOUT_SECONDS: "180"
LLM_RETRY_MAX_ATTEMPTS: "60"
LLM_RETRY_INITIAL_SECONDS: "5"
LLM_RETRY_MAX_SECONDS: "5"
LLM_RETRY_MAX_ATTEMPTS: "5"
LLM_RETRY_INITIAL_SECONDS: "3"
LLM_RETRY_MAX_SECONDS: "30"
LLM_REQUESTS_PER_MINUTE: "10"
LLM_API_KEY: local-not-required
LOCAL_LLM_BASE_URL: http://local-llm:8080/v1
LOCAL_LLM_MODEL: gemma-4-E4B-it-GGUF
LOCAL_LLM_MODEL: ${LOCAL_LLM_MODEL_NAME:-local-gguf-model}
LOCAL_LLM_API_KEY: local-not-required
CHAT_AI_BASE_URL: https://chat-ai.academiccloud.de/v1
CHAT_AI_MODEL: gemma-4-31b-it
CHAT_AI_API_KEY: ${CHAT_AI_API_KEY:-}
volumes:
- asyst-backend-data:/data
depends_on:
- local-llm
healthcheck:
test:
- CMD
- python
- -c
- import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)
interval: 10s
timeout: 3s
retries: 12
start_period: 10s
restart: unless-stopped
frontend:
build:
context: ./asyst-ui
args:
VITE_API_BASE_URL: /api
ports:
- "5173:5173"
environment:
VITE_API_BASE_URL: http://localhost:8000
- "${FRONTEND_PORT:-5173}:80"
depends_on:
- backend
backend:
condition: service_healthy
restart: unless-stopped
local-llm:
image: ghcr.io/ggml-org/llama.cpp:server
ports:
- "8080:8080"
- "${LOCAL_LLM_PORT:-8080}:8080"
volumes:
- ${LOCAL_LLM_MODEL_DIR:-./models}:/models:ro
command:
- -m
- /models/gemma-4-E4B-it-Q4_K_M.gguf
- /models/${LOCAL_LLM_MODEL_FILE:-gemma-4-E4B-it-Q4_K_M.gguf}
- --alias
- ${LOCAL_LLM_MODEL_NAME:-local-gguf-model}
- --host
- 0.0.0.0
- --port
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment