Skip to content

Commit 08b40f5

Browse files
authored
Merge pull request #2 from phonnz/ft-llms
LLM client
2 parents 09659c4 + b2d7caa commit 08b40f5

File tree

6 files changed

+365
-1
lines changed

6 files changed

+365
-1
lines changed

fullstack/lib/fullstack/application.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ defmodule Fullstack.Application do
2424
Fullstack.Services.Counters,
2525
Fullstack.Servers.Generators.Transactions,
2626
{Registry, [keys: :unique, name: :wallet_projectors]},
27-
{Registry, [keys: :unique, name: Fullstack.Wallet.Aggregate.WalletAggregators]}
27+
{Registry, [keys: :unique, name: Fullstack.Wallet.Aggregate.WalletAggregators]},
28+
Fullstack.Agents.AgentServer
2829
] ++ prod_child()
2930

3031
# See https://hexdocs.pm/elixir/Supervisor.html
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
defmodule Fullstack.Agents.AgentServer do
2+
use GenServer
3+
require Logger
4+
5+
def start_link(opts \\ []) do
6+
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
7+
end
8+
9+
def ask_question(question, pid) do
10+
GenServer.cast(__MODULE__, {:ask_question, question, pid})
11+
end
12+
13+
@impl true
14+
def init(_opts) do
15+
{:ok, %{}}
16+
end
17+
18+
@impl true
19+
def handle_cast({:ask_question, question, live_view_pid}, state) do
20+
# Spawn a process to handle the streaming response
21+
spawn(fn -> process_question(question, live_view_pid) end)
22+
{:noreply, state}
23+
end
24+
25+
defp process_question(question, live_view_pid) do
26+
Logger.info("Processing question: #{question}")
27+
Fullstack.Llms.Client.Local.call(live_view_pid, question)
28+
end
29+
end
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
defmodule Fullstack.Llms.Client.Local do
2+
@default_question "What's the capital of Mexico?"
3+
def call(live_view_pid, text \\ @default_question) do
4+
data =
5+
%{
6+
"model" => "deepseek-r1",
7+
"prompt" => text
8+
}
9+
|> Jason.encode!()
10+
11+
req =
12+
Finch.build(:post, "http://localhost:11434/api/generate", [], data)
13+
14+
with {:ok, %{response: body}} <-
15+
Finch.stream(
16+
req,
17+
Fullstack.Finch,
18+
%{thinking: true, response: "", think: ""},
19+
fn
20+
{:status, value}, acc ->
21+
IO.inspect(value)
22+
acc
23+
24+
{:headers, value}, acc ->
25+
acc
26+
27+
{:data, value}, %{thinking: thinking} = acc ->
28+
word = Jason.decode!(value) |> Map.fetch!("response")
29+
30+
case word do
31+
"</think>" ->
32+
IO.puts("Answering...")
33+
%{acc | thinking: false}
34+
35+
"<think>" ->
36+
IO.puts("Thinking...")
37+
acc
38+
39+
new_word when thinking ->
40+
Map.update(acc, :think, new_word, &(&1 <> " #{new_word}"))
41+
42+
new_word when not thinking ->
43+
send(live_view_pid, {:stream_word, word <> " "})
44+
Map.update(acc, :response, new_word, &(&1 <> " #{new_word}"))
45+
end
46+
47+
{:done, value}, _ ->
48+
IO.inspect(value, label: :DONE)
49+
end,
50+
pool_timeout: 60_000,
51+
receive_timeout: 60_000
52+
) do
53+
send(live_view_pid, {:stream_complete})
54+
55+
{
56+
:ok,
57+
body
58+
}
59+
else
60+
x ->
61+
IO.inspect("Error: #{x}")
62+
end
63+
end
64+
end
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
defmodule FullstackWeb.Public.AgentLive do
2+
use FullstackWeb, :live_view
3+
alias Fullstack.Agents.AgentServer
4+
5+
def mount(_params, _session, socket) do
6+
{:ok,
7+
assign(socket,
8+
question: "",
9+
answer: "",
10+
streaming: false
11+
)}
12+
end
13+
14+
def render(assigns) do
15+
~H"""
16+
<div class="container mx-auto px-4 py-8">
17+
<h1 class="text-2xl font-bold mb-4">Question Answerer</h1>
18+
19+
<form phx-submit="submit_question">
20+
<div class="mb-4">
21+
<label class="block text-gray-700 mb-2" for="question">
22+
Ask a question:
23+
</label>
24+
<textarea
25+
id="question"
26+
name="question"
27+
class="w-full px-3 py-2 border border-gray-300 rounded-md"
28+
rows="3"
29+
value={@question}
30+
disabled={@streaming}
31+
><%= @question %></textarea>
32+
</div>
33+
34+
<button
35+
type="submit"
36+
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
37+
disabled={@streaming}
38+
>
39+
<%= if @streaming, do: "Processing...", else: "Ask" %>
40+
</button>
41+
</form>
42+
43+
<div class="mt-8">
44+
<h2 class="text-xl font-bold mb-2">Answer:</h2>
45+
<div class="p-4 bg-gray-100 rounded-md min-h-[100px]">
46+
<%= @answer %>
47+
<%= if @streaming do %>
48+
<span class="animate-pulse">|</span>
49+
<% end %>
50+
</div>
51+
</div>
52+
</div>
53+
"""
54+
end
55+
56+
def handle_event("submit_question", %{"question" => question}, socket) do
57+
if String.trim(question) != "" do
58+
socket = assign(socket, streaming: true, answer: "")
59+
60+
AgentServer.ask_question(question, self())
61+
62+
{:noreply, socket}
63+
else
64+
{:noreply, socket}
65+
end
66+
end
67+
68+
def handle_info({:stream_word, word}, socket) do
69+
updated_answer = socket.assigns.answer <> word
70+
{:noreply, assign(socket, answer: updated_answer)}
71+
end
72+
73+
def handle_info({:stream_complete}, socket) do
74+
{:noreply, assign(socket, streaming: false)}
75+
end
76+
end

