diff --git a/README.md b/README.md index 92a244f..13b393f 100644 --- a/README.md +++ b/README.md @@ -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 ``` +## 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 Public domain. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..6342c1b --- /dev/null +++ b/TODO.md @@ -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 diff --git a/main.c b/main.c index 5b968bb..1cbd9c8 100644 --- a/main.c +++ b/main.c @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -90,6 +91,220 @@ static void pm_nick_add(const char *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 "" */ static const char *current_channel(void) { @@ -608,9 +823,13 @@ static void handle_line(char *line) } else if (strcasecmp(target, nick) == 0) { wprintf(WL_MSG, "<%s> %s\n", sender, text); pm_nick_add(sender); + if (needs_translation(text)) + translate_async(text, WL_MSG); } else { int lvl = chan_to_level(target); wprintf(lvl, "<%s> %s\n", sender, text); + if (needs_translation(text)) + translate_async(text, lvl); } } } else if (strcmp(cmd, "NOTICE") == 0 && params) { @@ -1143,6 +1362,8 @@ int main(int argc, char *argv[]) memset(win_chans, 0, sizeof(win_chans)); + ai_config_load(); + logfp = fopen("irc.log", "a"); if (logfp) fprintf(logfp, "--- Session started ---\n"); @@ -1198,6 +1419,13 @@ int main(int argc, char *argv[]) char yank_buf[BUF_SIZE] = ""; size_t yank_len = 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 */ size_t tab_prefix_len = 0; /* length of prefix being completed */ 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; + /* 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; tv.tv_sec = 0; tv.tv_usec = esc_pending ? 50000 : 500000; @@ -1220,6 +1455,27 @@ int main(int argc, char *argv[]) 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) { esc_pending = 0; } @@ -1289,6 +1545,10 @@ int main(int argc, char *argv[]) if (esc_pending) { esc_pending = 0; + if (ch == '[') { + esc_bracket = 1; + continue; + } if (ch >= '1' && ch <= '9') { int lvl = ch - '1'; 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 */ if (ch != 0x09) tab_idx = -1; @@ -1310,8 +1599,18 @@ int main(int argc, char *argv[]) } else if (ch == '\r' || ch == '\n') { printf("\033[%d;1H\033[K", term_rows); 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); + } input_pos = 0; input_len = 0; printf("\033[%d;1H\033[32m>\033[0m ", term_rows);