Compare commits

...

34 Commits

Author SHA1 Message Date
2c74c0a058 refactor(ui): scroll mechanism — derive paged, defer render, split edges
Four-level refactor of the chat-window scrolling state:

L1. Extract win_page_space() and unify the two-branch clamp at the end of
    win_page_down into a single last_page_start formula. Removes the
    duplicated `getmaxy(stdscr) - 4` magic across page_up / page_down /
    sub_page_up / sub_page_down and the off-by-one between the two clamp
    branches.

L2. Remove ProfLayout::paged sticky flag. Replace with win_is_paged(),
    derived from y_pos and the current pad height. Eliminates the entire
    bug class of "stuck paged=1" — there is no flag to drift, no manual
    reset to forget. The earlier non-CHAT bottom-reach reset, the
    WIN_SCROLL_REACHED_BOTTOM reset, the win_clear paged=1 line, and the
    cl_ev_send_ai_msg paged=0 workaround all collapse into "y_pos governs
    everything." cl_ev_send_ai_msg now calls win_move_to_end so the user's
    own message is visible after scroll-up.

L3. _win_printf no longer drops messages when paged. The line is appended
    to the buffer and to the curses pad unconditionally; it lands below
    the visible viewport so the user's reading position is unchanged. The
    unread badge still increments. Suppressed messages are no longer lost
    forever — page-down naturally reveals them.

L4. Replace the win_scroll_state_t enum (INNER / REACHED_TOP /
    REACHED_BOTTOM, conflating layout edge with DB exhaustion) with a
    ScrollEdges struct of two independent booleans:
      db_exhausted_above — a previous page-up DB fetch returned empty
      db_exhausted_below — same for page-down
    The flags are flipped to TRUE on DB_RESPONSE_EMPTY and cleared by a
    scroll in the opposite direction (so MAM late-delivery and /history
    switch can re-trigger fetches). Buffer-edge state is no longer
    encoded here — y_pos handles that.

Tests: stub_ui gets a no-op win_move_to_end. 545/545 unit tests pass.
2026-05-10 16:17:01 +03:00
c167f48714 fix(ui): reset paged flag at buffer bottom for non-chat windows
Some checks failed
CI Code / Code Coverage (pull_request) Failing after 10m34s
CI Code / Check spelling (pull_request) Failing after 11m11s
CI Code / Check coding style (pull_request) Failing after 11m42s
CI Code / Linux (ubuntu) (pull_request) Failing after 12m25s
CI Code / Linux (debian) (pull_request) Failing after 13m2s
CI Code / Linux (arch) (pull_request) Failing after 13m33s
2026-05-10 15:09:32 +03:00
010b2062a4 fix(ai): reset paged flag to display user message
Some checks failed
CI Code / Linux (arch) (pull_request) Failing after 5m11s
CI Code / Code Coverage (pull_request) Failing after 11m1s
CI Code / Check spelling (pull_request) Failing after 11m4s
CI Code / Check coding style (pull_request) Failing after 11m11s
CI Code / Linux (ubuntu) (pull_request) Failing after 11m16s
CI Code / Linux (debian) (pull_request) Failing after 11m18s
When the user scrolls up to view history, the paged flag is set to 1.
This suppresses new messages in _win_printf(). Reset paged and
unread_msg flags before printing to ensure visibility.
2026-05-10 10:35:57 +00:00
ecb07fd00c fix(ai): improve API error parsing and fallback
Some checks failed
CI Code / Check spelling (pull_request) Successful in 20s
CI Code / Check coding style (pull_request) Successful in 35s
CI Code / Code Coverage (pull_request) Successful in 2m32s
CI Code / Linux (debian) (pull_request) Failing after 3m37s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m48s
CI Code / Linux (arch) (pull_request) Failing after 4m40s
Add early return for empty responses and fallback to console output
when the AI window is closed or invalid. Implement JSON error
parsing to extract readable messages from API failures, improving
debugging and user feedback.
2026-05-09 21:06:09 +00:00
a898cb212d feat(ai): implement robust JSON model parsing
Some checks failed
CI Code / Check coding style (pull_request) Successful in 46s
CI Code / Linux (arch) (pull_request) Failing after 9m46s
CI Code / Code Coverage (pull_request) Failing after 14m8s
CI Code / Check spelling (pull_request) Failing after 14m16s
CI Code / Linux (ubuntu) (pull_request) Failing after 14m24s
CI Code / Linux (debian) (pull_request) Failing after 14m33s
Rewrote internal JSON parsing to correctly handle whitespace, escaped
quotes, and strict field context extraction. This prevents incorrect
field names or provider names from being added to the model list.

Added `ai_parse_models_from_json` public API to facilitate testing.

Changed `/ai models` default behavior to always fetch fresh models.
Replaced `--refresh` flag with `--cached` to display local cache.

Added comprehensive unit tests covering OpenAI and Perplexity formats,
array format, empty/null JSON, escaped quotes, and whitespace handling.
2026-05-09 13:56:59 +00:00
07d267b5bc fix(ai): protect window validation with mutex
Some checks failed
CI Code / Check coding style (pull_request) Successful in 53s
CI Code / Check spelling (pull_request) Successful in 19s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m49s
CI Code / Linux (arch) (pull_request) Failing after 4m31s
CI Code / Code Coverage (pull_request) Successful in 2m23s
CI Code / Linux (debian) (pull_request) Failing after 5m58s
The existence check accesses shared state without synchronization,
which can cause race conditions when called from background threads.
Explicitly locking the mutex ensures safe access during validation.
2026-05-09 13:20:48 +00:00
acae543057 feat(ai): add model caching, settings, and commands
Some checks failed
CI Code / Check spelling (pull_request) Successful in 23s
CI Code / Check coding style (pull_request) Successful in 35s
CI Code / Code Coverage (pull_request) Successful in 2m28s
CI Code / Linux (debian) (pull_request) Failing after 6m18s
CI Code / Linux (arch) (pull_request) Failing after 8m46s
CI Code / Linux (ubuntu) (pull_request) Failing after 11m4s
- Introduce model caching with persistence to preferences
- Add provider default model and custom settings management
- Implement `/ai switch`, `/ai models`, and improve `/ai start`
- Add model name autocomplete for chat commands
- Update command definitions and help text
- Add unit tests for new functionality
2026-05-09 13:15:58 +00:00
731b55fa19 Merge branch 'master' into feat/ai
Some checks failed
CI Code / Check spelling (pull_request) Successful in 18s
CI Code / Check coding style (pull_request) Successful in 33s
CI Code / Code Coverage (pull_request) Successful in 2m29s
CI Code / Linux (debian) (pull_request) Failing after 3m35s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m46s
CI Code / Linux (arch) (pull_request) Failing after 4m32s
2026-05-06 13:57:13 +00:00
e229ed1281 fix(ai): return CURL_WRITEFUNC_ERROR on oversized response
Some checks failed
CI Code / Check coding style (pull_request) Successful in 1m4s
CI Code / Check spelling (pull_request) Successful in 1m10s
CI Code / Code Coverage (pull_request) Successful in 2m51s
CI Code / Linux (arch) (pull_request) Failing after 3m39s
CI Code / Linux (debian) (pull_request) Failing after 5m13s
CI Code / Linux (ubuntu) (pull_request) Failing after 5m21s
Return CURL_WRITEFUNC_ERROR instead of realsize when the response
exceeds 10MB. This causes curl to abort the transfer immediately
instead of continuing to stream data.
2026-05-01 19:15:46 +00:00
f4221e27ac fix(ai): fix leak from ai_get_provider_key
Some checks failed
CI Code / Check spelling (pull_request) Successful in 1m7s
CI Code / Check coding style (pull_request) Successful in 1m54s
CI Code / Linux (debian) (pull_request) Failing after 3m0s
CI Code / Linux (arch) (pull_request) Failing after 3m7s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m8s
CI Code / Code Coverage (pull_request) Successful in 2m53s
2026-05-01 18:50:21 +00:00
cdcc45b7c6 ref(pref): add newline at the end of the file 2026-05-01 18:47:46 +00:00
f133d81a05 fix(ai): use atomic operations for refcounting
Some checks failed
CI Code / Check coding style (pull_request) Successful in 1m8s
CI Code / Check spelling (pull_request) Successful in 1m7s
CI Code / Linux (debian) (pull_request) Failing after 2m58s
CI Code / Linux (arch) (pull_request) Failing after 3m7s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m8s
CI Code / Code Coverage (pull_request) Successful in 2m54s
Replace plain ref_count++/-- with g_atomic_int_inc and
g_atomic_int_dec_and_test for both AIProvider and AISession refcounts.
Prevents data races when ref/unref is called from worker threads.
2026-05-01 18:24:57 +00:00
a16237d16b fix(ai): convert \n escape sequences to actual newlines in LLM responses
Some checks failed
CI Code / Check spelling (pull_request) Successful in 51s
CI Code / Check coding style (pull_request) Successful in 1m7s
CI Code / Linux (debian) (pull_request) Failing after 2m56s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m5s
CI Code / Linux (arch) (pull_request) Failing after 3m5s
CI Code / Code Coverage (pull_request) Successful in 2m59s
2026-05-01 18:21:47 +00:00
cfe6ea46e2 feat(ai): disable request storage via store:false parameter
Some checks failed
CI Code / Check coding style (pull_request) Successful in 1m22s
CI Code / Linux (arch) (pull_request) Failing after 3m12s
CI Code / Check spelling (pull_request) Successful in 1m50s
CI Code / Linux (debian) (pull_request) Failing after 2m52s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m4s
CI Code / Code Coverage (pull_request) Successful in 2m49s
Add store:false to AI API requests to prevent OpenAI, Perplexity, and
other providers from storing conversation data or using it for training.

Both the OpenAI Responses API and Perplexity API support the store
parameter to control whether requests are persisted. Setting it to false
ensures conversations remain private and are not retained by the provider.

Privacy:
- Requests are not stored by AI providers
- Conversations are not used for model training
- Aligns with CProof's privacy-first design
2026-05-01 17:05:44 +00:00
2fc7f3d672 fix(ai): validate aiwin pointer to prevent UAF when window closed
Some checks failed
CI Code / Check spelling (pull_request) Successful in 53s
CI Code / Check coding style (pull_request) Successful in 1m11s
CI Code / Linux (debian) (pull_request) Failing after 2m58s
CI Code / Linux (arch) (pull_request) Failing after 3m11s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m10s
CI Code / Code Coverage (pull_request) Successful in 2m46s
Prevent use-after-free in the AI request worker thread when the user
closes the AI window during the 60-second HTTP request timeout. Without
this fix, the worker may dereference a dangling pointer and crash or
exhibit undefined behavior.

The _ai_request_thread function in ai_client.c previously cast user_data
to ProfAiWin* and used it directly without verifying the window was still
alive. Added wins_ai_exists() to iterate through all AI windows and check
if the pointer is still valid before use.

Safety:
- wins_ai_exists() iterates all AI windows (handles multiple AI windows)
- Worker skips UI update if window was freed, just cleans up
- Logs warning when dangling pointer is detected
2026-05-01 17:03:47 +00:00
fe0a46da58 refactor(ai): add UI display helpers
Some checks failed
CI Code / Check spelling (pull_request) Successful in 54s
CI Code / Check coding style (pull_request) Successful in 2m4s
CI Code / Linux (debian) (pull_request) Failing after 2m56s
CI Code / Linux (arch) (pull_request) Failing after 3m10s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m9s
CI Code / Code Coverage (pull_request) Successful in 2m55s
Add static _aiwin_display_error() and
_aiwin_display_response() helpers to reduce mutex repetition and
log warnings when aiwin is NULL or invalid.
2026-05-01 16:36:07 +00:00
cead417e78 refactor(ai): remove redundant callback layer from AI client
Some checks failed
CI Code / Check spelling (pull_request) Successful in 53s
CI Code / Check coding style (pull_request) Successful in 1m8s
CI Code / Linux (debian) (pull_request) Failing after 2m55s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m4s
CI Code / Linux (arch) (pull_request) Failing after 3m7s
CI Code / Code Coverage (pull_request) Successful in 2m42s
The ai_response_cb and ai_error_cb parameters were always passed the
same functions (aiwin_display_response and aiwin_display_error),
making the callback indirection redundant and complicating the call
chain.

Remove ai_callback_data_t, _ai_callback_invoke(), and
_ai_invoke_callback(). Simplify _ai_request_thread and ai_send_prompt
to directly call aiwin_display_error() and aiwin_display_response()
when user_data is a valid ProfAiWin*.
2026-05-01 13:34:00 +00:00
00f11eb704 fix(ai): guard NULL search_str and update tests for new cycling behavior
Some checks failed
CI Code / Check spelling (pull_request) Successful in 21s
CI Code / Check coding style (pull_request) Successful in 37s
CI Code / Linux (debian) (pull_request) Failing after 2m56s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m7s
CI Code / Code Coverage (pull_request) Successful in 2m55s
CI Code / Linux (arch) (pull_request) Failing after 3m45s
Update ai_providers_find() to handle NULL search_str safely by treating it
as an empty string, preventing a segfault in strdup() within autocomplete_complete().

Rename test_ai_providers_find_case_sensitive to test_ai_providers_find_case_insensitive
to reflect the new case-insensitive matching contract. Update all affected tests to
expect cycling behavior (NULL/empty returns first provider) and use auto_gchar for
automatic memory management.

Use `gchar` instead of `char` in ai_providers_find for consistency
2026-05-01 13:08:28 +00:00
4913a3d5a4 ref(ai): remove inaccurate logging 2026-05-01 12:56:57 +00:00
634fb7d7eb fix(ai): standardize lock handling in _ai_invoke_callback
Some checks failed
CI Code / Check spelling (pull_request) Successful in 20s
CI Code / Check coding style (pull_request) Successful in 34s
CI Code / Code Coverage (pull_request) Failing after 1m6s
CI Code / Linux (debian) (pull_request) Failing after 2m59s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m11s
CI Code / Linux (arch) (pull_request) Failing after 3m19s
Take the global lock mutex before invoking all callbacks (success
and error) to ensure thread-safety. Previously some paths took the
lock and others did not, creating asymmetric behavior that could
lead to data races when callbacks access UI state.
2026-05-01 11:35:09 +00:00
461c0c32dd refactor(ai): use stateful autocomplete for provider name matching
Some checks failed
CI Code / Check spelling (pull_request) Successful in 57s
CI Code / Check coding style (pull_request) Successful in 1m4s
CI Code / Code Coverage (pull_request) Failing after 1m7s
CI Code / Linux (debian) (pull_request) Failing after 2m58s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m9s
CI Code / Linux (arch) (pull_request) Failing after 3m47s
Replace the manual GHashTable iteration in ai_providers_find() with the
existing stateful autocomplete system. Provider names are now kept in
sync via autocomplete_add/remove during ai_add_provider/ai_remove_provider,
and ai_providers_find delegates to autocomplete_complete() for deterministic
tab-completion cycling through sorted results.

This eliminates manual GList allocation, iteration, and cleanup, while
providing consistent forward/backward cycling behavior shared by all
autocomplete callers.
2026-04-30 23:16:12 +00:00
002a6ed15b refactor(ai): flatten autocomplete into sequential prefix-matching chain
Some checks failed
CI Code / Check spelling (pull_request) Successful in 1m5s
CI Code / Check coding style (pull_request) Successful in 2m7s
CI Code / Linux (arch) (pull_request) Failing after 3m9s
CI Code / Code Coverage (pull_request) Successful in 2m54s
CI Code / Linux (debian) (pull_request) Successful in 4m28s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m41s
Replace the deeply nested conditional tree in _ai_autocomplete() with a
flat sequential chain of autocomplete_param_with_*() calls. Each call
tries a specific command prefix (e.g., "/ai set provider", "/ai start")
and returns immediately on match — a common, reliable pattern for
command-line tab completion.

