hajimi-claw 0.1.0
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.
- package/Cargo.lock +2602 -0
- package/Cargo.toml +57 -0
- package/README.md +73 -0
- package/bin/hajimi-claw.js +28 -0
- package/config.example.toml +32 -0
- package/crates/hajimi-claw-agent/Cargo.toml +25 -0
- package/crates/hajimi-claw-agent/src/lib.rs +351 -0
- package/crates/hajimi-claw-bot/Cargo.toml +18 -0
- package/crates/hajimi-claw-bot/src/lib.rs +305 -0
- package/crates/hajimi-claw-daemon/Cargo.toml +24 -0
- package/crates/hajimi-claw-daemon/src/lib.rs +173 -0
- package/crates/hajimi-claw-exec/Cargo.toml +21 -0
- package/crates/hajimi-claw-exec/src/lib.rs +419 -0
- package/crates/hajimi-claw-gateway/Cargo.toml +27 -0
- package/crates/hajimi-claw-gateway/src/lib.rs +747 -0
- package/crates/hajimi-claw-llm/Cargo.toml +19 -0
- package/crates/hajimi-claw-llm/src/lib.rs +367 -0
- package/crates/hajimi-claw-policy/Cargo.toml +14 -0
- package/crates/hajimi-claw-policy/src/lib.rs +381 -0
- package/crates/hajimi-claw-store/Cargo.toml +17 -0
- package/crates/hajimi-claw-store/src/lib.rs +730 -0
- package/crates/hajimi-claw-tools/Cargo.toml +21 -0
- package/crates/hajimi-claw-tools/src/lib.rs +758 -0
- package/crates/hajimi-claw-types/Cargo.toml +16 -0
- package/crates/hajimi-claw-types/src/lib.rs +300 -0
- package/package.json +26 -0
- package/scripts/npm-install.js +45 -0
- package/src/main.rs +4 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
use std::sync::Arc;
|
|
2
|
+
|
|
3
|
+
use async_trait::async_trait;
|
|
4
|
+
use chrono::Utc;
|
|
5
|
+
use hajimi_claw_agent::AgentRuntime;
|
|
6
|
+
use hajimi_claw_llm::{list_models, test_provider};
|
|
7
|
+
use hajimi_claw_policy::PolicyEngine;
|
|
8
|
+
use hajimi_claw_store::Store;
|
|
9
|
+
use hajimi_claw_types::{
|
|
10
|
+
ClawError, ClawResult, OnboardingSession, OnboardingStep, ProviderConfig, ProviderDraft,
|
|
11
|
+
ProviderKind, ProviderRecord,
|
|
12
|
+
};
|
|
13
|
+
use reqwest::Client;
|
|
14
|
+
use serde::{Deserialize, Serialize};
|
|
15
|
+
|
|
16
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
17
|
+
pub enum GatewayCommand {
|
|
18
|
+
Ask(String),
|
|
19
|
+
ShellOpen(Option<String>),
|
|
20
|
+
ShellExec(String),
|
|
21
|
+
ShellClose,
|
|
22
|
+
Status,
|
|
23
|
+
Approve(String),
|
|
24
|
+
ElevatedStart { minutes: i64, reason: String },
|
|
25
|
+
ElevatedStop,
|
|
26
|
+
Cancel(String),
|
|
27
|
+
Onboard,
|
|
28
|
+
OnboardCancel,
|
|
29
|
+
ProviderList,
|
|
30
|
+
ProviderUse(String),
|
|
31
|
+
ProviderBind(String),
|
|
32
|
+
ProviderCurrent,
|
|
33
|
+
ProviderTest(Option<String>),
|
|
34
|
+
ProviderModels(Option<String>),
|
|
35
|
+
Help,
|
|
36
|
+
Unknown(String),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
40
|
+
pub struct GatewayRequest {
|
|
41
|
+
pub actor_user_id: i64,
|
|
42
|
+
pub actor_chat_id: i64,
|
|
43
|
+
pub raw_text: String,
|
|
44
|
+
pub command: GatewayCommand,
|
|
45
|
+
pub current_session_id: Option<String>,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
49
|
+
pub enum SessionDirective {
|
|
50
|
+
Keep,
|
|
51
|
+
Set(String),
|
|
52
|
+
Clear,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
56
|
+
pub struct GatewayResponse {
|
|
57
|
+
pub text: String,
|
|
58
|
+
pub session: SessionDirective,
|
|
59
|
+
pub keyboard: Option<InlineKeyboard>,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
63
|
+
pub struct InlineKeyboard {
|
|
64
|
+
pub rows: Vec<Vec<InlineButton>>,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
68
|
+
pub struct InlineButton {
|
|
69
|
+
pub text: String,
|
|
70
|
+
pub data: String,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#[async_trait]
|
|
74
|
+
pub trait Gateway: Send + Sync {
|
|
75
|
+
async fn handle(&self, request: GatewayRequest) -> ClawResult<GatewayResponse>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
pub struct InProcessGateway {
|
|
79
|
+
runtime: Arc<AgentRuntime>,
|
|
80
|
+
policy: Arc<PolicyEngine>,
|
|
81
|
+
store: Arc<Store>,
|
|
82
|
+
client: Client,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
impl InProcessGateway {
|
|
86
|
+
pub fn new(runtime: Arc<AgentRuntime>, policy: Arc<PolicyEngine>, store: Arc<Store>) -> Self {
|
|
87
|
+
Self {
|
|
88
|
+
runtime,
|
|
89
|
+
policy,
|
|
90
|
+
store,
|
|
91
|
+
client: Client::new(),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#[async_trait]
|
|
97
|
+
impl Gateway for InProcessGateway {
|
|
98
|
+
async fn handle(&self, request: GatewayRequest) -> ClawResult<GatewayResponse> {
|
|
99
|
+
if !self
|
|
100
|
+
.policy
|
|
101
|
+
.authorize_telegram_actor(request.actor_user_id, request.actor_chat_id)
|
|
102
|
+
{
|
|
103
|
+
return Err(ClawError::AccessDenied(
|
|
104
|
+
"telegram actor is not authorized".into(),
|
|
105
|
+
));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if matches!(request.command, GatewayCommand::OnboardCancel) {
|
|
109
|
+
self.store
|
|
110
|
+
.clear_onboarding_session(request.actor_chat_id, request.actor_user_id)
|
|
111
|
+
.map_err(store_error)?;
|
|
112
|
+
return Ok(text_response("onboarding cancelled"));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if let Some(session) = self
|
|
116
|
+
.store
|
|
117
|
+
.load_onboarding_session(request.actor_chat_id, request.actor_user_id)
|
|
118
|
+
.map_err(store_error)?
|
|
119
|
+
{
|
|
120
|
+
if !request.raw_text.trim_start().starts_with('/') {
|
|
121
|
+
return self.continue_onboarding(session, request).await;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
match request.command {
|
|
126
|
+
GatewayCommand::Ask(prompt) => {
|
|
127
|
+
let provider_id = self
|
|
128
|
+
.store
|
|
129
|
+
.resolve_provider_for_chat(request.actor_chat_id)
|
|
130
|
+
.map_err(store_error)?
|
|
131
|
+
.map(|record| record.config.id);
|
|
132
|
+
Ok(GatewayResponse {
|
|
133
|
+
text: self
|
|
134
|
+
.runtime
|
|
135
|
+
.ask_with_provider(&prompt, None, provider_id)
|
|
136
|
+
.await?,
|
|
137
|
+
session: SessionDirective::Keep,
|
|
138
|
+
keyboard: None,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
GatewayCommand::ShellOpen(name) => {
|
|
142
|
+
let reply = self.runtime.shell_open(name, None).await?;
|
|
143
|
+
Ok(GatewayResponse {
|
|
144
|
+
text: reply.message,
|
|
145
|
+
session: SessionDirective::Set(reply.session_id),
|
|
146
|
+
keyboard: None,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
GatewayCommand::ShellExec(command) => {
|
|
150
|
+
let session_id = request.current_session_id.ok_or_else(|| {
|
|
151
|
+
ClawError::InvalidRequest("no active session, use /shell open first".into())
|
|
152
|
+
})?;
|
|
153
|
+
Ok(GatewayResponse {
|
|
154
|
+
text: self.runtime.shell_exec(&session_id, &command).await?,
|
|
155
|
+
session: SessionDirective::Keep,
|
|
156
|
+
keyboard: None,
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
GatewayCommand::ShellClose => {
|
|
160
|
+
let session_id = request.current_session_id.ok_or_else(|| {
|
|
161
|
+
ClawError::InvalidRequest("no active session, use /shell open first".into())
|
|
162
|
+
})?;
|
|
163
|
+
Ok(GatewayResponse {
|
|
164
|
+
text: self.runtime.shell_close(&session_id).await?,
|
|
165
|
+
session: SessionDirective::Clear,
|
|
166
|
+
keyboard: None,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
GatewayCommand::Status => Ok(text_response(&self.runtime.status()?)),
|
|
170
|
+
GatewayCommand::Approve(request_id) => {
|
|
171
|
+
Ok(text_response(&self.runtime.approve(&request_id)?))
|
|
172
|
+
}
|
|
173
|
+
GatewayCommand::ElevatedStart { minutes, reason } => Ok(text_response(
|
|
174
|
+
&self.runtime.request_elevated(minutes, reason),
|
|
175
|
+
)),
|
|
176
|
+
GatewayCommand::ElevatedStop => Ok(text_response(&self.runtime.stop_elevated())),
|
|
177
|
+
GatewayCommand::Cancel(task_id) => Ok(text_response(&format!(
|
|
178
|
+
"cancel is not implemented yet for task {task_id}"
|
|
179
|
+
))),
|
|
180
|
+
GatewayCommand::Onboard => self.start_onboarding(request).await,
|
|
181
|
+
GatewayCommand::ProviderList => self.provider_list().await,
|
|
182
|
+
GatewayCommand::ProviderUse(provider_id) => self.provider_use(&provider_id).await,
|
|
183
|
+
GatewayCommand::ProviderBind(provider_id) => {
|
|
184
|
+
self.provider_bind(request.actor_chat_id, &provider_id)
|
|
185
|
+
.await
|
|
186
|
+
}
|
|
187
|
+
GatewayCommand::ProviderCurrent => self.provider_current(request.actor_chat_id).await,
|
|
188
|
+
GatewayCommand::ProviderTest(provider_id) => {
|
|
189
|
+
self.provider_test(request.actor_chat_id, provider_id.as_deref())
|
|
190
|
+
.await
|
|
191
|
+
}
|
|
192
|
+
GatewayCommand::ProviderModels(provider_id) => {
|
|
193
|
+
self.provider_models(request.actor_chat_id, provider_id.as_deref())
|
|
194
|
+
.await
|
|
195
|
+
}
|
|
196
|
+
GatewayCommand::Help => Ok(text_response(&help_text())),
|
|
197
|
+
GatewayCommand::Unknown(raw) => Ok(text_response(&format!(
|
|
198
|
+
"unrecognized command: {raw}\n\n{}",
|
|
199
|
+
help_text()
|
|
200
|
+
))),
|
|
201
|
+
GatewayCommand::OnboardCancel => Ok(text_response("onboarding cancelled")),
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
impl InProcessGateway {
|
|
207
|
+
async fn start_onboarding(&self, request: GatewayRequest) -> ClawResult<GatewayResponse> {
|
|
208
|
+
let session = OnboardingSession {
|
|
209
|
+
user_id: request.actor_user_id,
|
|
210
|
+
chat_id: request.actor_chat_id,
|
|
211
|
+
step: OnboardingStep::ProviderLabel,
|
|
212
|
+
draft: ProviderDraft::default(),
|
|
213
|
+
updated_at: Utc::now(),
|
|
214
|
+
};
|
|
215
|
+
self.store
|
|
216
|
+
.save_onboarding_session(&session)
|
|
217
|
+
.map_err(store_error)?;
|
|
218
|
+
Ok(text_response_with_keyboard(
|
|
219
|
+
"hajimi onboard started.\nStep 1/5: send a short provider label, for example `OpenAI` or `Moonshot`.\nSend `/onboard cancel` to stop.",
|
|
220
|
+
Some(cancel_keyboard()),
|
|
221
|
+
))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async fn continue_onboarding(
|
|
225
|
+
&self,
|
|
226
|
+
mut session: OnboardingSession,
|
|
227
|
+
request: GatewayRequest,
|
|
228
|
+
) -> ClawResult<GatewayResponse> {
|
|
229
|
+
let input = request.raw_text.trim();
|
|
230
|
+
match session.step {
|
|
231
|
+
OnboardingStep::ProviderLabel => {
|
|
232
|
+
session.draft.label = Some(input.to_string());
|
|
233
|
+
session.step = OnboardingStep::ProviderKind;
|
|
234
|
+
session.updated_at = Utc::now();
|
|
235
|
+
self.store
|
|
236
|
+
.save_onboarding_session(&session)
|
|
237
|
+
.map_err(store_error)?;
|
|
238
|
+
Ok(text_response_with_keyboard(
|
|
239
|
+
"Step 2/5: choose provider kind below or send it manually: `openai-compatible` or `custom-chat-completions`.",
|
|
240
|
+
Some(provider_kind_keyboard()),
|
|
241
|
+
))
|
|
242
|
+
}
|
|
243
|
+
OnboardingStep::ProviderKind => {
|
|
244
|
+
session.draft.kind = Some(parse_provider_kind(input)?);
|
|
245
|
+
session.step = OnboardingStep::ProviderBaseUrl;
|
|
246
|
+
session.updated_at = Utc::now();
|
|
247
|
+
self.store
|
|
248
|
+
.save_onboarding_session(&session)
|
|
249
|
+
.map_err(store_error)?;
|
|
250
|
+
Ok(text_response(
|
|
251
|
+
"Step 3/5: send the API base URL, for example `https://api.openai.com/v1`.",
|
|
252
|
+
))
|
|
253
|
+
}
|
|
254
|
+
OnboardingStep::ProviderBaseUrl => {
|
|
255
|
+
session.draft.base_url = Some(normalize_base_url(input));
|
|
256
|
+
session.step = OnboardingStep::ProviderApiKey;
|
|
257
|
+
session.updated_at = Utc::now();
|
|
258
|
+
self.store
|
|
259
|
+
.save_onboarding_session(&session)
|
|
260
|
+
.map_err(store_error)?;
|
|
261
|
+
Ok(text_response(
|
|
262
|
+
"Step 4/5: send the API key. It will be encrypted before being stored in SQLite.",
|
|
263
|
+
))
|
|
264
|
+
}
|
|
265
|
+
OnboardingStep::ProviderApiKey => {
|
|
266
|
+
session.draft.api_key = Some(input.to_string());
|
|
267
|
+
session.step = OnboardingStep::ProviderModel;
|
|
268
|
+
session.updated_at = Utc::now();
|
|
269
|
+
self.store
|
|
270
|
+
.save_onboarding_session(&session)
|
|
271
|
+
.map_err(store_error)?;
|
|
272
|
+
Ok(text_response(
|
|
273
|
+
"Step 5/5: send the default model name, for example `gpt-4.1-mini`.",
|
|
274
|
+
))
|
|
275
|
+
}
|
|
276
|
+
OnboardingStep::ProviderModel => {
|
|
277
|
+
session.draft.model = Some(input.to_string());
|
|
278
|
+
let record = finalize_provider(session.draft.clone())?;
|
|
279
|
+
let make_default = self
|
|
280
|
+
.store
|
|
281
|
+
.get_default_provider()
|
|
282
|
+
.map_err(store_error)?
|
|
283
|
+
.is_none();
|
|
284
|
+
self.store
|
|
285
|
+
.upsert_provider(&ProviderRecord {
|
|
286
|
+
config: record.clone(),
|
|
287
|
+
is_default: make_default,
|
|
288
|
+
})
|
|
289
|
+
.map_err(store_error)?;
|
|
290
|
+
self.store
|
|
291
|
+
.bind_provider_to_chat(request.actor_chat_id, &record.id)
|
|
292
|
+
.map_err(store_error)?;
|
|
293
|
+
self.store
|
|
294
|
+
.clear_onboarding_session(request.actor_chat_id, request.actor_user_id)
|
|
295
|
+
.map_err(store_error)?;
|
|
296
|
+
|
|
297
|
+
let health = test_provider(&self.client, &record).await?;
|
|
298
|
+
Ok(text_response(&format!(
|
|
299
|
+
"onboarding complete.\nprovider=`{}` id=`{}`{}\nchat_binding=enabled\nhealth={}\n{}",
|
|
300
|
+
record.label,
|
|
301
|
+
record.id,
|
|
302
|
+
if make_default { " default=yes" } else { "" },
|
|
303
|
+
if health.ok { "ok" } else { "failed" },
|
|
304
|
+
health.message
|
|
305
|
+
)))
|
|
306
|
+
}
|
|
307
|
+
OnboardingStep::Completed => Ok(text_response("onboarding already completed")),
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async fn provider_list(&self) -> ClawResult<GatewayResponse> {
|
|
312
|
+
let providers = self.store.list_providers().map_err(store_error)?;
|
|
313
|
+
if providers.is_empty() {
|
|
314
|
+
return Ok(text_response(
|
|
315
|
+
"no providers configured. Use `/onboard` to add one.",
|
|
316
|
+
));
|
|
317
|
+
}
|
|
318
|
+
let text = providers
|
|
319
|
+
.into_iter()
|
|
320
|
+
.map(|provider| {
|
|
321
|
+
format!(
|
|
322
|
+
"`{}` {} kind={} model={}{}",
|
|
323
|
+
provider.config.id,
|
|
324
|
+
provider.config.label,
|
|
325
|
+
provider.config.kind.as_str(),
|
|
326
|
+
provider.config.model,
|
|
327
|
+
if provider.is_default { " default" } else { "" }
|
|
328
|
+
)
|
|
329
|
+
})
|
|
330
|
+
.collect::<Vec<_>>()
|
|
331
|
+
.join("\n");
|
|
332
|
+
Ok(text_response(&text))
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async fn provider_use(&self, provider_id: &str) -> ClawResult<GatewayResponse> {
|
|
336
|
+
ensure_provider_exists(&self.store, provider_id)?;
|
|
337
|
+
self.store
|
|
338
|
+
.set_default_provider(provider_id)
|
|
339
|
+
.map_err(store_error)?;
|
|
340
|
+
Ok(text_response(&format!(
|
|
341
|
+
"default provider set to `{provider_id}`"
|
|
342
|
+
)))
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async fn provider_bind(&self, chat_id: i64, provider_id: &str) -> ClawResult<GatewayResponse> {
|
|
346
|
+
ensure_provider_exists(&self.store, provider_id)?;
|
|
347
|
+
self.store
|
|
348
|
+
.bind_provider_to_chat(chat_id, provider_id)
|
|
349
|
+
.map_err(store_error)?;
|
|
350
|
+
Ok(text_response(&format!(
|
|
351
|
+
"chat `{chat_id}` now uses provider `{provider_id}`"
|
|
352
|
+
)))
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async fn provider_current(&self, chat_id: i64) -> ClawResult<GatewayResponse> {
|
|
356
|
+
let bound = self
|
|
357
|
+
.store
|
|
358
|
+
.get_bound_provider_id(chat_id)
|
|
359
|
+
.map_err(store_error)?;
|
|
360
|
+
let resolved = self
|
|
361
|
+
.store
|
|
362
|
+
.resolve_provider_for_chat(chat_id)
|
|
363
|
+
.map_err(store_error)?;
|
|
364
|
+
match resolved {
|
|
365
|
+
Some(provider) => Ok(text_response(&format!(
|
|
366
|
+
"current provider for chat `{chat_id}`: `{}` ({}){}\ndefault={}",
|
|
367
|
+
provider.config.id,
|
|
368
|
+
provider.config.label,
|
|
369
|
+
if bound.is_some() {
|
|
370
|
+
" bound=yes"
|
|
371
|
+
} else {
|
|
372
|
+
" bound=no"
|
|
373
|
+
},
|
|
374
|
+
provider.is_default
|
|
375
|
+
))),
|
|
376
|
+
None => Ok(text_response("no provider configured")),
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async fn provider_test(
|
|
381
|
+
&self,
|
|
382
|
+
chat_id: i64,
|
|
383
|
+
provider_id: Option<&str>,
|
|
384
|
+
) -> ClawResult<GatewayResponse> {
|
|
385
|
+
let provider = if let Some(provider_id) = provider_id {
|
|
386
|
+
self.store
|
|
387
|
+
.get_provider(provider_id)
|
|
388
|
+
.map_err(store_error)?
|
|
389
|
+
.ok_or_else(|| ClawError::NotFound(format!("provider not found: {provider_id}")))?
|
|
390
|
+
} else {
|
|
391
|
+
self.store
|
|
392
|
+
.resolve_provider_for_chat(chat_id)
|
|
393
|
+
.map_err(store_error)?
|
|
394
|
+
.ok_or_else(|| ClawError::NotFound("no provider configured".into()))?
|
|
395
|
+
};
|
|
396
|
+
let health = test_provider(&self.client, &provider.config).await?;
|
|
397
|
+
let models = if health.suggested_models.is_empty() {
|
|
398
|
+
String::from("none")
|
|
399
|
+
} else {
|
|
400
|
+
health.suggested_models.join(", ")
|
|
401
|
+
};
|
|
402
|
+
Ok(text_response(&format!(
|
|
403
|
+
"provider `{}` test={}\n{}\nmodels={}",
|
|
404
|
+
provider.config.id,
|
|
405
|
+
if health.ok { "ok" } else { "failed" },
|
|
406
|
+
health.message,
|
|
407
|
+
models
|
|
408
|
+
)))
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async fn provider_models(
|
|
412
|
+
&self,
|
|
413
|
+
chat_id: i64,
|
|
414
|
+
provider_id: Option<&str>,
|
|
415
|
+
) -> ClawResult<GatewayResponse> {
|
|
416
|
+
let provider = if let Some(provider_id) = provider_id {
|
|
417
|
+
self.store
|
|
418
|
+
.get_provider(provider_id)
|
|
419
|
+
.map_err(store_error)?
|
|
420
|
+
.ok_or_else(|| ClawError::NotFound(format!("provider not found: {provider_id}")))?
|
|
421
|
+
} else {
|
|
422
|
+
self.store
|
|
423
|
+
.resolve_provider_for_chat(chat_id)
|
|
424
|
+
.map_err(store_error)?
|
|
425
|
+
.ok_or_else(|| ClawError::NotFound("no provider configured".into()))?
|
|
426
|
+
};
|
|
427
|
+
let models = list_models(&self.client, &provider.config).await?;
|
|
428
|
+
if models.is_empty() {
|
|
429
|
+
return Ok(text_response("provider returned no models"));
|
|
430
|
+
}
|
|
431
|
+
Ok(text_response(&format!(
|
|
432
|
+
"models for `{}`:\n{}",
|
|
433
|
+
provider.config.id,
|
|
434
|
+
models.join("\n")
|
|
435
|
+
)))
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
pub fn parse_gateway_command(text: &str) -> GatewayCommand {
|
|
440
|
+
let trimmed = text.trim();
|
|
441
|
+
if let Some(rest) = trimmed.strip_prefix("/ask ") {
|
|
442
|
+
return GatewayCommand::Ask(rest.trim().into());
|
|
443
|
+
}
|
|
444
|
+
if trimmed == "/status" {
|
|
445
|
+
return GatewayCommand::Status;
|
|
446
|
+
}
|
|
447
|
+
if trimmed == "/onboard" {
|
|
448
|
+
return GatewayCommand::Onboard;
|
|
449
|
+
}
|
|
450
|
+
if trimmed == "/onboard cancel" {
|
|
451
|
+
return GatewayCommand::OnboardCancel;
|
|
452
|
+
}
|
|
453
|
+
if trimmed == "/provider list" {
|
|
454
|
+
return GatewayCommand::ProviderList;
|
|
455
|
+
}
|
|
456
|
+
if trimmed == "/provider current" {
|
|
457
|
+
return GatewayCommand::ProviderCurrent;
|
|
458
|
+
}
|
|
459
|
+
if let Some(rest) = trimmed.strip_prefix("/provider models") {
|
|
460
|
+
let value = rest.trim();
|
|
461
|
+
return GatewayCommand::ProviderModels((!value.is_empty()).then(|| value.to_string()));
|
|
462
|
+
}
|
|
463
|
+
if let Some(rest) = trimmed.strip_prefix("/provider use ") {
|
|
464
|
+
return GatewayCommand::ProviderUse(rest.trim().into());
|
|
465
|
+
}
|
|
466
|
+
if let Some(rest) = trimmed.strip_prefix("/provider bind ") {
|
|
467
|
+
return GatewayCommand::ProviderBind(rest.trim().into());
|
|
468
|
+
}
|
|
469
|
+
if let Some(rest) = trimmed.strip_prefix("/provider test") {
|
|
470
|
+
let value = rest.trim();
|
|
471
|
+
return GatewayCommand::ProviderTest((!value.is_empty()).then(|| value.to_string()));
|
|
472
|
+
}
|
|
473
|
+
if let Some(rest) = trimmed.strip_prefix("/approve ") {
|
|
474
|
+
return GatewayCommand::Approve(rest.trim().into());
|
|
475
|
+
}
|
|
476
|
+
if trimmed == "/elevated stop" {
|
|
477
|
+
return GatewayCommand::ElevatedStop;
|
|
478
|
+
}
|
|
479
|
+
if let Some(rest) = trimmed.strip_prefix("/elevated start ") {
|
|
480
|
+
let mut parts = rest.trim().splitn(2, ' ');
|
|
481
|
+
let minutes = parts
|
|
482
|
+
.next()
|
|
483
|
+
.and_then(|value| value.parse::<i64>().ok())
|
|
484
|
+
.unwrap_or(10);
|
|
485
|
+
let reason = parts.next().unwrap_or("manual request").trim().to_string();
|
|
486
|
+
return GatewayCommand::ElevatedStart { minutes, reason };
|
|
487
|
+
}
|
|
488
|
+
if trimmed == "/shell close" {
|
|
489
|
+
return GatewayCommand::ShellClose;
|
|
490
|
+
}
|
|
491
|
+
if let Some(rest) = trimmed.strip_prefix("/shell open") {
|
|
492
|
+
let name = rest.trim();
|
|
493
|
+
return GatewayCommand::ShellOpen((!name.is_empty()).then(|| name.to_string()));
|
|
494
|
+
}
|
|
495
|
+
if let Some(rest) = trimmed.strip_prefix("/shell exec ") {
|
|
496
|
+
return GatewayCommand::ShellExec(rest.trim().into());
|
|
497
|
+
}
|
|
498
|
+
if let Some(rest) = trimmed.strip_prefix("/cancel ") {
|
|
499
|
+
return GatewayCommand::Cancel(rest.trim().into());
|
|
500
|
+
}
|
|
501
|
+
if trimmed == "/help" || trimmed == "/start" {
|
|
502
|
+
return GatewayCommand::Help;
|
|
503
|
+
}
|
|
504
|
+
GatewayCommand::Unknown(trimmed.into())
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
pub fn help_text() -> String {
|
|
508
|
+
[
|
|
509
|
+
"/ask <text>",
|
|
510
|
+
"/onboard",
|
|
511
|
+
"/onboard cancel",
|
|
512
|
+
"/provider list",
|
|
513
|
+
"/provider current",
|
|
514
|
+
"/provider use <id>",
|
|
515
|
+
"/provider bind <id>",
|
|
516
|
+
"/provider test [id]",
|
|
517
|
+
"/provider models [id]",
|
|
518
|
+
"/shell open [name]",
|
|
519
|
+
"/shell exec <cmd>",
|
|
520
|
+
"/shell close",
|
|
521
|
+
"/status",
|
|
522
|
+
"/approve <request-id>",
|
|
523
|
+
"/elevated start <minutes> <reason>",
|
|
524
|
+
"/elevated stop",
|
|
525
|
+
"/cancel <task-id>",
|
|
526
|
+
]
|
|
527
|
+
.join("\n")
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
fn ensure_provider_exists(store: &Store, provider_id: &str) -> ClawResult<()> {
|
|
531
|
+
if store
|
|
532
|
+
.get_provider(provider_id)
|
|
533
|
+
.map_err(store_error)?
|
|
534
|
+
.is_none()
|
|
535
|
+
{
|
|
536
|
+
return Err(ClawError::NotFound(format!(
|
|
537
|
+
"provider not found: {provider_id}"
|
|
538
|
+
)));
|
|
539
|
+
}
|
|
540
|
+
Ok(())
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
fn finalize_provider(draft: ProviderDraft) -> ClawResult<ProviderConfig> {
|
|
544
|
+
let label = draft
|
|
545
|
+
.label
|
|
546
|
+
.ok_or_else(|| ClawError::InvalidRequest("missing provider label".into()))?;
|
|
547
|
+
let kind = draft
|
|
548
|
+
.kind
|
|
549
|
+
.ok_or_else(|| ClawError::InvalidRequest("missing provider kind".into()))?;
|
|
550
|
+
let base_url = draft
|
|
551
|
+
.base_url
|
|
552
|
+
.ok_or_else(|| ClawError::InvalidRequest("missing provider base_url".into()))?;
|
|
553
|
+
let api_key = draft
|
|
554
|
+
.api_key
|
|
555
|
+
.ok_or_else(|| ClawError::InvalidRequest("missing provider api_key".into()))?;
|
|
556
|
+
let model = draft
|
|
557
|
+
.model
|
|
558
|
+
.ok_or_else(|| ClawError::InvalidRequest("missing provider model".into()))?;
|
|
559
|
+
|
|
560
|
+
Ok(ProviderConfig {
|
|
561
|
+
id: slugify(&label),
|
|
562
|
+
label,
|
|
563
|
+
kind,
|
|
564
|
+
base_url,
|
|
565
|
+
api_key,
|
|
566
|
+
model,
|
|
567
|
+
enabled: true,
|
|
568
|
+
extra_headers: vec![],
|
|
569
|
+
created_at: Utc::now(),
|
|
570
|
+
})
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
fn parse_provider_kind(raw: &str) -> ClawResult<ProviderKind> {
|
|
574
|
+
match raw.trim().to_ascii_lowercase().as_str() {
|
|
575
|
+
"openai-compatible" | "openai" => Ok(ProviderKind::OpenAiCompatible),
|
|
576
|
+
"custom-chat-completions" | "custom" => Ok(ProviderKind::CustomChatCompletions),
|
|
577
|
+
_ => Err(ClawError::InvalidRequest(
|
|
578
|
+
"provider kind must be `openai-compatible` or `custom-chat-completions`".into(),
|
|
579
|
+
)),
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
fn normalize_base_url(raw: &str) -> String {
|
|
584
|
+
raw.trim().trim_end_matches('/').to_string()
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
fn slugify(value: &str) -> String {
|
|
588
|
+
let mut slug = value
|
|
589
|
+
.chars()
|
|
590
|
+
.map(|ch| {
|
|
591
|
+
if ch.is_ascii_alphanumeric() {
|
|
592
|
+
ch.to_ascii_lowercase()
|
|
593
|
+
} else {
|
|
594
|
+
'-'
|
|
595
|
+
}
|
|
596
|
+
})
|
|
597
|
+
.collect::<String>();
|
|
598
|
+
while slug.contains("--") {
|
|
599
|
+
slug = slug.replace("--", "-");
|
|
600
|
+
}
|
|
601
|
+
slug.trim_matches('-').to_string()
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
fn store_error(err: anyhow::Error) -> ClawError {
|
|
605
|
+
ClawError::Backend(err.to_string())
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
fn text_response(text: &str) -> GatewayResponse {
|
|
609
|
+
GatewayResponse {
|
|
610
|
+
text: text.to_string(),
|
|
611
|
+
session: SessionDirective::Keep,
|
|
612
|
+
keyboard: None,
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
fn text_response_with_keyboard(text: &str, keyboard: Option<InlineKeyboard>) -> GatewayResponse {
|
|
617
|
+
GatewayResponse {
|
|
618
|
+
text: text.to_string(),
|
|
619
|
+
session: SessionDirective::Keep,
|
|
620
|
+
keyboard,
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
fn provider_kind_keyboard() -> InlineKeyboard {
|
|
625
|
+
InlineKeyboard {
|
|
626
|
+
rows: vec![
|
|
627
|
+
vec![
|
|
628
|
+
InlineButton {
|
|
629
|
+
text: "OpenAI-compatible".into(),
|
|
630
|
+
data: "openai-compatible".into(),
|
|
631
|
+
},
|
|
632
|
+
InlineButton {
|
|
633
|
+
text: "Custom Chat".into(),
|
|
634
|
+
data: "custom-chat-completions".into(),
|
|
635
|
+
},
|
|
636
|
+
],
|
|
637
|
+
vec![InlineButton {
|
|
638
|
+
text: "Cancel onboarding".into(),
|
|
639
|
+
data: "/onboard cancel".into(),
|
|
640
|
+
}],
|
|
641
|
+
],
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
fn cancel_keyboard() -> InlineKeyboard {
|
|
646
|
+
InlineKeyboard {
|
|
647
|
+
rows: vec![vec![InlineButton {
|
|
648
|
+
text: "Cancel onboarding".into(),
|
|
649
|
+
data: "/onboard cancel".into(),
|
|
650
|
+
}]],
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
#[cfg(test)]
|
|
655
|
+
mod tests {
|
|
656
|
+
use std::sync::Arc;
|
|
657
|
+
|
|
658
|
+
use anyhow::Result;
|
|
659
|
+
use hajimi_claw_agent::AgentRuntime;
|
|
660
|
+
use hajimi_claw_exec::{LocalExecutor, PlatformMode};
|
|
661
|
+
use hajimi_claw_policy::PolicyEngine;
|
|
662
|
+
use hajimi_claw_store::Store;
|
|
663
|
+
use hajimi_claw_tools::ToolRegistry;
|
|
664
|
+
use tempfile::tempdir;
|
|
665
|
+
|
|
666
|
+
use super::{
|
|
667
|
+
Gateway, GatewayCommand, GatewayRequest, InProcessGateway, SessionDirective,
|
|
668
|
+
parse_gateway_command,
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
#[test]
|
|
672
|
+
fn parses_provider_test_command() {
|
|
673
|
+
assert_eq!(
|
|
674
|
+
parse_gateway_command("/provider test moonshot"),
|
|
675
|
+
GatewayCommand::ProviderTest(Some("moonshot".into()))
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
#[tokio::test]
|
|
680
|
+
async fn gateway_opens_session_and_sets_channel_state() -> Result<()> {
|
|
681
|
+
let dir = tempdir()?;
|
|
682
|
+
let mut config = hajimi_claw_policy::PolicyConfig::default();
|
|
683
|
+
config.allowed_workdirs = vec![dir.path().to_path_buf(), std::env::current_dir()?];
|
|
684
|
+
config.admin_user_id = 1;
|
|
685
|
+
config.admin_chat_id = 2;
|
|
686
|
+
let policy = Arc::new(PolicyEngine::new(config));
|
|
687
|
+
let executor = Arc::new(LocalExecutor::new(
|
|
688
|
+
policy.clone(),
|
|
689
|
+
PlatformMode::WindowsSafe,
|
|
690
|
+
));
|
|
691
|
+
let tools = Arc::new(ToolRegistry::default(executor, policy.clone()));
|
|
692
|
+
let store = Arc::new(Store::open_in_memory()?);
|
|
693
|
+
let runtime = Arc::new(AgentRuntime::for_tests(
|
|
694
|
+
tools,
|
|
695
|
+
store.clone(),
|
|
696
|
+
policy.clone(),
|
|
697
|
+
));
|
|
698
|
+
let gateway = InProcessGateway::new(runtime, policy, store);
|
|
699
|
+
|
|
700
|
+
let response = gateway
|
|
701
|
+
.handle(GatewayRequest {
|
|
702
|
+
actor_user_id: 1,
|
|
703
|
+
actor_chat_id: 2,
|
|
704
|
+
raw_text: "/shell open ops".into(),
|
|
705
|
+
command: GatewayCommand::ShellOpen(Some("ops".into())),
|
|
706
|
+
current_session_id: None,
|
|
707
|
+
})
|
|
708
|
+
.await?;
|
|
709
|
+
assert!(matches!(response.session, SessionDirective::Set(_)));
|
|
710
|
+
Ok(())
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
#[tokio::test]
|
|
714
|
+
async fn onboarding_session_starts() -> Result<()> {
|
|
715
|
+
let dir = tempdir()?;
|
|
716
|
+
let mut config = hajimi_claw_policy::PolicyConfig::default();
|
|
717
|
+
config.allowed_workdirs = vec![dir.path().to_path_buf(), std::env::current_dir()?];
|
|
718
|
+
config.admin_user_id = 1;
|
|
719
|
+
config.admin_chat_id = 2;
|
|
720
|
+
let policy = Arc::new(PolicyEngine::new(config));
|
|
721
|
+
let executor = Arc::new(LocalExecutor::new(
|
|
722
|
+
policy.clone(),
|
|
723
|
+
PlatformMode::WindowsSafe,
|
|
724
|
+
));
|
|
725
|
+
let tools = Arc::new(ToolRegistry::default(executor, policy.clone()));
|
|
726
|
+
let store = Arc::new(Store::open_in_memory()?);
|
|
727
|
+
let runtime = Arc::new(AgentRuntime::for_tests(
|
|
728
|
+
tools,
|
|
729
|
+
store.clone(),
|
|
730
|
+
policy.clone(),
|
|
731
|
+
));
|
|
732
|
+
let gateway = InProcessGateway::new(runtime, policy, store.clone());
|
|
733
|
+
|
|
734
|
+
let response = gateway
|
|
735
|
+
.handle(GatewayRequest {
|
|
736
|
+
actor_user_id: 1,
|
|
737
|
+
actor_chat_id: 2,
|
|
738
|
+
raw_text: "/onboard".into(),
|
|
739
|
+
command: GatewayCommand::Onboard,
|
|
740
|
+
current_session_id: None,
|
|
741
|
+
})
|
|
742
|
+
.await?;
|
|
743
|
+
assert!(response.text.contains("Step 1/5"));
|
|
744
|
+
assert!(store.load_onboarding_session(2, 1)?.is_some());
|
|
745
|
+
Ok(())
|
|
746
|
+
}
|
|
747
|
+
}
|