Skip to content

Commit 8344d1c

Browse files
authored
feat(cli): add interactive chat mode (#10226)
Add an opt-in `local-ai chat` command for testing chat models directly from the terminal without manually sending curl requests. The command connects to a running LocalAI server, lists available models through the existing OpenAI-compatible API, streams chat completions, and supports interactive commands such as `/models`, `/model`, `/clear`, and `/exit`. Keep `local-ai run` focused on the server lifecycle so the web UI, API clients, and multiple chat terminals can coexist against the same server. Document the new command and terminal workflow in the README and CLI docs. Tests: - go test -count=1 ./core/cli/chat - go test -count=1 ./core/cli Assisted-by: Codex:GPT-5 Signed-off-by: Ching Kao <0980124jim@gmail.com>
1 parent d2e6b93 commit 8344d1c

16 files changed

Lines changed: 718 additions & 5 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,16 @@ local-ai run https://gist.githubusercontent.com/.../phi-2.yaml
149149
local-ai run oci://localai/phi-2:latest
150150
```
151151

152+
To test a running LocalAI server from the terminal, open an interactive chat session from another shell. Inside the prompt, `/models` lists installed models and `/model <name>` switches between them.
153+
154+
```bash
155+
# Terminal 1
156+
local-ai run llama-3.2-1b-instruct:q4_k_m
157+
158+
# Terminal 2
159+
local-ai chat --model llama-3.2-1b-instruct:q4_k_m
160+
```
161+
152162
> **Automatic Backend Detection**: LocalAI automatically detects your GPU capabilities and downloads the appropriate backend. For advanced options, see [GPU Acceleration](https://localai.io/features/gpu-acceleration/).
153163
154164
For more details, see the [Getting Started guide](https://localai.io/basics/getting_started/).

core/cli/chat/chat.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package chat
2+
3+
import (
4+
"context"
5+
"io"
6+
"strings"
7+
)
8+
9+
type Options struct {
10+
Model string
11+
BaseURL string
12+
APIKey string
13+
In io.Reader
14+
Out io.Writer
15+
}
16+
17+
func Run(ctx context.Context, opts Options) error {
18+
if opts.In == nil {
19+
opts.In = strings.NewReader("")
20+
}
21+
if opts.Out == nil {
22+
opts.Out = io.Discard
23+
}
24+
25+
session, err := newChatSession(ctx, newLocalAIChatClient(opts.BaseURL, opts.APIKey), opts.Model)
26+
if err != nil {
27+
return err
28+
}
29+
return runTerminalChat(ctx, session, opts.In, opts.Out)
30+
}

core/cli/chat/chat_suite_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package chat
2+
3+
import (
4+
"testing"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
)
9+
10+
func TestChat(t *testing.T) {
11+
RegisterFailHandler(Fail)
12+
RunSpecs(t, "Chat Suite")
13+
}

core/cli/chat/chat_test.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package chat
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
11+
. "github.com/onsi/ginkgo/v2"
12+
. "github.com/onsi/gomega"
13+
)
14+
15+
var _ = Describe("Run chat", func() {
16+
It("streams a single chat response", func() {
17+
var capturedModel string
18+
var capturedAuth string
19+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+
if r.URL.Path == "/v1/models" {
21+
w.Header().Set("Content-Type", "application/json")
22+
fmt.Fprint(w, `{"object":"list","data":[{"id":"test-model","object":"model"}]}`)
23+
return
24+
}
25+
26+
Expect(r.URL.Path).To(Equal("/v1/chat/completions"))
27+
capturedAuth = r.Header.Get("Authorization")
28+
29+
var body struct {
30+
Model string `json:"model"`
31+
Messages []struct {
32+
Role string `json:"role"`
33+
Content string `json:"content"`
34+
} `json:"messages"`
35+
}
36+
Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
37+
capturedModel = body.Model
38+
Expect(body.Messages).To(HaveLen(1))
39+
Expect(body.Messages[0].Role).To(Equal("user"))
40+
Expect(body.Messages[0].Content).To(Equal("hello"))
41+
42+
w.Header().Set("Content-Type", "text/event-stream")
43+
fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n")
44+
fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"}}]}\n\n")
45+
fmt.Fprint(w, "data: [DONE]\n\n")
46+
}))
47+
defer server.Close()
48+
49+
var out bytes.Buffer
50+
err := Run(GinkgoT().Context(), Options{
51+
Model: "test-model",
52+
BaseURL: server.URL + "/v1",
53+
APIKey: "secret",
54+
In: strings.NewReader("hello\n/exit\n"),
55+
Out: &out,
56+
})
57+
58+
Expect(err).ToNot(HaveOccurred())
59+
Expect(capturedModel).To(Equal("test-model"))
60+
Expect(capturedAuth).To(Equal("Bearer secret"))
61+
Expect(out.String()).To(ContainSubstring("assistant: hi!"))
62+
Expect(out.String()).To(ContainSubstring("bye"))
63+
})
64+
65+
It("auto-selects the only available model", func() {
66+
server := chatTestServer([]string{"solo"}, nil)
67+
defer server.Close()
68+
69+
var out bytes.Buffer
70+
err := Run(GinkgoT().Context(), Options{
71+
BaseURL: server.URL + "/v1",
72+
In: strings.NewReader("/exit\n"),
73+
Out: &out,
74+
})
75+
76+
Expect(err).ToNot(HaveOccurred())
77+
Expect(out.String()).To(ContainSubstring("LocalAI chat (solo)"))
78+
})
79+
80+
It("returns an actionable error when no models are installed", func() {
81+
server := chatTestServer(nil, nil)
82+
defer server.Close()
83+
84+
err := Run(GinkgoT().Context(), Options{
85+
BaseURL: server.URL + "/v1",
86+
In: strings.NewReader(""),
87+
})
88+
89+
Expect(err).To(HaveOccurred())
90+
Expect(err.Error()).To(ContainSubstring("no chat models are installed"))
91+
Expect(err.Error()).To(ContainSubstring("local-ai models install <model>"))
92+
})
93+
94+
It("returns an actionable error when multiple models are available without a selection", func() {
95+
server := chatTestServer([]string{"alpha", "beta"}, nil)
96+
defer server.Close()
97+
98+
err := Run(GinkgoT().Context(), Options{
99+
BaseURL: server.URL + "/v1",
100+
In: strings.NewReader(""),
101+
})
102+
103+
Expect(err).To(HaveOccurred())
104+
Expect(err.Error()).To(ContainSubstring("multiple models are available"))
105+
Expect(err.Error()).To(ContainSubstring("--model"))
106+
Expect(err.Error()).To(ContainSubstring("alpha"))
107+
Expect(err.Error()).To(ContainSubstring("beta"))
108+
})
109+
110+
It("lists and switches models inside the chat", func() {
111+
requestedModels := []string{}
112+
server := chatTestServer([]string{"alpha", "beta"}, func(model string) {
113+
requestedModels = append(requestedModels, model)
114+
})
115+
defer server.Close()
116+
117+
var out bytes.Buffer
118+
err := Run(GinkgoT().Context(), Options{
119+
Model: "alpha",
120+
BaseURL: server.URL + "/v1",
121+
In: strings.NewReader("/models\n/model beta\nhello\n/exit\n"),
122+
Out: &out,
123+
})
124+
125+
Expect(err).ToNot(HaveOccurred())
126+
Expect(out.String()).To(ContainSubstring("* alpha"))
127+
Expect(out.String()).To(ContainSubstring(" beta"))
128+
Expect(out.String()).To(ContainSubstring("switched to beta; conversation cleared"))
129+
Expect(requestedModels).To(Equal([]string{"beta"}))
130+
})
131+
})
132+
133+
func chatTestServer(models []string, onChat func(model string)) *httptest.Server {
134+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
135+
switch r.URL.Path {
136+
case "/v1/models":
137+
w.Header().Set("Content-Type", "application/json")
138+
fmt.Fprint(w, `{"object":"list","data":[`)
139+
for i, model := range models {
140+
if i > 0 {
141+
fmt.Fprint(w, ",")
142+
}
143+
fmt.Fprintf(w, `{"id":%q,"object":"model"}`, model)
144+
}
145+
fmt.Fprint(w, `]}`)
146+
case "/v1/chat/completions":
147+
var body struct {
148+
Model string `json:"model"`
149+
}
150+
Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
151+
if onChat != nil {
152+
onChat(body.Model)
153+
}
154+
w.Header().Set("Content-Type", "text/event-stream")
155+
fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"}}]}\n\n")
156+
fmt.Fprint(w, "data: [DONE]\n\n")
157+
default:
158+
w.WriteHeader(http.StatusNotFound)
159+
}
160+
}))
161+
}

core/cli/chat/client.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package chat
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"sort"
9+
"strings"
10+
11+
openai "github.com/sashabaranov/go-openai"
12+
)
13+
14+
type chatClient interface {
15+
ListModels(ctx context.Context) ([]string, error)
16+
StreamChat(ctx context.Context, model string, messages []chatMessage, out io.Writer) (string, error)
17+
}
18+
19+
type localAIChatClient struct {
20+
client *openai.Client
21+
}
22+
23+
func newLocalAIChatClient(baseURL string, apiKey string) *localAIChatClient {
24+
cfg := openai.DefaultConfig(apiKey)
25+
cfg.BaseURL = baseURL
26+
return &localAIChatClient{client: openai.NewClientWithConfig(cfg)}
27+
}
28+
29+
func (c *localAIChatClient) ListModels(ctx context.Context) ([]string, error) {
30+
resp, err := c.client.ListModels(ctx)
31+
if err != nil {
32+
return nil, err
33+
}
34+
35+
models := make([]string, 0, len(resp.Models))
36+
for _, model := range resp.Models {
37+
if model.ID != "" {
38+
models = append(models, model.ID)
39+
}
40+
}
41+
sort.Strings(models)
42+
return models, nil
43+
}
44+
45+
func (c *localAIChatClient) StreamChat(ctx context.Context, model string, messages []chatMessage, out io.Writer) (string, error) {
46+
stream, err := c.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
47+
Model: model,
48+
Messages: openAIChatMessages(messages),
49+
})
50+
if err != nil {
51+
return "", friendlyChatError(err, model)
52+
}
53+
defer stream.Close()
54+
55+
var answer strings.Builder
56+
for {
57+
resp, err := stream.Recv()
58+
if errors.Is(err, io.EOF) {
59+
break
60+
}
61+
if err != nil {
62+
return answer.String(), friendlyChatError(err, model)
63+
}
64+
if len(resp.Choices) == 0 {
65+
continue
66+
}
67+
68+
token := resp.Choices[0].Delta.Content
69+
if token == "" {
70+
continue
71+
}
72+
answer.WriteString(token)
73+
if _, err := fmt.Fprint(out, token); err != nil {
74+
return answer.String(), err
75+
}
76+
}
77+
78+
return answer.String(), nil
79+
}
80+
81+
func openAIChatMessages(messages []chatMessage) []openai.ChatCompletionMessage {
82+
converted := make([]openai.ChatCompletionMessage, len(messages))
83+
for i, message := range messages {
84+
converted[i] = openai.ChatCompletionMessage{
85+
Role: message.Role,
86+
Content: message.Content,
87+
}
88+
}
89+
return converted
90+
}
91+
92+
func friendlyChatError(err error, model string) error {
93+
var apiErr *openai.APIError
94+
if errors.As(err, &apiErr) {
95+
switch apiErr.HTTPStatusCode {
96+
case 404:
97+
return fmt.Errorf("model %q is not available. Run `local-ai models list`, install a model with `local-ai models install <model>`, or switch with `/model <name>`", model)
98+
case 403:
99+
return fmt.Errorf("model %q is disabled. Enable it from LocalAI settings or choose another model with `/model <name>`", model)
100+
}
101+
if apiErr.Message != "" {
102+
return errors.New(apiErr.Message)
103+
}
104+
}
105+
106+
msg := err.Error()
107+
if strings.Contains(msg, "model") && strings.Contains(msg, "not found") {
108+
return fmt.Errorf("model %q is not available. Run `local-ai models list`, install a model with `local-ai models install <model>`, or switch with `/model <name>`", model)
109+
}
110+
111+
return err
112+
}

core/cli/chat/models.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package chat
2+
3+
import "strings"
4+
5+
func formatChatModelList(models []string, current string) string {
6+
var b strings.Builder
7+
for _, model := range models {
8+
prefix := " "
9+
if model == current {
10+
prefix = "* "
11+
}
12+
b.WriteString(prefix)
13+
b.WriteString(model)
14+
b.WriteByte('\n')
15+
}
16+
return b.String()
17+
}

0 commit comments

Comments
 (0)