Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ OpenAI, and only while you use ChatGPT.
go to send. Start talking
once `* Recording` shows. Your speech is transcribed by OpenAI's speech-to-text model, then
sent as your message to the chat model you picked. If the microphone picks up nothing, the
device says so and doesn't upload the recording.
device says so and doesn't upload the recording. Press **Esc** (T-Deck: swipe left, then Esc)
to stop a recording, a reply being written, or a reply being read aloud.
- **Back up**: choose **4) Storage** → **Back up** to copy your settings to the SD card.
- **Help**: type `help` to list all commands.

Expand Down Expand Up @@ -152,6 +153,11 @@ charges its battery over USB-C while SimplrSSH runs.
## Troubleshooting

- **WiFi won't connect**: make sure the network is 2.4 GHz and shows up in the scan.
- **SSH "Could not connect"**: the message says why. *Connection refused*: no SSH server on that
port, or the server is blocking the device (for example fail2ban after failed logins).
*No answer* / *no route*: wrong address, or the server isn't reachable from this WiFi (a
different network or a guest network that isolates devices). A host name also shows the
address it resolved to.
- **"HOST KEY HAS CHANGED"**: if you know the server was reinstalled, run
`hosts forget <host>` and connect again.
- **Keys not found**: the card must be FAT32 and the folder must be named `ssh_keys`.
Expand Down
1 change: 1 addition & 0 deletions main/board_tab5.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const char* const board::NAME = "Tab5";
const char* const board::HINT_TALK = "hold Ctrl";
const char* const board::HINT_SCROLL = "Sym+Up/Down or drag: scroll.";
const char* const board::HINT_HISTORY = "Up/Down: history.";
const char* const board::HINT_STOP = "Esc";
const char* const board::SCREEN_DESC = "a 5-inch tablet with a 1280x720 text screen";

// IO expander pins (M5Stack Tab5 pin map)
Expand Down
1 change: 1 addition & 0 deletions main/board_tdeck.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const char* const board::NAME = "T-Deck";
const char* const board::HINT_TALK = "hold the trackball";
const char* const board::HINT_SCROLL = "Trackball up/down or drag: scroll.";
const char* const board::HINT_HISTORY = "Left/right: history.";
const char* const board::HINT_STOP = "Esc (swipe left)";
const char* const board::SCREEN_DESC = "a tiny handheld with a 320x240 text screen";

