Skip to content

Commit befd7ee

Browse files
thatsmeclaude
andcommitted
Add Chat page with semantic memory search and provider selection (v0.2.2)
New admin UI page between Dashboard and Skills. Chat with AlexClaw using any configured LLM provider (cloud or local). Queries are enriched with semantic vector search across all stored memories — CVEs, news, research, past conversations. Responses and queries are stored back to memory for continuity. Features: - Provider dropdown with all configured models (Auto, Gemini, Claude, Ollama, LM Studio, etc.) - Async LLM calls with loading state - Memory hit counter showing how many memories matched the query - Conversation history context (last 6 messages) - Clear button to reset the conversation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d03b2d1 commit befd7ee

4 files changed

Lines changed: 266 additions & 33 deletions

File tree

lib/alex_claw_web/components/layouts/root.html.heex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
<a href="/" class="text-claw-500 font-bold text-lg">🦇 AlexClaw</a>
4141
<div class="flex space-x-4">
4242
<a href="/" class="text-gray-300 hover:text-white text-sm">Dashboard</a>
43+
<a href="/chat" class="text-gray-300 hover:text-white text-sm">Chat</a>
4344
<a href="/skills" class="text-gray-300 hover:text-white text-sm">Skills</a>
4445
<a href="/scheduler" class="text-gray-300 hover:text-white text-sm">Scheduler</a>
4546
<a href="/llm" class="text-gray-300 hover:text-white text-sm">LLM</a>
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
defmodule AlexClawWeb.AdminLive.Chat do
2+
@moduledoc "Interactive chat with memory-backed semantic search context."
3+
4+
use Phoenix.LiveView
5+
6+
alias AlexClaw.{Identity, LLM, Memory}
7+
8+
@impl true
9+
def mount(_params, _session, socket) do
10+
providers = LLM.list_provider_choices()
11+
12+
{:ok,
13+
assign(socket,
14+
page_title: "Chat",
15+
messages: [],
16+
loading: false,
17+
memory_hits: 0,
18+
provider: "auto",
19+
providers: providers
20+
)}
21+
end
22+
23+
@impl true
24+
def handle_event("send", %{"message" => message}, socket) do
25+
message = String.trim(message)
26+
27+
if message == "" do
28+
{:noreply, socket}
29+
else
30+
user_msg = %{role: :user, content: message, timestamp: DateTime.utc_now()}
31+
messages = socket.assigns.messages ++ [user_msg]
32+
provider = socket.assigns.provider
33+
34+
socket =
35+
socket
36+
|> assign(messages: messages, loading: true)
37+
|> start_async(:llm_response, fn -> generate_response(message, provider) end)
38+
39+
{:noreply, socket}
40+
end
41+
end
42+
43+
def handle_event("set_provider", %{"provider" => provider}, socket) do
44+
{:noreply, assign(socket, provider: provider)}
45+
end
46+
47+
def handle_event("clear", _params, socket) do
48+
{:noreply, assign(socket, messages: [], memory_hits: 0)}
49+
end
50+
51+
@impl true
52+
def handle_async(:llm_response, {:ok, {:error, reason}}, socket) do
53+
error_msg = %{
54+
role: :system,
55+
content: "Error: #{inspect(reason)}",
56+
timestamp: DateTime.utc_now()
57+
}
58+
59+
messages = socket.assigns.messages ++ [error_msg]
60+
{:noreply, assign(socket, messages: messages, loading: false)}
61+
end
62+
63+
def handle_async(:llm_response, {:ok, {response, memory_count}}, socket) do
64+
ai_msg = %{role: :assistant, content: response, timestamp: DateTime.utc_now()}
65+
messages = socket.assigns.messages ++ [ai_msg]
66+
67+
user_msg = List.last(Enum.filter(socket.assigns.messages, &(&1.role == :user)))
68+
69+
if user_msg do
70+
Memory.store(:conversation, "User: #{user_msg.content}", source: "web_chat")
71+
Memory.store(:conversation, "AlexClaw: #{response}", source: "web_chat")
72+
end
73+
74+
{:noreply, assign(socket, messages: messages, loading: false, memory_hits: memory_count)}
75+
end
76+
77+
def handle_async(:llm_response, {:exit, reason}, socket) do
78+
error_msg = %{
79+
role: :system,
80+
content: "Request failed: #{inspect(reason)}",
81+
timestamp: DateTime.utc_now()
82+
}
83+
84+
messages = socket.assigns.messages ++ [error_msg]
85+
{:noreply, assign(socket, messages: messages, loading: false)}
86+
end
87+
88+
defp generate_response(query, provider) do
89+
system = Identity.system_prompt(%{skill: :conversational})
90+
91+
memory_results = Memory.search(query, limit: 5)
92+
93+
memory_context =
94+
case memory_results do
95+
[] ->
96+
""
97+
98+
entries ->
99+
context =
100+
entries
101+
|> Enum.map_join("\n---\n", fn e ->
102+
kind_label = String.upcase(e.kind)
103+
"[#{kind_label}] #{String.slice(e.content, 0, 500)}"
104+
end)
105+
106+
"\n\nRelevant knowledge from memory:\n#{context}"
107+
end
108+
109+
recent =
110+
case Memory.recent(kind: :conversation, limit: 6) do
111+
[] ->
112+
""
113+
114+
entries ->
115+
history =
116+
entries
117+
|> Enum.reverse()
118+
|> Enum.map_join("\n", & &1.content)
119+
120+
"\n\nRecent conversation:\n#{history}"
121+
end
122+
123+
prompt = "#{memory_context}#{recent}\n\nUser: #{query}"
124+
125+
opts =
126+
case provider do
127+
"auto" -> [tier: :light]
128+
name -> [provider: name]
129+
end
130+
131+
case LLM.complete(prompt, opts ++ [system: system]) do
132+
{:ok, response} -> {response, length(memory_results)}
133+
{:error, reason} -> {:error, reason}
134+
end
135+
end
136+
137+
@impl true
138+
def render(assigns) do
139+
~H"""
140+
<div class="flex flex-col h-[calc(100vh-8rem)]">
141+
<div class="flex items-center justify-between mb-4">
142+
<div>
143+
<h1 class="text-xl font-bold text-white">Chat</h1>
144+
<p class="text-xs text-gray-500 mt-1">
145+
Conversation with semantic memory context
146+
<%= if @memory_hits > 0 do %>
147+
<span class="text-claw-500">{@memory_hits} memory matches</span> on last query
148+
<% end %>
149+
</p>
150+
</div>
151+
<div class="flex items-center gap-3">
152+
<form phx-change="set_provider">
153+
<select
154+
name="provider"
155+
class="bg-gray-800 border border-gray-700 rounded px-3 py-1.5 text-xs text-gray-300"
156+
>
157+
<option
158+
:for={p <- @providers}
159+
value={p.value}
160+
selected={@provider == p.value}
161+
>
162+
{p.label}
163+
</option>
164+
</select>
165+
</form>
166+
<button
167+
:if={@messages != []}
168+
phx-click="clear"
169+
class="px-3 py-1.5 text-xs text-gray-400 hover:text-white bg-gray-800 hover:bg-gray-700 rounded transition"
170+
>
171+
Clear
172+
</button>
173+
</div>
174+
</div>
175+
176+
<div
177+
class="flex-1 overflow-y-auto bg-gray-900 rounded-lg border border-gray-800 p-4 space-y-4"
178+
id="chat-messages"
179+
>
180+
<div :if={@messages == []} class="flex items-center justify-center h-full">
181+
<div class="text-center text-gray-500">
182+
<p class="text-lg mb-2">Ask anything.</p>
183+
<p class="text-xs">
184+
Responses are enriched with semantic search across all stored memories — news, CVEs, research, conversations.
185+
</p>
186+
</div>
187+
</div>
188+
189+
<div :for={msg <- @messages} class={["flex", msg.role == :user && "justify-end"]}>
190+
<div class={[
191+
"max-w-[75%] rounded-lg px-4 py-3 text-sm",
192+
msg.role == :user && "bg-claw-800 text-white",
193+
msg.role == :assistant && "bg-gray-800 text-gray-100",
194+
msg.role == :system && "bg-red-900/30 text-red-300 border border-red-800"
195+
]}>
196+
<div class="whitespace-pre-wrap break-words">{msg.content}</div>
197+
<div class="text-xs text-gray-500 mt-1">
198+
{Calendar.strftime(msg.timestamp, "%H:%M")}
199+
</div>
200+
</div>
201+
</div>
202+
203+
<div :if={@loading} class="flex">
204+
<div class="bg-gray-800 rounded-lg px-4 py-3 text-sm text-gray-400 animate-pulse">
205+
Thinking...
206+
</div>
207+
</div>
208+
</div>
209+
210+
<form phx-submit="send" class="mt-4 flex gap-3">
211+
<input
212+
type="text"
213+
name="message"
214+
placeholder="Ask about CVEs, security alerts, or anything in memory..."
215+
autocomplete="off"
216+
disabled={@loading}
217+
phx-debounce="100"
218+
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white text-sm focus:border-claw-500 focus:outline-none disabled:opacity-50"
219+
/>
220+
<button
221+
type="submit"
222+
disabled={@loading}
223+
class="px-6 py-3 bg-claw-700 hover:bg-claw-600 disabled:bg-gray-700 text-white text-sm rounded-lg transition"
224+
>
225+
Send
226+
</button>
227+
</form>
228+
</div>
229+
"""
230+
end
231+
end