The previous design nested provider-name autocomplete inside
g_strv_length() and g_strcmp0() checks, meaning /ai<tab> and /ai <tab>
would never reach the subcommand matcher. The new flat structure ensures
every prefix is tried in order, with the most specific prefixes first
and the generic /ai subcommand fallback last.

This pattern — sequential prefix matching with early return — is the
standard approach for autocomplete dispatch: each handler owns its
prefix, no shared state or argument parsing is needed, and adding a
new subcommand is a single append to the chain.
2026-04-30 23:10:14 +00:00
dc75f16221 fix(ai): move generic subcommand autocomplete outside nested conditionals
Some checks failed
CI Code / Check spelling (pull_request) Successful in 22s
CI Code / Check coding style (pull_request) Successful in 1m8s
CI Code / Linux (arch) (pull_request) Failing after 3m7s
CI Code / Code Coverage (pull_request) Successful in 2m58s
CI Code / Linux (debian) (pull_request) Successful in 4m22s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m35s
The generic `/ai <subcommand>` tab-completion was nested inside conditional
blocks that prevented it from firing in common cases — for example,
`/ai<tab>` or `/ai <tab>` would never match subcommands because the
`autocomplete_param_with_ac()` call was guarded by argument count checks
and nested inside the explicit handler branches.

Move the fallback subcommand autocomplete to execute unconditionally after
all explicit handler branches, ensuring `/ai <tab>` always lists available
subcommands regardless of argument count or which subcommand was typed.

Also move the `/ai set` subcommand fallback outside the inner else-block
so it runs whenever args[0] is "set", not only when num_args >= 2.
2026-04-30 22:51:07 +00:00
da0bf43d73 fix(ai): join multi-word arguments in cmd_ai_correct
Some checks failed
CI Code / Check spelling (pull_request) Successful in 24s
CI Code / Check coding style (pull_request) Successful in 37s
CI Code / Linux (arch) (pull_request) Failing after 3m7s
CI Code / Code Coverage (pull_request) Successful in 2m57s
CI Code / Linux (debian) (pull_request) Successful in 4m24s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m37s
Use g_strjoinv to join all arguments from args[1] onwards into a
single prompt string. Previously only args[1] was used, truncating
multi-word prompts like '/ai correct hello world' to just 'hello'
2026-04-30 19:24:39 +00:00
9e1f9b666e fix(ai): fix provider_name leak and const-cast in cmd_ai_start
Some checks failed
CI Code / Check spelling (pull_request) Successful in 55s
CI Code / Linux (arch) (pull_request) Failing after 3m25s
CI Code / Check coding style (pull_request) Successful in 1m38s
CI Code / Linux (debian) (pull_request) Successful in 4m42s
CI Code / Code Coverage (pull_request) Successful in 2m50s
CI Code / Linux (ubuntu) (pull_request) Successful in 8m12s
Use auto_gchar temporary for g_strndup result in cmd_ai_start to
prevent memory leak. Fix const-cast warning by using a proper
non-const temporary variable.
2026-04-30 19:14:49 +00:00
93ad7379e2 fix(ai): align ai_list_providers with documented contract
All checks were successful
CI Code / Check coding style (pull_request) Successful in 48s
CI Code / Check spelling (pull_request) Successful in 33s
CI Code / Linux (debian) (pull_request) Successful in 4m47s
CI Code / Linux (arch) (pull_request) Successful in 5m29s
CI Code / Code Coverage (pull_request) Successful in 2m45s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m29s
Remove ai_provider_ref() from ai_list_providers() so the returned
providers do not need to be unref'd by the caller. This matches the
header docstring which states "caller must not free the list or
providers". Also update all callers (cmd_ai_providers, tests) to
remove redundant ai_provider_unref() calls.
2026-04-30 19:09:46 +00:00
caa0b3ccba fix(ai): remove redundant g_strdup on api_key assignment
Some checks failed
CI Code / Check coding style (pull_request) Successful in 37s
CI Code / Check spelling (pull_request) Successful in 21s
CI Code / Linux (arch) (pull_request) Failing after 3m12s
CI Code / Code Coverage (pull_request) Successful in 2m47s
CI Code / Linux (debian) (pull_request) Successful in 4m21s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m53s
ai_get_provider_key() already returns a freshly g_strdup'd copy of
the API key. The outer g_strdup in ai_session_create() created an
unnecessary second copy, leaking the original pointer.
2026-04-30 19:06:49 +00:00
c9a5239117 fix(ai): remove API key from debug log
Some checks failed
CI Code / Check spelling (pull_request) Successful in 22s
CI Code / Check coding style (pull_request) Successful in 37s
CI Code / Linux (debian) (pull_request) Failing after 2m59s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m10s
CI Code / Code Coverage (pull_request) Successful in 2m58s
CI Code / Linux (arch) (pull_request) Failing after 3m59s
Remove the log_debug line in ai_client.c that leaked the first 10
characters of the API key into logs. This is also contained a memory leak (g_strndup
return value was never freed).
2026-04-30 19:05:33 +00:00
25e0459979 fix(ai): persist API keys to disk after set/remove operations
API keys set via /ai set token were only stored in memory and lost
on client restart. prefs_ai_set_token() and prefs_ai_remove_token()
mutated the in-memory prefs but never called _save_prefs(), unlike
other preference setters in the same file.

Both functions now call _save_prefs() after mutating the keyfile,
ensuring API keys persist across client restarts as advertised.
2026-04-30 19:01:10 +00:00
bccd3ecded fix(ai): remove duplicate assistant response in the message history
Some checks failed
CI Code / Check coding style (pull_request) Successful in 44s
CI Code / Linux (debian) (pull_request) Failing after 2m59s
CI Code / Linux (arch) (pull_request) Failing after 3m7s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m7s
CI Code / Check spelling (pull_request) Successful in 17s
CI Code / Code Coverage (pull_request) Successful in 2m46s
2026-04-30 18:05:51 +00:00
cff05ca802 ref(ai): move unfitting functions out of window.c; create aiwin.c
Some checks failed
CI Code / Check coding style (pull_request) Failing after 34s
CI Code / Check spelling (pull_request) Successful in 22s
CI Code / Linux (arch) (pull_request) Failing after 2m57s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m16s
CI Code / Linux (debian) (pull_request) Failing after 6m16s
CI Code / Code Coverage (pull_request) Successful in 7m12s
2026-04-30 18:01:24 +00:00
9c8ad57b59 ref(ai): add stub, fix includes, move aiwin_send_message location
Some checks failed
CI Code / Check spelling (pull_request) Successful in 22s
CI Code / Check coding style (pull_request) Successful in 34s
CI Code / Linux (debian) (pull_request) Failing after 2m57s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m9s
CI Code / Linux (arch) (pull_request) Failing after 3m13s
CI Code / Code Coverage (pull_request) Successful in 2m54s
2026-04-30 17:41:36 +00:00
1f1770bd58 fix(ai): set correct links in tests, fix headers 2026-04-30 17:02:54 +00:00
e43e8378b0 feat(ai): add AI client with multi-provider support and UI
Some checks failed
CI Code / Check coding style (pull_request) Successful in 47s
CI Code / Check spelling (pull_request) Successful in 59s
CI Code / Code Coverage (pull_request) Failing after 1m8s
CI Code / Linux (debian) (pull_request) Failing after 2m28s
CI Code / Linux (ubuntu) (pull_request) Failing after 2m41s
CI Code / Linux (arch) (pull_request) Failing after 3m15s
Add an AI client module that integrates with OpenAI-compatible API
providers (OpenAI, Perplexity, and custom providers) to provide
AI-assisted responses within the profanity client.

The implementation includes:

- src/ai/ai_client.c/h: Core AI client with provider management,
  session handling, and async HTTP request handling via libcurl.
  Supports per-provider API keys stored in preferences, reference-
  counted sessions, and conversation history tracking.

- src/ui/window.c/window_list.c: New AI window type (ProfAiWin) for
  displaying AI conversations, with response streaming and error
  display capabilities.

- Command integration: New `/ai` command (cmd_defs.c, cmd_funcs.c)
  for creating sessions, sending prompts, and managing providers.
  Provider autocomplete support in cmd_ac.c.

- Preferences integration: API keys for providers are persisted in
  the preferences system (config/preferences.c).

- Unit tests: 472 lines of comprehensive tests covering provider
  management, session lifecycle, JSON escaping, and autocomplete
  (tests/unittests/test_ai_client.c).

Architecture decisions:
- Asynchronous design: HTTP requests run on a separate thread to
  avoid blocking the main UI loop. Callbacks are invoked on the main
  thread via direct function call (profanity uses ncurses, not GLib
  main loop).
- Reference counting: Both AIProvider and AISession use ref counting
  for safe shared ownership.
- Response size limit: 10MB cap on HTTP responses to prevent OOM.
2026-04-29 19:23:33 +00:00
26 changed files with 4051 additions and 73 deletions

View File

@@ -37,6 +37,7 @@ core_sources = \
src/ui/window_list.c src/ui/window_list.h \
src/ui/rosterwin.c src/ui/occupantswin.c \
src/ui/buffer.c src/ui/buffer.h \
src/ui/aiwin.c \
src/ui/chatwin.c \
src/ui/mucwin.c \
src/ui/privwin.c \
@@ -78,7 +79,8 @@ core_sources = \
src/plugins/themes.c src/plugins/themes.h \
src/plugins/settings.c src/plugins/settings.h \
src/plugins/disco.c src/plugins/disco.h \
src/ui/tray.h src/ui/tray.c
src/ui/tray.h src/ui/tray.c \
src/ai/ai_client.h src/ai/ai_client.c
unittest_sources = \
src/xmpp/contact.c src/xmpp/contact.h src/common.c \
@@ -89,6 +91,7 @@ unittest_sources = \
src/xmpp/chat_state.h src/xmpp/chat_state.c \
src/xmpp/roster_list.c src/xmpp/roster_list.h \
src/xmpp/xmpp.h src/xmpp/form.c \
src/ai/ai_client.h src/ai/ai_client.c \
src/ui/ui.h \
src/otr/otr.h \
src/pgp/gpg.h \
@@ -138,6 +141,7 @@ unittest_sources = \
tests/unittests/xmpp/stub_message.c \
tests/unittests/ui/stub_ui.c tests/unittests/ui/stub_ui.h \
tests/unittests/ui/stub_vcardwin.c \
tests/unittests/ui/stub_ai.c \
tests/unittests/log/stub_log.c \
tests/unittests/chatlog/stub_chatlog.c \
tests/unittests/database/stub_database.c \
@@ -174,6 +178,7 @@ unittest_sources = \
tests/unittests/test_callbacks.c tests/unittests/test_callbacks.h \
tests/unittests/test_plugins_disco.c tests/unittests/test_plugins_disco.h \
tests/unittests/test_forced_encryption.c tests/unittests/test_forced_encryption.h \
tests/unittests/test_ai_client.c tests/unittests/test_ai_client.h \
tests/unittests/test_database_export.c tests/unittests/test_database_export.h \
tests/unittests/test_database_stress.c tests/unittests/test_database_stress.h \
tests/unittests/unittests.c

1482
src/ai/ai_client.c Normal file

File diff suppressed because it is too large Load Diff

257
src/ai/ai_client.h Normal file
View File

@@ -0,0 +1,257 @@
#ifndef AI_CLIENT_H
#define AI_CLIENT_H
#include <glib.h>
/**
* @brief AI message structure for conversation history.
*/
typedef struct ai_message_t
{
gchar* role; /* "user" or "assistant" */
gchar* content; /* Message content */
} AIMessage;
/**
* @brief AI provider configuration.
*/
typedef struct ai_provider_t
{
gchar* name; /* Provider name (e.g., "openai", "perplexity") */
gchar* api_url; /* API endpoint URL */
gchar* org_id; /* Optional organization ID */
gchar* project_id; /* Optional project ID (for some providers) */
gchar* default_model; /* Default model for this provider */
GHashTable* settings; /* Extensible per-provider settings (e.g., tools=enabled, search=disabled) */
GList* models; /* Cached models (gchar*) */
gboolean models_fresh; /* Whether models cache is current */
gint32 ref_count; /* Reference count (atomic) */
} AIProvider;
/**
* @brief AI chat session structure.
*/
typedef struct ai_session_t
{
gchar* provider_name; /* Provider name */
AIProvider* provider; /* Provider configuration */
gchar* model; /* Model identifier (e.g., "gpt-4", "sonar") */
gchar* api_key; /* API key for this session */
GList* history; /* Conversation history (GList of AIMessage*) */
gint32 ref_count; /* Reference count (atomic) */
} AISession;
/* ========================================================================
* Provider Management
* ======================================================================== */
/**
* Initialize the AI client and load default providers.
*/
void ai_client_init(void);
/**
* Shutdown the AI client and free resources.
*/
void ai_client_shutdown(void);
/**
* Get a provider by name.
* @param name The provider name (e.g., "openai", "perplexity")
* @return AIProvider*, or NULL if not found
*/
AIProvider* ai_get_provider(const gchar* name);
/**
* Add or update a provider configuration.
* @param name The provider name
* @param api_url The API endpoint URL
* @param org_id Optional organization ID (can be NULL)
* @return New AIProvider* (caller must unref when done)
*/
AIProvider* ai_add_provider(const gchar* name, const gchar* api_url, const gchar* org_id);
/**
* Remove a provider by name.
* @param name The provider name
* @return TRUE if provider was removed, FALSE if not found
*/
gboolean ai_remove_provider(const gchar* name);
/**
* Decrement the reference count of a provider.
* @param provider The provider to unreference
*/
void ai_provider_unref(AIProvider* provider);
/**
* List all configured providers.
* @return GList of AIProvider* (caller must not free the list or providers,
* and must not unref the returned providers)
*/
GList* ai_list_providers(void);
/**
* Find a provider name for autocomplete.
* @param search_str The search string
* @param previous Whether to go to previous match
* @param context Unused
* @return Provider name, or NULL if not found
*/
gchar* ai_providers_find(const char* const search_str, gboolean previous, void* context);
/**
* Find a model name for autocomplete.
* @param search_str The search string
* @param previous Whether to go to previous match
* @param context ProfAiWin* for the current session
* @return Model name, or NULL if not found
*/
gchar* ai_models_find(const char* const search_str, gboolean previous, void* context);
/**
* Get the API key for a provider.
* @param provider_name The provider name
* @return The API key, or NULL if not set (caller must free)
*/
gchar* ai_get_provider_key(const gchar* provider_name);
/**
* Set the API key for a provider.
* @param provider_name The provider name
* @param api_key The API key to set
*/
void ai_set_provider_key(const gchar* provider_name, const gchar* api_key);
/**
* Set the default model for a provider.
* @param provider_name The provider name
* @param model The default model name
*/
void ai_set_provider_default_model(const gchar* provider_name, const gchar* model);
/**
* Get the default model for a provider.
* @param provider_name The provider name
* @return The default model name (caller must not free), or NULL if not set
*/
const gchar* ai_get_provider_default_model(const gchar* provider_name);
/**
* Set a custom setting for a provider.
* @param provider_name The provider name
* @param setting The setting key (e.g., "tools", "search")
* @param value The setting value
*/
void ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const gchar* value);
/**
* Get a custom setting for a provider.
* @param provider_name The provider name
* @param setting The setting key
* @return The setting value (caller must free), or NULL if not set
*/
gchar* ai_get_provider_setting(const gchar* provider_name, const gchar* setting);
/**
* Fetch available models from a provider's API.
* @param provider_name The provider name
* @param user_data User data (ProfAiWin* for UI display)
* @return TRUE if the request was successfully queued, FALSE otherwise
*/
gboolean ai_fetch_models(const gchar* provider_name, gpointer user_data);
/**
* Check if models cache is fresh for a provider.
* @param provider_name The provider name
* @return TRUE if models are fresh, FALSE otherwise
*/
gboolean ai_models_are_fresh(const gchar* provider_name);
/* ========================================================================
* Model Parsing (for testing)
* ======================================================================== */
/**
* Parse model IDs from an OpenAI-compatible API response.
* Expected format: {"object":"list","data":[{"id":"model1",...},...]}
* @param provider The provider to add models to
* @param json The JSON response from the API
*/
void ai_parse_models_from_json(AIProvider* provider, const gchar* json);
/* ========================================================================
* Session Management
* ======================================================================== */
/**
* Create a new AI session with the specified provider and model.
* @param provider_name The provider name (e.g., "openai")
* @param model The model identifier (e.g., "gpt-4")
* @return New AISession*, or NULL on failure
*/
AISession* ai_session_create(const gchar* provider_name, const gchar* model);
/**
* Increment the reference count of an AI session.
* @param session The session to reference
* @return The same session pointer
*/
AISession* ai_session_ref(AISession* session);
/**
* Decrement the reference count and free the session when it reaches zero.
* @param session The session to unreference
*/
void ai_session_unref(AISession* session);
/**
* Add a message to the session history.
* @param session The session
* @param role The message role ("user" or "assistant")
* @param content The message content
*/
void ai_session_add_message(AISession* session, const gchar* role, const gchar* content);
/**
* Clear the conversation history.
* @param session The session
*/
void ai_session_clear_history(AISession* session);
/**
* Get the current model for a session.
* @param session The session
* @return The model name (caller must not free)
*/
const gchar* ai_session_get_model(AISession* session);
/**
* Set the model for a session.
* @param session The session
* @param model The model name
*/
void ai_session_set_model(AISession* session, const gchar* model);
/* ========================================================================
* Request Handling
* ======================================================================== */
/**
* Send a prompt to the AI provider asynchronously.
* @param session The AI session containing provider and model
* @param prompt The prompt to send
* @param user_data User data (ProfAiWin* for UI display)
* @return TRUE if the request was successfully queued, FALSE otherwise
*/
gboolean ai_send_prompt(AISession* session, const gchar* prompt,
gpointer user_data);
/**
* Escape a string for JSON embedding.
* @param str The string to escape
* @return Newly allocated escaped string (caller must free)
*/
gchar* ai_json_escape(const gchar* str);
#endif /* AI_CLIENT_H */

