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:
2026-04-30 11:39:52 +02:00
parent 2c585a3346
commit 4a5ca98c13
3 changed files with 342 additions and 1 deletions
+17
View File
@@ -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.
+25
View File
@@ -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
+300 -1
View File
@@ -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);