lib/alex_claw_web/router.ex

Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,64 +5,65 @@ defmodule AlexClawWeb.Router do
55
import Phoenix.LiveView.Router
66

77
pipeline :browser do
8-
plug :accepts, ["html"]
9-
plug :fetch_session
10-
plug :fetch_live_flash
11-
plug :put_root_layout, html: {AlexClawWeb.Layouts, :root}
12-
plug :protect_from_forgery
13-
plug :put_secure_browser_headers
14-
plug AlexClawWeb.Plugs.RateLimit
8+
plug(:accepts, ["html"])
9+
plug(:fetch_session)
10+
plug(:fetch_live_flash)
11+
plug(:put_root_layout, html: {AlexClawWeb.Layouts, :root})
12+
plug(:protect_from_forgery)
13+
plug(:put_secure_browser_headers)
14+
plug(AlexClawWeb.Plugs.RateLimit)
1515
end
1616

1717
pipeline :require_auth do
18-
plug AlexClawWeb.Plugs.RequireAuth
18+
plug(AlexClawWeb.Plugs.RequireAuth)
1919
end
2020

2121
scope "/", AlexClawWeb do
22-
pipe_through :browser
22+
pipe_through(:browser)
2323

24-
get "/login", AuthController, :login
25-
post "/login", AuthController, :authenticate
26-
get "/logout", AuthController, :logout
24+
get("/login", AuthController, :login)
25+
post("/login", AuthController, :authenticate)
26+
get("/logout", AuthController, :logout)
2727