View File

@@ -66,8 +66,11 @@
#include "omemo/omemo.h"
#endif
#include "ai/ai_client.h"
static char* _sub_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _notify_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _ai_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* prefix);
static char* _theme_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _autoaway_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _autoconnect_autocomplete(ProfWin* window, const char* const input, gboolean previous);
@@ -137,6 +140,7 @@ static char* _strophe_autocomplete(ProfWin* window, const char* const input, gbo
static char* _adhoc_cmd_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _vcard_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _force_encryption_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _script_autocomplete_func(const char* const prefix, gboolean previous, void* context);
@@ -280,11 +284,16 @@ static Autocomplete history_switch_ac;
static Autocomplete color_ac;
static Autocomplete correction_ac;
static Autocomplete avatar_ac;
static Autocomplete ai_subcommands_ac;
static Autocomplete ai_set_subcommands_ac;
static Autocomplete ai_set_custom_subcommands_ac;
static Autocomplete ai_remove_subcommands_ac;
static Autocomplete url_ac;
static Autocomplete executable_ac;
static Autocomplete executable_param_ac;
static Autocomplete intype_ac;
static Autocomplete mood_ac;
static Autocomplete ai_models_ac;
static Autocomplete mood_type_ac;
static Autocomplete strophe_ac;
static Autocomplete strophe_sm_ac;
@@ -456,7 +465,11 @@ static Autocomplete* all_acs[] = {
&vcard_togglable_param_ac,
&vcard_address_type_ac,
&force_encryption_ac,
&force_encryption_policy_ac
&force_encryption_policy_ac,
&ai_subcommands_ac,
&ai_set_subcommands_ac,
&ai_set_custom_subcommands_ac,
&ai_remove_subcommands_ac
};
static GHashTable* ac_funcs = NULL;
@@ -1169,6 +1182,27 @@ cmd_ac_init(void)
autocomplete_add(correction_ac, "off");
autocomplete_add(correction_ac, "char");
autocomplete_add_unsorted(ai_subcommands_ac, "set", FALSE);
autocomplete_add_unsorted(ai_subcommands_ac, "remove", FALSE);
autocomplete_add_unsorted(ai_subcommands_ac, "start", FALSE);
autocomplete_add_unsorted(ai_subcommands_ac, "clear", FALSE);
autocomplete_add_unsorted(ai_subcommands_ac, "providers", FALSE);
autocomplete_add_unsorted(ai_subcommands_ac, "switch", FALSE);
autocomplete_add_unsorted(ai_subcommands_ac, "models", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "provider", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "token", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "default-provider", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "default-model", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "custom", FALSE);
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "tools", FALSE);
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "search", FALSE);
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "memory", FALSE);
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "plugins", FALSE);
autocomplete_add_unsorted(ai_remove_subcommands_ac, "provider", FALSE);
autocomplete_add(avatar_ac, "set");
autocomplete_add(avatar_ac, "disable");
autocomplete_add(avatar_ac, "get");
@@ -1444,6 +1478,7 @@ cmd_ac_init(void)
g_hash_table_insert(ac_funcs, "/wins", _wins_autocomplete);
g_hash_table_insert(ac_funcs, "/wintitle", _wintitle_autocomplete);
g_hash_table_insert(ac_funcs, "/force-encryption", _force_encryption_autocomplete);
g_hash_table_insert(ac_funcs, "/ai", _ai_autocomplete);
}
void
@@ -4450,4 +4485,167 @@ _force_encryption_autocomplete(ProfWin* window, const char* const input, gboolea
result = autocomplete_param_with_ac(input, "/force-encryption", force_encryption_ac, TRUE, previous);
return result;
}
}
static char*
_ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
{
char* result = NULL;
/* Parse once for reuse - /ai <subcommand> [<arg1>] [<arg2>] */
gboolean parse_result = FALSE;
auto_gcharv gchar** args = parse_args(input, 1, 4, &parse_result);
gboolean space_at_end = g_str_has_suffix(input, " ");
int num_args = g_strv_length(args);
/* Top-level /ai <subcommand> autocomplete (e.g., /ai s<tab> -> /ai set) */
result = autocomplete_param_with_func(input, "/ai set provider", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai set token <provider> <token> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai set token", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai set default-provider <provider> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai set default-provider", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai set default-model <provider> <model> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai set default-model", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai set custom <provider> <setting> <value> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai set custom", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai set custom <provider> <setting> - autocomplete settings
/* args[0]="set", args[1]="custom", args[2]=provider (if typed), args[3]=setting (if typed) */
if (num_args == 3 && g_strcmp0(args[1], "custom") == 0) {
/* /ai set custom <provider> - check if provider is valid */
if (ai_get_provider(args[2])) {
/* Valid provider, try settings autocomplete */
result = autocomplete_param_with_ac(input, "/ai set custom ", ai_set_custom_subcommands_ac, TRUE, previous);
if (result) {
return result;
}
}
}
// /ai set <subcommand> - autocomplete subcommands
result = autocomplete_param_with_ac(input, "/ai set", ai_set_subcommands_ac, TRUE, previous);
if (result) {
return result;
}
// /ai remove provider <name> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai remove provider", ai_providers_find, previous, NULL);
if (result) {
return result;
}
result = autocomplete_param_with_ac(input, "/ai remove", ai_remove_subcommands_ac, TRUE, previous);
if (result) {
return result;
}
// /ai start [<provider>] [<model>] - use shared parse_args
if (parse_result && args && args[0] != NULL && g_strcmp0(args[0], "start") == 0) {
/* args[0]="start", args[1]=provider (if typed), args[2]=model (if typed) */
if (num_args == 1 || (num_args == 2 && !space_at_end)) {
result = autocomplete_param_with_func(input, "/ai start", ai_providers_find, previous, NULL);
} else {
/* /ai start <provider> <model> - model autocomplete */
result = _ai_model_autocomplete(window, input, previous, "/ai start ");
}
if (result) {
return result;
}
}
// /ai switch <provider> <model> - autocomplete model names for specified provider
result = _ai_model_autocomplete(window, input, previous, "/ai switch");
if (result) {
return result;
}
result = autocomplete_param_with_func(input, "/ai switch", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai switch <model> - autocomplete model names from current session's provider
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
result = autocomplete_param_with_func(input, "/ai switch", ai_models_find, previous, aiwin);
if (result) {
return result;
}
// /ai models <provider> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai models", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai clear - no autocomplete
// /ai providers - no autocomplete
result = autocomplete_param_with_ac(input, "/ai", ai_subcommands_ac, FALSE, previous);
return result;
}
/**
* Autocomplete model names for /ai <cmd> <provider> <model> patterns.
* Extracts provider from input and provides model completions from that provider's cached models.
* Uses static ai_models_ac to preserve cycling state (last_found) across calls.
*/
static char*
_ai_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* cmd_prefix)
{
if (!g_str_has_prefix(input, cmd_prefix)) {
return NULL;
}
/* Extract provider name from input (after cmd_prefix) */
auto_gchar gchar* rest = g_strdup(input + strlen(cmd_prefix));
char* space = strchr(rest, ' ');
if (space) {
*space = '\0';
}
/* Look up the provider by name and get its models */
AIProvider* prov = ai_get_provider(rest);
if (!prov || !prov->models) {
return NULL;
}
if (!ai_models_ac) {
ai_models_ac = autocomplete_new();
}
/* Convert GList* to char** for autocomplete_update */
int model_count = g_list_length(prov->models);
char** model_array = g_new0(char*, model_count + 1);
GList* curr = prov->models;
int i = 0;
while (curr) {
model_array[i++] = curr->data;
curr = g_list_next(curr);
}
autocomplete_update(ai_models_ac, model_array);
g_free(model_array);
auto_gchar gchar* full_prefix = g_strdup_printf("%s%s", cmd_prefix, rest);
char* result = autocomplete_param_with_ac(input, full_prefix, ai_models_ac, TRUE, previous);
return result;
}

View File

@@ -2787,6 +2787,71 @@ static const struct cmd_t command_defs[] = {
"/force-encryption policy block")
},
{ CMD_PREAMBLE("/ai",
parse_args, 0, 5, NULL)
CMD_SUBFUNCS(
{ "set", cmd_ai_set },
{ "remove", cmd_ai_remove },
{ "start", cmd_ai_start },
{ "clear", cmd_ai_clear },
{ "providers", cmd_ai_providers },
{ "switch", cmd_ai_switch },
{ "models", cmd_ai_models })
CMD_MAINFUNC(cmd_ai)
CMD_TAGS(
CMD_TAG_CHAT)
CMD_SYN(
"/ai",
"/ai set provider <name> <url>",
"/ai set token <provider> <token>",
"/ai set default-provider <provider>",
"/ai set default-model <provider> <model>",
"/ai set custom <provider> <setting> <value>",
"/ai remove provider <name>",
"/ai providers",
"/ai start [<provider>] [<model>]",
"/ai switch <provider> [<model>]",
"/ai switch <model>",
"/ai models <provider>",
"/ai clear")
CMD_DESC(
"Interact with AI models via OpenAI-compatible APIs. "
"Supports multiple providers (openai, perplexity, custom). "
"Each provider has its own API key, endpoint, default model, and settings. "
"Chat history is maintained per session and not persisted locally.")
CMD_ARGS(
{ "", "Display current AI settings and configured providers" },
{ "set provider <name> <url>", "Add or update a provider with custom API endpoint" },
{ "set token <provider> <token>", "Set API token for a provider (e.g., openai, perplexity)" },
{ "set default-provider <provider>", "Set global default provider for /ai start" },
{ "set default-model <provider> <model>", "Set default model for a provider" },
{ "set custom <provider> <setting> <value>", "Set provider-level setting (e.g., tools, search)" },
{ "remove provider <name>", "Remove a custom provider" },
{ "providers", "List configured providers with full details" },
{ "start [<provider>] [<model>]", "Start new AI chat (space-separated, uses defaults if omitted)" },
{ "switch <provider> [<model>]", "Switch to different provider (and optionally model)" },
{ "switch <model>", "Change model in current session (keeps provider)" },
{ "models <provider>", "Fetch and display available models for provider" },
{ "clear", "Clear current chat history" })
CMD_EXAMPLES(
"/ai",
"/ai set token openai sk-xxx",
"/ai set token perplexity pplx-xxx",
"/ai set provider custom https://my-api.com/v1/chat/completions",
"/ai set default-provider perplexity",
"/ai set default-model perplexity sonar",
"/ai set custom perplexity tools enabled",
"/ai remove provider custom",
"/ai start",
"/ai start perplexity",
"/ai start perplexity sonar",
"/ai start openai gpt-4o",
"/ai switch gpt-4o",
"/ai switch openai gpt-5.4-nano",
"/ai models perplexity",
"/ai clear")
},
// NEXT-COMMAND (search helper)
};

View File

