zuzu 0.0.1-java
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +374 -0
- data/bin/setup +74 -0
- data/bin/zuzu +128 -0
- data/lib/zuzu/agent.rb +112 -0
- data/lib/zuzu/agent_fs.rb +224 -0
- data/lib/zuzu/app.rb +194 -0
- data/lib/zuzu/channels/base.rb +21 -0
- data/lib/zuzu/channels/in_app.rb +11 -0
- data/lib/zuzu/channels/whatsapp.rb +63 -0
- data/lib/zuzu/config.rb +51 -0
- data/lib/zuzu/llamafile_manager.rb +68 -0
- data/lib/zuzu/llm_client.rb +82 -0
- data/lib/zuzu/memory.rb +47 -0
- data/lib/zuzu/store.rb +86 -0
- data/lib/zuzu/tool_registry.rb +43 -0
- data/lib/zuzu/tools/file_tool.rb +25 -0
- data/lib/zuzu/tools/shell_tool.rb +31 -0
- data/lib/zuzu/tools/web_tool.rb +17 -0
- data/lib/zuzu/version.rb +5 -0
- data/lib/zuzu.rb +24 -0
- data/templates/app.rb +22 -0
- metadata +137 -0
data/lib/zuzu/agent.rb
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Zuzu
|
|
6
|
+
# Prompt-based agent loop using <zuzu_tool_call> tags.
|
|
7
|
+
# Works with any instruction-following model — no native function-calling required.
|
|
8
|
+
class Agent
|
|
9
|
+
MAX_ITERATIONS = 10
|
|
10
|
+
|
|
11
|
+
TOOL_CALL_RE = /<zuzu_tool_call>(.*?)<\/zuzu_tool_call>/m
|
|
12
|
+
TOOL_RESULT_RE = /<zuzu_tool_result>.*?<\/zuzu_tool_result>/m
|
|
13
|
+
|
|
14
|
+
SYSTEM_PROMPT = <<~PROMPT.strip
|
|
15
|
+
You are Zuzu, a helpful desktop AI assistant.
|
|
16
|
+
|
|
17
|
+
You have access to a sandboxed virtual filesystem called AgentFS. It is completely
|
|
18
|
+
separate from the host computer's filesystem. All file paths refer to AgentFS only.
|
|
19
|
+
You cannot access or modify any files on the host system.
|
|
20
|
+
|
|
21
|
+
Available tools — use the tag format shown below:
|
|
22
|
+
|
|
23
|
+
- write_file : Write text to an AgentFS file. Args: path (string), content (string)
|
|
24
|
+
- read_file : Read an AgentFS file. Args: path (string)
|
|
25
|
+
- list_directory : List an AgentFS directory. Args: path (string, default "/")
|
|
26
|
+
- run_command : Run a sandboxed command against AgentFS. Args: command (string)
|
|
27
|
+
Supported: ls [path], cat <path>, pwd, echo <text>
|
|
28
|
+
- http_get : Fetch a public URL from the internet. Args: url (string)
|
|
29
|
+
|
|
30
|
+
To call a tool, output exactly this on its own line:
|
|
31
|
+
<zuzu_tool_call>{"name":"TOOL_NAME","args":{"key":"value"}}</zuzu_tool_call>
|
|
32
|
+
|
|
33
|
+
Rules:
|
|
34
|
+
- One tool call per turn. Wait for the <zuzu_tool_result> before calling another.
|
|
35
|
+
- After the task is complete, respond in plain text only (no XML tags of any kind).
|
|
36
|
+
- Do NOT verify or re-read files after writing them unless explicitly asked.
|
|
37
|
+
- Do NOT repeat a tool call you have already made.
|
|
38
|
+
- Never reference the host filesystem, shell environment, or paths like /home/user.
|
|
39
|
+
- Be concise and accurate.
|
|
40
|
+
PROMPT
|
|
41
|
+
|
|
42
|
+
def initialize(agent_fs:, memory:, llm:)
|
|
43
|
+
@fs = agent_fs
|
|
44
|
+
@memory = memory
|
|
45
|
+
@llm = llm
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def process(user_message, &on_tool_call)
|
|
49
|
+
@memory.append(:user, user_message)
|
|
50
|
+
|
|
51
|
+
# Only system prompt + current message — no history injected into agent context.
|
|
52
|
+
# Prior non-tool-call responses cause models to skip tool use.
|
|
53
|
+
messages = [
|
|
54
|
+
{ 'role' => 'system', 'content' => SYSTEM_PROMPT },
|
|
55
|
+
{ 'role' => 'user', 'content' => user_message }
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
final = nil
|
|
59
|
+
seen_calls = Hash.new(0)
|
|
60
|
+
|
|
61
|
+
MAX_ITERATIONS.times do
|
|
62
|
+
response = @llm.chat(messages)
|
|
63
|
+
content = response['content'].to_s.strip
|
|
64
|
+
tool_calls = extract_tool_calls(content)
|
|
65
|
+
|
|
66
|
+
if tool_calls.empty?
|
|
67
|
+
final = content.gsub(TOOL_RESULT_RE, '').strip
|
|
68
|
+
break
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
messages << { 'role' => 'assistant', 'content' => content }
|
|
72
|
+
|
|
73
|
+
results = tool_calls.map do |tc|
|
|
74
|
+
sig = "#{tc['name']}:#{tc['args'].to_json}"
|
|
75
|
+
seen_calls[sig] += 1
|
|
76
|
+
if seen_calls[sig] > 2
|
|
77
|
+
$stderr.puts "[zuzu] loop detected for #{tc['name']}, breaking"
|
|
78
|
+
next "<zuzu_tool_result>#{JSON.generate({ name: tc['name'], result: 'Already done. Give your final answer now.' })}</zuzu_tool_result>"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
out = ToolRegistry.execute(tc['name'], tc['args'], @fs)
|
|
82
|
+
$stderr.puts "[zuzu] tool #{tc['name']}(#{tc['args'].inspect}) => #{out.to_s[0, 120]}"
|
|
83
|
+
on_tool_call&.call(tc['name'], tc['args'], out)
|
|
84
|
+
"<zuzu_tool_result>#{JSON.generate({ name: tc['name'], result: out })}</zuzu_tool_result>"
|
|
85
|
+
end.join("\n")
|
|
86
|
+
|
|
87
|
+
messages << { 'role' => 'user', 'content' => results }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
final ||= 'Max iterations reached.'
|
|
91
|
+
@memory.append(:assistant, final)
|
|
92
|
+
final
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
$stderr.puts "[zuzu] Agent error: #{e.message}"
|
|
95
|
+
$stderr.puts e.backtrace.first(10).map { |l| " #{l}" }.join("\n")
|
|
96
|
+
error_msg = "Error: #{e.message}"
|
|
97
|
+
@memory.append(:assistant, error_msg)
|
|
98
|
+
error_msg
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def extract_tool_calls(content)
|
|
104
|
+
content.scan(TOOL_CALL_RE).filter_map do |match|
|
|
105
|
+
data = JSON.parse(match[0].strip)
|
|
106
|
+
{ 'name' => data['name'].to_s, 'args' => data['args'] || {} }
|
|
107
|
+
rescue JSON::ParserError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zuzu
|
|
4
|
+
# Virtual filesystem backed by SQLite via JDBC.
|
|
5
|
+
# Provides sandboxed file I/O and a key-value store for the agent.
|
|
6
|
+
class AgentFS
|
|
7
|
+
SCHEMA = <<~SQL
|
|
8
|
+
CREATE TABLE IF NOT EXISTS fs_inodes (
|
|
9
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
10
|
+
type TEXT NOT NULL CHECK(type IN ('file','dir')),
|
|
11
|
+
content BLOB,
|
|
12
|
+
size INTEGER DEFAULT 0,
|
|
13
|
+
created_at INTEGER NOT NULL,
|
|
14
|
+
updated_at INTEGER NOT NULL
|
|
15
|
+
);
|
|
16
|
+
CREATE TABLE IF NOT EXISTS fs_dentries (
|
|
17
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
18
|
+
name TEXT NOT NULL,
|
|
19
|
+
parent_id INTEGER NOT NULL,
|
|
20
|
+
inode_id INTEGER NOT NULL,
|
|
21
|
+
UNIQUE(parent_id, name)
|
|
22
|
+
);
|
|
23
|
+
CREATE TABLE IF NOT EXISTS kv_store (
|
|
24
|
+
key TEXT PRIMARY KEY,
|
|
25
|
+
value TEXT,
|
|
26
|
+
updated_at INTEGER
|
|
27
|
+
);
|
|
28
|
+
CREATE TABLE IF NOT EXISTS tool_calls (
|
|
29
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
30
|
+
tool_name TEXT,
|
|
31
|
+
input TEXT,
|
|
32
|
+
output TEXT,
|
|
33
|
+
started_at REAL,
|
|
34
|
+
finished_at REAL
|
|
35
|
+
);
|
|
36
|
+
SQL
|
|
37
|
+
|
|
38
|
+
attr_reader :store
|
|
39
|
+
|
|
40
|
+
def initialize(store = nil)
|
|
41
|
+
@store = store || Store.new
|
|
42
|
+
bootstrap
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# ── File operations ──────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def write_file(path, content)
|
|
48
|
+
parts = split(path)
|
|
49
|
+
filename = parts.pop
|
|
50
|
+
parent = ensure_parents(parts)
|
|
51
|
+
now = epoch
|
|
52
|
+
bytes = content.to_s.to_java_bytes
|
|
53
|
+
|
|
54
|
+
dentry = find_dentry(parent, filename)
|
|
55
|
+
if dentry
|
|
56
|
+
@store.execute(
|
|
57
|
+
"UPDATE fs_inodes SET content = ?, size = ?, updated_at = ? WHERE id = ?",
|
|
58
|
+
[bytes, content.to_s.bytesize, now, dentry['inode_id']]
|
|
59
|
+
)
|
|
60
|
+
else
|
|
61
|
+
@store.execute(
|
|
62
|
+
"INSERT INTO fs_inodes (type, content, size, created_at, updated_at) VALUES ('file', ?, ?, ?, ?)",
|
|
63
|
+
[bytes, content.to_s.bytesize, now, now]
|
|
64
|
+
)
|
|
65
|
+
inode_id = @store.last_insert_id
|
|
66
|
+
@store.execute(
|
|
67
|
+
"INSERT INTO fs_dentries (name, parent_id, inode_id) VALUES (?, ?, ?)",
|
|
68
|
+
[filename, parent, inode_id]
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def read_file(path)
|
|
75
|
+
inode = resolve(path)
|
|
76
|
+
return nil unless inode
|
|
77
|
+
row = @store.query_one("SELECT content FROM fs_inodes WHERE id = ?", [inode])
|
|
78
|
+
return nil unless row
|
|
79
|
+
blob = row['content']
|
|
80
|
+
blob.is_a?(Java::byte[]) ? String.from_java_bytes(blob) : blob.to_s
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def list_dir(path = '/')
|
|
84
|
+
inode = resolve(path)
|
|
85
|
+
return [] unless inode
|
|
86
|
+
@store.query_all("SELECT name FROM fs_dentries WHERE parent_id = ?", [inode]).map { |r| r['name'] }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def mkdir(path)
|
|
90
|
+
parts = split(path)
|
|
91
|
+
dirname = parts.pop
|
|
92
|
+
parent = ensure_parents(parts)
|
|
93
|
+
return false if find_dentry(parent, dirname)
|
|
94
|
+
|
|
95
|
+
now = epoch
|
|
96
|
+
@store.execute(
|
|
97
|
+
"INSERT INTO fs_inodes (type, content, size, created_at, updated_at) VALUES ('dir', NULL, 0, ?, ?)",
|
|
98
|
+
[now, now]
|
|
99
|
+
)
|
|
100
|
+
inode_id = @store.last_insert_id
|
|
101
|
+
@store.execute(
|
|
102
|
+
"INSERT INTO fs_dentries (name, parent_id, inode_id) VALUES (?, ?, ?)",
|
|
103
|
+
[dirname, parent, inode_id]
|
|
104
|
+
)
|
|
105
|
+
true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def delete(path)
|
|
109
|
+
parts = split(path)
|
|
110
|
+
name = parts.pop
|
|
111
|
+
parent = resolve_segments(parts)
|
|
112
|
+
return false unless parent
|
|
113
|
+
dentry = find_dentry(parent, name)
|
|
114
|
+
return false unless dentry
|
|
115
|
+
@store.execute("DELETE FROM fs_dentries WHERE id = ?", [dentry['id']])
|
|
116
|
+
@store.execute("DELETE FROM fs_inodes WHERE id = ?", [dentry['inode_id']])
|
|
117
|
+
true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def exists?(path) = !resolve(path).nil?
|
|
121
|
+
|
|
122
|
+
def stat(path)
|
|
123
|
+
inode_id = resolve(path)
|
|
124
|
+
return nil unless inode_id
|
|
125
|
+
@store.query_one(
|
|
126
|
+
"SELECT id, type, size, created_at, updated_at FROM fs_inodes WHERE id = ?", [inode_id]
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# ── Key-value store ──────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
def kv_set(key, value)
|
|
133
|
+
@store.execute(
|
|
134
|
+
"INSERT OR REPLACE INTO kv_store (key, value, updated_at) VALUES (?, ?, ?)",
|
|
135
|
+
[key, value, epoch]
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def kv_get(key)
|
|
140
|
+
row = @store.query_one("SELECT value FROM kv_store WHERE key = ?", [key])
|
|
141
|
+
row && row['value']
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def kv_delete(key)
|
|
145
|
+
@store.execute("DELETE FROM kv_store WHERE key = ?", [key])
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def kv_list(prefix = '')
|
|
149
|
+
@store.query_all("SELECT key, value FROM kv_store WHERE key LIKE ?", ["#{prefix}%"])
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# ── Tool-call audit log ──────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
def record_tool_call(name, input, output, started_at, finished_at)
|
|
155
|
+
@store.execute(
|
|
156
|
+
"INSERT INTO tool_calls (tool_name, input, output, started_at, finished_at) VALUES (?, ?, ?, ?, ?)",
|
|
157
|
+
[name, input.to_s, output.to_s, started_at, finished_at]
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def split(path) = path.to_s.split('/').reject(&:empty?)
|
|
164
|
+
def epoch = (Time.now.to_f * 1000).to_i
|
|
165
|
+
|
|
166
|
+
def resolve(path)
|
|
167
|
+
resolve_segments(split(path))
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def resolve_segments(parts)
|
|
171
|
+
current = 1 # root inode
|
|
172
|
+
parts.each do |name|
|
|
173
|
+
d = find_dentry(current, name)
|
|
174
|
+
return nil unless d
|
|
175
|
+
current = d['inode_id']
|
|
176
|
+
end
|
|
177
|
+
current
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def find_dentry(parent_id, name)
|
|
181
|
+
@store.query_one(
|
|
182
|
+
"SELECT id, inode_id FROM fs_dentries WHERE parent_id = ? AND name = ?",
|
|
183
|
+
[parent_id, name]
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def ensure_parents(parts)
|
|
188
|
+
current = 1
|
|
189
|
+
parts.each do |name|
|
|
190
|
+
d = find_dentry(current, name)
|
|
191
|
+
if d
|
|
192
|
+
current = d['inode_id']
|
|
193
|
+
else
|
|
194
|
+
now = epoch
|
|
195
|
+
@store.execute(
|
|
196
|
+
"INSERT INTO fs_inodes (type, content, size, created_at, updated_at) VALUES ('dir', NULL, 0, ?, ?)",
|
|
197
|
+
[now, now]
|
|
198
|
+
)
|
|
199
|
+
inode_id = @store.last_insert_id
|
|
200
|
+
@store.execute(
|
|
201
|
+
"INSERT INTO fs_dentries (name, parent_id, inode_id) VALUES (?, ?, ?)",
|
|
202
|
+
[name, current, inode_id]
|
|
203
|
+
)
|
|
204
|
+
current = inode_id
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
current
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def bootstrap
|
|
211
|
+
SCHEMA.split(';').each do |ddl|
|
|
212
|
+
next if ddl.strip.empty?
|
|
213
|
+
stmt = @store.connection.create_statement
|
|
214
|
+
stmt.execute_update(ddl)
|
|
215
|
+
stmt.close
|
|
216
|
+
end
|
|
217
|
+
now = epoch
|
|
218
|
+
@store.execute(
|
|
219
|
+
"INSERT OR IGNORE INTO fs_inodes (id, type, content, size, created_at, updated_at) VALUES (1, 'dir', NULL, 0, ?, ?)",
|
|
220
|
+
[now, now]
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
data/lib/zuzu/app.rb
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'glimmer-dsl-swt'
|
|
4
|
+
|
|
5
|
+
module Zuzu
|
|
6
|
+
class App
|
|
7
|
+
include Glimmer::UI::Application
|
|
8
|
+
|
|
9
|
+
USER_BG = rgb(66, 133, 244)
|
|
10
|
+
ASSIST_BG = rgb(225, 235, 255)
|
|
11
|
+
|
|
12
|
+
attr_accessor :user_input
|
|
13
|
+
|
|
14
|
+
before_body do
|
|
15
|
+
@store = Store.new
|
|
16
|
+
@fs = AgentFS.new(@store)
|
|
17
|
+
@memory = Memory.new(@store)
|
|
18
|
+
@llm = LlmClient.new
|
|
19
|
+
@agent = Agent.new(agent_fs: @fs, memory: @memory, llm: @llm)
|
|
20
|
+
@user_input = ''
|
|
21
|
+
|
|
22
|
+
@channels = [Channels::InApp.new(@agent)]
|
|
23
|
+
if Zuzu.config.channels.include?('whatsapp')
|
|
24
|
+
@channels << Channels::WhatsApp.new(@agent)
|
|
25
|
+
end
|
|
26
|
+
@channels.each(&:start)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
after_body do
|
|
30
|
+
body_root.on_widget_disposed do
|
|
31
|
+
@channels.each(&:stop)
|
|
32
|
+
@store.close
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
body {
|
|
37
|
+
shell {
|
|
38
|
+
grid_layout 1, false
|
|
39
|
+
text Zuzu.config.app_name
|
|
40
|
+
size Zuzu.config.window_width, Zuzu.config.window_height
|
|
41
|
+
|
|
42
|
+
# ── Chat display ─────────────────────────────────────
|
|
43
|
+
scrolled_composite(:v_scroll) {
|
|
44
|
+
layout_data(:fill, :fill, true, true)
|
|
45
|
+
|
|
46
|
+
@chat_display = text(:multi, :read_only, :wrap) {
|
|
47
|
+
background :white
|
|
48
|
+
font name: 'Monospace', height: 13
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# ── Input row ────────────────────────────────────────
|
|
53
|
+
composite {
|
|
54
|
+
layout_data(:fill, :fill, true, false)
|
|
55
|
+
grid_layout 3, false
|
|
56
|
+
|
|
57
|
+
@input_text = text(:border) {
|
|
58
|
+
layout_data(:fill, :fill, true, false)
|
|
59
|
+
text <=> [self, :user_input]
|
|
60
|
+
on_key_pressed do |e|
|
|
61
|
+
send_message if e.character == 13
|
|
62
|
+
end
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
button {
|
|
66
|
+
text 'Send'
|
|
67
|
+
on_widget_selected { send_message }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
button {
|
|
71
|
+
text 'Admin Panel'
|
|
72
|
+
on_widget_selected { open_admin_panel }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
def self.launch!(use_llamafile: false)
|
|
79
|
+
if use_llamafile
|
|
80
|
+
@llamafile = LlamafileManager.new
|
|
81
|
+
@llamafile.start!
|
|
82
|
+
end
|
|
83
|
+
launch
|
|
84
|
+
ensure
|
|
85
|
+
@llamafile&.stop!
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def send_message
|
|
91
|
+
input = user_input.to_s.strip
|
|
92
|
+
return if input.empty?
|
|
93
|
+
|
|
94
|
+
self.user_input = ''
|
|
95
|
+
add_bubble(:user, input)
|
|
96
|
+
|
|
97
|
+
Thread.new do
|
|
98
|
+
begin
|
|
99
|
+
response = @agent.process(input) do |tool_name, args, result|
|
|
100
|
+
async_exec { add_bubble(:tool, "#{tool_name}(#{args.map { |k, v| "#{k}: #{v.to_s[0, 40]}" }.join(', ')}) → #{result.to_s[0, 80]}") }
|
|
101
|
+
end
|
|
102
|
+
async_exec { add_bubble(:assistant, response) }
|
|
103
|
+
rescue => e
|
|
104
|
+
$stderr.puts "[zuzu] Send error: #{e.message}"
|
|
105
|
+
async_exec { add_bubble(:assistant, "Error: #{e.message}") }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def add_bubble(role, msg)
|
|
111
|
+
prefix = case role
|
|
112
|
+
when :user then "You: "
|
|
113
|
+
when :assistant then "Assistant: "
|
|
114
|
+
when :tool then " [tool] "
|
|
115
|
+
end
|
|
116
|
+
current = @chat_display.swt_widget.get_text
|
|
117
|
+
separator = role == :tool ? "\n" : "\n\n"
|
|
118
|
+
separator = '' if current.empty?
|
|
119
|
+
@chat_display.swt_widget.set_text(current + separator + prefix + msg.to_s)
|
|
120
|
+
@chat_display.swt_widget.set_top_index(@chat_display.swt_widget.get_line_count)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def open_admin_panel
|
|
124
|
+
file_list_widget = nil
|
|
125
|
+
|
|
126
|
+
admin = shell {
|
|
127
|
+
text 'Admin Panel'
|
|
128
|
+
minimum_size 380, 500
|
|
129
|
+
grid_layout 1, false
|
|
130
|
+
|
|
131
|
+
label {
|
|
132
|
+
layout_data(:fill, :fill, true, false)
|
|
133
|
+
text 'AgentFS — Virtual File Browser'
|
|
134
|
+
font height: 12, style: :bold
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
file_list_widget = list(:single, :v_scroll, :border) {
|
|
138
|
+
layout_data(:fill, :fill, true, true)
|
|
139
|
+
font name: 'Monospace', height: 11
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
button {
|
|
143
|
+
layout_data(:fill, :fill, true, false)
|
|
144
|
+
text 'Create Test File'
|
|
145
|
+
on_widget_selected {
|
|
146
|
+
@fs.write_file('/test.txt', "Hello from AgentFS!\nCreated at: #{Time.now}")
|
|
147
|
+
populate_file_list(file_list_widget)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
button {
|
|
152
|
+
layout_data(:fill, :fill, true, false)
|
|
153
|
+
text 'Clear Chat History'
|
|
154
|
+
on_widget_selected {
|
|
155
|
+
@memory.clear
|
|
156
|
+
message_box {
|
|
157
|
+
text 'Done'
|
|
158
|
+
message 'Conversation history cleared.'
|
|
159
|
+
}.open
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
button {
|
|
164
|
+
layout_data(:fill, :fill, true, false)
|
|
165
|
+
text 'Refresh'
|
|
166
|
+
on_widget_selected { populate_file_list(file_list_widget) }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
populate_file_list(file_list_widget)
|
|
171
|
+
admin.open
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def populate_file_list(file_list)
|
|
175
|
+
entries = walk_fs('/')
|
|
176
|
+
items = entries.empty? ? ['(empty)'] : entries
|
|
177
|
+
file_list.swt_widget.set_items(items.to_java(:string))
|
|
178
|
+
rescue => e
|
|
179
|
+
file_list.swt_widget.set_items(["Error: #{e.message}"].to_java(:string))
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def walk_fs(path, indent = 0)
|
|
183
|
+
@fs.list_dir(path).flat_map do |name|
|
|
184
|
+
child = path == '/' ? "/#{name}" : "#{path}/#{name}"
|
|
185
|
+
stat = @fs.stat(child)
|
|
186
|
+
if stat && stat['type'] == 'dir'
|
|
187
|
+
["#{' ' * indent}+ #{name}/"] + walk_fs(child, indent + 1)
|
|
188
|
+
else
|
|
189
|
+
["#{' ' * indent} #{name}"]
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zuzu
|
|
4
|
+
module Channels
|
|
5
|
+
# Abstract base class for message channels.
|
|
6
|
+
class Base
|
|
7
|
+
def initialize(agent)
|
|
8
|
+
@agent = agent
|
|
9
|
+
@running = false
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def start = raise(NotImplementedError)
|
|
13
|
+
def stop = raise(NotImplementedError)
|
|
14
|
+
def running? = @running
|
|
15
|
+
|
|
16
|
+
def handle(message)
|
|
17
|
+
@agent.process(message)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'webrick'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'net/http'
|
|
6
|
+
|
|
7
|
+
module Zuzu
|
|
8
|
+
module Channels
|
|
9
|
+
# WhatsApp Cloud API webhook receiver.
|
|
10
|
+
class WhatsApp < Base
|
|
11
|
+
API_BASE = 'https://graph.facebook.com/v19.0'
|
|
12
|
+
|
|
13
|
+
def start
|
|
14
|
+
port = Integer(ENV.fetch('WHATSAPP_PORT', 9292))
|
|
15
|
+
@server = WEBrick::HTTPServer.new(
|
|
16
|
+
Port: port, Logger: WEBrick::Log.new(nil, 0), AccessLog: []
|
|
17
|
+
)
|
|
18
|
+
@server.mount_proc('/webhook') { |req, res| dispatch(req, res) }
|
|
19
|
+
@running = true
|
|
20
|
+
@thread = Thread.new { @server.start }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def stop
|
|
24
|
+
@server&.shutdown
|
|
25
|
+
@thread&.join
|
|
26
|
+
@running = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def dispatch(req, res)
|
|
32
|
+
if req.request_method == 'GET'
|
|
33
|
+
res.body = req.query['hub.challenge'].to_s
|
|
34
|
+
elsif req.request_method == 'POST'
|
|
35
|
+
payload = JSON.parse(req.body)
|
|
36
|
+
entry = payload.dig('entry', 0, 'changes', 0, 'value')
|
|
37
|
+
msg = entry&.dig('messages', 0)
|
|
38
|
+
if msg&.dig('type') == 'text'
|
|
39
|
+
text = msg.dig('text', 'body')
|
|
40
|
+
to = msg['from']
|
|
41
|
+
reply = handle(text)
|
|
42
|
+
send_reply(to, reply)
|
|
43
|
+
end
|
|
44
|
+
res.body = 'OK'
|
|
45
|
+
end
|
|
46
|
+
res.status = 200
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def send_reply(to, text)
|
|
50
|
+
token = ENV['WHATSAPP_TOKEN']
|
|
51
|
+
phone_id = ENV['WHATSAPP_PHONE_ID']
|
|
52
|
+
uri = URI("#{API_BASE}/#{phone_id}/messages")
|
|
53
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
54
|
+
http.use_ssl = true
|
|
55
|
+
req = Net::HTTP::Post.new(uri, 'Content-Type' => 'application/json',
|
|
56
|
+
'Authorization' => "Bearer #{token}")
|
|
57
|
+
req.body = { messaging_product: 'whatsapp', to: to,
|
|
58
|
+
type: 'text', text: { body: text } }.to_json
|
|
59
|
+
http.request(req)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/zuzu/config.rb
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zuzu
|
|
4
|
+
# Central configuration singleton.
|
|
5
|
+
#
|
|
6
|
+
# Zuzu.configure do |c|
|
|
7
|
+
# c.app_name = "My Agent"
|
|
8
|
+
# c.llamafile_path = "models/my.llamafile"
|
|
9
|
+
# c.port = 8080
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
class Config
|
|
13
|
+
attr_accessor :port, :model, :channels, :log_level, :app_name,
|
|
14
|
+
:window_width, :window_height
|
|
15
|
+
|
|
16
|
+
attr_reader :db_path, :llamafile_path
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@port = 8080
|
|
20
|
+
@model = 'LLaMA_CPP'
|
|
21
|
+
@db_path = File.join('.zuzu', 'zuzu.db')
|
|
22
|
+
@llamafile_path = nil
|
|
23
|
+
@channels = []
|
|
24
|
+
@log_level = :info
|
|
25
|
+
@app_name = 'Zuzu'
|
|
26
|
+
@window_width = 860
|
|
27
|
+
@window_height = 620
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Expand paths so relative paths resolve against the caller's directory,
|
|
31
|
+
# not whatever the current working directory happens to be at runtime.
|
|
32
|
+
def db_path=(path)
|
|
33
|
+
@db_path = path ? File.expand_path(path) : path
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def llamafile_path=(path)
|
|
37
|
+
@llamafile_path = path ? File.expand_path(path) : path
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@config = Config.new
|
|
42
|
+
|
|
43
|
+
def self.config
|
|
44
|
+
yield @config if block_given?
|
|
45
|
+
@config
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
alias_method :configure, :config
|
|
50
|
+
end
|
|
51
|
+
end
|