Compare commits
1 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
5c26b81a2f |
108
README.md
108
README.md
@@ -1,65 +1,75 @@
|
|||||||
<div align="center">
|

|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" height="200px" srcset="https://github.com/jmorganca/ollama/assets/3325447/318048d2-b2dd-459c-925a-ac8449d5f02c">
|
|
||||||
<img alt="logo" height="200px" src="https://github.com/jmorganca/ollama/assets/3325447/c7d6e15f-7f4d-4776-b568-c084afa297c2">
|
|
||||||
</picture>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
# Ollama
|
# Ollama
|
||||||
|
|
||||||
Create, run, and share self-contained large language models (LLMs). Ollama bundles a model’s weights, configuration, prompts, and more into self-contained packages that run anywhere.
|
Run large language models with `llama.cpp`.
|
||||||
|
|
||||||
> Note: Ollama is in early preview. Please report any issues you find.
|
> Note: certain models that can be run with Ollama are intended for research and/or non-commercial use only.
|
||||||
|
|
||||||
## Download
|
### Features
|
||||||
|
|
||||||
- [Download](https://ollama.ai/download) for macOS on Apple Silicon (Intel coming soon)
|
- Download and run popular large language models
|
||||||
- Download for Windows and Linux (coming soon)
|
- Switch between multiple models on the fly
|
||||||
- Build [from source](#building)
|
- Hardware acceleration where available (Metal, CUDA)
|
||||||
|
- Fast inference server written in Go, powered by [llama.cpp](https://github.com/ggerganov/llama.cpp)
|
||||||
|
- REST API to use with your application (python, typescript SDKs coming soon)
|
||||||
|
|
||||||
## Examples
|
## Install
|
||||||
|
|
||||||
### Quickstart
|
- [Download](https://ollama.ai/download) for macOS with Apple Silicon (Intel coming soon)
|
||||||
|
- Download for Windows (coming soon)
|
||||||
|
|
||||||
|
You can also build the [binary from source](#building).
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
Run a fast and simple model.
|
||||||
|
|
||||||
```
|
```
|
||||||
ollama run llama2
|
ollama run orca
|
||||||
>>> hi
|
|
||||||
Hello! How can I help you today?
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Creating a custom model
|
## Example models
|
||||||
|
|
||||||
Create a `Modelfile`:
|
### 💬 Chat
|
||||||
|
|
||||||
|
Have a conversation.
|
||||||
|
|
||||||
```
|
```
|
||||||
FROM llama2
|
ollama run vicuna "Why is the sky blue?"
|
||||||
PROMPT """
|
|
||||||
You are Mario from Super Mario Bros. Answer as Mario, the assistant, only.
|
|
||||||
|
|
||||||
User: {{ .Prompt }}
|
|
||||||
Mario:
|
|
||||||
"""
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Next, create and run the model:
|
### 🗺️ Instructions
|
||||||
|
|
||||||
|
Get a helping hand.
|
||||||
|
|
||||||
```
|
```
|
||||||
ollama create mario -f ./Modelfile
|
ollama run orca "Write an email to my boss."
|
||||||
ollama run mario
|
|
||||||
>>> hi
|
|
||||||
Hello! It's your friend Mario.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Model library
|
### 🔎 Ask questions about documents
|
||||||
|
|
||||||
Ollama includes a library of open-source, pre-trained models. More models are coming soon.
|
Send the contents of a document and ask questions about it.
|
||||||
|
|
||||||
| Model | Parameters | Size | Download |
|
```
|
||||||
| ----------- | ---------- | ----- | ------------------------- |
|
ollama run nous-hermes "$(cat input.txt)", please summarize this story
|
||||||
| Llama2 | 7B | 3.8GB | `ollama pull llama2` |
|
```
|
||||||
| Orca Mini | 3B | 1.9GB | `ollama pull orca` |
|
|
||||||
| Vicuna | 7B | 3.8GB | `ollama pull vicuna` |
|
### 📖 Storytelling
|
||||||
| Nous-Hermes | 13B | 7.3GB | `ollama pull nous-hermes` |
|
|
||||||
|
Venture into the unknown.
|
||||||
|
|
||||||
|
```
|
||||||
|
ollama run nous-hermes "Once upon a time"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced usage
|
||||||
|
|
||||||
|
### Run a local model
|
||||||
|
|
||||||
|
```
|
||||||
|
ollama run ~/Downloads/vicuna-7b-v1.3.ggmlv3.q4_1.bin
|
||||||
|
```
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
@@ -76,5 +86,23 @@ To run it start the server:
|
|||||||
Finally, run a model!
|
Finally, run a model!
|
||||||
|
|
||||||
```
|
```
|
||||||
./ollama run llama2
|
./ollama run ~/Downloads/vicuna-7b-v1.3.ggmlv3.q4_1.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### `POST /api/pull`
|
||||||
|
|
||||||
|
Download a model
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -X POST http://localhost:11343/api/pull -d '{"model": "orca"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/generate`
|
||||||
|
|
||||||
|
Complete a prompt
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -X POST http://localhost:11434/api/generate -d '{"model": "orca", "prompt": "hello!"}'
|
||||||
```
|
```
|
||||||
|
@@ -160,11 +160,11 @@ func (c *Client) Generate(ctx context.Context, req *GenerateRequest, fn Generate
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type PullProgressFunc func(ProgressResponse) error
|
type PullProgressFunc func(PullProgress) error
|
||||||
|
|
||||||
func (c *Client) Pull(ctx context.Context, req *PullRequest, fn PullProgressFunc) error {
|
func (c *Client) Pull(ctx context.Context, req *PullRequest, fn PullProgressFunc) error {
|
||||||
return c.stream(ctx, http.MethodPost, "/api/pull", req, func(bts []byte) error {
|
return c.stream(ctx, http.MethodPost, "/api/pull", req, func(bts []byte) error {
|
||||||
var resp ProgressResponse
|
var resp PullProgress
|
||||||
if err := json.Unmarshal(bts, &resp); err != nil {
|
if err := json.Unmarshal(bts, &resp); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -173,11 +173,11 @@ func (c *Client) Pull(ctx context.Context, req *PullRequest, fn PullProgressFunc
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type PushProgressFunc func(ProgressResponse) error
|
type PushProgressFunc func(PushProgress) error
|
||||||
|
|
||||||
func (c *Client) Push(ctx context.Context, req *PushRequest, fn PushProgressFunc) error {
|
func (c *Client) Push(ctx context.Context, req *PushRequest, fn PushProgressFunc) error {
|
||||||
return c.stream(ctx, http.MethodPost, "/api/push", req, func(bts []byte) error {
|
return c.stream(ctx, http.MethodPost, "/api/push", req, func(bts []byte) error {
|
||||||
var resp ProgressResponse
|
var resp PushProgress
|
||||||
if err := json.Unmarshal(bts, &resp); err != nil {
|
if err := json.Unmarshal(bts, &resp); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
11
api/types.go
11
api/types.go
@@ -43,11 +43,12 @@ type PullRequest struct {
|
|||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProgressResponse struct {
|
type PullProgress struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Digest string `json:"digest,omitempty"`
|
Digest string `json:"digest,omitempty"`
|
||||||
Total int `json:"total,omitempty"`
|
Total int `json:"total,omitempty"`
|
||||||
Completed int `json:"completed,omitempty"`
|
Completed int `json:"completed,omitempty"`
|
||||||
|
Percent float64 `json:"percent,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PushRequest struct {
|
type PushRequest struct {
|
||||||
@@ -56,6 +57,14 @@ type PushRequest struct {
|
|||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PushProgress struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Digest string `json:"digest,omitempty"`
|
||||||
|
Total int `json:"total,omitempty"`
|
||||||
|
Completed int `json:"completed,omitempty"`
|
||||||
|
Percent float64 `json:"percent,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ListResponse struct {
|
type ListResponse struct {
|
||||||
Models []ListResponseModel `json:"models"`
|
Models []ListResponseModel `json:"models"`
|
||||||
}
|
}
|
||||||
|
@@ -19,7 +19,7 @@ export default function () {
|
|||||||
const [step, setStep] = useState<Step>(Step.WELCOME)
|
const [step, setStep] = useState<Step>(Step.WELCOME)
|
||||||
const [commandCopied, setCommandCopied] = useState<boolean>(false)
|
const [commandCopied, setCommandCopied] = useState<boolean>(false)
|
||||||
|
|
||||||
const command = 'ollama run llama2'
|
const command = 'ollama run orca'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='drag'>
|
<div className='drag'>
|
||||||
@@ -77,11 +77,7 @@ export default function () {
|
|||||||
{command}
|
{command}
|
||||||
</pre>
|
</pre>
|
||||||
<button
|
<button
|
||||||
className={`no-drag absolute right-[5px] px-2 py-2 ${
|
className={`no-drag absolute right-[5px] px-2 py-2 ${commandCopied ? 'text-gray-900 opacity-100 hover:cursor-auto' : 'text-gray-200 opacity-50 hover:cursor-pointer'} hover:text-gray-900 hover:font-bold group-hover:opacity-100`}
|
||||||
commandCopied
|
|
||||||
? 'text-gray-900 opacity-100 hover:cursor-auto'
|
|
||||||
: 'text-gray-200 opacity-50 hover:cursor-pointer'
|
|
||||||
} hover:font-bold hover:text-gray-900 group-hover:opacity-100`}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
copy(command)
|
copy(command)
|
||||||
setCommandCopied(true)
|
setCommandCopied(true)
|
||||||
@@ -89,15 +85,13 @@ export default function () {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{commandCopied ? (
|
{commandCopied ? (
|
||||||
<CheckIcon className='h-4 w-4 font-bold text-gray-500' />
|
<CheckIcon className='h-4 w-4 text-gray-500 font-bold' />
|
||||||
) : (
|
) : (
|
||||||
<DocumentDuplicateIcon className='h-4 w-4 text-gray-500' />
|
<DocumentDuplicateIcon className='h-4 w-4 text-gray-500' />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className='mx-auto my-4 w-[70%] text-xs text-gray-400'>
|
<p className='mx-auto my-4 w-[70%] text-xs text-gray-400'>Run this command in your favorite terminal.</p>
|
||||||
Run this command in your favorite terminal.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
28
cmd/cmd.go
28
cmd/cmd.go
@@ -9,7 +9,6 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -26,11 +25,6 @@ import (
|
|||||||
|
|
||||||
func create(cmd *cobra.Command, args []string) error {
|
func create(cmd *cobra.Command, args []string) error {
|
||||||
filename, _ := cmd.Flags().GetString("file")
|
filename, _ := cmd.Flags().GetString("file")
|
||||||
filename, err := filepath.Abs(filename)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
client := api.NewClient()
|
client := api.NewClient()
|
||||||
|
|
||||||
var spinner *Spinner
|
var spinner *Spinner
|
||||||
@@ -89,7 +83,7 @@ func push(cmd *cobra.Command, args []string) error {
|
|||||||
client := api.NewClient()
|
client := api.NewClient()
|
||||||
|
|
||||||
request := api.PushRequest{Name: args[0]}
|
request := api.PushRequest{Name: args[0]}
|
||||||
fn := func(resp api.ProgressResponse) error {
|
fn := func(resp api.PushProgress) error {
|
||||||
fmt.Println(resp.Status)
|
fmt.Println(resp.Status)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -135,23 +129,25 @@ func RunPull(cmd *cobra.Command, args []string) error {
|
|||||||
func pull(model string) error {
|
func pull(model string) error {
|
||||||
client := api.NewClient()
|
client := api.NewClient()
|
||||||
|
|
||||||
var currentDigest string
|
|
||||||
var bar *progressbar.ProgressBar
|
var bar *progressbar.ProgressBar
|
||||||
|
|
||||||
|
currentLayer := ""
|
||||||
request := api.PullRequest{Name: model}
|
request := api.PullRequest{Name: model}
|
||||||
fn := func(resp api.ProgressResponse) error {
|
fn := func(resp api.PullProgress) error {
|
||||||
if resp.Digest != currentDigest && resp.Digest != "" {
|
if resp.Digest != currentLayer && resp.Digest != "" {
|
||||||
currentDigest = resp.Digest
|
if currentLayer != "" {
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
currentLayer = resp.Digest
|
||||||
|
layerStr := resp.Digest[7:23] + "..."
|
||||||
bar = progressbar.DefaultBytes(
|
bar = progressbar.DefaultBytes(
|
||||||
int64(resp.Total),
|
int64(resp.Total),
|
||||||
fmt.Sprintf("pulling %s...", resp.Digest[7:19]),
|
"pulling "+layerStr,
|
||||||
)
|
)
|
||||||
|
} else if resp.Digest == currentLayer && resp.Digest != "" {
|
||||||
bar.Set(resp.Completed)
|
|
||||||
} else if resp.Digest == currentDigest && resp.Digest != "" {
|
|
||||||
bar.Set(resp.Completed)
|
bar.Set(resp.Completed)
|
||||||
} else {
|
} else {
|
||||||
currentDigest = ""
|
currentLayer = ""
|
||||||
fmt.Println(resp.Status)
|
fmt.Println(resp.Status)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
@@ -1,15 +0,0 @@
|
|||||||
# Examples
|
|
||||||
|
|
||||||
This directory contains examples that can be created and run with `ollama`.
|
|
||||||
|
|
||||||
To create a model:
|
|
||||||
|
|
||||||
```
|
|
||||||
ollama create example -f <example file>
|
|
||||||
```
|
|
||||||
|
|
||||||
To run a model:
|
|
||||||
|
|
||||||
```
|
|
||||||
ollama run example
|
|
||||||
```
|
|
@@ -1,7 +0,0 @@
|
|||||||
FROM llama2
|
|
||||||
PARAMETER temperature 1
|
|
||||||
PROMPT """
|
|
||||||
System: You are Mario from super mario bros, acting as an assistant.
|
|
||||||
User: {{ .Prompt }}
|
|
||||||
Assistant:
|
|
||||||
"""
|
|
15
examples/python/README.md
Normal file
15
examples/python/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Python
|
||||||
|
|
||||||
|
This is a simple example of calling the Ollama api from a python app.
|
||||||
|
|
||||||
|
First, download a model:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -L https://huggingface.co/TheBloke/orca_mini_3B-GGML/resolve/main/orca-mini-3b.ggmlv3.q4_1.bin -o orca.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run it using the example script. You'll need to have Ollama running on your machine.
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 main.py orca.bin
|
||||||
|
```
|
32
examples/python/main.py
Normal file
32
examples/python/main.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python main.py <model file>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
conn = http.client.HTTPConnection('localhost', 11434)
|
||||||
|
|
||||||
|
headers = { 'Content-Type': 'application/json' }
|
||||||
|
|
||||||
|
# generate text from the model
|
||||||
|
conn.request("POST", "/api/generate", json.dumps({
|
||||||
|
'model': os.path.join(os.getcwd(), sys.argv[1]),
|
||||||
|
'prompt': 'write me a short story',
|
||||||
|
'stream': True
|
||||||
|
}), headers)
|
||||||
|
|
||||||
|
response = conn.getresponse()
|
||||||
|
|
||||||
|
def parse_generate(data):
|
||||||
|
for event in data.decode('utf-8').split("\n"):
|
||||||
|
if not event:
|
||||||
|
continue
|
||||||
|
yield event
|
||||||
|
|
||||||
|
if response.status == 200:
|
||||||
|
for chunk in response:
|
||||||
|
for event in parse_generate(chunk):
|
||||||
|
print(json.loads(event)['response'], end="", flush=True)
|
137
server/images.go
137
server/images.go
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -41,9 +42,10 @@ type Layer struct {
|
|||||||
Size int `json:"size"`
|
Size int `json:"size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LayerReader struct {
|
type LayerWithBuffer struct {
|
||||||
Layer
|
Layer
|
||||||
io.Reader
|
|
||||||
|
Buffer *bytes.Buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfigV2 struct {
|
type ConfigV2 struct {
|
||||||
@@ -159,7 +161,7 @@ func CreateModel(name string, mf io.Reader, fn func(status string)) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var layers []*LayerReader
|
var layers []*LayerWithBuffer
|
||||||
params := make(map[string]string)
|
params := make(map[string]string)
|
||||||
|
|
||||||
for _, c := range commands {
|
for _, c := range commands {
|
||||||
@@ -272,7 +274,7 @@ func CreateModel(name string, mf io.Reader, fn func(status string)) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeLayerFromLayers(layers []*LayerReader, mediaType string) []*LayerReader {
|
func removeLayerFromLayers(layers []*LayerWithBuffer, mediaType string) []*LayerWithBuffer {
|
||||||
j := 0
|
j := 0
|
||||||
for _, l := range layers {
|
for _, l := range layers {
|
||||||
if l.MediaType != mediaType {
|
if l.MediaType != mediaType {
|
||||||
@@ -283,7 +285,7 @@ func removeLayerFromLayers(layers []*LayerReader, mediaType string) []*LayerRead
|
|||||||
return layers[:j]
|
return layers[:j]
|
||||||
}
|
}
|
||||||
|
|
||||||
func SaveLayers(layers []*LayerReader, fn func(status string), force bool) error {
|
func SaveLayers(layers []*LayerWithBuffer, fn func(status string), force bool) error {
|
||||||
// Write each of the layers to disk
|
// Write each of the layers to disk
|
||||||
for _, layer := range layers {
|
for _, layer := range layers {
|
||||||
fp, err := GetBlobsPath(layer.Digest)
|
fp, err := GetBlobsPath(layer.Digest)
|
||||||
@@ -301,10 +303,10 @@ func SaveLayers(layers []*LayerReader, fn func(status string), force bool) error
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
if _, err = io.Copy(out, layer.Reader); err != nil {
|
_, err = io.Copy(out, layer.Buffer)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
fn(fmt.Sprintf("using already created layer %s", layer.Digest))
|
fn(fmt.Sprintf("using already created layer %s", layer.Digest))
|
||||||
}
|
}
|
||||||
@@ -313,7 +315,7 @@ func SaveLayers(layers []*LayerReader, fn func(status string), force bool) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateManifest(name string, cfg *LayerReader, layers []*Layer) error {
|
func CreateManifest(name string, cfg *LayerWithBuffer, layers []*Layer) error {
|
||||||
mp := ParseModelPath(name)
|
mp := ParseModelPath(name)
|
||||||
|
|
||||||
manifest := ManifestV2{
|
manifest := ManifestV2{
|
||||||
@@ -339,7 +341,7 @@ func CreateManifest(name string, cfg *LayerReader, layers []*Layer) error {
|
|||||||
return os.WriteFile(fp, manifestJSON, 0o644)
|
return os.WriteFile(fp, manifestJSON, 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetLayerWithBufferFromLayer(layer *Layer) (*LayerReader, error) {
|
func GetLayerWithBufferFromLayer(layer *Layer) (*LayerWithBuffer, error) {
|
||||||
fp, err := GetBlobsPath(layer.Digest)
|
fp, err := GetBlobsPath(layer.Digest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -359,7 +361,7 @@ func GetLayerWithBufferFromLayer(layer *Layer) (*LayerReader, error) {
|
|||||||
return newLayer, nil
|
return newLayer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func paramsToReader(params map[string]string) (io.ReadSeeker, error) {
|
func paramsToReader(params map[string]string) (io.Reader, error) {
|
||||||
opts := api.DefaultOptions()
|
opts := api.DefaultOptions()
|
||||||
typeOpts := reflect.TypeOf(opts)
|
typeOpts := reflect.TypeOf(opts)
|
||||||
|
|
||||||
@@ -417,7 +419,7 @@ func paramsToReader(params map[string]string) (io.ReadSeeker, error) {
|
|||||||
return bytes.NewReader(bts), nil
|
return bytes.NewReader(bts), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getLayerDigests(layers []*LayerReader) ([]string, error) {
|
func getLayerDigests(layers []*LayerWithBuffer) ([]string, error) {
|
||||||
var digests []string
|
var digests []string
|
||||||
for _, l := range layers {
|
for _, l := range layers {
|
||||||
if l.Digest == "" {
|
if l.Digest == "" {
|
||||||
@@ -429,30 +431,34 @@ func getLayerDigests(layers []*LayerReader) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateLayer creates a Layer object from a given file
|
// CreateLayer creates a Layer object from a given file
|
||||||
func CreateLayer(f io.ReadSeeker) (*LayerReader, error) {
|
func CreateLayer(f io.Reader) (*LayerWithBuffer, error) {
|
||||||
digest, size := GetSHA256Digest(f)
|
buf := new(bytes.Buffer)
|
||||||
f.Seek(0, 0)
|
_, err := io.Copy(buf, f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
layer := &LayerReader{
|
digest, size := GetSHA256Digest(buf)
|
||||||
|
|
||||||
|
layer := &LayerWithBuffer{
|
||||||
Layer: Layer{
|
Layer: Layer{
|
||||||
MediaType: "application/vnd.docker.image.rootfs.diff.tar",
|
MediaType: "application/vnd.docker.image.rootfs.diff.tar",
|
||||||
Digest: digest,
|
Digest: digest,
|
||||||
Size: size,
|
Size: size,
|
||||||
},
|
},
|
||||||
Reader: f,
|
Buffer: buf,
|
||||||
}
|
}
|
||||||
|
|
||||||
return layer, nil
|
return layer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func PushModel(name, username, password string, fn func(api.ProgressResponse)) error {
|
func PushModel(name, username, password string, fn func(status, digest string, Total, Completed int, Percent float64)) error {
|
||||||
mp := ParseModelPath(name)
|
mp := ParseModelPath(name)
|
||||||
|
|
||||||
fn(api.ProgressResponse{Status: "retrieving manifest"})
|
fn("retrieving manifest", "", 0, 0, 0)
|
||||||
|
|
||||||
manifest, err := GetManifest(mp)
|
manifest, err := GetManifest(mp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fn(api.ProgressResponse{Status: "couldn't retrieve manifest"})
|
fn("couldn't retrieve manifest", "", 0, 0, 0)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,21 +480,11 @@ func PushModel(name, username, password string, fn func(api.ProgressResponse)) e
|
|||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
completed += layer.Size
|
completed += layer.Size
|
||||||
fn(api.ProgressResponse{
|
fn("using existing layer", layer.Digest, total, completed, float64(completed)/float64(total))
|
||||||
Status: "using existing layer",
|
|
||||||
Digest: layer.Digest,
|
|
||||||
Total: total,
|
|
||||||
Completed: completed,
|
|
||||||
})
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
fn(api.ProgressResponse{
|
fn("starting upload", layer.Digest, total, completed, float64(completed)/float64(total))
|
||||||
Status: "starting upload",
|
|
||||||
Digest: layer.Digest,
|
|
||||||
Total: total,
|
|
||||||
Completed: completed,
|
|
||||||
})
|
|
||||||
|
|
||||||
location, err := startUpload(mp, username, password)
|
location, err := startUpload(mp, username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -502,19 +498,10 @@ func PushModel(name, username, password string, fn func(api.ProgressResponse)) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
completed += layer.Size
|
completed += layer.Size
|
||||||
fn(api.ProgressResponse{
|
fn("upload complete", layer.Digest, total, completed, float64(completed)/float64(total))
|
||||||
Status: "upload complete",
|
|
||||||
Digest: layer.Digest,
|
|
||||||
Total: total,
|
|
||||||
Completed: completed,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn(api.ProgressResponse{
|
fn("pushing manifest", "", total, completed, float64(completed/total))
|
||||||
Status: "pushing manifest",
|
|
||||||
Total: total,
|
|
||||||
Completed: completed,
|
|
||||||
})
|
|
||||||
url := fmt.Sprintf("%s://%s/v2/%s/manifests/%s", mp.ProtocolScheme, mp.Registry, mp.GetNamespaceRepository(), mp.Tag)
|
url := fmt.Sprintf("%s://%s/v2/%s/manifests/%s", mp.ProtocolScheme, mp.Registry, mp.GetNamespaceRepository(), mp.Tag)
|
||||||
headers := map[string]string{
|
headers := map[string]string{
|
||||||
"Content-Type": "application/vnd.docker.distribution.manifest.v2+json",
|
"Content-Type": "application/vnd.docker.distribution.manifest.v2+json",
|
||||||
@@ -537,19 +524,15 @@ func PushModel(name, username, password string, fn func(api.ProgressResponse)) e
|
|||||||
return fmt.Errorf("registry responded with code %d: %v", resp.StatusCode, string(body))
|
return fmt.Errorf("registry responded with code %d: %v", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn(api.ProgressResponse{
|
fn("success", "", total, completed, 1.0)
|
||||||
Status: "success",
|
|
||||||
Total: total,
|
|
||||||
Completed: completed,
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func PullModel(name, username, password string, fn func(api.ProgressResponse)) error {
|
func PullModel(name, username, password string, fn func(status, digest string, Total, Completed int, Percent float64)) error {
|
||||||
mp := ParseModelPath(name)
|
mp := ParseModelPath(name)
|
||||||
|
|
||||||
fn(api.ProgressResponse{Status: "pulling manifest"})
|
fn("pulling manifest", "", 0, 0, 0)
|
||||||
|
|
||||||
manifest, err := pullModelManifest(mp, username, password)
|
manifest, err := pullModelManifest(mp, username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -567,15 +550,16 @@ func PullModel(name, username, password string, fn func(api.ProgressResponse)) e
|
|||||||
total += manifest.Config.Size
|
total += manifest.Config.Size
|
||||||
|
|
||||||
for _, layer := range layers {
|
for _, layer := range layers {
|
||||||
|
fn("starting download", layer.Digest, total, completed, float64(completed)/float64(total))
|
||||||
if err := downloadBlob(mp, layer.Digest, username, password, fn); err != nil {
|
if err := downloadBlob(mp, layer.Digest, username, password, fn); err != nil {
|
||||||
fn(api.ProgressResponse{Status: fmt.Sprintf("error downloading: %v", err), Digest: layer.Digest})
|
fn(fmt.Sprintf("error downloading: %v", err), layer.Digest, 0, 0, 0)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
completed += layer.Size
|
completed += layer.Size
|
||||||
|
fn("download complete", layer.Digest, total, completed, float64(completed)/float64(total))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn(api.ProgressResponse{Status: "writing manifest"})
|
fn("writing manifest", "", total, completed, 1.0)
|
||||||
|
|
||||||
manifestJSON, err := json.Marshal(manifest)
|
manifestJSON, err := json.Marshal(manifest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -593,7 +577,7 @@ func PullModel(name, username, password string, fn func(api.ProgressResponse)) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fn(api.ProgressResponse{Status: "success"})
|
fn("success", "", total, completed, 1.0)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -625,7 +609,7 @@ func pullModelManifest(mp ModelPath, username, password string) (*ManifestV2, er
|
|||||||
return m, err
|
return m, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func createConfigLayer(layers []string) (*LayerReader, error) {
|
func createConfigLayer(layers []string) (*LayerWithBuffer, error) {
|
||||||
// TODO change architecture and OS
|
// TODO change architecture and OS
|
||||||
config := ConfigV2{
|
config := ConfigV2{
|
||||||
Architecture: "arm64",
|
Architecture: "arm64",
|
||||||
@@ -644,26 +628,22 @@ func createConfigLayer(layers []string) (*LayerReader, error) {
|
|||||||
buf := bytes.NewBuffer(configJSON)
|
buf := bytes.NewBuffer(configJSON)
|
||||||
digest, size := GetSHA256Digest(buf)
|
digest, size := GetSHA256Digest(buf)
|
||||||
|
|
||||||
layer := &LayerReader{
|
layer := &LayerWithBuffer{
|
||||||
Layer: Layer{
|
Layer: Layer{
|
||||||
MediaType: "application/vnd.docker.container.image.v1+json",
|
MediaType: "application/vnd.docker.container.image.v1+json",
|
||||||
Digest: digest,
|
Digest: digest,
|
||||||
Size: size,
|
Size: size,
|
||||||
},
|
},
|
||||||
Reader: buf,
|
Buffer: buf,
|
||||||
}
|
}
|
||||||
return layer, nil
|
return layer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSHA256Digest returns the SHA256 hash of a given buffer and returns it, and the size of buffer
|
// GetSHA256Digest returns the SHA256 hash of a given buffer and returns it, and the size of buffer
|
||||||
func GetSHA256Digest(r io.Reader) (string, int) {
|
func GetSHA256Digest(data *bytes.Buffer) (string, int) {
|
||||||
h := sha256.New()
|
layerBytes := data.Bytes()
|
||||||
n, err := io.Copy(h, r)
|
hash := sha256.Sum256(layerBytes)
|
||||||
if err != nil {
|
return "sha256:" + hex.EncodeToString(hash[:]), len(layerBytes)
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("sha256:%x", h.Sum(nil)), int(n)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func startUpload(mp ModelPath, username string, password string) (string, error) {
|
func startUpload(mp ModelPath, username string, password string) (string, error) {
|
||||||
@@ -745,20 +725,16 @@ func uploadBlob(location string, layer *Layer, username string, password string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloadBlob(mp ModelPath, digest string, username, password string, fn func(api.ProgressResponse)) error {
|
func downloadBlob(mp ModelPath, digest string, username, password string, fn func(status, digest string, Total, Completed int, Percent float64)) error {
|
||||||
fp, err := GetBlobsPath(digest)
|
fp, err := GetBlobsPath(digest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if fi, _ := os.Stat(fp); fi != nil {
|
_, err = os.Stat(fp)
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
// we already have the file, so return
|
// we already have the file, so return
|
||||||
fn(api.ProgressResponse{
|
log.Printf("already have %s\n", digest)
|
||||||
Digest: digest,
|
|
||||||
Total: int(fi.Size()),
|
|
||||||
Completed: int(fi.Size()),
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,21 +783,10 @@ func downloadBlob(mp ModelPath, digest string, username, password string, fn fun
|
|||||||
total := remaining + completed
|
total := remaining + completed
|
||||||
|
|
||||||
for {
|
for {
|
||||||
fn(api.ProgressResponse{
|
fn(fmt.Sprintf("Downloading %s", digest), digest, int(total), int(completed), float64(completed)/float64(total))
|
||||||
Status: fmt.Sprintf("downloading %s", digest),
|
|
||||||
Digest: digest,
|
|
||||||
Total: int(total),
|
|
||||||
Completed: int(completed),
|
|
||||||
})
|
|
||||||
|
|
||||||
if completed >= total {
|
if completed >= total {
|
||||||
if err := os.Rename(fp+"-partial", fp); err != nil {
|
if err := os.Rename(fp+"-partial", fp); err != nil {
|
||||||
fn(api.ProgressResponse{
|
fn(fmt.Sprintf("error renaming file: %v", err), digest, int(total), int(completed), 1)
|
||||||
Status: fmt.Sprintf("error renaming file: %v", err),
|
|
||||||
Digest: digest,
|
|
||||||
Total: int(total),
|
|
||||||
Completed: int(completed),
|
|
||||||
})
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -101,10 +101,15 @@ func pull(c *gin.Context) {
|
|||||||
ch := make(chan any)
|
ch := make(chan any)
|
||||||
go func() {
|
go func() {
|
||||||
defer close(ch)
|
defer close(ch)
|
||||||
fn := func(r api.ProgressResponse) {
|
fn := func(status, digest string, total, completed int, percent float64) {
|
||||||
ch <- r
|
ch <- api.PullProgress{
|
||||||
|
Status: status,
|
||||||
|
Digest: digest,
|
||||||
|
Total: total,
|
||||||
|
Completed: completed,
|
||||||
|
Percent: percent,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := PullModel(req.Name, req.Username, req.Password, fn); err != nil {
|
if err := PullModel(req.Name, req.Username, req.Password, fn); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -124,10 +129,15 @@ func push(c *gin.Context) {
|
|||||||
ch := make(chan any)
|
ch := make(chan any)
|
||||||
go func() {
|
go func() {
|
||||||
defer close(ch)
|
defer close(ch)
|
||||||
fn := func(r api.ProgressResponse) {
|
fn := func(status, digest string, total, completed int, percent float64) {
|
||||||
ch <- r
|
ch <- api.PushProgress{
|
||||||
|
Status: status,
|
||||||
|
Digest: digest,
|
||||||
|
Total: total,
|
||||||
|
Completed: completed,
|
||||||
|
Percent: percent,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := PushModel(req.Name, req.Username, req.Password, fn); err != nil {
|
if err := PushModel(req.Name, req.Username, req.Password, fn); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -185,8 +195,7 @@ func list(c *gin.Context) {
|
|||||||
if !info.IsDir() {
|
if !info.IsDir() {
|
||||||
fi, err := os.Stat(path)
|
fi, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("skipping file: %s", fp)
|
return err
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
path := path[len(fp)+1:]
|
path := path[len(fp)+1:]
|
||||||
slashIndex := strings.LastIndex(path, "/")
|
slashIndex := strings.LastIndex(path, "/")
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import Header from '../header'
|
|
||||||
import Downloader from './downloader'
|
import Downloader from './downloader'
|
||||||
import Signup from './signup'
|
import Signup from './signup'
|
||||||
|
|
||||||
@@ -27,19 +26,22 @@ export default async function Download() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<main className='flex min-h-screen max-w-2xl flex-col p-4 lg:p-24 items-center mx-auto'>
|
||||||
<Header />
|
<img src='/ollama.png' className='w-16 h-auto' />
|
||||||
<main className='flex min-h-screen max-w-6xl flex-col py-20 px-16 lg:p-32 items-center mx-auto'>
|
<section className='my-12 text-center'>
|
||||||
<img src='/ollama.png' className='w-16 h-auto' />
|
<h2 className='my-2 max-w-md text-3xl tracking-tight'>Downloading Ollama</h2>
|
||||||
<section className='mt-12 mb-8 text-center'>
|
<h3 className='text-sm text-neutral-500'>
|
||||||
<h2 className='my-2 max-w-md text-3xl tracking-tight'>Downloading...</h2>
|
Problems downloading?{' '}
|
||||||
<h3 className='text-base text-neutral-500 mt-12 max-w-[16rem]'>
|
<a href={asset.browser_download_url} className='underline'>
|
||||||
While Ollama downloads, sign up to get notified of new updates.
|
Try again
|
||||||
</h3>
|
</a>
|
||||||
<Downloader url={asset.browser_download_url} />
|
</h3>
|
||||||
</section>
|
<Downloader url={asset.browser_download_url} />
|
||||||
|
</section>
|
||||||
|
<section className='max-w-sm flex flex-col w-full items-center border border-neutral-200 rounded-xl px-8 pt-8 pb-2'>
|
||||||
|
<p className='text-lg leading-tight text-center mb-6 max-w-[260px]'>Sign up for updates</p>
|
||||||
<Signup />
|
<Signup />
|
||||||
</main>
|
</section>
|
||||||
</>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -28,7 +28,7 @@ export default function Signup() {
|
|||||||
|
|
||||||
return false
|
return false
|
||||||
}}
|
}}
|
||||||
className='flex self-stretch flex-col gap-3 h-32 md:mx-40 lg:mx-72'
|
className='flex self-stretch flex-col gap-3 h-32'
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
@@ -37,13 +37,13 @@ export default function Signup() {
|
|||||||
onChange={e => setEmail(e.target.value)}
|
onChange={e => setEmail(e.target.value)}
|
||||||
type='email'
|
type='email'
|
||||||
placeholder='your@email.com'
|
placeholder='your@email.com'
|
||||||
className='border border-neutral-200 rounded-lg px-4 py-2 focus:outline-none placeholder-neutral-300'
|
className='bg-neutral-100 rounded-lg px-4 py-2 focus:outline-none placeholder-neutral-500'
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type='submit'
|
type='submit'
|
||||||
value='Get updates'
|
value='Get updates'
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className='bg-black text-white disabled:text-neutral-200 disabled:bg-neutral-700 rounded-full px-4 py-2 focus:outline-none cursor-pointer'
|
className='bg-black text-white disabled:text-neutral-200 disabled:bg-neutral-700 rounded-lg px-4 py-2 focus:outline-none cursor-pointer'
|
||||||
/>
|
/>
|
||||||
{success && <p className='text-center text-sm'>You're signed up for updates</p>}
|
{success && <p className='text-center text-sm'>You're signed up for updates</p>}
|
||||||
</form>
|
</form>
|
||||||
|
@@ -1,24 +0,0 @@
|
|||||||
const navigation = [
|
|
||||||
{ name: 'Discord', href: 'https://discord.gg/MrfB5FbNWN' },
|
|
||||||
{ name: 'GitHub', href: 'https://github.com/jmorganca/ollama' },
|
|
||||||
{ name: 'Download', href: '/download' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function Header() {
|
|
||||||
return (
|
|
||||||
<header className='absolute inset-x-0 top-0 z-50'>
|
|
||||||
<nav className='mx-auto flex items-center justify-between px-10 py-4'>
|
|
||||||
<a className='flex-1 font-bold' href='/'>
|
|
||||||
Ollama
|
|
||||||
</a>
|
|
||||||
<div className='flex space-x-8'>
|
|
||||||
{navigation.map(item => (
|
|
||||||
<a key={item.name} href={item.href} className='text-sm leading-6 text-gray-900'>
|
|
||||||
{item.name}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
)
|
|
||||||
}
|
|
@@ -1,32 +1,34 @@
|
|||||||
import { AiFillApple } from 'react-icons/ai'
|
import { AiFillApple } from 'react-icons/ai'
|
||||||
|
|
||||||
import models from '../../models.json'
|
import models from '../../models.json'
|
||||||
import Header from './header'
|
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
return (
|
return (
|
||||||
<>
|
<main className='flex min-h-screen max-w-2xl flex-col p-4 lg:p-24'>
|
||||||
<Header />
|
<img src='/ollama.png' className='w-16 h-auto' />
|
||||||
<main className='flex min-h-screen max-w-6xl flex-col py-20 px-16 md:p-32 items-center mx-auto'>
|
<section className='my-4'>
|
||||||
<img src='/ollama.png' className='w-16 h-auto' />
|
<p className='my-3 max-w-md'>
|
||||||
<section className='my-12 text-center'>
|
<a className='underline' href='https://github.com/jmorganca/ollama'>
|
||||||
<div className='flex flex-col space-y-2'>
|
Ollama
|
||||||
<h2 className='md:max-w-[18rem] mx-auto my-2 text-3xl tracking-tight'>Portable large language models</h2>
|
</a>{' '}
|
||||||
<h3 className='md:max-w-xs mx-auto text-base text-neutral-500'>
|
is a tool for running large language models, currently for macOS with Windows and Linux coming soon.
|
||||||
Bundle a model’s weights, configuration, prompts, data and more into self-contained packages that run anywhere.
|
<br />
|
||||||
</h3>
|
<br />
|
||||||
|
<a href='/download'>
|
||||||
|
<button className='bg-black text-white text-sm py-2 px-3 rounded-lg flex items-center gap-2'>
|
||||||
|
<AiFillApple className='h-auto w-5 relative -top-px' /> Download for macOS
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section className='my-4'>
|
||||||
|
<h2 className='mb-4 text-lg'>Example models you can try running:</h2>
|
||||||
|
{models.map(m => (
|
||||||
|
<div className='my-2 grid font-mono' key={m.name}>
|
||||||
|
<code className='py-0.5'>ollama run {m.name}</code>
|
||||||
</div>
|
</div>
|
||||||
<div className='mx-auto flex flex-col space-y-4 mt-12'>
|
))}
|
||||||
<a href='/download' className='md:mx-10 lg:mx-14 bg-black text-white rounded-full px-4 py-2 focus:outline-none cursor-pointer'>
|
</section>
|
||||||
Download
|
</main>
|
||||||
</a>
|
|
||||||
<p className='text-neutral-500 text-sm '>
|
|
||||||
Available for macOS with Apple Silicon <br />
|
|
||||||
Windows & Linux support coming soon.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user