fullstack/lib/fullstack_web/router.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ defmodule FullstackWeb.Router do
2222
live "/fibonacci", Public.FibonacciLive.Index, :index
2323
live "/transactions", Public.TransactionsLive.PublicTransactions, :public_transactions
2424
live "/devices", Public.DevicesLive.Index, :index
25+
live "/agent", Public.AgentLive
2526

2627
resources "/about", AboutController, only: [:index]
2728
live "/chat", ChatLive

livebook/groq-instructor.livemd

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
<!-- livebook:{"app_settings":{"access_type":"public","show_source":true,"slug":"groq-agent"},"autosave_interval_s":30} -->
2+
3+
# Groq instructor
4+
5+
```elixir
6+
Mix.install([
7+
{:groq, "~> 0.1"},
8+
{:hackney, "~> 1.18"},
9+
{:jason, "~> 1.4"},
10+
{:phoenix_live_view, "~> 0.18.3"},
11+
{:kino, "~> 0.9.0"}
12+
])
13+
```
14+
15+
## Section
16+
17+
```elixir
18+
Mix.install([
19+
{:groq, "~> 0.1"},
20+
{:hackney, "~> 1.18"},
21+
{:jason, "~> 1.4"},
22+
{:phoenix_live_view, "~> 0.18.3"},
23+
{:kino, "~> 0.9.0"}
24+
])
25+
```
26+
27+
```elixir
28+
json_library = if Code.ensure_loaded?(Jason), do: Jason, else: :error
29+
30+
Application.put_env(:groq, :json_library, json_library)
31+
32+
System.fetch_env!("GROQ_API_KEY")
33+
34+
# Start the Groq application
35+
case Application.ensure_all_started(:groq) do
36+
{:ok, _} -> IO.puts("Groq application started successfully")
37+
{:error, error} -> IO.puts("Failed to start Groq application: #{inspect(error)}")
38+
end
39+
```
40+
41+
```elixir
42+
Application.ensure_all_started(:hackney)
43+
Application.ensure_all_started(:groq)
44+
```
45+
46+
```elixir
47+
defmodule MathAgent do
48+
def calculate(expression) do
49+
try do
50+
result = Code.eval_string(expression) |> elem(0)
51+
Jason.encode!(%{result: result})
52+
rescue
53+
_ -> Jason.encode!(%{error: "Invalid expression"})
54+
end
55+
end
56+
57+
def function_properties do
58+
[
59+
%{
60+
"type" => "function",
61+
"function" => %{
62+
"name" => "calculate",
63+
"description" => "Evaluate a mathematical expression",
64+
"parameters" => %{
65+
"type" => "object",
66+
"properties" => %{
67+
"expression" => %{
68+
"type" => "string",
69+
"description" => "The mathematical expression to evaluate"
70+
}
71+
},
72+
"required" => ["expression"]
73+
}
74+
}
75+
}
76+
]
77+
end
78+
79+
def create_chat_completion(messages) do
80+
Groq.ChatCompletion.create(%{
81+
"model" => "llama3-groq-70b-8192-tool-use-preview",
82+
"messages" => messages,
83+
"tools" => function_properties(),
84+
"tool_choice" => "auto",
85+
"max_tokens" => 4096
86+
})
87+
end
88+
89+
def handle_response({:ok, result}) do
90+
case result do
91+
%{"choices" => choices} when is_list(choices) and length(choices) > 0 ->
92+
first_choice = Enum.at(choices, 0)
93+
handle_message(first_choice["message"])
94+
95+
_ ->
96+
{:error, "Unexpected response structure: #{inspect(result)}"}
97+
end
98+
end
99+
100+
def handle_response({:error, error}) do
101+
{:error, "Error: #{inspect(error)}"}
102+
end
103+
104+
defp handle_message(%{"tool_calls" => [tool_call | _]} = message) do
105+
IO.puts("\nModel is using a tool:")
106+
IO.inspect(message, label: "Full message")
107+
108+
%{"function" => %{"name" => function_name, "arguments" => arguments}} = tool_call
109+
110+
case function_name do
111+
"calculate" ->
112+
args = Jason.decode!(arguments)
113+
IO.puts("Calling calculate function with expression: #{args["expression"]}")
114+
result = calculate(args["expression"])
115+
IO.puts("Calculate function result: #{result}")
116+
{:tool_call, tool_call["id"], function_name, result}
117+
118+
_ ->
119+
{:error, "Unknown function: #{function_name}"}
120+
end
121+
end
122+
123+
defp handle_message(message) do
124+
IO.puts("\nModel is not using a tool:")
125+
IO.inspect(message, label: "Full message")
126+
{:ok, message["content"]}
127+
end
128+
129+
def run_conversation(user_prompt) do
130+
IO.puts("Starting conversation with user prompt: #{user_prompt}")
131+
132+
initial_messages = [
133+
%{
134+
"role" => "system",
135+
"content" => "Another fake answer!"
136+
},
137+
%{
138+
"role" => "user",
139+
"content" => user_prompt
140+
}
141+
]
142+
143+
case create_chat_completion(initial_messages) do
144+
{:ok, result} ->
145+
IO.puts("\nReceived initial response from Groq API")
146+
147+
case handle_response({:ok, result}) do
148+
{:tool_call, id, name, content} ->
149+
IO.puts("\nProcessing tool call result")
150+
151+
tool_message = %{
152+
"tool_call_id" => id,
153+
"role" => "tool",
154+
"name" => name,
155+
"content" => content
156+
}
157+
158+
first_choice = Enum.at(result["choices"], 0)
159+
new_messages = initial_messages ++ [first_choice["message"], tool_message]
160+
IO.puts("\nSending follow-up request to Groq API")
161+
162+
case create_chat_completion(new_messages) do
163+
{:ok, final_result} ->
164+
IO.puts("\nReceived final response from Groq API")
165+
handle_response({:ok, final_result})
166+
167+
error ->
168+
error
169+
end
170+
171+
other ->
172+
other
173+
end
174+
175+
error ->
176+
error
177+
end
178+
end
179+
end
180+
```
181+
182+
```elixir
183+
user_input_no_tool = "What's the capital of France?"
184+
185+
IO.puts(
186+
"\n\nRunning conversation with input that might not use the tool: #{user_input_no_tool}\n"
187+
)
188+
189+
case MathAgent.run_conversation(user_input_no_tool) do
190+
{:ok, response} -> IO.puts("\nFinal Response: #{response}")
191+
{:error, error} -> IO.puts("\nError: #{error}")
192+
end
193+
```

0 commit comments

Comments
 (0)