@@ -81,7 +81,6 @@
#include "tools/bookmark_ignore.h"
#include "tools/editor.h"
#include "plugins/plugins.h"
#include "ui/inputwin.h"
#include "ui/ui.h"
#include "ui/window_list.h"
#include "xmpp/avatar.h"
@@ -117,6 +116,8 @@
#include "tools/clipboard.h"
#endif
#include "ai/ai_client.h"
#ifdef HAVE_PYTHON
#include "plugins/python_plugins.h"
#endif
@@ -8466,7 +8467,7 @@ _cmd_execute_default(ProfWin* window, const char* inp)
}
// handle non commands in non chat or plugin windows
if (window->type != WIN_CHAT && window->type != WIN_MUC && window->type != WIN_PRIVATE && window->type != WIN_PLUGIN && window->type != WIN_XML) {
if (window->type != WIN_CHAT && window->type != WIN_MUC && window->type != WIN_PRIVATE && window->type != WIN_PLUGIN && window->type != WIN_XML && window->type != WIN_AI) {
cons_show("Unknown command: %s", inp);
cons_alert(NULL);
return TRUE;
@@ -8512,6 +8513,12 @@ _cmd_execute_default(ProfWin* window, const char* inp)
connection_send_stanza(inp);
break;
}
case WIN_AI:
{
ProfAiWin* aiwin = (ProfAiWin*)window;
cl_ev_send_ai_msg(aiwin, inp, NULL);
break;
}
default:
break;
}
@@ -10780,6 +10787,500 @@ cmd_vcard_save(ProfWin* window, const char* const command, gchar** args)
return TRUE;
}
gboolean
cmd_ai(ProfWin* window, const char* const command, gchar** args)
{
if (args[0] == NULL) {
// Display current AI settings
cons_show("AI Chat - OpenAI-compatible API client");
cons_show("");
// List configured providers with full details
GList* providers = ai_list_providers();
cons_show("Configured providers:");
for (GList* curr = providers; curr; curr = g_list_next(curr)) {
AIProvider* provider = curr->data;
auto_gchar gchar* key = ai_get_provider_key(provider->name);
const gchar* default_model = ai_get_provider_default_model(provider->name);
cons_show(" %s", provider->name);
cons_show(" URL: %s", provider->api_url);
cons_show(" Key: %s", key ? "configured" : "NOT configured");
if (default_model) {
cons_show(" Default model: %s", default_model);
}
if (provider->models && g_list_length(provider->models) > 0) {
cons_show(" Cached models: %d", g_list_length(provider->models));
}
cons_show("");
}
g_list_free(providers);
cons_show("Use '/ai start' to begin a chat (uses default provider/model).");
cons_show("Use '/ai models <provider>' to fetch available models.");
cons_show("Available models: https://models.litellm.ai/");
return TRUE;
}
cons_bad_cmd_usage(command);
return TRUE;
}
gboolean
cmd_ai_set(ProfWin* window, const char* const command, gchar** args)
{
if (args[1] == NULL) {
cons_bad_cmd_usage(command);
return TRUE;
}
if (g_strcmp0(args[1], "provider") == 0) {
// /ai set provider <name> <url>
if (g_strv_length(args) < 4) {
cons_bad_cmd_usage(command);
return TRUE;
}
if (ai_add_provider(args[2], args[3], NULL)) {
cons_show("Provider '%s' configured with URL: %s", args[2], args[3]);
} else {
cons_show_error("Failed to configure provider '%s'.", args[2]);
}
cons_show("");
return TRUE;
} else if (g_strcmp0(args[1], "token") == 0) {
// /ai set token <provider> <token>
if (g_strv_length(args) < 4) {
cons_bad_cmd_usage(command);
return TRUE;
}
ai_set_provider_key(args[2], args[3]);
cons_show("API token set for provider: %s", args[2]);
cons_show("");
return TRUE;
} else if (g_strcmp0(args[1], "default-model") == 0) {
// /ai set default-model <provider> <model>
if (g_strv_length(args) < 4) {
cons_bad_cmd_usage(command);
return TRUE;
}
ai_set_provider_default_model(args[2], args[3]);
cons_show("Default model for provider '%s' set to: %s", args[2], args[3]);
cons_show("");
return TRUE;
} else if (g_strcmp0(args[1], "custom") == 0) {
// /ai set custom <provider> <setting> <value>
if (g_strv_length(args) < 5) {
cons_bad_cmd_usage(command);
return TRUE;
}
ai_set_provider_setting(args[2], args[3], args[4]);
cons_show("Setting '%s' for provider '%s' set to: %s", args[3], args[2], args[4]);
cons_show("");
return TRUE;
} else if (g_strcmp0(args[1], "default-provider") == 0) {
// /ai set default-provider <provider>
if (g_strv_length(args) < 3) {
cons_bad_cmd_usage(command);
return TRUE;
}
const gchar* provider_name = args[2];
AIProvider* provider = ai_get_provider(provider_name);
if (!provider) {
cons_show_error("Provider '%s' not found. Use '/ai set provider %s <url>' to add it.",
provider_name, provider_name);
return TRUE;
}
prefs_set_string(PREF_AI_PROVIDER, provider_name);
cons_show("Default provider set to: %s", provider_name);
cons_show("");
return TRUE;
}
cons_bad_cmd_usage(command);
return TRUE;
}
gboolean
cmd_ai_remove(ProfWin* window, const char* const command, gchar** args)
{
// /ai remove provider <name>
if (g_strcmp0(args[1], "provider") != 0) {
cons_bad_cmd_usage(command);
return TRUE;
}
if (g_strv_length(args) < 3) {
cons_bad_cmd_usage(command);
return TRUE;
}
if (ai_remove_provider(args[2])) {
cons_show("Provider '%s' removed.", args[2]);
} else {
cons_show("Provider '%s' not found.", args[2]);
}
cons_show("");
return TRUE;
}
gboolean
cmd_ai_start(ProfWin* window, const char* const command, gchar** args)
{
// /ai start [<provider>] [<model>]
// Space-separated, no slash syntax
const gchar* provider_name = NULL;
const gchar* model = NULL;
if (g_strv_length(args) >= 2) {
provider_name = args[1];
}
if (g_strv_length(args) >= 3) {
model = args[2];
}
// Resolve defaults
auto_gchar gchar* owned_provider_name = NULL;
if (!provider_name) {
// Use default provider from preferences
auto_gchar gchar* default_provider = prefs_get_string(PREF_AI_PROVIDER);
if (default_provider && strlen(default_provider) > 0) {
provider_name = default_provider;
}
}
if (!provider_name) {
provider_name = "openai"; // Fallback
}
AIProvider* provider = ai_get_provider(provider_name);
if (!provider) {
cons_show_error("Provider '%s' not found. Use '/ai set provider %s <url>' to add it.",
provider_name, provider_name);
return TRUE;
}
// Get model: explicit > provider default > hardcoded fallback
if (!model) {
model = ai_get_provider_default_model(provider_name);
}
if (!model) {
model = "gpt-4o"; // Fallback
}
// Check for API key
gchar* api_key = ai_get_provider_key(provider_name);
if (!api_key || strlen(api_key) == 0) {
cons_show_error("No API key set for provider '%s'. Use '/ai set token %s <key>' first.",
provider_name, provider_name);
g_free(api_key);
return TRUE;
}
g_free(api_key);
// Create new AI session
AISession* session = ai_session_create(provider_name, model);
if (!session) {
log_error("[AI-CMD] Failed to create AI session");
cons_show_error("Failed to create AI session for %s/%s.", provider_name, model);
return TRUE;
}
log_debug("[AI-CMD] AI session created successfully");
// Create AI chat window
log_debug("[AI-CMD] Calling wins_new_ai()...");
ProfWin* ai_win = wins_new_ai(session);
if (!ai_win) {
log_error("[AI-CMD] wins_new_ai() returned NULL");
cons_show_error("Failed to create AI chat window.");
ai_session_unref(session);
return TRUE;
}
log_debug("[AI-CMD] wins_new_ai() returned successfully, window type: %d", ai_win->type);
// Add welcome message to the AI window
log_debug("[AI-CMD] Adding welcome messages...");
win_println(ai_win, THEME_DEFAULT, "-", "AI Chat: %s/%s", provider_name, model);
win_println(ai_win, THEME_DEFAULT, "-", "Type your message and press Enter to start a conversation.");
log_debug("[AI-CMD] Welcome messages added");
// Focus the new window
log_debug("[AI-CMD] Calling ui_focus_win()...");
ui_focus_win(ai_win);
log_debug("[AI-CMD] ui_focus_win() returned");
cons_show("Started AI chat: %s/%s", provider_name, model);
cons_show("");
log_debug("[AI-CMD] AI start command completed");
return TRUE;
}
gboolean
cmd_ai_model(ProfWin* window, const char* const command, gchar** args)
{
// /ai model <model>
if (args[1] == NULL) {
cons_bad_cmd_usage(command);
return TRUE;
}
const gchar* model = args[1];
// Get current AI window
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
if (!aiwin) {
cons_show("No active AI chat window. Use '/ai start' first.");
return TRUE;
}
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
if (!aiwin->session) {
cons_show("No active session in this chat window.");
return TRUE;
}
ai_session_set_model(aiwin->session, model);
cons_show("Session model changed to: %s", model);
cons_show("");
return TRUE;
}
gboolean
cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
{
// /ai switch <provider> [<model>] - Change provider and optionally model
// /ai switch <model> - Change model only (keeps current provider)
// Modifies the existing session's provider and model instead of recreating it
if (g_strv_length(args) < 2) {
cons_bad_cmd_usage(command);
return TRUE;
}
// Get current AI window
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
if (!aiwin) {
cons_show("No active AI chat window. Use '/ai start' first.");
return TRUE;
}
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
if (!aiwin->session) {
cons_show("No active session in this chat window.");
return TRUE;
}
const gchar* arg1 = args[1];
const gchar* arg2 = (g_strv_length(args) >= 3) ? args[2] : NULL;
const gchar* provider_name;
const gchar* model;
gboolean changed_provider = FALSE;
// Check if arg1 is a known provider
AIProvider* provider = ai_get_provider(arg1);
if (provider) {
// arg1 is a provider name
provider_name = arg1;
changed_provider = TRUE;
// Get model: arg2 > provider default > error
if (arg2) {
model = arg2;
} else {
model = ai_get_provider_default_model(provider_name);
}
if (!model) {
cons_show_error("No model specified and no default model set for provider '%s'.", provider_name);
cons_show("Use '/ai set default-model %s <model>' or '/ai switch %s <model>'.", provider_name, provider_name);
return TRUE;
}
} else {
// arg1 is a model name, keep current provider
provider_name = aiwin->session->provider_name;
model = arg1;
}
// Check for API key
auto_gchar gchar* api_key = ai_get_provider_key(provider_name);
if (!api_key || strlen(api_key) == 0) {
cons_show_error("No API key set for provider '%s'. Use '/ai set token %s <key>' first.",
provider_name, provider_name);
return TRUE;
}
// Update the existing session's provider and model
if (changed_provider) {
AIProvider* new_provider = ai_get_provider(provider_name);
g_free(aiwin->session->provider_name);
aiwin->session->provider_name = g_strdup(provider_name);
ai_provider_unref(aiwin->session->provider);
aiwin->session->provider = new_provider;
aiwin->session->provider->ref_count++;
}
g_free(aiwin->session->model);
aiwin->session->model = g_strdup(model);
g_free(aiwin->session->api_key);
aiwin->session->api_key = g_strdup(api_key);
// Update window title
win_println((ProfWin*)aiwin, THEME_DEFAULT, "-", "AI Chat: %s/%s", provider_name, model);
cons_show("Switched to %s/%s", provider_name, model);
cons_show("");
return TRUE;
}
gboolean
cmd_ai_models(ProfWin* window, const char* const command, gchar** args)
{
// /ai models <provider> [--cached]
// Default: fetch fresh models from API
// --cached: display cached models (if available)
if (args[1] == NULL) {
cons_bad_cmd_usage(command);
return TRUE;
}
const gchar* provider_name = args[1];
gboolean use_cached = (g_strv_length(args) >= 3) && (g_strcmp0(args[2], "--cached") == 0);
AIProvider* provider = ai_get_provider(provider_name);
if (!provider) {
cons_show_error("Provider '%s' not found.", provider_name);
return TRUE;
}
if (use_cached) {
// Display cached models (if available)
if (!provider->models) {
cons_show("No cached models for provider '%s'. Use '/ai models %s' to fetch from API.", provider_name, provider_name);
return TRUE;
}
cons_show("Cached models for provider '%s':", provider_name);
GList* curr = provider->models;
while (curr) {
cons_show(" %s", (gchar*)curr->data);
curr = g_list_next(curr);
}
cons_show("");
cons_show("Use '/ai models %s' to fetch fresh models from the API.", provider_name);
} else {
// Default: fetch fresh models from API
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
ai_fetch_models(provider_name, aiwin);
}
return TRUE;
}
gboolean
cmd_ai_clear(ProfWin* window, const char* const command, gchar** args)
{
// /ai clear
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
if (aiwin) {
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
if (aiwin->session) {
ai_session_clear_history(aiwin->session);
}
cons_show("Chat history cleared.");
} else {
cons_show("No active AI chat window to clear.");
}
cons_show("");
return TRUE;
}
gboolean
cmd_ai_correct(ProfWin* window, const char* const command, gchar** args)
{
// /ai correct <message>
// Join all arguments from args[1] onwards to support multi-word prompts
auto_gchar gchar* prompt = g_strjoinv(" ", &args[1]);
if (prompt == NULL || strlen(prompt) == 0) {
cons_bad_cmd_usage(command);
return TRUE;
}
// Get current AI window
ProfAiWin* aiwin = wins_get_ai();
if (!aiwin) {
cons_show("No active AI chat window. Use '/ai start <provider>/<model>' first.");
return TRUE;
}
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
if (!aiwin->session) {
cons_show("No active session in this chat window.");
return TRUE;
}
// Get the last user message and replace it
GList* history = aiwin->session->history;
GList* last_user_msg = NULL;
for (GList* curr = history; curr; curr = g_list_next(curr)) {
AIMessage* msg = curr->data;
if (g_strcmp0(msg->role, "user") == 0) {
last_user_msg = curr;
}
}
if (!last_user_msg) {
cons_show("No user messages in this chat to correct.");
return TRUE;
}
// Replace the last user message
AIMessage* msg = last_user_msg->data;
g_free(msg->content);
msg->content = g_strdup(prompt);
// Resend the prompt
cons_show("Correcting message...");
ai_send_prompt(aiwin->session, prompt, aiwin);
return TRUE;
}
gboolean
cmd_ai_providers(ProfWin* window, const char* const command, gchar** args)
{
if (args[1] == NULL || g_strcmp0(args[1], "list") != 0) {
// List all available providers
cons_show("Available AI providers:");
cons_show("");
cons_show(" openai - OpenAI API (https://api.openai.com)");
cons_show(" perplexity - Perplexity API (https://api.perplexity.ai)");
cons_show("");
cons_show("Add custom providers with: /ai set provider <name> <url>");
return TRUE;
}
// List configured providers with key status
cons_show("Configured providers:");
cons_show("");
GList* providers = ai_list_providers();
for (GList* curr = providers; curr; curr = g_list_next(curr)) {
AIProvider* provider = curr->data;
gchar* key = ai_get_provider_key(provider->name);
cons_show(" %s", provider->name);
cons_show(" URL: %s", provider->api_url);
cons_show(" Key: %s", key ? "configured" : "NOT configured");
if (provider->org_id && strlen(provider->org_id) > 0) {
cons_show(" Org: %s", provider->org_id);
}
cons_show("");
g_free(key);
}
g_list_free(providers);
return TRUE;
}
gboolean
cmd_force_encryption(ProfWin* window, const char* const command, gchar** args)
{

View File

@@ -166,6 +166,14 @@ gboolean cmd_console(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_command_list(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_command_exec(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_change_password(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_ai(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_ai_set(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_ai_remove(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_ai_start(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_ai_clear(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_ai_providers(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_ai_switch(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_ai_models(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_plugins(ProfWin* window, const char* const command, gchar** args);
gboolean cmd_plugins_sourcepath(ProfWin* window, const char* const command, gchar** args);

View File

@@ -66,6 +66,7 @@
#define PREF_GROUP_MUC "muc"
#define PREF_GROUP_PLUGINS "plugins"
#define PREF_GROUP_EXECUTABLES "executables"
#define PREF_GROUP_AI "ai"
#define INPBLOCK_DEFAULT 1000
@@ -1706,6 +1707,162 @@ _save_prefs(void)
save_keyfile(&prefs_prof_keyfile);
}
/* ========================================================================
* AI Token Management
* ======================================================================== */
gboolean
prefs_ai_set_token(const char* const provider, const char* const token)
{
if (!provider)
return FALSE;
if (!prefs)
return FALSE;
if (token) {
g_key_file_set_string(prefs, PREF_GROUP_AI, provider, token);
_save_prefs();
return TRUE;
} else {
return prefs_ai_remove_token(provider);
}
}
gboolean
prefs_ai_remove_token(const char* const provider)
{
if (!provider)
return FALSE;
if (!prefs)
return FALSE;
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, provider, NULL)) {
return FALSE;
}
g_key_file_remove_key(prefs, PREF_GROUP_AI, provider, NULL);
_save_prefs();
return TRUE;
}
char*
prefs_ai_get_token(const char* const provider)
{
if (!provider || !prefs)
return NULL;
return g_key_file_get_string(prefs, PREF_GROUP_AI, provider, NULL);
}
GList*
prefs_ai_list_tokens(void)
{
if (!prefs || !g_key_file_has_group(prefs, PREF_GROUP_AI)) {
return NULL;
}
GList* result = NULL;
gsize len;
auto_gcharv gchar** keys = g_key_file_get_keys(prefs, PREF_GROUP_AI, &len, NULL);
for (gsize i = 0; i < len; i++) {
char* name = keys[i];
auto_gchar gchar* value = g_key_file_get_string(prefs, PREF_GROUP_AI, name, NULL);
if (value) {
result = g_list_append(result, g_strdup(name));
}
}
return result;
}
void
prefs_free_ai_tokens(GList* tokens)
{
if (tokens) {
g_list_free_full(tokens, g_free);
}
}
gboolean
prefs_ai_set_models(const char* const provider, const gchar* const* models, gint count)
{
if (!provider || count <= 0)
return FALSE;
if (!prefs)
return FALSE;
/* Build semicolon-separated model list */
GString* model_str = g_string_new("");
for (int i = 0; i < count; i++) {
if (i > 0) {
g_string_append_c(model_str, ';');
}
g_string_append(model_str, models[i]);
}
g_key_file_set_string(prefs, PREF_GROUP_AI, g_strconcat(provider, "_models", NULL), model_str->str);
g_string_free(model_str, TRUE);
_save_prefs();
return TRUE;
}
gboolean
prefs_ai_remove_models(const char* const provider)
{
if (!provider)
return FALSE;
if (!prefs)
return FALSE;
auto_gchar gchar* key = g_strconcat(provider, "_models", NULL);
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, key, NULL)) {
return FALSE;
}
g_key_file_remove_key(prefs, PREF_GROUP_AI, key, NULL);
_save_prefs();
return TRUE;
}
GList*
prefs_ai_get_models(const char* const provider)
{
if (!provider || !prefs)
return NULL;
auto_gchar gchar* key = g_strconcat(provider, "_models", NULL);
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, key, NULL)) {
return NULL;
}
auto_gchar gchar* model_str = g_key_file_get_string(prefs, PREF_GROUP_AI, key, NULL);
if (!model_str || strlen(model_str) == 0) {
return NULL;
}
GList* result = NULL;
gchar** models = g_strsplit(model_str, ";", -1);
for (int i = 0; models[i] != NULL; i++) {
result = g_list_append(result, g_strdup(models[i]));
}
g_strfreev(models);
return result;
}
void
prefs_free_ai_models(GList* models)
{
if (models) {
g_list_free_full(models, g_free);
}
}
// get the preference group for a specific preference
// for example the PREF_BEEP setting ("beep" in .profrc, see _get_key) belongs
// to the [ui] section.
@@ -1865,6 +2022,9 @@ _get_group(preference_t pref)
return PREF_GROUP_OMEMO;
case PREF_OX_LOG:
return PREF_GROUP_OX;
case PREF_AI_PROVIDER:
case PREF_AI_API_KEY:
return PREF_GROUP_AI;
default:
return NULL;
}
@@ -2142,6 +2302,10 @@ _get_key(preference_t pref)
return "stamp.incoming";
case PREF_OX_LOG:
return "log";
case PREF_AI_PROVIDER:
return "provider";
case PREF_AI_API_KEY:
return "api_key";
case PREF_MOOD:
return "mood";
case PREF_VCARD_PHOTO_CMD:
@@ -2319,6 +2483,10 @@ _get_default_string(preference_t pref)
return "on";
case PREF_FORCE_ENCRYPTION_MODE:
return "resend-to-confirm";
case PREF_AI_PROVIDER:
return "openai";
case PREF_AI_API_KEY:
return "";
default:
return NULL;
}

View File

@@ -183,6 +183,9 @@ typedef enum {
PREF_OX_LOG,
PREF_MOOD,
PREF_STROPHE_VERBOSITY,
PREF_AI_PROVIDER,
PREF_AI_API_KEY,
PREF_AI_DEFAULT_MODEL,
PREF_STROPHE_SM_ENABLED,
PREF_STROPHE_SM_RESEND,
PREF_VCARD_PHOTO_CMD,
@@ -356,4 +359,17 @@ gboolean prefs_get_room_notify(const char* const roomjid);
gboolean prefs_get_room_notify_mention(const char* const roomjid);
gboolean prefs_get_room_notify_trigger(const char* const roomjid);
/* AI token management */
gboolean prefs_ai_set_token(const char* const provider, const char* const token);
gboolean prefs_ai_remove_token(const char* const provider);
char* prefs_ai_get_token(const char* const provider);
GList* prefs_ai_list_tokens(void);
void prefs_free_ai_tokens(GList* tokens);
/* AI model cache management */
gboolean prefs_ai_set_models(const char* const provider, const gchar* const* models, gint count);
gboolean prefs_ai_remove_models(const char* const provider);
GList* prefs_ai_get_models(const char* const provider);
void prefs_free_ai_models(GList* models);
#endif

View File

@@ -37,8 +37,10 @@
#include "config.h"
#include <stdlib.h>
#include <assert.h>
#include <glib.h>
#include "ai/ai_client.h"
#include "log.h"
#include "chatlog.h"
#include "database.h"
@@ -47,7 +49,7 @@
#include "event/common.h"
#include "plugins/plugins.h"
#include "ui/inputwin.h"
#include "ui/window_list.h"
#include "ui/window.h"
#include "xmpp/chat_session.h"
#include "xmpp/session.h"
#include "xmpp/xmpp.h"
@@ -284,3 +286,29 @@ allow_unencrypted_message(ProfChatWin* chatwin, const char* const msg)
win_println((ProfWin*)chatwin, THEME_ERROR, "-", "Message not sent: invalid encryption mode (%s). Use '/force-encryption policy resend-to-confirm'.", force_enc_mode);
return FALSE;
}
void
cl_ev_send_ai_msg(ProfAiWin* aiwin, const char* const message, const char* const id)
{
if (!aiwin || !message) {
return;
}
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
if (!aiwin->session) {
log_error("[AI] AI session not initialized");
return;
}
// Snap viewport to bottom so the user's own message is visible even if
// they had scrolled up to read history; otherwise win_is_paged() in
// _win_printf would suppress the print.
win_move_to_end(&aiwin->window);
// Display user message in AI window.
win_print_outgoing(&aiwin->window, ">>", id, NULL, message);
// Send to AI provider.
ai_send_prompt(aiwin->session, message, aiwin);
}

View File

@@ -51,6 +51,7 @@ void cl_ev_send_msg(ProfChatWin* chatwin, const char* const msg, const char* con
void cl_ev_send_muc_msg_corrected(ProfMucWin* mucwin, const char* const msg, const char* const oob_url, gboolean correct_last_msg);
void cl_ev_send_muc_msg(ProfMucWin* mucwin, const char* const msg, const char* const oob_url);
void cl_ev_send_priv_msg(ProfPrivateWin* privwin, const char* const msg, const char* const oob_url);
void cl_ev_send_ai_msg(ProfAiWin* aiwin, const char* const message, const char* const id);
// Checks if an unencrypted message can be sent based on encryption preferences.
// Returns TRUE if allowed, FALSE if blocked.

View File

@@ -69,6 +69,7 @@
#include "xmpp/xmpp.h"
#include "xmpp/muc.h"
#include "xmpp/chat_session.h"
#include "ai/ai_client.h"
#include "xmpp/chat_state.h"
#include "xmpp/contact.h"
#include "xmpp/roster_list.h"
@@ -249,6 +250,7 @@ _init(char* log_level, char* config_file, char* log_file, char* theme_name)
}
session_init();
cmd_init();
ai_client_init();
log_info("Initialising contact list");
muc_init();
tlscerts_init();

61
src/ui/aiwin.c Normal file
View File

@@ -0,0 +1,61 @@
/*
* aiwin.c - AI Chat Window Management
*
* Provides functions for managing the AI chat window (ProfAiWin) in the
* profanity client. This includes retrieving window strings for identification,
* displaying AI/LLM responses in the chat view, and handling error messages
* from AI interactions.
*
* Key functions:
* aiwin_get_string() - Returns a descriptive string for the window
* aiwin_display_response() - Displays an AI/LLM response and appends it
* to the conversation history
* aiwin_display_error() - Displays an error message from AI operations
*
* vim: expandtab:ts=4:sts=4:sw=4
*/
#include "config.h"
#include <assert.h>
#include "ai/ai_client.h"
#include "log.h"
#include "ui/ui.h"
#include "ui/win_types.h"
char*
aiwin_get_string(ProfAiWin* win)
{
assert(win->memcheck == PROFAIWIN_MEMCHECK);
return g_strdup_printf("AI Chat (%s: %s)", win->session->provider_name, win->session->model);
}
void
aiwin_display_response(ProfAiWin* win, const char* response)
{
log_debug("[AI-WIN] aiwin_display_response ENTER: win=%p, response='%s'", (void*)win, response);
assert(win->memcheck == PROFAIWIN_MEMCHECK);
if (!response) {
log_error("[AI-WIN] FAIL: null response");
return;
}
// Display AI response
win_println(&win->window, THEME_DEFAULT, "<< LLM:", "%s", response);
log_debug("[AI-WIN] Displayed AI response");
}
void
aiwin_display_error(ProfAiWin* win, const char* error_msg)
{
log_debug("[AI-WIN] aiwin_display_error ENTER: win=%p, error='%s'", (void*)win, error_msg);
assert(win->memcheck == PROFAIWIN_MEMCHECK);
const char* msg = error_msg ? error_msg : "Unknown error";
win_println(&win->window, THEME_ERROR, "[ERROR]", "%s", msg);
// Also show error in console so user sees it regardless of active window
cons_show_error("AI Error: %s", msg);
}

View File

@@ -147,7 +147,7 @@ void
ui_update(void)
{
ProfWin* current = wins_get_current();
if (current->layout->paged == 0) {
if (!win_is_paged(current)) {
win_move_to_end(current);
}

View File

@@ -283,7 +283,7 @@ _show_attention(ProfWin* current, gboolean attention)
static void
_show_scrolled(ProfWin* current)
{
if (current && current->layout->paged == 1) {
if (win_is_paged(current)) {
int bracket_attrs = theme_attrs(THEME_TITLE_BRACKET);
int scrolled_attrs = theme_attrs(THEME_TITLE_SCROLLED);

View File

@@ -386,6 +386,8 @@ ProfWin* win_create_config(const char* const title, DataForm* form, ProfConfWinC
ProfWin* win_create_private(const char* const fulljid);
ProfWin* win_create_plugin(const char* const plugin_name, const char* const tag);
ProfWin* win_create_vcard(vCard* vcard);
ProfWin* win_create_ai(AISession* session);
ProfWin* wins_new_ai(AISession* session);
void win_update_virtual(ProfWin* window);
void win_free(ProfWin* window);
gboolean win_notify_remind(ProfWin* window);
@@ -445,4 +447,9 @@ void notify_invite(const char* const from, const char* const room, const char* c
void notify(const char* const message, int timeout, const char* const category);
void notify_subscription(const char* const from);
/* AI window */
char* aiwin_get_string(ProfAiWin* win);
void aiwin_display_response(ProfAiWin* win, const char* response);
void aiwin_display_error(ProfAiWin* win, const char* error_msg);
#endif

View File

@@ -62,6 +62,7 @@
#define PROFXMLWIN_MEMCHECK 87333463
#define PROFPLUGINWIN_MEMCHECK 43434777
#define PROFVCARDWIN_MEMCHECK 68947523
#define PROFAIWIN_MEMCHECK 91827364
typedef enum {
FIELD_HIDDEN,
@@ -119,7 +120,6 @@ typedef struct prof_layout_t
WINDOW* win;
ProfBuff buffer;
int y_pos;
int paged;
int unread_msg;
} ProfLayout;
@@ -144,19 +144,28 @@ typedef enum {
WIN_PRIVATE,
WIN_XML,
WIN_PLUGIN,
WIN_VCARD
WIN_VCARD,
WIN_AI
} win_type_t;
typedef enum {
WIN_SCROLL_INNER,
WIN_SCROLL_REACHED_TOP,
WIN_SCROLL_REACHED_BOTTOM
} win_scroll_state_t;
// ScrollEdges tracks the two end-of-history conditions independently,
// disentangling buffer-layout state (handled implicitly via y_pos) from
// DB-content state. db_exhausted_above means a previous page-up fetch came
// back empty: there is nothing older in the chat database, so the next
// page-up should not re-issue the SQL/flatfile read. db_exhausted_below
// is the analogous flag for newer rows. Both default to FALSE, are flipped
// to TRUE on a respective DB_RESPONSE_EMPTY fetch, and are cleared by
// scrolling in the opposite direction so a re-attempt happens after new
// activity (e.g. MAM late delivery, /history switch).
typedef struct {
gboolean db_exhausted_above;
gboolean db_exhausted_below;
} ScrollEdges;
typedef struct prof_win_t
{
win_type_t type;
win_scroll_state_t scroll_state;
ScrollEdges scroll_edges;
ProfLayout* layout;
Autocomplete urls_ac;
Autocomplete quotes_ac;
@@ -258,4 +267,16 @@ typedef struct prof_vcard_win_t
unsigned long memcheck;
} ProfVcardWin;
/* Forward declaration */
typedef struct ai_session_t AISession;
typedef struct prof_ai_win_t
{
ProfWin window;
ProfBuff message_buffer;
GString* conversation_history;
AISession* session;
unsigned long memcheck;
} ProfAiWin;
#endif

View File

@@ -36,6 +36,7 @@
#include "config.h"
#include "database.h"
#include "ui/win_types.h"
#include "ui/window_list.h"
#include <stdlib.h>
@@ -62,6 +63,7 @@
#include "ui/screen.h"
#include "xmpp/xmpp.h"
#include "xmpp/roster_list.h"
#include "ai/ai_client.h"
static const char* LOADING_MESSAGE = "Loading older messages…";
static const char* CONS_WIN_TITLE = "CProof. Type /help for help information.";
@@ -82,6 +84,26 @@ _check_subwin_width(int cols, int width)
return cols <= 1 ? 1 : CLAMP(width, 1, cols - 1);
}
// Visible chat-area height in lines: stdscr minus the title bar (1), the
// status bar (1), the input prompt area (1), and the separator (1).
int
win_page_space(void)
{
return getmaxy(stdscr) - 4;
}
// True when the user has scrolled away from the live tail and there is
// content below the visible viewport. Derived from y_pos and the current
// height of the pad — no sticky flag, no possibility of staleness.
gboolean
win_is_paged(const ProfWin* window)
{
if (!window || !window->layout || !window->layout->win)
return FALSE;
int total_rows = getcury(window->layout->win);
return (total_rows - window->layout->y_pos) > win_page_space();
}
int
win_roster_cols(void)
{
@@ -115,7 +137,6 @@ _win_create_simple_layout(void)
wbkgd(layout->base.win, theme_attrs(THEME_TEXT));
layout->base.buffer = buffer_create();
layout->base.y_pos = 0;
layout->base.paged = 0;
layout->base.unread_msg = 0;
scrollok(layout->base.win, TRUE);
@@ -133,7 +154,6 @@ _win_create_split_layout(void)
wbkgd(layout->base.win, theme_attrs(THEME_TEXT));
layout->base.buffer = buffer_create();
layout->base.y_pos = 0;
layout->base.paged = 0;
layout->base.unread_msg = 0;
scrollok(layout->base.win, TRUE);
layout->subwin = NULL;
@@ -148,7 +168,7 @@ win_create_console(void)
{
ProfConsoleWin* new_win = malloc(sizeof(ProfConsoleWin));
new_win->window.type = WIN_CONSOLE;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_split_layout();
return &new_win->window;
@@ -160,7 +180,7 @@ win_create_chat(const char* const barejid)
assert(barejid != NULL);
ProfChatWin* new_win = malloc(sizeof(ProfChatWin));
new_win->window.type = WIN_CHAT;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->barejid = strdup(barejid);
@@ -194,7 +214,7 @@ win_create_muc(const char* const roomjid)
int cols = getmaxx(stdscr);
new_win->window.type = WIN_MUC;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
ProfLayoutSplit* layout = malloc(sizeof(ProfLayoutSplit));
layout->base.type = LAYOUT_SPLIT;
@@ -213,7 +233,6 @@ win_create_muc(const char* const roomjid)
layout->memcheck = LAYOUT_SPLIT_MEMCHECK;
layout->base.buffer = buffer_create();
layout->base.y_pos = 0;
layout->base.paged = 0;
layout->base.unread_msg = 0;
scrollok(layout->base.win, TRUE);
new_win->window.layout = (ProfLayout*)layout;
@@ -252,7 +271,7 @@ win_create_config(const char* const roomjid, DataForm* form, ProfConfWinCallback
assert(form != NULL);
ProfConfWin* new_win = malloc(sizeof(ProfConfWin));
new_win->window.type = WIN_CONFIG;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->roomjid = strdup(roomjid);
new_win->form = form;
@@ -271,7 +290,7 @@ win_create_private(const char* const fulljid)
assert(fulljid != NULL);
ProfPrivateWin* new_win = malloc(sizeof(ProfPrivateWin));
new_win->window.type = WIN_PRIVATE;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->fulljid = strdup(fulljid);
new_win->unread = 0;
@@ -288,7 +307,7 @@ win_create_xmlconsole(void)
{
ProfXMLWin* new_win = malloc(sizeof(ProfXMLWin));
new_win->window.type = WIN_XML;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->memcheck = PROFXMLWIN_MEMCHECK;
@@ -303,7 +322,7 @@ win_create_plugin(const char* const plugin_name, const char* const tag)
assert(tag != NULL);
ProfPluginWin* new_win = malloc(sizeof(ProfPluginWin));
new_win->window.type = WIN_PLUGIN;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->tag = strdup(tag);
@@ -320,7 +339,7 @@ win_create_vcard(vCard* vcard)
assert(vcard != NULL);
ProfVcardWin* new_win = malloc(sizeof(ProfVcardWin));
new_win->window.type = WIN_VCARD;
new_win->window.scroll_state = WIN_SCROLL_INNER;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->vcard = vcard;
@@ -329,6 +348,22 @@ win_create_vcard(vCard* vcard)
return &new_win->window;
}
ProfWin*
win_create_ai(AISession* session)
{
ProfAiWin* new_win = g_new0(ProfAiWin, 1);
new_win->window.type = WIN_AI;
new_win->window.scroll_edges = (ScrollEdges){ FALSE, FALSE };
new_win->window.layout = _win_create_simple_layout();
new_win->message_buffer = buffer_create();
new_win->conversation_history = g_string_new("");
new_win->session = session ? ai_session_ref(session) : NULL;
new_win->memcheck = PROFAIWIN_MEMCHECK;
return &new_win->window;
}
gchar*
win_get_title(ProfWin* window)
{
@@ -407,6 +442,13 @@ win_get_title(ProfWin* window)
return g_string_free(title, FALSE);
}
case WIN_AI:
{
const ProfAiWin* aiwin = (ProfAiWin*)window;
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
return g_strdup_printf("AI Assistant (%s: %s)", aiwin->session->provider_name, aiwin->session->model);
}
}
assert(FALSE);
}
@@ -454,6 +496,10 @@ win_get_tab_identifier(ProfWin* window)
{
return strdup("vcard");
}
case WIN_AI:
{
return strdup("ai");
}
}
assert(FALSE);
}
@@ -536,6 +582,12 @@ win_to_string(ProfWin* window)
ProfVcardWin* vcardwin = (ProfVcardWin*)window;
return vcardwin_get_string(vcardwin);
}
case WIN_AI:
{
ProfAiWin* aiwin = (ProfAiWin*)window;
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
return aiwin_get_string(aiwin);
}
}
assert(FALSE);
}
@@ -662,6 +714,17 @@ win_free(ProfWin* window)
free(pluginwin->plugin_name);
break;
}
case WIN_AI:
{
ProfAiWin* aiwin = (ProfAiWin*)window;
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
buffer_free(aiwin->message_buffer);
g_string_free(aiwin->conversation_history, TRUE);
if (aiwin->session) {
ai_session_unref(aiwin->session);
}
break;
}
default:
break;
}
@@ -676,11 +739,13 @@ win_page_up(ProfWin* window, int scroll_size)
int* page_start = &(window->layout->y_pos);
int page_start_initial = *page_start;
// Size of the visible chat page
int page_space = getmaxy(stdscr) - 4;
int page_space = win_page_space();
if (scroll_size == 0)
scroll_size = page_space;
win_scroll_state_t* scroll_state = &window->scroll_state;
*scroll_state = (*scroll_state == WIN_SCROLL_REACHED_BOTTOM) ? WIN_SCROLL_INNER : *scroll_state;
ScrollEdges* edges = &window->scroll_edges;
// Going up means the bottom of the DB may have new content again; allow
// a re-fetch on the next page-down.
edges->db_exhausted_below = FALSE;
*page_start -= scroll_size;
gboolean has_scroll_past_buffer = *page_start < 0;
@@ -690,16 +755,18 @@ win_page_up(ProfWin* window, int scroll_size)
gboolean is_still_fetching_mam = first_entry && (first_entry->theme_item == THEME_ROOMINFO && g_strcmp0(first_entry->message, LOADING_MESSAGE) == 0);
if (!is_still_fetching_mam) {
// WIN_SCROLL_REACHED_TOP means that we reached top of DB and there is no need to refetch data from it
gboolean is_db_offset_jump_needed = false;
if (*scroll_state != WIN_SCROLL_REACHED_TOP) {
// db_exhausted_above suppresses further upward DB fetches; once
// a previous fetch returned empty, there is nothing older.
gboolean is_db_offset_jump_needed = FALSE;
if (!edges->db_exhausted_above) {
db_history_result_t db_response = chatwin_db_history(chatwin, NULL, NULL, TRUE);
is_db_offset_jump_needed = db_response == DB_RESPONSE_SUCCESS;
*scroll_state = db_response == DB_RESPONSE_EMPTY ? WIN_SCROLL_REACHED_TOP : WIN_SCROLL_INNER;
log_debug("Scroll state after DB history fetch: %s", *scroll_state ? "WIN_SCROLL_REACHED_TOP" : "WIN_SCROLL_INNER");
edges->db_exhausted_above = (db_response == DB_RESPONSE_EMPTY);
log_debug("Scroll edges after DB history fetch: above=%d below=%d",
edges->db_exhausted_above, edges->db_exhausted_below);
}
if (*scroll_state == WIN_SCROLL_REACHED_TOP) {
if (edges->db_exhausted_above) {
*page_start = 0;
if (prefs_get_boolean(PREF_MAM)) {
win_print_loading_history(window);
@@ -731,9 +798,7 @@ win_page_up(ProfWin* window, int scroll_size)
win_update_virtual(window);
}
// Update scrolling state
int total_rows = getcury(window->layout->win);
window->layout->paged = (total_rows) - *page_start > page_space;
// paged is derived on demand from y_pos / total_rows; nothing to set here.
}
void
@@ -742,13 +807,15 @@ win_page_down(ProfWin* window, int scroll_size)
int total_rows = getcury(window->layout->win);
int total_rows_with_unread = total_rows + window->layout->unread_msg;
int* page_start = &(window->layout->y_pos);
int page_space = getmaxy(stdscr) - 4;
int page_space = win_page_space();
int page_start_initial = *page_start;
if (scroll_size == 0)
scroll_size = page_space;
win_scroll_state_t* scroll_state = &window->scroll_state;
*scroll_state = (*scroll_state == WIN_SCROLL_REACHED_TOP) ? WIN_SCROLL_INNER : *scroll_state;
ScrollEdges* edges = &window->scroll_edges;
// Going down means the top of the DB may have new content again; allow
// a re-fetch on the next page-up (e.g. MAM late delivery).
edges->db_exhausted_above = FALSE;
*page_start += scroll_size;
@@ -759,7 +826,7 @@ win_page_down(ProfWin* window, int scroll_size)
if ((past_bottom || at_page_space_and_past_unread) && is_chat) {
int bf_size = buffer_size(window->layout->buffer);
if (bf_size > 0 && *scroll_state != WIN_SCROLL_REACHED_BOTTOM) {
if (bf_size > 0 && !edges->db_exhausted_below) {
// How many lines are left until end of the screen
int current_offset = total_rows - *page_start;
GDateTime* now = g_date_time_new_now_local();
@@ -768,7 +835,7 @@ win_page_down(ProfWin* window, int scroll_size)
auto_gchar gchar* end_date = g_date_time_format_iso8601(now);
db_history_result_t db_response = chatwin_db_history((ProfChatWin*)window, start, end_date, FALSE);
if (db_response == DB_RESPONSE_EMPTY)
*scroll_state = WIN_SCROLL_REACHED_BOTTOM;
edges->db_exhausted_below = TRUE;
// similar to page_up (see explanation there)
// Get offset of the message that was the latest prior to loading messages from the DB
@@ -780,26 +847,22 @@ win_page_down(ProfWin* window, int scroll_size)
total_rows = getcury(window->layout->win);
// near the end, but will display only part of the page space, move a bit back to show full page
if ((total_rows - *page_start) < page_space) {
*page_start = total_rows - page_space;
// went past end, show last page
} else if (*page_start >= total_rows) {
*page_start = total_rows - page_space - 1;
// Clamp to the last full page so the viewport never goes past the buffer
// and always shows a full page when one is available.
int last_page_start = total_rows > page_space ? total_rows - page_space : 0;
if (*page_start > last_page_start) {
*page_start = last_page_start;
}
window->layout->paged = 1;
// update only if position has changed
if ((page_start_initial != *page_start) || window->layout->unread_msg) {
win_update_virtual(window);
}
/* Switch off page if no messages left to read.
* TODO: update buffer end handling to check messages just after last entry.
*/
if (*scroll_state == WIN_SCROLL_REACHED_BOTTOM) {
window->layout->paged = 0;
// Once the user is back at the buffer's last page the unread badge no
// longer reflects anything they have not seen — clear it. paged itself
// is derived from y_pos so there is no flag to flip.
if (*page_start >= last_page_start) {
window->layout->unread_msg = 0;
}
}
@@ -808,8 +871,7 @@ void
win_sub_page_down(ProfWin* window)
{
if (window->layout->type == LAYOUT_SPLIT) {
int rows = getmaxy(stdscr);
int page_space = rows - 4;
int page_space = win_page_space();
ProfLayoutSplit* split_layout = (ProfLayoutSplit*)window->layout;
int sub_y = getcury(split_layout->subwin);
int* sub_y_pos = &(split_layout->sub_y_pos);
@@ -832,8 +894,7 @@ void
win_sub_page_up(ProfWin* window)
{
if (window->layout->type == LAYOUT_SPLIT) {
int rows = getmaxy(stdscr);
int page_space = rows - 4;
int page_space = win_page_space();
ProfLayoutSplit* split_layout = (ProfLayoutSplit*)window->layout;
int* sub_y_pos = &(split_layout->sub_y_pos);
@@ -860,7 +921,6 @@ win_clear(ProfWin* window)
int y = getcury(window->layout->win);
int* page_start = &(window->layout->y_pos);
*page_start = y;
window->layout->paged = 1;
window->layout->unread_msg = 0;
win_update_virtual(window);
}
@@ -970,14 +1030,12 @@ win_refresh_with_subwin(ProfWin* window)
void
win_move_to_end(ProfWin* window)
{
window->layout->paged = 0;
window->layout->unread_msg = 0;
int rows = getmaxy(stdscr);
int y = getcury(window->layout->win);
int size = rows - 3;
int page_space = win_page_space();
window->layout->y_pos = y - (size - 1);
window->layout->y_pos = y - page_space;
if (window->layout->y_pos < 0) {
window->layout->y_pos = 0;
}
@@ -1754,12 +1812,13 @@ win_newline(ProfWin* window)
static void
_win_printf(ProfWin* window, const char* show_char, int pad_indent, GDateTime* timestamp, int flags, theme_item_t theme_item, const char* const display_from, const char* const from_jid, const char* const message_id, const char* const message, ...)
{
/* Prevent printing and buffer update when user is viewing message history [SCROLLING]*/
if (window->layout->paged && wins_is_current(window)) {
window->layout->unread_msg++;
return;
}
/* Append the message to the curses pad and the in-memory buffer
* unconditionally. When the user is scrolled up (paged), the new line
* lands on rows below the visible viewport so it does not disturb what
* they are reading; they encounter it naturally on page-down. The
* unread badge is bumped so the title bar still reflects new activity.
*/
const gboolean is_paged_now = win_is_paged(window) && wins_is_current(window);
if (timestamp == NULL) {
timestamp = g_date_time_new_now_local();
@@ -1776,6 +1835,10 @@ _win_printf(ProfWin* window, const char* show_char, int pad_indent, GDateTime* t
_win_print_internal(window, show_char, pad_indent, timestamp, flags, theme_item, display_from, msg, NULL);
buffer_append(window->layout->buffer, show_char, pad_indent, timestamp, flags, theme_item, display_from, from_jid, msg, NULL, message_id, y_start_pos, getcury(window->layout->win));
if (is_paged_now) {
window->layout->unread_msg++;
}
inp_nonblocking(TRUE);
g_date_time_unref(timestamp);

View File

@@ -81,6 +81,8 @@ void win_redraw(ProfWin* window);
void win_print_loading_history(ProfWin* window);
int win_roster_cols(void);
int win_occpuants_cols(void);
int win_page_space(void);
gboolean win_is_paged(const ProfWin* window);
void win_sub_print(WINDOW* win, char* msg, gboolean newline, gboolean wrap, int indent);
void win_sub_newline_lazy(WINDOW* win);
void win_mark_received(ProfWin* window, const char* const id);

View File

@@ -44,9 +44,11 @@
#include "common.h"
#include "config/preferences.h"
#include "log.h"
#include "config/theme.h"
#include "plugins/plugins.h"
#include "ui/ui.h"
#include "ui/window.h"
#include "ui/window_list.h"
#include "xmpp/xmpp.h"
#include "xmpp/roster_list.h"
@@ -374,6 +376,8 @@ wins_set_current_by_num(int i)
status_bar_active(1, conswin->type, "console");
}
}
} else {
log_error("Window not found for index %d", i);
}
}
@@ -714,6 +718,20 @@ wins_new_vcard(vCard* vcard)
return newwin;
}
ProfWin*
wins_new_ai(AISession* session)
{
int result = _wins_get_next_available_num(keys);
ProfWin* newwin = win_create_ai(session);
if (!newwin) {
return NULL;
}
_wins_htable_insert(windows, GINT_TO_POINTER(result), newwin);
autocomplete_add(wins_ac, "ai");
autocomplete_add(wins_close_ac, "ai");
return newwin;
}
gboolean
wins_do_notify_remind(void)
{
@@ -841,7 +859,7 @@ wins_get_prune_wins(void)
while (curr) {
ProfWin* window = curr->data;
if (win_unread(window) == 0 && window->type != WIN_MUC && window->type != WIN_CONFIG && window->type != WIN_XML && window->type != WIN_CONSOLE) {
if (win_unread(window) == 0 && window->type != WIN_MUC && window->type != WIN_CONFIG && window->type != WIN_XML && window->type != WIN_CONSOLE && window->type != WIN_AI) {
result = g_slist_append(result, window);
}
curr = g_list_next(curr);
@@ -849,6 +867,45 @@ wins_get_prune_wins(void)
return result;
}
ProfAiWin*
wins_get_ai(void)
{
GList* curr = values;
while (curr) {
ProfWin* window = curr->data;
if (window->type == WIN_AI) {
ProfAiWin* aiwin = (ProfAiWin*)window;
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
return aiwin;
}
curr = g_list_next(curr);
}
return NULL;
}
gboolean
wins_ai_exists(ProfAiWin* const aiwin)
{
if (!aiwin) {
return FALSE;
}
GList* curr = values;
while (curr) {
ProfWin* window = curr->data;
if (window->type == WIN_AI) {
if ((ProfAiWin*)window == aiwin) {
return TRUE;
}
}
curr = g_list_next(curr);
}
return FALSE;
}
void
wins_lost_connection(void)
{

View File

@@ -63,6 +63,8 @@ ProfPrivateWin* wins_get_private(const char* const fulljid);
ProfPluginWin* wins_get_plugin(const char* const tag);
ProfXMLWin* wins_get_xmlconsole(void);
ProfVcardWin* wins_get_vcard(void);
ProfAiWin* wins_get_ai(void);
gboolean wins_ai_exists(ProfAiWin* const aiwin);
void wins_close_plugin(char* tag);

View File

@@ -0,0 +1,869 @@
#include "prof_cmocka.h"
#include "common.h"
#include "ai/ai_client.h"
#include <stdlib.h>
/* ========================================================================
* Setup/Teardown
* ======================================================================== */
int
ai_client_setup(void** state)
{
ai_client_init();
return 0;
}
int
ai_client_teardown(void** state)
{
ai_client_shutdown();
return 0;
}
/* ========================================================================
* Provider Management Tests
* ======================================================================== */
void
test_ai_client_init(void** state)
{
/* After init, default providers should exist */
AIProvider* openai = ai_get_provider("openai");
assert_non_null(openai);
assert_string_equal("openai", openai->name);
assert_string_equal("https://api.openai.com/", openai->api_url);
AIProvider* perplexity = ai_get_provider("perplexity");
assert_non_null(perplexity);
assert_string_equal("perplexity", perplexity->name);
assert_string_equal("https://api.perplexity.ai/", perplexity->api_url);
}
void
test_ai_add_provider(void** state)
{
/* Add a custom provider (hash table owns ref; caller gets non-owning pointer) */
AIProvider* provider = ai_add_provider("custom", "https://custom.api.com/v1", "my-org");
assert_non_null(provider);
assert_string_equal("custom", provider->name);
assert_string_equal("https://custom.api.com/v1", provider->api_url);
assert_string_equal("my-org", provider->org_id);
/* Update existing provider (returns ref; caller owns it) */
AIProvider* updated = ai_add_provider("custom", "https://new.api.com/v1", NULL);
assert_non_null(updated);
assert_string_equal("https://new.api.com/v1", updated->api_url);
assert_null(updated->org_id);
ai_provider_unref(updated);
}
void
test_ai_remove_provider(void** state)
{
/* Remove default provider should fail */
assert_false(ai_remove_provider("nonexistent"));
/* Add and remove custom provider */
ai_add_provider("temp", "https://temp.api.com/v1", NULL);
assert_true(ai_remove_provider("temp"));
assert_null(ai_get_provider("temp"));
}
void
test_ai_list_providers(void** state)
{
GList* providers = ai_list_providers();
assert_non_null(providers);
assert_int_equal(2, g_list_length(providers)); /* openai and perplexity */
/* Free list (ai_list_providers returns non-ref'd providers; caller must not unref) */
g_list_free(providers);
/* Add another provider */
ai_add_provider("test", "https://test.api.com/v1", NULL);
providers = ai_list_providers();
assert_int_equal(3, g_list_length(providers));
/* Free list */
g_list_free(providers);
}
/* ========================================================================
* API Key Tests
* ======================================================================== */
void
test_ai_set_provider_key(void** state)
{
ai_set_provider_key("openai", "sk-test-key-123");
{
auto_gchar gchar* key = ai_get_provider_key("openai");
assert_non_null(key);
assert_string_equal("sk-test-key-123", key);
}
/* Update key */
ai_set_provider_key("openai", "sk-new-key-456");
{
auto_gchar gchar* key = ai_get_provider_key("openai");
assert_string_equal("sk-new-key-456", key);
}
/* Remove key */
ai_set_provider_key("openai", NULL);
{
auto_gchar gchar* key = ai_get_provider_key("openai");
assert_null(key);
}
}
void
test_ai_get_provider_key(void** state)
{
/* No key set initially */
{
auto_gchar gchar* key = ai_get_provider_key("openai");
assert_null(key);
}
/* Set and get key */
ai_set_provider_key("perplexity", "pplx-abc123");
{
auto_gchar gchar* key = ai_get_provider_key("perplexity");
assert_non_null(key);
assert_string_equal("pplx-abc123", key);
}
/* Wrong provider returns null */
{
auto_gchar gchar* key = ai_get_provider_key("openai");
assert_null(key);
}
}
/* ========================================================================
* Session Tests
* ======================================================================== */
void
test_ai_session_create(void** state)
{
AISession* session = ai_session_create("openai", "gpt-4");
assert_non_null(session);
assert_string_equal("openai", session->provider_name);
assert_string_equal("gpt-4", session->model);
assert_null(session->api_key); /* No key set */
ai_session_unref(session);
}
void
test_ai_session_ref_unref(void** state)
{
AISession* session = ai_session_create("openai", "gpt-4");
assert_non_null(session);
/* Reference */
AISession* ref = ai_session_ref(session);
assert_true(ref == session);
/* Unreference twice */
ai_session_unref(session);
ai_session_unref(ref); /* Should free here */
}
void
test_ai_session_add_message(void** state)
{
AISession* session = ai_session_create("openai", "gpt-4");
assert_non_null(session);
ai_session_add_message(session, "user", "Hello");
ai_session_add_message(session, "assistant", "Hi there!");
assert_int_equal(2, g_list_length(session->history));
AIMessage* first = session->history->data;
assert_string_equal("user", first->role);
assert_string_equal("Hello", first->content);
AIMessage* second = g_list_next(session->history)->data;
assert_string_equal("assistant", second->role);
assert_string_equal("Hi there!", second->content);
ai_session_unref(session);
}
void
test_ai_session_clear_history(void** state)
{
AISession* session = ai_session_create("openai", "gpt-4");
ai_session_add_message(session, "user", "Message 1");
ai_session_add_message(session, "user", "Message 2");
ai_session_add_message(session, "assistant", "Response");
assert_int_equal(3, g_list_length(session->history));
ai_session_clear_history(session);
assert_int_equal(0, g_list_length(session->history));
ai_session_unref(session);
}
void
test_ai_session_set_model(void** state)
{
AISession* session = ai_session_create("openai", "gpt-4");
assert_string_equal("gpt-4", session->model);
ai_session_set_model(session, "gpt-3.5-turbo");
assert_string_equal("gpt-3.5-turbo", session->model);
ai_session_unref(session);
}
/* ========================================================================
* JSON Escape Tests
* ======================================================================== */
void
test_ai_json_escape(void** state)
{
gchar* escaped = ai_json_escape("hello \"world\"");
assert_string_equal("hello \\\"world\\\"", escaped);
g_free(escaped);
}
void
test_ai_json_escape_null(void** state)
{
gchar* escaped = ai_json_escape(NULL);
assert_string_equal("", escaped);
g_free(escaped);
}
void
test_ai_json_escape_empty(void** state)
{
gchar* escaped = ai_json_escape("");
assert_string_equal("", escaped);
g_free(escaped);
}
void
test_ai_json_escape_special_chars(void** state)
{
gchar* escaped = ai_json_escape("line1\nline2\ttab\\backslash\"quote");
assert_string_equal("line1\\nline2\\ttab\\\\backslash\\\"quote", escaped);
g_free(escaped);
}
void
test_ai_json_escape_percent_signs(void** state)
{
/* Critical: % characters in content must be escaped for JSON, not treated as format specifiers */
gchar* escaped = ai_json_escape("100% complete with %s and %d format strings");
assert_string_equal("100% complete with %s and %d format strings", escaped);
g_free(escaped);
}
void
test_ai_json_escape_backslash_quote(void** state)
{
/* Test escaped quote handling */
gchar* escaped = ai_json_escape("He said \"hello\" and \\ goodbye");
assert_string_equal("He said \\\"hello\\\" and \\\\ goodbye", escaped);
g_free(escaped);
}
void
test_ai_session_api_key_is_copied(void** state)
{
/* Verify that session owns its own copy of the API key */
ai_set_provider_key("openai", "sk-test-key-123");
AISession* session = ai_session_create("openai", "gpt-4");
assert_non_null(session);
assert_string_equal("sk-test-key-123", session->api_key);
/* Remove the provider key - session should still have its copy */
ai_set_provider_key("openai", NULL);
assert_non_null(session->api_key);
assert_string_equal("sk-test-key-123", session->api_key);
ai_session_unref(session);
}
void
test_ai_add_provider_update_existing(void** state)
{
/* Add a provider (hash table owns ref) */
AIProvider* provider = ai_add_provider("custom", "https://first.api.com/v1", "org1");
assert_non_null(provider);
assert_string_equal("https://first.api.com/v1", provider->api_url);
assert_string_equal("org1", provider->org_id);
/* Update the same provider (returns ref) */
provider = ai_add_provider("custom", "https://second.api.com/v1", "org2");
assert_non_null(provider);
assert_string_equal("https://second.api.com/v1", provider->api_url);
assert_string_equal("org2", provider->org_id);
ai_provider_unref(provider);
}
void
test_ai_add_provider_null_name_returns_null(void** state)
{
assert_null(ai_add_provider(NULL, "https://api.com/v1", NULL));
}
void
test_ai_add_provider_null_url_returns_null(void** state)
{
assert_null(ai_add_provider("test", NULL, NULL));
}
void
test_ai_session_create_null_provider_returns_null(void** state)
{
assert_null(ai_session_create("nonexistent", "gpt-4"));
}
void
test_ai_session_create_null_model_returns_null(void** state)
{
assert_null(ai_session_create("openai", NULL));
}
void
test_ai_session_api_key_null_when_no_key_set(void** state)
{
/* openai has no key set by default */
AISession* session = ai_session_create("openai", "gpt-4");
assert_non_null(session);
assert_null(session->api_key);
ai_session_unref(session);
}
/* ========================================================================
* Provider Autocomplete Tests
* ======================================================================== */
void
test_ai_providers_find_forward(void** state)
{
/* Test forward iteration - should return first match */
auto_gchar gchar* result = ai_providers_find("o", FALSE, NULL);
assert_non_null(result);
assert_string_equal("openai", result);
}
void
test_ai_providers_find_forward_perplexity(void** state)
{
/* Test forward iteration for perplexity */
auto_gchar gchar* result = ai_providers_find("p", FALSE, NULL);
assert_non_null(result);
assert_string_equal("perplexity", result);
}
void
test_ai_providers_find_forward_custom(void** state)
{
/* Add a custom provider and test */
ai_add_provider("custom", "https://custom.api.com/v1", NULL);
auto_gchar gchar* result = ai_providers_find("c", FALSE, NULL);
assert_non_null(result);
assert_string_equal("custom", result);
}
void
test_ai_providers_find_forward_no_match(void** state)
{
/* Test no match */
auto_gchar gchar* result = ai_providers_find("z", FALSE, NULL);
assert_null(result);
}
void
test_ai_providers_find_forward_partial_match(void** state)
{
/* Test partial match - should return providers starting with "ope" */
auto_gchar gchar* result = ai_providers_find("ope", FALSE, NULL);
assert_non_null(result);
assert_string_equal("openai", result);
}
void
test_ai_providers_find_next(void** state)
{
/* Test that stateless implementation returns same result each call */
auto_gchar gchar* result1 = ai_providers_find("o", FALSE, NULL);
assert_non_null(result1);
assert_string_equal("openai", result1);
/* Second call with same params returns same result (stateless) */
auto_gchar gchar* result2 = ai_providers_find("o", FALSE, NULL);
assert_non_null(result2);
assert_string_equal("openai", result2);
}
void
test_ai_providers_find_previous(void** state)
{
/* Test that previous=TRUE returns last match in list */
/* With only "openai" starting with "o", both FALSE and TRUE return same result */
auto_gchar gchar* result1 = ai_providers_find("o", FALSE, NULL);
assert_non_null(result1);
assert_string_equal("openai", result1);
/* previous=TRUE also returns "openai" (only one match, so first==last) */
auto_gchar gchar* result2 = ai_providers_find("o", TRUE, NULL);
assert_non_null(result2);
assert_string_equal("openai", result2);
}
void
test_ai_providers_find_null_search_str(void** state)
{
/* NULL search_str triggers cycling: returns first provider in list */
auto_gchar gchar* result = ai_providers_find(NULL, FALSE, NULL);
assert_non_null(result);
assert_string_equal("openai", result);
}
void
test_ai_providers_find_empty_search_str(void** state)
{
/* Empty search_str triggers cycling: returns first provider in list */
auto_gchar gchar* result = ai_providers_find("", FALSE, NULL);
assert_non_null(result);
assert_string_equal("openai", result);
}
void
test_ai_providers_find_case_insensitive(void** state)
{
/* Test that matching is case-insensitive (via g_ascii_strdown) */
auto_gchar gchar* result = ai_providers_find("OPENAI", FALSE, NULL);
assert_non_null(result);
assert_string_equal("openai", result);
result = ai_providers_find("OpenAI", FALSE, NULL);
assert_non_null(result);
assert_string_equal("openai", result);
result = ai_providers_find("openai", FALSE, NULL);
assert_non_null(result);
assert_string_equal("openai", result);
}
/* ========================================================================
* AI Autocomplete Integration Tests
* ======================================================================== */
void
test_ai_start_provider_autocomplete_only_on_exact(void** state)
{
auto_gchar gchar* result = ai_providers_find("", FALSE, NULL);
assert_non_null(result);
assert_string_equal("openai", result);
}
void
test_ai_models_find_null_session(void** state)
{
auto_gchar gchar* result = ai_models_find("gpt", FALSE, NULL);
assert_null(result);
}
void
test_ai_models_find_null_provider(void** state)
{
ai_add_provider("temp", "https://temp.api.com/v1", NULL);
AISession* session = ai_session_create("temp", "gpt-4");
assert_non_null(session);
ai_session_unref(session);
}
void
test_ai_providers_find_cycling(void** state)
{
/* NULL search_str should cycle through providers */
auto_gchar gchar* result1 = ai_providers_find(NULL, FALSE, NULL);
assert_non_null(result1);
auto_gchar gchar* result2 = ai_providers_find(NULL, FALSE, NULL);
assert_non_null(result2);
/* Cycling: consecutive calls with NULL should return different providers */
assert_string_not_equal(result1, result2);
}
/* ========================================================================
* Provider Default Model and Settings Tests
* ======================================================================== */
void
test_ai_set_provider_default_model(void** state)
{
/* Set default model for openai provider */
ai_set_provider_default_model("openai", "gpt-5");
/* Verify the model was set */
const gchar* model = ai_get_provider_default_model("openai");
assert_non_null(model);
assert_string_equal("gpt-5", model);
/* Update default model */
ai_set_provider_default_model("openai", "gpt-4o");
model = ai_get_provider_default_model("openai");
assert_non_null(model);
assert_string_equal("gpt-4o", model);
/* Set default model for perplexity */
ai_set_provider_default_model("perplexity", "sonar-pro");
model = ai_get_provider_default_model("perplexity");
assert_non_null(model);
assert_string_equal("sonar-pro", model);
}
void
test_ai_get_provider_default_model(void** state)
{
/* NULL provider returns NULL */
assert_null(ai_get_provider_default_model(NULL));
/* Non-existent provider returns NULL */
assert_null(ai_get_provider_default_model("nonexistent"));
/* Default providers may or may not have default models set initially */
/* After setting, they should return the model */
ai_set_provider_default_model("openai", "test-model");
assert_string_equal("test-model", ai_get_provider_default_model("openai"));
/* NULL model argument should be ignored (no change) */
ai_set_provider_default_model("openai", NULL);
/* After setting NULL, the model should still be "test-model" because
* ai_set_provider_default_model returns early on NULL model */
assert_string_equal("test-model", ai_get_provider_default_model("openai"));
}
void
test_ai_set_provider_setting(void** state)
{
/* NULL provider should be ignored */
ai_set_provider_setting(NULL, "tools", "enabled");
/* NULL setting should be ignored */
ai_set_provider_setting("openai", NULL, "enabled");
/* Non-existent provider should log warning but not crash */
ai_set_provider_setting("nonexistent", "tools", "enabled");
/* Set a setting for openai */
ai_set_provider_setting("openai", "tools", "enabled");
ai_set_provider_setting("openai", "search", "disabled");
/* Verify settings were set */
gchar* tools = ai_get_provider_setting("openai", "tools");
assert_non_null(tools);
assert_string_equal("enabled", tools);
g_free(tools);
gchar* search = ai_get_provider_setting("openai", "search");
assert_non_null(search);
assert_string_equal("disabled", search);
g_free(search);
/* NULL value removes the setting */
ai_set_provider_setting("openai", "tools", NULL);
tools = ai_get_provider_setting("openai", "tools");
assert_null(tools);
/* Setting that was never set returns NULL */
assert_null(ai_get_provider_setting("openai", "nonexistent_setting"));
}
void
test_ai_get_provider_setting(void** state)
{
/* NULL provider returns NULL */
assert_null(ai_get_provider_setting(NULL, "tools"));
/* NULL setting returns NULL */
assert_null(ai_get_provider_setting("openai", NULL));
/* Non-existent provider returns NULL */
assert_null(ai_get_provider_setting("nonexistent", "tools"));
/* Provider without settings returns NULL */
assert_null(ai_get_provider_setting("perplexity", "tools"));
/* Set and get setting */
ai_set_provider_setting("openai", "temperature", "0.7");
gchar* temp = ai_get_provider_setting("openai", "temperature");
assert_non_null(temp);
assert_string_equal("0.7", temp);
g_free(temp);
/* Setting value is a copy (caller must free) */
ai_set_provider_setting("openai", "max_tokens", "2048");
temp = ai_get_provider_setting("openai", "max_tokens");
assert_non_null(temp);
assert_string_equal("2048", temp);
g_free(temp);
}
/* ========================================================================
* Model Caching Tests
* ======================================================================== */
void
test_ai_models_are_fresh_initial(void** state)
{
/* After init, models should be fresh (or at least not crash) */
/* The ai_client_init() loads models from prefs, which may be empty initially */
/* So we just verify the function works correctly */
/* NULL provider returns FALSE */
assert_false(ai_models_are_fresh(NULL));
/* Non-existent provider returns FALSE */
assert_false(ai_models_are_fresh("nonexistent"));
/* Default providers - check that function doesn't crash */
/* Freshness depends on whether models were loaded from prefs */
gboolean fresh_openai = ai_models_are_fresh("openai");
assert_true(fresh_openai == TRUE || fresh_openai == FALSE); /* Just verify no crash */
gboolean fresh_perplexity = ai_models_are_fresh("perplexity");
assert_true(fresh_perplexity == TRUE || fresh_perplexity == FALSE); /* Just verify no crash */
}
/* ========================================================================
* Model Parsing Tests
* ======================================================================== */
void
test_ai_parse_models_openai_format(void** state)
{
/* Test parsing OpenAI-compatible API response format */
/* Format: {"object":"list","data":[{"id":"model1",...},...]} */
ai_client_init();
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
/* JSON response from Perplexity/OpenAI API */
const gchar* json = "{\"object\":\"list\","
"\"data\":["
"{\"id\":\"anthropic/claude-haiku-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
"{\"id\":\"anthropic/claude-opus-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
"{\"id\":\"openai/gpt-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
"{\"id\":\"google/gemini-3-flash-preview\",\"object\":\"model\",\"created\":0,\"owned_by\":\"google\"}"
"]}";
ai_parse_models_from_json(provider, json);
/* Verify models were parsed correctly */
assert_int_equal(4, g_list_length(provider->models));
/* Check specific models */
GList* models = provider->models;
assert_string_equal("anthropic/claude-haiku-4-5", models->data);
models = g_list_next(models);
assert_string_equal("anthropic/claude-opus-4-5", models->data);
models = g_list_next(models);
assert_string_equal("openai/gpt-5", models->data);
models = g_list_next(models);
assert_string_equal("google/gemini-3-flash-preview", models->data);
ai_provider_unref(provider);
ai_client_shutdown();
}
void
test_ai_parse_models_perplexity_format(void** state)
{
/* Test parsing actual Perplexity API response with all models from curl test */
ai_client_init();
AIProvider* provider = ai_add_provider("perplexity", "https://api.perplexity.ai/", NULL);
const gchar* json = "{\"object\":\"list\","
"\"data\":["
"{\"id\":\"anthropic/claude-haiku-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
"{\"id\":\"anthropic/claude-opus-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
"{\"id\":\"anthropic/claude-opus-4-6\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
"{\"id\":\"anthropic/claude-opus-4-7\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
"{\"id\":\"anthropic/claude-sonnet-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
"{\"id\":\"anthropic/claude-sonnet-4-6\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
"{\"id\":\"google/gemini-3-flash-preview\",\"object\":\"model\",\"created\":0,\"owned_by\":\"google\"},"
"{\"id\":\"google/gemini-3.1-pro-preview\",\"object\":\"model\",\"created\":0,\"owned_by\":\"google\"},"
"{\"id\":\"nvidia/nemotron-3-super-120b-a12b\",\"object\":\"model\",\"created\":0,\"owned_by\":\"nvidia\"},"
"{\"id\":\"openai/gpt-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
"{\"id\":\"openai/gpt-5-mini\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
"{\"id\":\"openai/gpt-5.1\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
"{\"id\":\"openai/gpt-5.2\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
"{\"id\":\"openai/gpt-5.4\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
"{\"id\":\"openai/gpt-5.4-mini\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
"{\"id\":\"openai/gpt-5.4-nano\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
"{\"id\":\"openai/gpt-5.5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
"{\"id\":\"perplexity/sonar\",\"object\":\"model\",\"created\":0,\"owned_by\":\"perplexity\"},"
"{\"id\":\"xai/grok-4-1-fast-non-reasoning\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"},"
"{\"id\":\"xai/grok-4.20-multi-agent\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"},"
"{\"id\":\"xai/grok-4.20-non-reasoning\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"},"
"{\"id\":\"xai/grok-4.20-reasoning\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"},"
"{\"id\":\"xai/grok-4.3\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"}"
"]}";
ai_parse_models_from_json(provider, json);
/* Verify all 23 models were parsed correctly */
assert_int_equal(23, g_list_length(provider->models));
/* Verify NO field names or owned_by values are in the model list */
/* The bug was that "id", "object", "model", "created", "owned_by" and provider names
* like "anthropic", "google", "openai", "nvidia", "perplexity", "xai" were extracted */
GList* models = provider->models;
while (models) {
const gchar* model = (const gchar*)models->data;
/* These should never be model IDs */
assert_false(g_strcmp0(model, "id") == 0);
assert_false(g_strcmp0(model, "object") == 0);
assert_false(g_strcmp0(model, "model") == 0);
assert_false(g_strcmp0(model, "created") == 0);
assert_false(g_strcmp0(model, "owned_by") == 0);
assert_false(g_strcmp0(model, "list") == 0);
/* These are owned_by values, not model IDs */
assert_false(g_strcmp0(model, "anthropic") == 0);
assert_false(g_strcmp0(model, "google") == 0);
assert_false(g_strcmp0(model, "openai") == 0);
assert_false(g_strcmp0(model, "nvidia") == 0);
assert_false(g_strcmp0(model, "perplexity") == 0);
assert_false(g_strcmp0(model, "xai") == 0);
models = g_list_next(models);
}
/* Verify first and last model IDs */
models = provider->models;
assert_string_equal("anthropic/claude-haiku-4-5", models->data);
models = g_list_last(models);
assert_string_equal("xai/grok-4.3", models->data);
ai_provider_unref(provider);
ai_client_shutdown();
}
void
test_ai_parse_models_array_format(void** state)
{
/* Test parsing simple array format: ["model1", "model2", ...] */
ai_client_init();
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
const gchar* json = "[\"model1\", \"model2\", \"model3\"]";
ai_parse_models_from_json(provider, json);
assert_int_equal(3, g_list_length(provider->models));
GList* models = provider->models;
assert_string_equal("model1", models->data);
models = g_list_next(models);
assert_string_equal("model2", models->data);
models = g_list_next(models);
assert_string_equal("model3", models->data);
ai_provider_unref(provider);
ai_client_shutdown();
}
void
test_ai_parse_models_empty_json(void** state)
{
/* Test parsing empty/invalid JSON */
ai_client_init();
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
ai_parse_models_from_json(provider, "");
assert_null(provider->models);
assert_int_equal(0, g_list_length(provider->models));
ai_parse_models_from_json(provider, "{\"invalid\": true}");
assert_null(provider->models);
assert_int_equal(0, g_list_length(provider->models));
ai_provider_unref(provider);
ai_client_shutdown();
}
void
test_ai_parse_models_null_handling(void** state)
{
/* Test NULL handling */
ai_client_init();
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
/* NULL json should not crash */
ai_parse_models_from_json(provider, NULL);
assert_null(provider->models);
ai_provider_unref(provider);
ai_client_shutdown();
}
void
test_ai_parse_models_escaped_quotes(void** state)
{
/* Test parsing model IDs with escaped quotes */
ai_client_init();
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
const gchar* json = "{\"object\":\"list\","
"\"data\":["
"{\"id\":\"test/model\\\"with\\\"quotes\",\"object\":\"model\",\"created\":0,\"owned_by\":\"test\"}"
"]}";
ai_parse_models_from_json(provider, json);
assert_int_equal(1, g_list_length(provider->models));
/* Model ID should have the escaped quotes handled */
GList* models = provider->models;
assert_non_null(models);
ai_provider_unref(provider);
ai_client_shutdown();
}
void
test_ai_parse_models_with_whitespace(void** state)
{
/* Test parsing JSON with whitespace after colons */
ai_client_init();
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
/* JSON with spaces after colons (common in formatted JSON) */
const gchar* json = "{ \"object\": \"list\", "
"\"data\": [ "
"{ \"id\": \"model-with-spaces\", \"object\": \"model\", \"created\": 0, \"owned_by\": \"test\" }"
"]}";
ai_parse_models_from_json(provider, json);
assert_int_equal(1, g_list_length(provider->models));
GList* models = provider->models;
assert_string_equal("model-with-spaces", models->data);
ai_provider_unref(provider);
ai_client_shutdown();
}

View File

@@ -0,0 +1,62 @@
/*
* test_ai_client.h - AI client unit test declarations
*/
int ai_client_setup(void** state);
int ai_client_teardown(void** state);
void test_ai_client_init(void** state);
void test_ai_add_provider(void** state);
void test_ai_remove_provider(void** state);
void test_ai_list_providers(void** state);
void test_ai_set_provider_key(void** state);
void test_ai_get_provider_key(void** state);
void test_ai_session_create(void** state);
void test_ai_session_ref_unref(void** state);
void test_ai_session_add_message(void** state);
void test_ai_session_clear_history(void** state);
void test_ai_session_set_model(void** state);
void test_ai_json_escape(void** state);
void test_ai_json_escape_null(void** state);
void test_ai_json_escape_empty(void** state);
void test_ai_json_escape_special_chars(void** state);
void test_ai_json_escape_percent_signs(void** state);
void test_ai_json_escape_backslash_quote(void** state);
void test_ai_session_api_key_is_copied(void** state);
void test_ai_add_provider_update_existing(void** state);
void test_ai_add_provider_null_name_returns_null(void** state);
void test_ai_add_provider_null_url_returns_null(void** state);
void test_ai_session_create_null_provider_returns_null(void** state);
void test_ai_session_create_null_model_returns_null(void** state);
void test_ai_session_api_key_null_when_no_key_set(void** state);
/* Provider autocomplete tests */
void test_ai_providers_find_forward(void** state);
void test_ai_providers_find_forward_perplexity(void** state);
void test_ai_providers_find_forward_custom(void** state);
void test_ai_providers_find_forward_no_match(void** state);
void test_ai_providers_find_forward_partial_match(void** state);
void test_ai_providers_find_next(void** state);
void test_ai_providers_find_previous(void** state);
void test_ai_providers_find_null_search_str(void** state);
void test_ai_providers_find_empty_search_str(void** state);
void test_ai_providers_find_case_insensitive(void** state);
/* Provider default model and settings tests */
void test_ai_set_provider_default_model(void** state);
void test_ai_get_provider_default_model(void** state);
void test_ai_set_provider_setting(void** state);
void test_ai_get_provider_setting(void** state);
/* Model caching tests */
void test_ai_models_are_fresh_initial(void** state);
/* Model parsing tests */
void test_ai_parse_models_openai_format(void** state);
void test_ai_parse_models_perplexity_format(void** state);
void test_ai_parse_models_array_format(void** state);
void test_ai_parse_models_empty_json(void** state);
void test_ai_parse_models_null_handling(void** state);
void test_ai_parse_models_escaped_quotes(void** state);
void test_ai_parse_models_with_whitespace(void** state);
/* AI autocomplete integration tests */
void test_ai_start_provider_autocomplete_only_on_exact(void** state);
void test_ai_models_find_null_session(void** state);
void test_ai_models_find_null_provider(void** state);
void test_ai_providers_find_cycling(void** state);

View File

@@ -0,0 +1,41 @@
/*
* stub_ai.c - Stubs for AI window functions
*/
#include "ui/window.h"
#include "ai/ai_client.h"
ProfWin*
win_create_ai(AISession* session)
{
/* Return NULL to simulate failure in unit tests */
(void)session;
return NULL;
}
void
aiwin_display_response(ProfAiWin* win, const char* response)
{
/* Stub: do nothing */
(void)win;
(void)response;
}
void
aiwin_display_error(ProfAiWin* win, const char* error_msg)
{
/* Stub: do nothing */
(void)win;
(void)error_msg;
}
void
win_print_outgoing(ProfWin* window, const char* show_char, const char* const id, const char* const replace_id, const char* const message)
{
/* Stub: do nothing */
(void)window;
(void)show_char;
(void)id;
(void)replace_id;
(void)message;
}

View File

@@ -1433,3 +1433,8 @@ cons_has_alerts(void)
{
return FALSE;
}
void
win_move_to_end(ProfWin* window)
{
}

View File

@@ -36,6 +36,7 @@
#include "test_form.h"
#include "test_callbacks.h"
#include "test_plugins_disco.h"
#include "test_ai_client.h"
#include "test_database_export.h"
#include "test_database_stress.h"
@@ -659,6 +660,62 @@ main(int argc, char* argv[])
cmocka_unit_test_setup_teardown(test_cmd_force_encryption_invalid_policy, load_preferences, close_preferences),
cmocka_unit_test_setup_teardown(test_allow_unencrypted_message_invalid_mode, load_preferences, close_preferences),
// AI client tests
cmocka_unit_test_setup_teardown(test_ai_client_init, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_add_provider, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_remove_provider, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_list_providers, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_set_provider_key, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_get_provider_key, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_create, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_ref_unref, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_add_message, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_clear_history, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_set_model, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_api_key_is_copied, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_add_provider_update_existing, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_add_provider_null_name_returns_null, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_add_provider_null_url_returns_null, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_create_null_provider_returns_null, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_create_null_model_returns_null, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_session_api_key_null_when_no_key_set, ai_client_setup, ai_client_teardown),
/* Provider autocomplete tests */
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_perplexity, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_custom, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_no_match, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_partial_match, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_next, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_previous, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_null_search_str, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_empty_search_str, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_case_insensitive, ai_client_setup, ai_client_teardown),
/* Provider default model and settings tests */
cmocka_unit_test_setup_teardown(test_ai_set_provider_default_model, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_get_provider_default_model, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_set_provider_setting, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_get_provider_setting, ai_client_setup, ai_client_teardown),
/* Model caching tests */
cmocka_unit_test_setup_teardown(test_ai_models_are_fresh_initial, ai_client_setup, ai_client_teardown),
/* Model parsing tests */
cmocka_unit_test(test_ai_parse_models_openai_format),
cmocka_unit_test(test_ai_parse_models_perplexity_format),
cmocka_unit_test(test_ai_parse_models_array_format),
cmocka_unit_test(test_ai_parse_models_empty_json),
cmocka_unit_test(test_ai_parse_models_null_handling),
cmocka_unit_test(test_ai_parse_models_escaped_quotes),
cmocka_unit_test(test_ai_parse_models_with_whitespace),
/* AI autocomplete integration tests */
cmocka_unit_test_setup_teardown(test_ai_start_provider_autocomplete_only_on_exact, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_models_find_null_session, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_models_find_null_provider, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_providers_find_cycling, ai_client_setup, ai_client_teardown),
cmocka_unit_test(test_ai_json_escape),
cmocka_unit_test(test_ai_json_escape_null),
cmocka_unit_test(test_ai_json_escape_empty),
cmocka_unit_test(test_ai_json_escape_special_chars),
cmocka_unit_test(test_ai_json_escape_percent_signs),
cmocka_unit_test(test_ai_json_escape_backslash_quote),
// Flatfile export/import round-trip
cmocka_unit_test(test_ff_roundtrip_simple_chat),
cmocka_unit_test(test_ff_roundtrip_with_all_metadata),