28-
get "/auth/google/callback", OAuthCallbackController, :google
28+
get("/auth/google/callback", OAuthCallbackController, :google)
2929
end
3030

3131
scope "/", AlexClawWeb do
32-
pipe_through [:browser, :require_auth]
32+
pipe_through([:browser, :require_auth])
3333

34-
live "/", AdminLive.Dashboard
35-
live "/skills", AdminLive.Skills
36-
live "/scheduler", AdminLive.Scheduler
37-
live "/llm", AdminLive.LLM
38-
live "/feeds", AdminLive.Feeds
39-
live "/resources", AdminLive.Resources
40-
live "/workflows", AdminLive.Workflows
41-
live "/workflows/:id/runs", AdminLive.WorkflowRuns
42-
live "/database", AdminLive.Database
43-
live "/config", AdminLive.Config
44-
live "/memory", AdminLive.Memory
45-
live "/logs", AdminLive.Logs
34+
live("/", AdminLive.Dashboard)
35+
live("/chat", AdminLive.Chat)
36+
live("/skills", AdminLive.Skills)
37+
live("/scheduler", AdminLive.Scheduler)
38+
live("/llm", AdminLive.LLM)
39+
live("/feeds", AdminLive.Feeds)
40+
live("/resources", AdminLive.Resources)
41+
live("/workflows", AdminLive.Workflows)
42+
live("/workflows/:id/runs", AdminLive.WorkflowRuns)
43+
live("/database", AdminLive.Database)
44+
live("/config", AdminLive.Config)
45+
live("/memory", AdminLive.Memory)
46+
live("/logs", AdminLive.Logs)
4647

47-
get "/database/download", DatabaseController, :download
48+
get("/database/download", DatabaseController, :download)
4849
end
4950

5051
# Webhook routes (authenticate via HMAC, not session)
5152
pipeline :webhook do
52-
plug :accepts, ["json"]
53+
plug(:accepts, ["json"])
5354
end
5455

5556
scope "/webhooks", AlexClawWeb do
56-
pipe_through :webhook
57-
post "/github", GitHubWebhookController, :handle
57+
pipe_through(:webhook)
58+
post("/github", GitHubWebhookController, :handle)
5859
end
5960

6061
if Mix.env() in [:dev, :test] do
6162
import Phoenix.LiveDashboard.Router
6263

6364
scope "/dev" do
64-
pipe_through :browser
65-
live_dashboard "/dashboard", metrics: AlexClawWeb.Telemetry
65+
pipe_through(:browser)
66+
live_dashboard("/dashboard", metrics: AlexClawWeb.Telemetry)
6667
end
6768
end
6869
end

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
defmodule AlexClaw.MixProject do
22
use Mix.Project
33

4-
@version "0.2.1"
4+
@version "0.2.2"
55

66
def project do
77
[

0 commit comments

Comments
 (0)