static C3Keyboard* s_keyboard = NULL;
Expand Down
60 changes: 55 additions & 5 deletions main/chat_app.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
return key;
}
for (char c : key) {
if (!isalnum((unsigned char)c) && c != '-' && c != '_') {

Check warning on line 75 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Static analysis (cppcheck)

cppcheck useStlAlgorithm

style: Consider using std::any_of algorithm instead of a raw loop.
problem = "The key contains a character OpenAI keys don't use (only letters, digits, - and _).";
key.clear();
break;
Expand All @@ -88,10 +88,10 @@
void SSHTerminal::chat_load_settings()
{
nvs_handle_t h;
if (settings_nvs::open("chat", NVS_READONLY, &h) != ESP_OK) {

Check warning on line 91 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Security lint (flawfinder)

flawfinder

[2] (misc) open:Check when opening files - can an attacker redirect it (via symlinks), force the opening of special file type (e.g., device files), move things around to create a race condition, control its ancestors, or change its contents? (CWE-362).
return;
}
char model[64];

Check warning on line 94 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Security lint (flawfinder)

flawfinder

[2] (buffer) char:Statically-sized arrays can be improperly restricted, leading to potential overflows or other issues (CWE-119!/CWE-120). Perform bounds checking, use functions that limit length, or ensure that the size is larger than the maximum possible length.
size_t len = sizeof(model);
bool saved_model = nvs_get_str(h, "model", model, &len) == ESP_OK && model[0];
if (saved_model) {
Expand Down Expand Up @@ -119,7 +119,7 @@
void SSHTerminal::chat_save_settings()
{
nvs_handle_t h;
esp_err_t err = settings_nvs::open("chat", NVS_READWRITE, &h);

Check warning on line 122 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Security lint (flawfinder)

flawfinder

[2] (misc) open:Check when opening files - can an attacker redirect it (via symlinks), force the opening of special file type (e.g., device files), move things around to create a race condition, control its ancestors, or change its contents? (CWE-362).
if (err == ESP_OK) {
err = nvs_set_str(h, "model", chat_model.c_str());
if (err == ESP_OK) {
Expand Down Expand Up @@ -213,7 +213,7 @@

void SSHTerminal::chat_models_worker(void* param)
{
SSHTerminal* t = (SSHTerminal*)param;

Check warning on line 216 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Static analysis (cppcheck)

cppcheck cstyleCast

style: C-style pointer casting
std::string key, error;
openai::Models models;
esp_err_t err = vault::get(KEY_ID, key);
Expand Down Expand Up @@ -383,10 +383,11 @@
lv_obj_set_style_width(chat_view, 2, LV_PART_SCROLLBAR);
lv_obj_add_event_cb(chat_view, scroll_end_cb, LV_EVENT_SCROLL_END, this);
chat_follow = true;
lv_obj_add_flag(terminal_output, LV_OBJ_FLAG_HIDDEN);

Check warning on line 386 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / build

Compiler -Wdeprecated-declarations

'void lv_obj_add_flag(lv_obj_t*, lv_obj_flag_t)' is deprecated: Use the dedicated lv_obj_set_<flag>() setters instead, e.g. lv_obj_set_hidden(obj, true).

Check warning on line 386 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Compiler warnings

Compiler -Wdeprecated-declarations

'void lv_obj_add_flag(lv_obj_t*, lv_obj_flag_t)' is deprecated: Use the dedicated lv_obj_set_<flag>() setters instead, e.g. lv_obj_set_hidden(obj, true).

Check warning on line 386 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / build

Compiler -Wdeprecated-declarations

'void lv_obj_add_flag(lv_obj_t*, lv_obj_flag_t)' is deprecated: Use the dedicated lv_obj_set_<flag>() setters instead, e.g. lv_obj_set_hidden(obj, true).
lv_obj_move_foreground(side_panel);

chat_add_bubble(BUBBLE_INFO, "ChatGPT (" + chat_model + ") - type, or " + board::HINT_TALK + " to talk.\n"
chat_add_bubble(BUBBLE_INFO, "ChatGPT (" + chat_model + ") - type, or " + board::HINT_TALK + " to talk. " +
board::HINT_STOP + " stops a reply.\n"
"'clear' new chat | 'settings' | 'exit' menu");
for (const auto& m : chat_history) {
chat_add_bubble(m.from_user ? BUBBLE_USER : BUBBLE_AI, m.text);
Expand All @@ -397,6 +398,7 @@
void SSHTerminal::close_chat()
{
chat_recording = false;
chat_cancel = chat_busy; // Leaving stops a reply or speech in progress
chat_generation = chat_generation + 1; // A running worker stops touching the view
chat_active = false;
input_banner.clear();
Expand All @@ -404,10 +406,23 @@
lv_obj_delete(chat_view);
chat_view = NULL;
}
lv_obj_clear_flag(terminal_output, LV_OBJ_FLAG_HIDDEN);

Check warning on line 409 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / build

Compiler -Wdeprecated-declarations

'void lv_obj_remove_flag(lv_obj_t*, lv_obj_flag_t)' is deprecated: Use the dedicated lv_obj_set_<flag>() setters instead, e.g. lv_obj_set_hidden(obj, false).

Check warning on line 409 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Compiler warnings

Compiler -Wdeprecated-declarations

'void lv_obj_remove_flag(lv_obj_t*, lv_obj_flag_t)' is deprecated: Use the dedicated lv_obj_set_<flag>() setters instead, e.g. lv_obj_set_hidden(obj, false).

Check warning on line 409 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / build

Compiler -Wdeprecated-declarations

'void lv_obj_remove_flag(lv_obj_t*, lv_obj_flag_t)' is deprecated: Use the dedicated lv_obj_set_<flag>() setters instead, e.g. lv_obj_set_hidden(obj, false).
update_input_display();
}

// Esc in the chat (display lock held): stops recording, the reply being written, or speech
bool SSHTerminal::chat_stop()
{
if (!chat_busy) {
return false;
}
chat_cancel = true;
chat_recording = false;
input_banner = "Stopping...";
update_input_display();
return true;
}

void SSHTerminal::chat_clear()
{
if (chat_busy) {
Expand Down Expand Up @@ -439,7 +454,7 @@
wizard.menu = WizardStep::ChatMenu;
wizard_goto(WizardStep::ChatMenu);
} else if (chat_busy) {
chat_add_bubble(BUBBLE_INFO, "Still answering - one moment.");
chat_add_bubble(BUBBLE_INFO, "Still answering - " + std::string(board::HINT_STOP) + " stops it.");
} else if (!wifi_connected) {
chat_add_bubble(BUBBLE_INFO, "WiFi is off. Type 'exit', then pick WiFi.");
} else {
Expand All @@ -452,6 +467,7 @@
void SSHTerminal::chat_start_job(bool voice)
{
chat_busy = true;
chat_cancel = false;
chat_job_voice = voice;
if (xTaskCreate(chat_worker, "chat", WORKER_STACK, this, 4, NULL) != pdPASS) {
chat_busy = false;
Expand All @@ -465,7 +481,7 @@

void SSHTerminal::chat_worker(void* param)
{
SSHTerminal* t = (SSHTerminal*)param;

Check warning on line 484 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Static analysis (cppcheck)

cppcheck cstyleCast

style: C-style pointer casting
t->chat_job();
t->chat_ui_banner("");
t->chat_busy = false;
Expand Down Expand Up @@ -509,7 +525,7 @@
}

if (chat_job_voice) {
int16_t* pcm = (int16_t*)heap_caps_malloc(MAX_RECORD_SAMPLES * 2, MALLOC_CAP_SPIRAM);

Check warning on line 528 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Static analysis (cppcheck)

cppcheck cstyleCast

style: C-style pointer casting
if (!pcm || audio::mic_start() != ESP_OK) {
chat_recording = false;
heap_caps_free(pcm);
Expand All @@ -519,7 +535,7 @@
}
size_t n = 0;
int shown = -1;
while (chat_recording && n < MAX_RECORD_SAMPLES) {
while (chat_recording && !chat_cancel && n < MAX_RECORD_SAMPLES) {
n += audio::mic_read(pcm + n, std::min<size_t>(1600, MAX_RECORD_SAMPLES - n));
int secs = n / audio::MIC_RATE;
if (secs != shown) {
Expand All @@ -529,6 +545,12 @@
}
chat_recording = false;
audio::mic_stop();
if (chat_cancel) {
heap_caps_free(pcm);
chat_ui_bubble(gen, BUBBLE_INFO, "Stopped.");
vault::wipe(key);
return;
}

size_t skip = std::min(n, SKIP_SAMPLES);
int16_t* voice = pcm + skip;
Expand All @@ -551,7 +573,10 @@
}
}
heap_caps_free(pcm);
if (heard.empty()) {
if (heard.empty() || chat_cancel) {
if (chat_cancel) {
chat_ui_bubble(gen, BUBBLE_INFO, "Stopped.");
}
vault::wipe(key);
return;
}
Expand Down Expand Up @@ -583,6 +608,9 @@
std::string reply;
int64_t last_draw = 0;
esp_err_t err = openai::chat(key, model, context, [&](const std::string& piece) {
if (chat_cancel) {
return false;
}
if (reply.size() < 4000) {
reply += piece;
}
Expand All @@ -591,8 +619,26 @@
last_draw = now;
chat_ui_update(gen, label, reply);
}
return true;
}, error);

if (chat_cancel) {
// Stopped: keep what was written so far (it is part of the conversation), or nothing
chat_ui_update(gen, label, reply.empty() ? std::string("[stopped]") : reply + " [stopped]");
if (bsp_display_lock(1000)) {
if (!reply.empty()) {
chat_history.push_back({false, reply});
} else if (!chat_history.empty() && chat_history.back().from_user) {
chat_history.pop_back();
}
while (chat_history.size() > MAX_HISTORY) {
chat_history.erase(chat_history.begin());
}
bsp_display_unlock();
}
vault::wipe(key);
return;
}
if (err != ESP_OK || reply.empty()) {
chat_ui_update(gen, label, "! " + (error.empty() ? std::string("No reply") : error));
if (bsp_display_lock(1000)) {
Expand All @@ -618,9 +664,12 @@
uint8_t carry = 0;
bool has_carry = false;
openai::speak(key, tts_model, reply, [&](const uint8_t* data, size_t len) {
if (chat_cancel) {
return false; // Esc: silence now
}
// Keep whole 16-bit samples across chunk boundaries
if (has_carry && len > 0) {
uint8_t pair[2] = {carry, data[0]};

Check warning on line 672 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Static analysis (cppcheck)

cppcheck constVariable

style: Variable 'pair' can be declared as const array
audio::speaker_write(pair, 2);
data++;
len--;
Expand All @@ -632,6 +681,7 @@
len--;
}
audio::speaker_write(data, len);
return true;
}, error);
audio::speaker_stop();
}
Expand All @@ -650,7 +700,7 @@
return;
}
if (chat_busy) {
chat_add_bubble(BUBBLE_INFO, "Still answering - one moment.");
chat_add_bubble(BUBBLE_INFO, "Still answering - " + std::string(board::HINT_STOP) + " stops it.");
} else if (!wifi_connected) {
chat_add_bubble(BUBBLE_INFO, "WiFi is off. Type 'exit', then pick WiFi.");
} else {
Expand Down Expand Up @@ -771,9 +821,9 @@
std::string problem = "No openai.key file at the top of the SD card.";
for (const char* name : {"openai.key", "openai.key.txt", "openai.txt"}) {
std::string path = std::string(sdcard::MOUNT_POINT) + "/" + name;
FILE* f = fopen(path.c_str(), "rb");

Check warning on line 824 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Security lint (flawfinder)

flawfinder

[2] (misc) fopen:Check when opening files - can an attacker redirect it (via symlinks), force the opening of special file type (e.g., device files), move things around to create a race condition, control its ancestors, or change its contents? (CWE-362).
if (f) {
char buf[1024];

Check warning on line 826 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Security lint (flawfinder)

flawfinder

[2] (buffer) char:Statically-sized arrays can be improperly restricted, leading to potential overflows or other issues (CWE-119!/CWE-120). Perform bounds checking, use functions that limit length, or ensure that the size is larger than the maximum possible length.
size_t n = fread(buf, 1, sizeof(buf), f);
fclose(f);
std::string raw(buf, n);
Expand Down Expand Up @@ -841,7 +891,7 @@

case WizardStep::ChatModel: {
int n = !input.empty() && input.size() <= 2 && std::all_of(input.begin(), input.end(), ::isdigit)
? atoi(input.c_str()) : -1;

Check warning on line 894 in main/chat_app.cpp

View workflow job for this annotation

GitHub Actions / Security lint (flawfinder)

flawfinder

[2] (integer) atoi:Unless checked, the resulting number can exceed the expected range (CWE-190). If source untrusted, check both minimum and maximum, even if the input had no minus sign (large numbers can roll over into negative number; consider saving to an unsigned value if that is intended).
size_t shown = std::min(chat_models.size(), MODELS_SHOWN);
if (n >= 1 && (size_t)n <= shown) {
chat_model = chat_models[n - 1];
Expand Down
1 change: 1 addition & 0 deletions main/include/board.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ namespace board
extern const char* const HINT_TALK; // e.g. "hold the trackball"
extern const char* const HINT_SCROLL;
extern const char* const HINT_HISTORY;
extern const char* const HINT_STOP; // How to press Esc (stops a ChatGPT reply)
extern const char* const SCREEN_DESC; // For the ChatGPT system prompt
}

Expand Down
8 changes: 4 additions & 4 deletions main/include/openai_client.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,17 @@ namespace openai
};
esp_err_t list_models(const std::string& key, Models& out, std::string& error);

// Streams the reply: on_text is called with each new piece of text
// Streams the reply: on_text is called with each new piece of text; returning false stops it
esp_err_t chat(const std::string& key, const std::string& model, const std::vector<Message>& history,
const std::function<void(const std::string&)>& on_text, std::string& error);
const std::function<bool(const std::string&)>& on_text, std::string& error);

// 16-bit mono PCM at `rate` Hz -> text
esp_err_t transcribe(const std::string& key, const std::string& model, const int16_t* pcm, size_t samples,
uint32_t rate, std::string& text, std::string& error);

// Text -> 24 kHz 16-bit mono PCM, streamed to on_audio
// Text -> 24 kHz 16-bit mono PCM, streamed to on_audio; returning false stops it
esp_err_t speak(const std::string& key, const std::string& model, const std::string& text,
const std::function<void(const uint8_t*, size_t)>& on_audio, std::string& error);
const std::function<bool(const uint8_t*, size_t)>& on_audio, std::string& error);
}

#endif
2 changes: 2 additions & 0 deletions main/include/ssh_terminal.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ class SSHTerminal
bool chat_job_voice = false;
volatile bool chat_busy = false;
volatile bool chat_recording = false;
volatile bool chat_cancel = false; // Esc: the worker stops recording, answering or speaking
volatile uint32_t chat_generation = 0; // Bumped when the chat view closes
std::vector<std::string> chat_models; // From OpenAI, newest first (empty until asked)
bool chat_models_for_menu = false;
Expand Down Expand Up @@ -339,6 +340,7 @@ class SSHTerminal
void open_chat();
void close_chat();
void chat_clear();
bool chat_stop();
void chat_submit(const std::string& text);
void chat_start_job(bool voice);
void chat_job();
Expand Down
12 changes: 7 additions & 5 deletions main/openai_client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ static esp_err_t finish(esp_http_client_handle_t client, bool ok)
}

esp_err_t openai::chat(const std::string& key, const std::string& model, const std::vector<Message>& history,
const std::function<void(const std::string&)>& on_text, std::string& error)
const std::function<bool(const std::string&)>& on_text, std::string& error)
{
cJSON* root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "model", model.c_str());
Expand Down Expand Up @@ -169,8 +169,8 @@ esp_err_t openai::chat(const std::string& key, const std::string& model, const s
cJSON* event = cJSON_Parse(line.c_str() + 6);
cJSON* choice = cJSON_GetArrayItem(cJSON_GetObjectItem(event, "choices"), 0);
cJSON* content = cJSON_GetObjectItem(cJSON_GetObjectItem(choice, "delta"), "content");
if (cJSON_IsString(content) && content->valuestring[0]) {
on_text(content->valuestring);
if (cJSON_IsString(content) && content->valuestring[0] && !on_text(content->valuestring)) {
done = true; // Stopped by the user: the connection is closed below
}
cJSON_Delete(event);
}
Expand Down Expand Up @@ -228,7 +228,7 @@ esp_err_t openai::transcribe(const std::string& key, const std::string& model, c
}

esp_err_t openai::speak(const std::string& key, const std::string& model, const std::string& text,
const std::function<void(const uint8_t*, size_t)>& on_audio, std::string& error)
const std::function<bool(const uint8_t*, size_t)>& on_audio, std::string& error)
{
cJSON* root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "model", model.c_str());
Expand All @@ -253,7 +253,9 @@ esp_err_t openai::speak(const std::string& key, const std::string& model, const
uint8_t buf[1024];
int n;
while ((n = esp_http_client_read(client, (char*)buf, sizeof(buf) & ~1u)) > 0) {
on_audio(buf, n);
if (!on_audio(buf, n)) {
break; // Stopped by the user
}
}
ESP_LOGI(TAG, "Speech done");
return finish(client, true);
Expand Down
Loading
Loading