Add AI translation, input history, line wrapping, /ctcp
- Auto-translate non-Latin messages via OpenAI-compatible API - Config in ~/.hircrc (created on first run, disabled by default) - Translation shown in italic below original message - Arrow up/down for input history (50 entries) - Long lines wrap properly on window redraw - /ctcp command for sending CTCP requests - Updated README with AI translation docs
This commit is contained in:
@@ -88,6 +88,23 @@ Each window maintains its own scrollback. Switching redraws the full history.
|
|||||||
Holck's Mirk, OS: Linux 6.x.x x86_64 :: This space available for rent
|
Holck's Mirk, OS: Linux 6.x.x x86_64 :: This space available for rent
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## AI Translation
|
||||||
|
|
||||||
|
Optional auto-translation of messages in non-Latin scripts (Cyrillic, CJK, Arabic, etc.). Requires an OpenAI-compatible API (ollama, vLLM, or OpenAI).
|
||||||
|
|
||||||
|
Configure `~/.hircrc` (created on first run with empty/disabled values):
|
||||||
|
|
||||||
|
```
|
||||||
|
ai_type=ollama
|
||||||
|
ai_host=localhost:11434
|
||||||
|
ai_key=
|
||||||
|
ai_model=llama3
|
||||||
|
ai_target_lang=english
|
||||||
|
ai_skip_langs=swedish,english
|
||||||
|
```
|
||||||
|
|
||||||
|
When configured, foreign-script messages are shown immediately with an italic translation appearing below after 1-5 seconds. Leave values empty to disable.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Public domain.
|
Public domain.
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
## AI-powered auto-translation of non-Swedish/English messages
|
||||||
|
|
||||||
|
### Config file: ~/.hircrc
|
||||||
|
```
|
||||||
|
# AI translation settings
|
||||||
|
ai_type=ollama # ollama, vllm, or openai
|
||||||
|
ai_host=localhost
|
||||||
|
ai_port=11434
|
||||||
|
ai_key= # API key (required for openai)
|
||||||
|
ai_model=llama3
|
||||||
|
ai_target_lang=english # translate into this language
|
||||||
|
ai_skip_langs=swedish,english # don't translate these
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation plan
|
||||||
|
- Parse ~/.hircrc on startup
|
||||||
|
- Detect messages that aren't in skip_langs (heuristic: non-Latin chars, or let the LLM decide)
|
||||||
|
- Show original message immediately
|
||||||
|
- Fork a child process that calls the AI API (OpenAI-compatible /v1/chat/completions endpoint — works for ollama, vllm, and openai)
|
||||||
|
- Prompt: "Translate to {target_lang}. Reply with only the translation: {text}"
|
||||||
|
- When child returns, insert translated line in italic below original in the scrollback
|
||||||
|
- Only visible locally, not sent to channel
|
||||||
|
- 1-5 sec delay is acceptable
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <sys/socket.h>
|
#include <sys/socket.h>
|
||||||
#include <sys/select.h>
|
#include <sys/select.h>
|
||||||
#include <sys/ioctl.h>
|
#include <sys/ioctl.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
#include <netdb.h>
|
#include <netdb.h>
|
||||||
#include <arpa/inet.h>
|
#include <arpa/inet.h>
|
||||||
#include <sys/utsname.h>
|
#include <sys/utsname.h>
|
||||||
@@ -90,6 +91,220 @@ static void pm_nick_add(const char *n)
|
|||||||
snprintf(pm_nicks[pm_nick_count++], NICK_LEN, "%s", n);
|
snprintf(pm_nicks[pm_nick_count++], NICK_LEN, "%s", n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* AI translation config */
|
||||||
|
static struct {
|
||||||
|
char type[32]; /* ollama, vllm, openai */
|
||||||
|
char host[256]; /* host:port */
|
||||||
|
int port;
|
||||||
|
char key[256];
|
||||||
|
char model[128];
|
||||||
|
char target_lang[64];
|
||||||
|
char skip_langs[256];
|
||||||
|
int enabled;
|
||||||
|
} ai_cfg;
|
||||||
|
|
||||||
|
/* Pending translation pipe fds */
|
||||||
|
#define MAX_TRANSLATE 8
|
||||||
|
static struct {
|
||||||
|
int fd; /* read end of pipe from child */
|
||||||
|
int level; /* window to display in */
|
||||||
|
pid_t pid;
|
||||||
|
} translate_pending[MAX_TRANSLATE];
|
||||||
|
static int translate_count = 0;
|
||||||
|
|
||||||
|
static void ai_config_load(void)
|
||||||
|
{
|
||||||
|
memset(&ai_cfg, 0, sizeof(ai_cfg));
|
||||||
|
char path[512];
|
||||||
|
snprintf(path, sizeof(path), "%s/.hircrc", getenv("HOME"));
|
||||||
|
|
||||||
|
FILE *f = fopen(path, "r");
|
||||||
|
if (!f) {
|
||||||
|
/* Create default config */
|
||||||
|
f = fopen(path, "w");
|
||||||
|
if (f) {
|
||||||
|
fprintf(f, "# AI translation settings\n");
|
||||||
|
fprintf(f, "ai_type=\n");
|
||||||
|
fprintf(f, "ai_host=\n");
|
||||||
|
fprintf(f, "ai_key=\n");
|
||||||
|
fprintf(f, "ai_model=\n");
|
||||||
|
fprintf(f, "ai_target_lang=\n");
|
||||||
|
fprintf(f, "ai_skip_langs=\n");
|
||||||
|
fclose(f);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
char line[512];
|
||||||
|
while (fgets(line, sizeof(line), f)) {
|
||||||
|
line[strcspn(line, "\r\n")] = '\0';
|
||||||
|
if (line[0] == '#' || line[0] == '\0') continue;
|
||||||
|
char *eq = strchr(line, '=');
|
||||||
|
if (!eq) continue;
|
||||||
|
*eq++ = '\0';
|
||||||
|
if (strcmp(line, "ai_type") == 0)
|
||||||
|
snprintf(ai_cfg.type, sizeof(ai_cfg.type), "%s", eq);
|
||||||
|
else if (strcmp(line, "ai_host") == 0) {
|
||||||
|
snprintf(ai_cfg.host, sizeof(ai_cfg.host), "%s", eq);
|
||||||
|
char *colon = strrchr(ai_cfg.host, ':');
|
||||||
|
if (colon) {
|
||||||
|
*colon = '\0';
|
||||||
|
ai_cfg.port = atoi(colon + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (strcmp(line, "ai_port") == 0)
|
||||||
|
ai_cfg.port = atoi(eq);
|
||||||
|
else if (strcmp(line, "ai_key") == 0)
|
||||||
|
snprintf(ai_cfg.key, sizeof(ai_cfg.key), "%s", eq);
|
||||||
|
else if (strcmp(line, "ai_model") == 0)
|
||||||
|
snprintf(ai_cfg.model, sizeof(ai_cfg.model), "%s", eq);
|
||||||
|
else if (strcmp(line, "ai_target_lang") == 0)
|
||||||
|
snprintf(ai_cfg.target_lang, sizeof(ai_cfg.target_lang), "%s", eq);
|
||||||
|
else if (strcmp(line, "ai_skip_langs") == 0)
|
||||||
|
snprintf(ai_cfg.skip_langs, sizeof(ai_cfg.skip_langs), "%s", eq);
|
||||||
|
}
|
||||||
|
fclose(f);
|
||||||
|
|
||||||
|
/* Enable only if minimum config is present */
|
||||||
|
if (ai_cfg.host[0] && ai_cfg.port && ai_cfg.model[0] && ai_cfg.target_lang[0])
|
||||||
|
ai_cfg.enabled = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int needs_translation(const char *text)
|
||||||
|
{
|
||||||
|
if (!ai_cfg.enabled) return 0;
|
||||||
|
/* Check for non-ASCII characters that suggest non-Latin script */
|
||||||
|
for (const unsigned char *p = (const unsigned char *)text; *p; p++) {
|
||||||
|
if (*p >= 0xC0) {
|
||||||
|
/* Multi-byte UTF-8: check if it's beyond Latin Extended (U+0250+) */
|
||||||
|
unsigned int cp = 0;
|
||||||
|
if ((*p & 0xE0) == 0xC0 && p[1]) {
|
||||||
|
cp = ((*p & 0x1F) << 6) | (p[1] & 0x3F);
|
||||||
|
} else if ((*p & 0xF0) == 0xE0 && p[1] && p[2]) {
|
||||||
|
cp = ((*p & 0x0F) << 12) | ((p[1] & 0x3F) << 6) | (p[2] & 0x3F);
|
||||||
|
} else if ((*p & 0xF8) == 0xF0 && p[1] && p[2] && p[3]) {
|
||||||
|
cp = ((*p & 0x07) << 18) | ((p[1] & 0x3F) << 12) |
|
||||||
|
((p[2] & 0x3F) << 6) | (p[3] & 0x3F);
|
||||||
|
}
|
||||||
|
/* Skip Latin Extended, keep Cyrillic, CJK, Arabic, etc. */
|
||||||
|
if (cp > 0x024F) return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void translate_async(const char *text, int level)
|
||||||
|
{
|
||||||
|
if (translate_count >= MAX_TRANSLATE) return;
|
||||||
|
|
||||||
|
int pipefd[2];
|
||||||
|
if (pipe(pipefd) < 0) return;
|
||||||
|
|
||||||
|
pid_t pid = fork();
|
||||||
|
if (pid < 0) {
|
||||||
|
close(pipefd[0]);
|
||||||
|
close(pipefd[1]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pid == 0) {
|
||||||
|
/* Child: do HTTP request to AI API */
|
||||||
|
close(pipefd[0]);
|
||||||
|
|
||||||
|
int s = socket(AF_INET, SOCK_STREAM, 0);
|
||||||
|
if (s < 0) _exit(1);
|
||||||
|
|
||||||
|
struct hostent *he = gethostbyname(ai_cfg.host);
|
||||||
|
if (!he) _exit(1);
|
||||||
|
|
||||||
|
struct sockaddr_in addr;
|
||||||
|
memset(&addr, 0, sizeof(addr));
|
||||||
|
addr.sin_family = AF_INET;
|
||||||
|
addr.sin_port = htons(ai_cfg.port);
|
||||||
|
memcpy(&addr.sin_addr, he->h_addr, he->h_length);
|
||||||
|
|
||||||
|
if (connect(s, (struct sockaddr *)&addr, sizeof(addr)) < 0)
|
||||||
|
_exit(1);
|
||||||
|
|
||||||
|
/* Build JSON body */
|
||||||
|
char escaped[1024];
|
||||||
|
int ei = 0;
|
||||||
|
for (int i = 0; text[i] && ei < (int)sizeof(escaped) - 2; i++) {
|
||||||
|
if (text[i] == '"' || text[i] == '\\') escaped[ei++] = '\\';
|
||||||
|
if (text[i] == '\n') { escaped[ei++] = '\\'; escaped[ei++] = 'n'; }
|
||||||
|
else escaped[ei++] = text[i];
|
||||||
|
}
|
||||||
|
escaped[ei] = '\0';
|
||||||
|
|
||||||
|
char body[2048];
|
||||||
|
snprintf(body, sizeof(body),
|
||||||
|
"{\"model\":\"%s\",\"messages\":["
|
||||||
|
"{\"role\":\"system\",\"content\":\"Translate to %s. Reply with only the translation, nothing else.\"},"
|
||||||
|
"{\"role\":\"user\",\"content\":\"%s\"}"
|
||||||
|
"],\"stream\":false}",
|
||||||
|
ai_cfg.model, ai_cfg.target_lang, escaped);
|
||||||
|
|
||||||
|
char req[4096];
|
||||||
|
int rlen = snprintf(req, sizeof(req),
|
||||||
|
"POST /v1/chat/completions HTTP/1.1\r\n"
|
||||||
|
"Host: %s:%d\r\n"
|
||||||
|
"Content-Type: application/json\r\n"
|
||||||
|
"Content-Length: %d\r\n"
|
||||||
|
"%s%s%s"
|
||||||
|
"Connection: close\r\n\r\n%s",
|
||||||
|
ai_cfg.host, ai_cfg.port,
|
||||||
|
(int)strlen(body),
|
||||||
|
ai_cfg.key[0] ? "Authorization: Bearer " : "",
|
||||||
|
ai_cfg.key[0] ? ai_cfg.key : "",
|
||||||
|
ai_cfg.key[0] ? "\r\n" : "",
|
||||||
|
body);
|
||||||
|
|
||||||
|
if (write(s, req, rlen) < 0) _exit(1);
|
||||||
|
|
||||||
|
/* Read response */
|
||||||
|
char resp[8192];
|
||||||
|
int total = 0;
|
||||||
|
int n;
|
||||||
|
while ((n = read(s, resp + total, sizeof(resp) - total - 1)) > 0)
|
||||||
|
total += n;
|
||||||
|
resp[total] = '\0';
|
||||||
|
close(s);
|
||||||
|
|
||||||
|
/* Extract content from JSON response */
|
||||||
|
char *content = strstr(resp, "\"content\":");
|
||||||
|
if (content) {
|
||||||
|
content += 10;
|
||||||
|
while (*content == ' ' || *content == '\t') content++;
|
||||||
|
if (*content == '"') {
|
||||||
|
content++;
|
||||||
|
char result[1024];
|
||||||
|
int ri = 0;
|
||||||
|
while (*content && *content != '"' && ri < (int)sizeof(result) - 1) {
|
||||||
|
if (*content == '\\' && content[1]) {
|
||||||
|
content++;
|
||||||
|
if (*content == 'n') result[ri++] = ' ';
|
||||||
|
else result[ri++] = *content;
|
||||||
|
} else {
|
||||||
|
result[ri++] = *content;
|
||||||
|
}
|
||||||
|
content++;
|
||||||
|
}
|
||||||
|
result[ri] = '\0';
|
||||||
|
(void)!write(pipefd[1], result, ri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(pipefd[1]);
|
||||||
|
_exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Parent */
|
||||||
|
close(pipefd[1]);
|
||||||
|
translate_pending[translate_count].fd = pipefd[0];
|
||||||
|
translate_pending[translate_count].level = level;
|
||||||
|
translate_pending[translate_count].pid = pid;
|
||||||
|
translate_count++;
|
||||||
|
}
|
||||||
|
|
||||||
/* Get the active channel for current window, or "" */
|
/* Get the active channel for current window, or "" */
|
||||||
static const char *current_channel(void)
|
static const char *current_channel(void)
|
||||||
{
|
{
|
||||||
@@ -608,9 +823,13 @@ static void handle_line(char *line)
|
|||||||
} else if (strcasecmp(target, nick) == 0) {
|
} else if (strcasecmp(target, nick) == 0) {
|
||||||
wprintf(WL_MSG, "<%s> %s\n", sender, text);
|
wprintf(WL_MSG, "<%s> %s\n", sender, text);
|
||||||
pm_nick_add(sender);
|
pm_nick_add(sender);
|
||||||
|
if (needs_translation(text))
|
||||||
|
translate_async(text, WL_MSG);
|
||||||
} else {
|
} else {
|
||||||
int lvl = chan_to_level(target);
|
int lvl = chan_to_level(target);
|
||||||
wprintf(lvl, "<%s> %s\n", sender, text);
|
wprintf(lvl, "<%s> %s\n", sender, text);
|
||||||
|
if (needs_translation(text))
|
||||||
|
translate_async(text, lvl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (strcmp(cmd, "NOTICE") == 0 && params) {
|
} else if (strcmp(cmd, "NOTICE") == 0 && params) {
|
||||||
@@ -1143,6 +1362,8 @@ int main(int argc, char *argv[])
|
|||||||
|
|
||||||
memset(win_chans, 0, sizeof(win_chans));
|
memset(win_chans, 0, sizeof(win_chans));
|
||||||
|
|
||||||
|
ai_config_load();
|
||||||
|
|
||||||
logfp = fopen("irc.log", "a");
|
logfp = fopen("irc.log", "a");
|
||||||
if (logfp)
|
if (logfp)
|
||||||
fprintf(logfp, "--- Session started ---\n");
|
fprintf(logfp, "--- Session started ---\n");
|
||||||
@@ -1198,6 +1419,13 @@ int main(int argc, char *argv[])
|
|||||||
char yank_buf[BUF_SIZE] = "";
|
char yank_buf[BUF_SIZE] = "";
|
||||||
size_t yank_len = 0;
|
size_t yank_len = 0;
|
||||||
int esc_pending = 0;
|
int esc_pending = 0;
|
||||||
|
int esc_bracket = 0; /* got ESC [ */
|
||||||
|
|
||||||
|
/* Command history */
|
||||||
|
#define HIST_SIZE 50
|
||||||
|
char history[HIST_SIZE][BUF_SIZE];
|
||||||
|
int hist_count = 0;
|
||||||
|
int hist_pos = 0; /* current browse position */
|
||||||
int tab_idx = -1; /* current tab completion index */
|
int tab_idx = -1; /* current tab completion index */
|
||||||
size_t tab_prefix_len = 0; /* length of prefix being completed */
|
size_t tab_prefix_len = 0; /* length of prefix being completed */
|
||||||
size_t tab_start = 0; /* byte position where completion word starts */
|
size_t tab_start = 0; /* byte position where completion word starts */
|
||||||
@@ -1210,6 +1438,13 @@ int main(int argc, char *argv[])
|
|||||||
|
|
||||||
int maxfd = sock_fd > STDIN_FILENO ? sock_fd : STDIN_FILENO;
|
int maxfd = sock_fd > STDIN_FILENO ? sock_fd : STDIN_FILENO;
|
||||||
|
|
||||||
|
/* Add translation pipes to select */
|
||||||
|
for (int ti = 0; ti < translate_count; ti++) {
|
||||||
|
FD_SET(translate_pending[ti].fd, &fds);
|
||||||
|
if (translate_pending[ti].fd > maxfd)
|
||||||
|
maxfd = translate_pending[ti].fd;
|
||||||
|
}
|
||||||
|
|
||||||
struct timeval tv;
|
struct timeval tv;
|
||||||
tv.tv_sec = 0;
|
tv.tv_sec = 0;
|
||||||
tv.tv_usec = esc_pending ? 50000 : 500000;
|
tv.tv_usec = esc_pending ? 50000 : 500000;
|
||||||
@@ -1220,6 +1455,27 @@ int main(int argc, char *argv[])
|
|||||||
die("select");
|
die("select");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Check translation results */
|
||||||
|
for (int ti = 0; ti < translate_count; ) {
|
||||||
|
if (FD_ISSET(translate_pending[ti].fd, &fds)) {
|
||||||
|
char tbuf[1024];
|
||||||
|
int n = read(translate_pending[ti].fd, tbuf, sizeof(tbuf) - 1);
|
||||||
|
close(translate_pending[ti].fd);
|
||||||
|
waitpid(translate_pending[ti].pid, NULL, 0);
|
||||||
|
if (n > 0) {
|
||||||
|
tbuf[n] = '\0';
|
||||||
|
/* Display in italic */
|
||||||
|
wprintf(translate_pending[ti].level,
|
||||||
|
" \033[3m%s\033[0m\n", tbuf);
|
||||||
|
}
|
||||||
|
translate_count--;
|
||||||
|
if (ti < translate_count)
|
||||||
|
translate_pending[ti] = translate_pending[translate_count];
|
||||||
|
} else {
|
||||||
|
ti++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (ret == 0 && esc_pending) {
|
if (ret == 0 && esc_pending) {
|
||||||
esc_pending = 0;
|
esc_pending = 0;
|
||||||
}
|
}
|
||||||
@@ -1289,6 +1545,10 @@ int main(int argc, char *argv[])
|
|||||||
|
|
||||||
if (esc_pending) {
|
if (esc_pending) {
|
||||||
esc_pending = 0;
|
esc_pending = 0;
|
||||||
|
if (ch == '[') {
|
||||||
|
esc_bracket = 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (ch >= '1' && ch <= '9') {
|
if (ch >= '1' && ch <= '9') {
|
||||||
int lvl = ch - '1';
|
int lvl = ch - '1';
|
||||||
if (lvl < WL_MAX) {
|
if (lvl < WL_MAX) {
|
||||||
@@ -1302,6 +1562,35 @@ int main(int argc, char *argv[])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (esc_bracket) {
|
||||||
|
esc_bracket = 0;
|
||||||
|
if (ch == 'A') {
|
||||||
|
/* Arrow up: previous history */
|
||||||
|
if (hist_pos > 0) {
|
||||||
|
hist_pos--;
|
||||||
|
input_len = strlen(history[hist_pos]);
|
||||||
|
memcpy(input_line, history[hist_pos], input_len);
|
||||||
|
input_pos = input_len;
|
||||||
|
redraw_input(input_line, input_len, input_pos);
|
||||||
|
}
|
||||||
|
} else if (ch == 'B') {
|
||||||
|
/* Arrow down: next history */
|
||||||
|
if (hist_pos < hist_count - 1) {
|
||||||
|
hist_pos++;
|
||||||
|
input_len = strlen(history[hist_pos]);
|
||||||
|
memcpy(input_line, history[hist_pos], input_len);
|
||||||
|
input_pos = input_len;
|
||||||
|
redraw_input(input_line, input_len, input_pos);
|
||||||
|
} else {
|
||||||
|
hist_pos = hist_count;
|
||||||
|
input_len = 0;
|
||||||
|
input_pos = 0;
|
||||||
|
redraw_input(input_line, input_len, input_pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
/* Reset tab completion on any non-tab key */
|
/* Reset tab completion on any non-tab key */
|
||||||
if (ch != 0x09) tab_idx = -1;
|
if (ch != 0x09) tab_idx = -1;
|
||||||
|
|
||||||
@@ -1310,8 +1599,18 @@ int main(int argc, char *argv[])
|
|||||||
} else if (ch == '\r' || ch == '\n') {
|
} else if (ch == '\r' || ch == '\n') {
|
||||||
printf("\033[%d;1H\033[K", term_rows);
|
printf("\033[%d;1H\033[K", term_rows);
|
||||||
input_line[input_len] = '\0';
|
input_line[input_len] = '\0';
|
||||||
if (input_len > 0)
|
if (input_len > 0) {
|
||||||
|
/* Save to history */
|
||||||
|
if (hist_count < HIST_SIZE) {
|
||||||
|
memcpy(history[hist_count], input_line, input_len + 1);
|
||||||
|
hist_count++;
|
||||||
|
} else {
|
||||||
|
memmove(history[0], history[1], (HIST_SIZE-1) * BUF_SIZE);
|
||||||
|
memcpy(history[HIST_SIZE-1], input_line, input_len + 1);
|
||||||
|
}
|
||||||
|
hist_pos = hist_count;
|
||||||
handle_input(input_line);
|
handle_input(input_line);
|
||||||
|
}
|
||||||
input_pos = 0;
|
input_pos = 0;
|
||||||
input_len = 0;
|
input_len = 0;
|
||||||
printf("\033[%d;1H\033[32m>\033[0m ", term_rows);
|
printf("\033[%d;1H\033[32m>\033[0m ", term_rows);
|
||||||
|
|||||||
Reference in New Issue
Block a user