Compare commits

..

54 Commits

Author SHA1 Message Date
65c6012677 test(ai): refactor coverage to be defaults-agnostic post feat/ai merge
Self-setup providers in tests instead of relying on hardcoded openai/perplexity
defaults. Multi-provider tests now use distinct URLs per provider; functional
tests check for http/https URL presence rather than specific provider names.

Drop ai_models_find tests (function removed upstream in feat/ai). Replace with
reset-hook + persistence coverage: providers_reset_ac restart cycle, provider
add/remove round-trip across init, model cache round-trip across init.

Keep three autocomplete prefix-change tests red in-suite (documented as latent
API-hygiene gap, unreachable from UI today). NULL-search test body retained
but registration commented out — currently SIGSEGVs the cmocka runner.

Add stub_xmpp connection_create_stanza_id to satisfy new cmd_funcs.c call site.
2026-05-14 11:59:44 +03:00
befdd0ba10 test(ai): autocomplete tests — 5 expected failures documenting real bugs
Adds 10 tests for ai_models_find and additional edge cases on
ai_providers_find. 5 pass as sanity; 5 fail intentionally, documenting
two distinct bugs in the autocomplete integration:

BUG A — ai_models_find cycling is broken
  src/ai/ai_client.c ai_models_find() allocates a fresh Autocomplete on
  every call and frees it before returning. The Autocomplete's last_found
  cursor is therefore never persisted, so repeated calls with the same
  prefix always return the first match. Tab+Tab+Tab cannot reach models
  2, 3, ...
  Caught by:
    - test_ai_models_find_cycles_through_matches
    - test_ai_models_find_null_search_cycles_all

BUG B — providers_ac is never reset between user input cycles
  src/ai/ai_client.c keeps the static providers_ac AC, but
  src/command/cmd_ac.c's all_acs[] (which cmd_ac_reset() iterates) does
  not include &providers_ac. So when the input loop fires the global
  reset, providers_ac retains its last_found cursor from the previous
  prefix.
  Result: tab-completing a new prefix continues from the old cursor
  position instead of restarting at the head of the new prefix's matches.
  A provider added mid-session may also be missed depending on cursor
  position.
  Caught by:
    - test_ai_providers_find_state_resets_on_prefix_change
    - test_ai_providers_find_empty_then_prefix
    - test_ai_providers_find_after_add_includes_new

Sanity (5 passing):
  - test_ai_models_find_no_models_returns_null
  - test_ai_models_find_returns_match_for_prefix
  - test_ai_models_find_no_match_returns_null
  - test_ai_models_find_previous_direction
  - test_ai_providers_find_after_remove_skips_removed

The failing tests are checked in as-is — they go green when the bugs
are fixed. See PR description for suggested fixes.
2026-05-14 08:47:24 +00:00
d676f3b087 test(ai): expand unit and functional coverage for /ai feature
Unit tests (tests/unittests/test_ai_client.c): +50 cases on top of the
existing 50 — total 100. Covers:
  - chat response parser (ai_parse_response): OpenAI content + Perplexity
    text formats, escape decoding, empty/null/missing inputs, format-string
    safety, multiline content
  - error envelope parser (ai_parse_error_message): standard envelope,
    nested escapes, missing fields, null/empty
  - extended JSON escape: \b, \f, \r, all-specials, UTF-8 pass-through
  - provider autocomplete cycling with >=2 matches and wrap-around
  - session edge cases: NULL args, 100-message order preservation,
    set_model(NULL), ref/unref(NULL)
  - provider edge cases: get/remove with NULL, double-remove, survival
    via session ref after ai_remove_provider
  - settings: multi-key independence, missing key, cross-provider
    isolation
  - model parsing edges: data not array, empty data, "id" outside data,
    multiple models
  - prefs round-trip: set token -> shutdown -> init -> token reloaded
    from disk (uses load_preferences fixture)

Functional tests:
  - Console only (test_ai.c): 15 cases for the /ai command surface that
    don't need HTTP. Covers /ai help, providers list, set provider/token,
    start with/without key/unknown provider, clear, remove, default
    provider/model, switch without window, bad subcommand.

Infrastructure:
  - TEST_GROUPS bumped to 5 in proftest.c; AI tests live in their own
    Group 5 because mixing them with stabber-driven tests in Group 4
    poisoned stbbr_stop() teardown.
  - PROF_FUNC_TEST_AI macro in functionaltests.c registers AI tests
    with ai_init_test() which wraps init_prof_test with a prof_connect()
    so stabber sees a graceful disconnect at teardown.

Source-level changes to enable testing:
  - src/ai/ai_client.{c,h}: dropped 'static' from _parse_ai_response
    (renamed ai_parse_response) and _parse_error_response (renamed
    ai_parse_error_message); declared under a "Parsing helpers (exposed
    for testing)" section. Same approach as ai_parse_models_from_json.

Results in Docker (cproof-debian image):
  - 595/595 unit tests pass
  - 15/15 AI functional tests pass (Group 5)
2026-05-14 08:47:24 +00:00
30ad6e2a6e fix(ac): use g_strdup instead of strdup for NULL-safety
Some checks failed
CI Code / Check spelling (pull_request) Successful in 19s
CI Code / Check coding style (pull_request) Successful in 34s
CI Code / Code Coverage (pull_request) Failing after 1m9s
CI Code / Linux (debian) (pull_request) Failing after 2m55s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m7s
CI Code / Linux (arch) (pull_request) Failing after 3m47s
2026-05-14 03:57:08 +00:00
7cf82c99f0 fix(ai): destroy mutex and remove locking in unref
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) Failing after 1m9s
CI Code / Linux (debian) (pull_request) Failing after 2m48s
CI Code / Linux (ubuntu) (pull_request) Failing after 2m56s
CI Code / Linux (arch) (pull_request) Failing after 3m35s
2026-05-14 03:54:30 +00:00
f93f7cdf2c fix(ai): unlock mutex before early return in error callback
Some checks failed
CI Code / Check coding style (pull_request) Successful in 36s
CI Code / Check spelling (pull_request) Successful in 20s
CI Code / Code Coverage (pull_request) Failing after 1m9s
CI Code / Linux (debian) (pull_request) Failing after 2m45s
CI Code / Linux (arch) (pull_request) Failing after 3m32s
CI Code / Linux (ubuntu) (pull_request) Failing after 2m53s
The mutex was not released when the aiwin pointer was invalid, leading to a potential deadlock in the AI thread.
2026-05-14 03:52:20 +00:00
a9f0153c0f fix(ai): prevent duplicate user message in AI JSON payload
Some checks failed
CI Code / Check spelling (pull_request) Successful in 17s
CI Code / Check coding style (pull_request) Successful in 32s
CI Code / Code Coverage (pull_request) Failing after 1m9s
CI Code / Linux (debian) (pull_request) Failing after 2m53s
CI Code / Linux (ubuntu) (pull_request) Failing after 2m59s
CI Code / Linux (arch) (pull_request) Failing after 3m39s
The user message was being added to session->history before calling
_build_json_payload_from_list(), which independently appends the prompt
to the JSON payload. This caused the same message to appear twice in
the API request.

Fix: Build JSON payload first, then add user message to history under
the same lock to maintain atomicity.
2026-05-14 03:50:12 +00:00
5a074b280d refactor(ai): remove hardcoded gpt-4o fallback model
Some checks failed
CI Code / Check coding style (pull_request) Successful in 38s
CI Code / Check spelling (pull_request) Successful in 18s
CI Code / Linux (arch) (pull_request) Failing after 3m33s
CI Code / Code Coverage (pull_request) Failing after 1m9s
CI Code / Linux (debian) (pull_request) Failing after 2m46s
CI Code / Linux (ubuntu) (pull_request) Failing after 2m53s
Remove the hardcoded "gpt-4o" fallback in cmd_ai_start to ensure
model selection relies exclusively on provider configuration defaults.
2026-05-14 03:37:59 +00:00
fba6a040ee refactor(cmd_ac): DRY AI autocomplete and fix trailing space API
Some checks failed
CI Code / Check spelling (pull_request) Successful in 18s
CI Code / Check coding style (pull_request) Successful in 32s
CI Code / Code Coverage (pull_request) Failing after 1m9s
CI Code / Linux (debian) (pull_request) Failing after 2m53s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m0s
CI Code / Linux (arch) (pull_request) Failing after 3m40s
- Replace _ai_model_autocomplete() with new _ai_provider_and_model_autocomplete()
  that handles both provider and model autocomplete in one function
- Add /ai set default-model model autocomplete support
- Remove trailing space requirement from cmd_prefix parameter (use \"/ai start\"
  instead of \"/ai start \")
- Fix const correctness in autocomplete_param_with_func() signature
- Remove unused space_at_end variable
2026-05-14 03:34:48 +00:00
89a224b017 feat(ai): allow /ai models command from console
Remove window type check from cmd_ai_models() so fetching models works
from console. Response is shown via cons_show() when called from console,
or in the AI window when called from there.
2026-05-13 22:02:02 +00:00
b28b719d11 feat(ai): persist default providers to config on first use
Some checks failed
CI Code / Check spelling (pull_request) Successful in 23s
CI Code / Check coding style (pull_request) Successful in 38s
CI Code / Code Coverage (pull_request) Failing after 1m9s
CI Code / Linux (ubuntu) (pull_request) Failing after 2h57m20s
CI Code / Linux (debian) (pull_request) Failing after 2h57m22s
CI Code / Linux (arch) (pull_request) Failing after 2h57m25s
Fix /ai set provider <name> <url> not persisting across restarts, and
default providers (openai, perplexity) disappearing when user adds a
custom provider.

- Add prefs_ai_set_provider(), prefs_ai_remove_provider(),
  prefs_ai_get_provider_url(), prefs_ai_list_providers(),
  prefs_free_ai_providers() for provider persistence.

- Implement prefs_ai_get_providers() in preferences.c: if no providers
  are configured, write default providers to config and return them.
  This ensures defaults are persisted so users can modify them directly.

- Move default provider URL definitions from ai_client.c to
  preferences.c. ai_client.c no longer knows about specific provider
  names.

- Simplify ai_client_init() to call prefs_ai_get_providers() and
  initialize with whatever it receives.

- ai_add_provider() persists to config; ai_remove_provider() removes
  from config.

Behavior: On first run, default providers are written to config. User
can then modify, remove, or add providers freely and they persist.
2026-05-13 21:44:02 +00:00
b21d537e80 feat(ai): persist custom providers to config and move defaults to preferences
Some checks failed
CI Code / Check spelling (pull_request) Successful in 22s
CI Code / Code Coverage (pull_request) Failing after 1m12s
CI Code / Linux (debian) (pull_request) Failing after 3m13s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m21s
CI Code / Linux (arch) (pull_request) Failing after 4m1s
CI Code / Check coding style (pull_request) Failing after 13m40s
Fix /ai set provider <name> <url> not persisting across restarts.

- Add prefs_ai_set_provider(), prefs_ai_remove_provider(),
  prefs_ai_get_provider_url(), prefs_ai_list_providers(),
  prefs_free_ai_providers(), and prefs_ai_get_default_provider_url()
  to preferences.h/c for provider persistence.

- Move default provider URL definitions (openai, perplexity) from
  ai_client.c to preferences.c, following the established pattern
  where defaults are returned as fallbacks when no config value exists.

- Modify ai_add_provider() to persist new/updated providers to config.

- Modify ai_remove_provider() to remove provider from config.

- Add internal _ai_add_provider_nopersist() helper for adding default
  providers and loading from config without double-persisting.

- Update ai_client_init() to load custom providers from config on
  startup, allowing users to override defaults or add new ones.

Users now have full control: default providers remain as fallbacks,
user-added providers persist to config, and users can override or
remove defaults freely.
2026-05-13 18:28:35 +00:00
2f1637ca8e fix(ai): add reset for models_ac and streamline model autocomplete handling
Some checks failed
CI Code / Check spelling (pull_request) Successful in 22s
CI Code / Check coding style (pull_request) Successful in 36s
CI Code / Code Coverage (pull_request) Failing after 1m11s
CI Code / Linux (debian) (pull_request) Failing after 3m10s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m20s
CI Code / Linux (arch) (pull_request) Failing after 4m3s
Replace the custom ai_models_find function with a dedicated
ai_models_ac autocomplete list. Update the /ai switch trigger to
include a trailing space for consistent parsing.
2026-05-13 12:59:29 +00:00
277c82fb5d refactor(ai): remove default command and enforce strict window context
Some checks failed
CI Code / Check spelling (pull_request) Successful in 23s
CI Code / Check coding style (pull_request) Successful in 37s
CI Code / Code Coverage (pull_request) Failing after 1m8s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m5s
CI Code / Linux (arch) (pull_request) Failing after 3m51s
CI Code / Linux (debian) (pull_request) Failing after 5m58s
- Remove `/ai set default-provider` command and related autocomplete logic
- Remove global `wins_get_ai()` window accessor function
- Enforce strict requirement that `/ai model`, `/ai switch`, `/ai models`, and `/ai clear` commands must be executed from within an active AI chat window
- Update `/ai start` to require `<provider>` argument

BREAKING CHANGE: `/ai model`, `/ai switch`, `/ai models`, and `/ai clear` commands now require an active AI window context. The `/ai set default-provider` command has been removed.
2026-05-13 12:41:20 +00:00
1ecaceb59f fix(ai): reset provider autocomplete state on search string change
Some checks failed
CI Code / Code Coverage (pull_request) Failing after 12m29s
CI Code / Check spelling (pull_request) Failing after 13m4s
CI Code / Check coding style (pull_request) Failing after 13m36s
CI Code / Linux (ubuntu) (pull_request) Failing after 14m13s
CI Code / Linux (debian) (pull_request) Failing after 14m48s
CI Code / Linux (arch) (pull_request) Failing after 15m25s
When typing /ai start <prefix><tab>, the autocomplete was not properly
handling search string changes. For example, typing "/ai start o<tab>"
would return "openai", but then typing "/ai start p<tab>" would still
return "openai" because the autocomplete state was not reset.

This commit:
- Adds ai_providers_reset_ac() function to reset provider autocomplete
  state, following the same pattern as accounts_reset_all_search()
- Integrates ai_providers_reset_ac() into cmd_ac_reset() for proper
  state cleanup when user presses Ctrl+C or switches contexts
2026-05-13 09:40:52 +00:00
18fb7fa733 fix(ai): pass stanza ID to AI message send function
Some checks failed
CI Code / Code Coverage (pull_request) Failing after 12m1s
CI Code / Check spelling (pull_request) Failing after 12m26s
CI Code / Check coding style (pull_request) Failing after 13m0s
CI Code / Linux (ubuntu) (pull_request) Failing after 13m34s
CI Code / Linux (debian) (pull_request) Failing after 14m1s
CI Code / Linux (arch) (pull_request) Failing after 14m23s
2026-05-12 17:08:30 +00:00
a75a06b4a2 Fix(ai): protect AI session state from concurrent access races
Some checks failed
CI Code / Check spelling (pull_request) Successful in 20s
CI Code / Check coding style (pull_request) Successful in 35s
CI Code / Linux (debian) (pull_request) Failing after 3m47s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m54s
CI Code / Code Coverage (pull_request) Successful in 6m41s
CI Code / Linux (arch) (pull_request) Failing after 9m31s
cmd_ai_switch() and cmd_ai_clear() mutated session fields (provider_name,
provider, model, api_key, history) on the main thread without coordination,
while _ai_request_thread() read the same fields concurrently in the worker
thread. This caused use-after-free when:

- g_free(session->model) was called between worker's read and g_strdup()
- ai_provider_unref(session->provider) freed the provider struct while
  worker accessed session->provider->api_url
- ai_session_clear_history() freed the history list while worker walked it

Fix:
1. Add pthread_mutex_t lock to AISession struct to protect all session
   fields from concurrent access.

2. Introduce ai_session_switch() public API that atomically switches
   provider, model, and API key under the session lock. This encapsulates
   all session mutations within ai_client.c so cmd_funcs.c never touches
   session fields directly.

3. Have _ai_request_thread() snapshot all session state under the session
   lock before making requests, using local copies for the duration of
   the curl request.

4. Add ai_provider_ref()/ai_provider_unref() around _ai_generic_request_thread()
   to prevent provider UAF during model-fetch requests.

5. Protect ai_session_add_message(), ai_session_clear_history(), and
   ai_session_set_model() with the session lock.

No pthread.h needed in cmd_funcs.c — all locking is encapsulated within
ai_client.c via the public API. No deadlock risk from nested locks.

Files changed:
- src/ai/ai_client.h: Add lock field, ai_session_switch() declaration
- src/ai/ai_client.c: Mutex init/destroy, session mutation protection,
  worker thread snapshot, provider ref management
- src/command/cmd_funcs.c: Use ai_session_switch() API, remove pthread.h
2026-05-11 23:42:24 +00:00
9f4621883a fix(ai): add mutex to AISession for thread safety
Some checks failed
CI Code / Linux (debian) (pull_request) Failing after 3m27s
CI Code / Linux (arch) (pull_request) Failing after 5m14s
CI Code / Code Coverage (pull_request) Failing after 10m52s
CI Code / Check spelling (pull_request) Failing after 10m57s
CI Code / Check coding style (pull_request) Failing after 11m2s
CI Code / Linux (ubuntu) (pull_request) Failing after 11m8s
Introduce a pthread mutex to protect all AISession fields from
concurrent access between the main thread and background request
worker.

- Initialize and destroy mutex during session lifecycle
- Lock session state when modifying or reading history, model, and
  provider fields
- Snapshot session state under lock in the request worker thread to
  prevent UAF races during API calls
- Refactor _build_json_payload to accept direct parameters for safe
  usage under lock
- Update command handlers to safely switch provider/model under lock
2026-05-11 23:26:14 +00:00
6fe8c370df ref(ai): remove org_id from provider configuration
Some checks failed
CI Code / Code Coverage (pull_request) Failing after 10m47s
CI Code / Check spelling (pull_request) Failing after 10m58s
CI Code / Check coding style (pull_request) Failing after 11m10s
CI Code / Linux (ubuntu) (pull_request) Failing after 11m30s
CI Code / Linux (debian) (pull_request) Failing after 11m42s
CI Code / Linux (arch) (pull_request) Failing after 11m54s
The org_id field was removed from AIProvider as it was no longer
populated through the command surface. The /ai set org command was
previously removed, leaving org_id as dead code.

This change removes org_id from:
- AIProvider struct definition
- ai_provider_new() and ai_provider_unref()
- ai_add_provider() API signature
- _build_curl_headers() (OpenAI-Organization header)
- cmd_ai_set() and cmd_ai_providers()
- All unit tests
2026-05-11 22:58:39 +00:00
b1dbe714e8 fix(ai): use atomic ai_provider_ref() in cmd_ai_switch
Some checks failed
CI Code / Linux (arch) (pull_request) Failing after 10m9s
CI Code / Code Coverage (pull_request) Failing after 14m6s
CI Code / Check spelling (pull_request) Failing after 14m27s
CI Code / Check coding style (pull_request) Failing after 14m44s
CI Code / Linux (ubuntu) (pull_request) Failing after 15m9s
CI Code / Linux (debian) (pull_request) Failing after 15m43s
src/command/cmd_funcs:11118 used a plain non-atomic ref_count++ on
ai_provider, which is a data race against concurrent
ai_provider_unref() calls using g_atomic_int_dec_and_test.

- Expose ai_provider_ref() in ai_client.h (was static)
- Replace non-atomic ref_count++ with ai_provider_ref() in cmd_ai_switch"
2026-05-11 21:15:10 +00:00
107f7778e2 fix(ai): collapse validate and dispatch into single critical section
Some checks failed
CI Code / Check spelling (pull_request) Successful in 31s
CI Code / Check coding style (pull_request) Successful in 43s
CI Code / Code Coverage (pull_request) Successful in 2m28s
CI Code / Linux (debian) (pull_request) Failing after 3m40s
CI Code / Linux (arch) (pull_request) Failing after 4m32s
CI Code / Linux (ubuntu) (pull_request) Failing after 6m18s
_aiwin_validate() no longer acquires the mutex. Callers now hold the
lock across both the existence check and the display dispatch,
eliminating the TOCTOU window where the window could be freed.
2026-05-11 20:54:42 +00:00
b634eed5af refactor(ai): remove cmd_ai_correct function
Some checks failed
CI Code / Check spelling (pull_request) Successful in 21s
CI Code / Check coding style (pull_request) Successful in 34s
CI Code / Code Coverage (pull_request) Successful in 2m28s
CI Code / Linux (ubuntu) (pull_request) Failing after 3m43s
CI Code / Linux (arch) (pull_request) Failing after 4m43s
CI Code / Linux (debian) (pull_request) Failing after 6m15s
The /ai correct command implementation has been removed.
2026-05-11 20:34:06 +00: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
17 changed files with 440 additions and 614 deletions

2
.gitignore vendored
View File

@@ -17,8 +17,6 @@ compile_commands.json
.project
.settings/
.vscode/
.kilo/
.roo/
*.plist/
# autotools

View File

@@ -35,8 +35,6 @@ static Autocomplete providers_ac = NULL;
static AIProvider* _ai_add_provider_nopersist(const gchar* name, const gchar* api_url);
static void _ai_load_models_for_provider(AIProvider* provider);
static void _ai_save_models_for_provider(AIProvider* provider);
static const gchar* _find_json_field(const gchar* json, const gchar* field);
static gchar* _extract_json_string(const gchar* json, const gchar* field);
/* ========================================================================
* Curl helpers
@@ -186,85 +184,6 @@ ai_json_escape(const gchar* str)
return g_string_free(result, FALSE);
}
/* Decode JSON string escapes in [start, end) into a freshly allocated buffer.
* Handles \" \\ \/ \n \t \r \b \f. \uXXXX sequences are passed through verbatim
* since the rest of the AI pipeline already deals in UTF-8 byte streams. */
static gchar*
_json_unescape_substring(const gchar* start, const gchar* end)
{
gchar* out = g_new0(gchar, (end - start) + 1);
gchar* w = out;
for (const gchar* in = start; in < end;) {
if (*in == '\\' && in + 1 < end) {
switch (in[1]) {
case '"':
*w++ = '"';
in += 2;
break;
case '\\':
*w++ = '\\';
in += 2;
break;
case '/':
*w++ = '/';
in += 2;
break;
case 'n':
*w++ = '\n';
in += 2;
break;
case 't':
*w++ = '\t';
in += 2;
break;
case 'r':
*w++ = '\r';
in += 2;
break;
case 'b':
*w++ = '\b';
in += 2;
break;
case 'f':
*w++ = '\f';
in += 2;
break;
default:
*w++ = *in++;
break;
}
} else {
*w++ = *in++;
}
}
*w = '\0';
return out;
}
/* Locate "field":"...value..." in json and return the unescaped value.
* Skips JSON whitespace around the colon and treats any \X (X != \0) inside
* the string body as an opaque escape so an embedded \" cannot be misread as
* the closing quote. */
static gchar*
_extract_json_string(const gchar* json, const gchar* field)
{
const gchar* val = _find_json_field(json, field);
if (!val || *val != '"')
return NULL;
const gchar* start = val + 1;
for (const gchar* p = start; *p; p++) {
if (*p == '\\' && *(p + 1) != '\0') {
p++; /* skip the escaped character; loop ++ advances past it */
continue;
}
if (*p == '"') {
return _json_unescape_substring(start, p);
}
}
return NULL;
}
/* ========================================================================
* Provider Management
* ======================================================================== */
@@ -377,31 +296,13 @@ ai_client_init(void)
/* Load saved API keys from config */
ai_load_keys();
/* Load cached models, persisted default-model and custom settings for
* all providers. */
/* Load cached models for all providers */
GList* provider_list = ai_list_providers();
for (GList* curr = provider_list; curr; curr = g_list_next(curr)) {
AIProvider* provider = (AIProvider*)curr->data;
GList* models_curr = provider_list;
while (models_curr) {
AIProvider* provider = (AIProvider*)models_curr->data;
_ai_load_models_for_provider(provider);
auto_gchar gchar* dm = prefs_ai_get_default_model(provider->name);
if (dm && dm[0] != '\0') {
g_free(provider->default_model);
provider->default_model = g_strdup(dm);
}
GList* settings = prefs_ai_list_settings(provider->name);
for (GList* s = settings; s; s = g_list_next(s)) {
const gchar* name = (const gchar*)s->data;
auto_gchar gchar* val = prefs_ai_get_setting(provider->name, name);
if (val) {
if (!provider->settings) {
provider->settings = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
}
g_hash_table_insert(provider->settings, g_strdup(name), g_strdup(val));
}
}
prefs_free_ai_settings(settings);
models_curr = g_list_next(models_curr);
}
g_list_free(provider_list);
@@ -497,9 +398,8 @@ ai_remove_provider(const gchar* name)
if (!name || !providers)
return FALSE;
/* Wipe the whole [ai/<name>] subsection from config — url, key,
* default_model, models, custom settings — in one call. */
prefs_ai_remove_section(name);
/* Remove from config before removing from hash table */
prefs_ai_remove_provider(name);
/* Sync autocomplete before removing */
autocomplete_remove(providers_ac, name);
@@ -601,7 +501,6 @@ ai_set_provider_default_model(const gchar* provider_name, const gchar* model)
g_free(provider->default_model);
provider->default_model = g_strdup(model);
prefs_ai_set_default_model(provider_name, model);
log_info("Default model for provider '%s' set to: %s", provider_name, model);
}
@@ -636,11 +535,9 @@ ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const
if (value) {
g_hash_table_insert(provider->settings, g_strdup(setting), g_strdup(value));
prefs_ai_set_setting(provider_name, setting, value);
log_info("Setting '%s' for provider '%s' set to: %s", setting, provider_name, value);
} else {
g_hash_table_remove(provider->settings, setting);
prefs_ai_remove_setting(provider_name, setting);
log_info("Setting '%s' removed for provider '%s'", setting, provider_name);
}
}
@@ -712,11 +609,8 @@ _curl_exec_and_handle(CURL* curl, const gchar* url, struct curl_slist* headers,
log_error("Request failed for '%s': %s", provider->name, error_msg);
_aiwin_display_error(user_data, error_msg);
} else if (http_code >= 400) {
auto_gchar gchar* parsed = ai_parse_error_message(response->data);
auto_gchar gchar* error_msg = parsed
? g_strdup_printf("HTTP %ld: %s", http_code, parsed)
: g_strdup_printf("HTTP %ld: %s", http_code,
response->data && strlen(response->data) > 0 ? response->data : "Unknown error");
auto_gchar gchar* error_msg = g_strdup_printf("HTTP %ld: %s", http_code,
response->data ? response->data : "Unknown error");
log_error("Request HTTP error for '%s': %s", provider->name, error_msg);
_aiwin_display_error(user_data, error_msg);
} else {
@@ -1085,18 +979,9 @@ _models_response_handler(AIProvider* provider, const gchar* response, gpointer u
{
_parse_and_cache_models(provider, response);
gint count = g_list_length(provider->models);
GString* msg = g_string_new(NULL);
g_string_append_printf(msg, "Cached %d models for provider '%s':", count, provider->name);
for (GList* curr = provider->models; curr; curr = g_list_next(curr)) {
g_string_append_printf(msg, "\n %s", (const gchar*)curr->data);
}
if (count > 0) {
g_string_append_printf(msg, "\nUse '/ai start %s <model>' to start a chat.", provider->name);
}
auto_gchar gchar* out = g_string_free(msg, FALSE);
_aiwin_display_response(user_data, out);
auto_gchar gchar* msg = g_strdup_printf("Cached %d models for provider '%s'. Use '/ai start <provider> <model>' to start a chat.",
g_list_length(provider->models), provider->name);
_aiwin_display_response(user_data, msg);
}
static gpointer
@@ -1371,30 +1256,139 @@ ai_parse_response(const gchar* response_json)
if (!response_json || strlen(response_json) == 0)
return NULL;
/* Perplexity /v1/agent: {"output":[{"content":[{"text":"...","type":"output_text"}]}]}
* Legacy OpenAI: {"choices":[{"message":{"content":"..."}}]} */
gchar* text = _extract_json_string(response_json, "text");
if (text)
return text;
/* Try Perplexity /v1/agent format first: look for "text":"..." inside output array
* Response: {"output":[{"content":[{"text":"...","type":"output_text"}]}]} */
const gchar* text_start = strstr(response_json, "\"text\":\"");
if (text_start) {
text_start += strlen("\"text\":\"");
const gchar* p = text_start;
while (*p) {
if (*p == '\\' && *(p + 1) == '"') {
p += 2;
continue;
}
if (*p == '"') {
gsize len = p - text_start;
gchar* result = g_new0(gchar, len + 1);
gchar* out = result;
const gchar* in = text_start;
while (in < p) {
if (*in == '\\' && *(in + 1) == '"') {
*out++ = '"';
in += 2;
} else if (*in == '\\' && *(in + 1) == 'n') {
*out++ = '\n';
in += 2;
} else {
*out++ = *in++;
}
}
*out = '\0';
return result;
}
p++;
}
}
return _extract_json_string(response_json, "content");
/* Try legacy OpenAI format: "content":"..."
* Response: {"choices":[{"message":{"content":"..."}}]} */
const gchar* content_start = strstr(response_json, "\"content\":\"");
if (!content_start)
return NULL;
content_start += strlen("\"content\":\"");
/* Find the closing quote, accounting for escaped quotes */
const gchar* p = content_start;
while (*p) {
if (*p == '\\' && *(p + 1) == '"') {
/* Escaped quote, skip both characters */
p += 2;
continue;
}
if (*p == '"') {
/* Found unescaped closing quote */
gsize len = p - content_start;
/* Unescape the content: convert \" back to " */
gchar* result = g_new0(gchar, len + 1);
gchar* out = result;
const gchar* in = content_start;
while (in < p) {
if (*in == '\\' && *(in + 1) == '"') {
*out++ = '"';
in += 2;
} else if (*in == '\\' && *(in + 1) == 'n') {
*out++ = '\n';
in += 2;
} else {
*out++ = *in++;
}
}
*out = '\0';
return result;
}
p++;
}
return NULL;
}
/* Extract the human-readable error.message from a provider error envelope:
* {"error":{"message":"...","type":"...","code":...}}
* Scoped to the substring after "error": so unrelated top-level "message"
* fields do not get picked up. */
/**
* Extract error message from an API error JSON response.
* Handles format: {"error":{"message":"...","type":"...","code":...}}
*/
gchar*
ai_parse_error_message(const gchar* error_json)
{
if (!error_json || strlen(error_json) == 0)
return NULL;
/* Look for "message":"..." inside "error" object */
const gchar* error_obj = strstr(error_json, "\"error\":");
if (!error_obj)
return NULL;
return _extract_json_string(error_obj, "message");
const gchar* message_key = strstr(error_obj, "\"message\":\"");
if (!message_key)
return NULL;
message_key += strlen("\"message\":\"");
const gchar* msg_start = message_key;
const gchar* p = msg_start;
while (*p) {
if (*p == '\\' && *(p + 1) == '"') {
p += 2;
continue;
}
if (*p == '"') {
gsize len = p - msg_start;
gchar* result = g_new0(gchar, len + 1);
gchar* out = result;
const gchar* in = msg_start;
while (in < p) {
if (*in == '\\' && *(in + 1) == '"') {
*out++ = '"';
in += 2;
} else if (*in == '\\' && *(in + 1) == 'n') {
*out++ = '\n';
in += 2;
} else if (*in == '\\' && *(in + 1) == '\\') {
*out++ = '\\';
in += 2;
} else if (*in == '\\' && *(in + 1) == 't') {
*out++ = '\t';
in += 2;
} else {
*out++ = *in++;
}
}
*out = '\0';
return result;
}
p++;
}
return NULL;
}
static gpointer
@@ -1419,10 +1413,10 @@ _ai_request_thread(gpointer data)
* list modifications). We must snapshot all fields atomically.
* ===================================================================== */
pthread_mutex_lock(&session->lock);
auto_gchar gchar* local_provider_name = g_strdup(session->provider_name);
gchar* local_provider_name = g_strdup(session->provider_name);
AIProvider* local_provider = ai_provider_ref(session->provider);
auto_gchar gchar* local_model = g_strdup(session->model);
auto_gchar gchar* local_api_key = g_strdup(session->api_key ? session->api_key : "");
gchar* local_model = g_strdup(session->model);
gchar* local_api_key = g_strdup(session->api_key ? session->api_key : "");
pthread_mutex_unlock(&session->lock);
log_debug("[AI-THREAD] Session: %s/%s", local_provider_name, local_model);
@@ -1434,8 +1428,6 @@ _ai_request_thread(gpointer data)
local_provider_name, local_provider_name);
log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg);
_aiwin_display_error(user_data, error_msg);
ai_provider_unref(local_provider);
ai_session_unref(session);
g_free(args);
return NULL;
}
@@ -1446,8 +1438,6 @@ _ai_request_thread(gpointer data)
log_error("AI request failed for %s/%s: Failed to initialize curl",
local_provider_name, local_model);
_aiwin_display_error(user_data, "Failed to initialize curl.");
ai_provider_unref(local_provider);
ai_session_unref(session);
g_free(args);
return NULL;
}
@@ -1496,7 +1486,6 @@ _ai_request_thread(gpointer data)
long http_code = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
log_debug("[AI-THREAD] HTTP response code: %ld", http_code);
auto_gchar gchar* response_data = g_steal_pointer(&response.data);
if (res != CURLE_OK) {
auto_gchar gchar* error_msg = g_strdup(curl_easy_strerror(res));
@@ -1505,14 +1494,17 @@ _ai_request_thread(gpointer data)
} else if (http_code >= 400) {
/* Handle HTTP errors */
log_debug("[AI-THREAD] HTTP error response body (%zu bytes): %s",
response.size, response_data);
response.size, response.data ? response.data : "NULL");
/* Try to extract the actual error message from the JSON response */
auto_gchar gchar* parsed_error = ai_parse_error_message(response_data);
auto_gchar gchar* error_msg = parsed_error ? g_strdup_printf("HTTP %ld: %s", http_code, parsed_error) : g_strdup_printf("HTTP %ld: %s", http_code, response_data && strlen(response_data) > 0 ? response_data : "Unknown error");
auto_gchar gchar* parsed_error = ai_parse_error_message(response.data);
auto_gchar gchar* error_msg = parsed_error ? g_strdup_printf("HTTP %ld: %s", http_code, parsed_error) : g_strdup_printf("HTTP %ld: %s", http_code, response.data && strlen(response.data) > 0 ? response.data : "Unknown error");
log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg);
_aiwin_display_error(user_data, error_msg);
} else {
log_debug("[AI-THREAD] Raw API response (%zu bytes): %s", response.size, response_data);
/* Parse response - transfer ownership to auto_gchar for cleanup */
log_debug("[AI-THREAD] Raw API response (%zu bytes): %s", response.size, response.data ? response.data : "NULL");
auto_gchar gchar* response_data = response.data;
response.data = NULL;
auto_gchar gchar* content = ai_parse_response(response_data);
if (content) {
/* Add assistant response to history (under lock) */
@@ -1533,9 +1525,12 @@ _ai_request_thread(gpointer data)
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
/* Cleanup local copies */
g_free(local_provider_name);
ai_provider_unref(local_provider);
g_free(local_model);
g_free(local_api_key);
ai_session_unref(session);
g_free(args);
return NULL;

View File

@@ -106,6 +106,15 @@ GList* ai_list_providers(void);
*/
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);
/**
* Reset the provider autocomplete state.
* Called from cmd_ac_reset() to clear autocomplete state.

View File

@@ -1132,7 +1132,6 @@ cmd_ac_init(void)
autocomplete_add(statusbar_show_ac, "name");
autocomplete_add(statusbar_show_ac, "number");
autocomplete_add(statusbar_show_ac, "read");
autocomplete_add(statusbar_show_ac, "dbbackend");
autocomplete_add(statusbar_tabmode_ac, "actlist");
autocomplete_add(statusbar_tabmode_ac, "dynamic");
@@ -1147,6 +1146,9 @@ cmd_ac_init(void)
autocomplete_add(status_state_ac, "xa");
autocomplete_add(status_state_ac, "dnd");
autocomplete_add(logging_ac, "chat");
autocomplete_add(logging_ac, "group");
autocomplete_add(privacy_ac, "logging");
autocomplete_add(privacy_ac, "os");
@@ -4077,6 +4079,11 @@ _logging_autocomplete(ProfWin* window, const char* const input, gboolean previou
return result;
}
result = autocomplete_param_with_func(input, "/logging chat", prefs_autocomplete_boolean_choice, previous, NULL);
if (result) {
return result;
}
result = autocomplete_param_with_ac(input, "/logging group", logging_group_ac, TRUE, previous);
return result;
}

View File

@@ -1289,8 +1289,8 @@ static const struct cmd_t command_defs[] = {
CMD_TAGS(
CMD_TAG_UI)
CMD_SYN(
"/statusbar show name|number|read|dbbackend",
"/statusbar hide name|number|read|dbbackend",
"/statusbar show name|number|read",
"/statusbar hide name|number|read",
"/statusbar maxtabs <value>",
"/statusbar tablen <value>",
"/statusbar tabmode default|dynamic|actlist",
@@ -1308,7 +1308,6 @@ static const struct cmd_t command_defs[] = {
{ "show|hide name", "Show or hide names in tabs." },
{ "show|hide number", "Show or hide numbers in tabs." },
{ "show|hide read", "Show or hide inactive tabs." },
{ "show|hide dbbackend", "Show or hide the database backend indicator [sqlite] or [flatfile]." },
{ "self user|barejid|fulljid", "Show account user name, barejid, fulljid as status bar title." },
{ "self off", "Disable showing self as status bar title." },
{ "chat user|jid", "Show users name, or fulljid. Change needs a redraw/restart to take effect." },
@@ -1660,14 +1659,23 @@ static const struct cmd_t command_defs[] = {
},
{ CMD_PREAMBLE("/logging",
parse_args, 0, 0, NULL)
parse_args, 2, 3, &cons_logging_setting)
CMD_MAINFUNC(cmd_logging)
CMD_TAGS(
CMD_TAG_CHAT)
CMD_SYN(
"/logging")
"/logging chat|group on|off")
CMD_DESC(
"Deprecated. Use '/history' instead.")
"Configure chat logging. "
"Switch logging on or off. "
"Chat logging will be enabled if /history is set to on. "
"When disabling this option, /history will also be disabled. ")
CMD_ARGS(
{ "chat on|off", "Enable/Disable regular chat logging." },
{ "group on|off", "Enable/Disable groupchat (room) logging." })
CMD_EXAMPLES(
"/logging chat on",
"/logging group off")
},
{ CMD_PREAMBLE("/states",
@@ -1880,16 +1888,15 @@ static const struct cmd_t command_defs[] = {
"/history export [<jid>]",
"/history import [<jid>]")
CMD_DESC(
"Enable or disable chat history. "
"When on, messages are persisted via the active database backend and shown in chat windows. "
"When off, persistence stops and old history is hidden on chat-window open. "
"Switch chat history on or off, /logging chat will automatically be enabled when this setting is on. "
"When history is enabled, previous messages are shown in chat windows. "
"Use 'backend' to show the active database backend. "
"Use 'switch' to change the active database backend at runtime without reconnecting. "
"Use 'verify' to check integrity of stored message history. "
"Use 'export' to copy messages from SQLite to flat-file format, or 'import' to copy from flat-file to SQLite. "
"Both export and import merge with existing data (duplicates are skipped).")
CMD_ARGS(
{ "on|off", "Enable or disable persistence and showing chat history." },
{ "on|off", "Enable or disable showing chat history." },
{ "backend", "Show the name of the active database backend." },
{ "switch sqlite|flatfile", "Switch the active database backend at runtime." },
{ "verify [<jid>]", "Verify integrity of message history. Optionally specify a JID to check only one contact." },
@@ -2734,7 +2741,7 @@ static const struct cmd_t command_defs[] = {
"clientid to set the client identification name "
"session_alarm to configure an alarm when more clients log in.")
CMD_ARGS(
{ "logging on|redact|off|flatfile", "Switch chat logging backend. 'on' uses the SQLite database (default). 'flatfile' stores messages as plain-text files under ~/.local/share/profanity/flatlog/ that can be edited with any text editor. 'off' disables logging entirely. 'redact' keeps logging but replaces every message body with '[REDACTED]'. Note: 'off' may have side effects such as not being able to decrypt OMEMO messages received later via MAM. Backend changes ('on' / 'flatfile') take effect immediately when connected, otherwise on next connect." },
{ "logging on|redact|off|flatfile", "Switch chat logging. 'on' uses SQLite database (default). 'flatfile' stores messages as plain text files in ~/.local/share/profanity/flatlog/ that can be manually edited with any text editor. 'off' disables logging entirely. 'redact' stores messages with content replaced by '[redacted]'. Note: 'off' might have unintended consequences, such as not being able to decrypt OMEMO encrypted messages received later via MAM. The 'flatfile' setting takes effect on next connection." },
{ "os on|off", "Choose whether to include the OS name if a user asks for software information (XEP-0092)." }
)
CMD_EXAMPLES(

View File

@@ -6002,12 +6002,6 @@ cmd_statusbar(ProfWin* window, const char* const command, gchar** args)
ui_resize();
return TRUE;
}
if (g_strcmp0(args[1], "dbbackend") == 0) {
prefs_set_boolean(PREF_STATUSBAR_SHOW_DBBACKEND, TRUE);
cons_show("Enabled showing database backend indicator.");
ui_resize();
return TRUE;
}
cons_bad_cmd_usage(command);
return TRUE;
}
@@ -6041,12 +6035,6 @@ cmd_statusbar(ProfWin* window, const char* const command, gchar** args)
ui_resize();
return TRUE;
}
if (g_strcmp0(args[1], "dbbackend") == 0) {
prefs_set_boolean(PREF_STATUSBAR_SHOW_DBBACKEND, FALSE);
cons_show("Disabled showing database backend indicator.");
ui_resize();
return TRUE;
}
cons_bad_cmd_usage(command);
return TRUE;
}
@@ -6680,48 +6668,36 @@ cmd_privacy(ProfWin* window, const char* const command, gchar** args)
}
if (g_strcmp0(args[0], "logging") == 0) {
const gchar* arg = args[1];
gchar* arg = args[1];
if (arg == NULL) {
cons_bad_cmd_usage(command);
return TRUE;
}
const gboolean is_on = (g_strcmp0(arg, "on") == 0);
const gboolean is_off = (g_strcmp0(arg, "off") == 0);
const gboolean is_redact = (g_strcmp0(arg, "redact") == 0);
const gboolean is_flatfile = (g_strcmp0(arg, "flatfile") == 0);
if (!is_on && !is_off && !is_redact && !is_flatfile) {
if (g_strcmp0(arg, "on") == 0) {
cons_show("Logging enabled.");
prefs_set_string(PREF_DBLOG, arg);
prefs_set_boolean(PREF_CHLOG, TRUE);
prefs_set_boolean(PREF_HISTORY, TRUE);
} else if (g_strcmp0(arg, "off") == 0) {
cons_show("Logging disabled.");
prefs_set_string(PREF_DBLOG, arg);
prefs_set_boolean(PREF_CHLOG, FALSE);
prefs_set_boolean(PREF_HISTORY, FALSE);
} else if (g_strcmp0(arg, "redact") == 0) {
cons_show("Messages are going to be redacted.");
prefs_set_string(PREF_DBLOG, arg);
} else if (g_strcmp0(arg, "flatfile") == 0) {
auto_gchar gchar* ff_path = files_get_data_path(DIR_FLATLOG);
cons_show("Using flat-file backend for message logging. Takes effect on next connection.");
cons_show("Flatfile directory: %s", ff_path);
prefs_set_string(PREF_DBLOG, arg);
prefs_set_boolean(PREF_CHLOG, TRUE);
prefs_set_boolean(PREF_HISTORY, TRUE);
} else {
cons_bad_cmd_usage(command);
return TRUE;
}
prefs_set_string(PREF_DBLOG, arg);
prefs_set_boolean(PREF_CHLOG, !is_off);
prefs_set_boolean(PREF_HISTORY, !is_off);
// For backend-switching values (on = sqlite, flatfile), apply the
// change immediately when connected so the user does not have to
// reconnect to see the new backend take effect. off/redact change
// write semantics but keep the currently-open backend alive.
if ((is_on || is_flatfile)
&& active_db_backend
&& connection_get_status() == JABBER_CONNECTED
&& g_strcmp0(active_db_backend->name, is_on ? "sqlite" : "flatfile") != 0) {
log_database_switch_backend(is_on ? "on" : "flatfile");
}
if (is_on) {
cons_show("Database logging enabled (sqlite).");
} else if (is_off) {
cons_show("Database logging disabled.");
} else if (is_redact) {
cons_show("Database logging set to redact (message bodies replaced with [REDACTED]).");
} else if (is_flatfile) {
auto_gchar gchar* ff_path = files_get_data_path(DIR_FLATLOG);
cons_show("Database logging set to flat-file backend.");
cons_show("Flatfile directory: %s", ff_path);
}
return TRUE;
}
@@ -6731,7 +6707,26 @@ cmd_privacy(ProfWin* window, const char* const command, gchar** args)
gboolean
cmd_logging(ProfWin* window, const char* const command, gchar** args)
{
cons_show("The '/logging' command is deprecated, use '/history' instead.");
if (args[0] == NULL) {
cons_logging_setting();
return TRUE;
}
if (strcmp(args[0], "chat") == 0 && args[1] != NULL) {
_cmd_set_boolean_preference(args[1], "Chat logging", PREF_CHLOG);
// if set to off, disable history
if (strcmp(args[1], "off") == 0) {
prefs_set_boolean(PREF_HISTORY, FALSE);
}
return TRUE;
} else if (g_strcmp0(args[0], "group") == 0 && args[1] != NULL) {
_cmd_set_boolean_preference(args[1], "Groupchat logging", PREF_GRLOG);
return TRUE;
}
cons_bad_cmd_usage(command);
return TRUE;
}
@@ -6878,20 +6873,9 @@ cmd_history(ProfWin* window, const char* const command, gchar** args)
_cmd_set_boolean_preference(args[0], "Chat history", PREF_HISTORY);
// /history off should stop persisting messages too, not just hide
// existing history on chat-window open. /history on resumes
// persistence (default backend) and parallel text chatlogs.
// if set to on, set chlog (/logging chat on)
if (strcmp(args[0], "on") == 0) {
prefs_set_boolean(PREF_CHLOG, TRUE);
auto_gchar gchar* dblog = prefs_get_string(PREF_DBLOG);
if (g_strcmp0(dblog, "off") == 0) {
prefs_set_string(PREF_DBLOG, "on");
cons_show("Database logging re-enabled (sqlite).");
}
} else if (strcmp(args[0], "off") == 0) {
prefs_set_boolean(PREF_CHLOG, FALSE);
prefs_set_string(PREF_DBLOG, "off");
cons_show("Database logging disabled.");
}
return TRUE;
@@ -7308,7 +7292,7 @@ cmd_pgp(ProfWin* window, const char* const command, gchar** args)
prefs_set_string(PREF_PGP_LOG, "on");
cons_show("PGP messages will be logged as plaintext.");
if (!prefs_get_boolean(PREF_CHLOG)) {
cons_show("Chat logging is currently disabled, use '/history on' to enable.");
cons_show("Chat logging is currently disabled, use '/logging chat on' to enable.");
}
} else if (g_strcmp0(choice, "off") == 0) {
prefs_set_string(PREF_PGP_LOG, "off");
@@ -7317,7 +7301,7 @@ cmd_pgp(ProfWin* window, const char* const command, gchar** args)
prefs_set_string(PREF_PGP_LOG, "redact");
cons_show("PGP messages will be logged as '[redacted]'.");
if (!prefs_get_boolean(PREF_CHLOG)) {
cons_show("Chat logging is currently disabled, use '/history on' to enable.");
cons_show("Chat logging is currently disabled, use '/logging chat on' to enable.");
}
} else {
cons_bad_cmd_usage(command);
@@ -7804,7 +7788,7 @@ cmd_ox_log(ProfWin* window, const char* const command, gchar** args)
prefs_set_string(PREF_OX_LOG, "on");
cons_show("OX messages will be logged as plaintext.");
if (!prefs_get_boolean(PREF_CHLOG)) {
cons_show("Chat logging is currently disabled, use '/history on' to enable.");
cons_show("Chat logging is currently disabled, use '/logging chat on' to enable.");
}
} else if (g_strcmp0(choice, "off") == 0) {
prefs_set_string(PREF_OX_LOG, "off");
@@ -7813,7 +7797,7 @@ cmd_ox_log(ProfWin* window, const char* const command, gchar** args)
prefs_set_string(PREF_OX_LOG, "redact");
cons_show("OX messages will be logged as '[redacted]'.");
if (!prefs_get_boolean(PREF_CHLOG)) {
cons_show("Chat logging is currently disabled, use '/history on' to enable.");
cons_show("Chat logging is currently disabled, use '/logging chat on' to enable.");
}
} else {
cons_bad_cmd_usage(command);
@@ -7853,7 +7837,7 @@ cmd_otr_log(ProfWin* window, const char* const command, gchar** args)
prefs_set_string(PREF_OTR_LOG, "on");
cons_show("OTR messages will be logged as plaintext.");
if (!prefs_get_boolean(PREF_CHLOG)) {
cons_show("Chat logging is currently disabled, use '/history on' to enable.");
cons_show("Chat logging is currently disabled, use '/logging chat on' to enable.");
}
} else if (g_strcmp0(choice, "off") == 0) {
prefs_set_string(PREF_OTR_LOG, "off");
@@ -7862,7 +7846,7 @@ cmd_otr_log(ProfWin* window, const char* const command, gchar** args)
prefs_set_string(PREF_OTR_LOG, "redact");
cons_show("OTR messages will be logged as '[redacted]'.");
if (!prefs_get_boolean(PREF_CHLOG)) {
cons_show("Chat logging is currently disabled, use '/history on' to enable.");
cons_show("Chat logging is currently disabled, use '/logging chat on' to enable.");
}
} else {
cons_bad_cmd_usage(command);
@@ -8532,8 +8516,7 @@ _cmd_execute_default(ProfWin* window, const char* inp)
case WIN_AI:
{
ProfAiWin* aiwin = (ProfAiWin*)window;
auto_gchar gchar* stanza_id = connection_create_stanza_id();
cl_ev_send_ai_msg(aiwin, inp, stanza_id);
cl_ev_send_ai_msg(aiwin, inp, connection_create_stanza_id());
break;
}
default:
@@ -8799,7 +8782,7 @@ cmd_omemo_log(ProfWin* window, const char* const command, gchar** args)
prefs_set_string(PREF_OMEMO_LOG, "on");
cons_show("OMEMO messages will be logged as plaintext.");
if (!prefs_get_boolean(PREF_CHLOG)) {
cons_show("Chat logging is currently disabled, use '/history on' to enable.");
cons_show("Chat logging is currently disabled, use '/logging chat on' to enable.");
}
} else if (g_strcmp0(choice, "off") == 0) {
prefs_set_string(PREF_OMEMO_LOG, "off");
@@ -8808,7 +8791,7 @@ cmd_omemo_log(ProfWin* window, const char* const command, gchar** args)
prefs_set_string(PREF_OMEMO_LOG, "redact");
cons_show("OMEMO messages will be logged as '[redacted]'.");
if (!prefs_get_boolean(PREF_CHLOG)) {
cons_show("Chat logging is currently disabled, use '/history on' to enable.");
cons_show("Chat logging is currently disabled, use '/logging chat on' to enable.");
}
} else {
cons_bad_cmd_usage(command);
@@ -10834,6 +10817,7 @@ cmd_ai(ProfWin* window, const char* const command, gchar** args)
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;
}
@@ -10997,9 +10981,6 @@ cmd_ai_start(ProfWin* window, const char* const command, gchar** args)
}
log_debug("[AI-CMD] wins_new_ai() returned successfully, window type: %d", ai_win->type);
// Release the reference held by cmd_ai_start, since the window now holds one
ai_session_unref(session);
// 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);
@@ -11193,24 +11174,30 @@ cmd_ai_clear(ProfWin* window, const char* const command, gchar** args)
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();
if (!providers) {
cons_show(" (none configured)");
cons_show("");
cons_show("Add a provider with: /ai set provider <name> <url>");
return TRUE;
}
for (GList* curr = providers; curr; curr = g_list_next(curr)) {
AIProvider* provider = curr->data;
auto_gchar gchar* key = ai_get_provider_key(provider->name);
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");
cons_show("");
g_free(key);
}
g_list_free(providers);

View File

@@ -67,8 +67,6 @@
#define PREF_GROUP_PLUGINS "plugins"
#define PREF_GROUP_EXECUTABLES "executables"
#define PREF_GROUP_AI "ai"
#define PREF_GROUP_AI_PREFIX "ai/" /* per-provider subsection prefix: [ai/<provider>] */
#define PREF_AI_SETTING_PREFIX "setting." /* per-provider custom setting key prefix */
#define INPBLOCK_DEFAULT 1000
@@ -245,69 +243,6 @@ _prefs_load(void)
g_key_file_remove_key(prefs, PREF_GROUP_UI, "titlebar.muc.title.jid", NULL);
g_key_file_remove_key(prefs, PREF_GROUP_UI, "titlebar.muc.title.name", NULL);
}
/* Migrate legacy flat [ai] layout
* openai_url=..., openai=<token>, openai_models=..., openai_default_model=...
* into per-provider subsections
* [ai/openai] url=..., key=..., models=..., default_model=...
* Idempotent: once the flat keys are gone, this is a no-op. */
if (g_key_file_has_group(prefs, PREF_GROUP_AI)) {
gsize key_count = 0;
auto_gcharv gchar** all_keys = g_key_file_get_keys(prefs, PREF_GROUP_AI, &key_count, NULL);
/* Collect provider names from <name>_url keys first so we can migrate
* the whole bundle (url + token + models + default_model) atomically
* per provider, without misclassifying unrelated flat keys. */
GHashTable* to_migrate = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
for (gsize i = 0; i < key_count; i++) {
const gchar* k = all_keys[i];
if (g_str_has_suffix(k, "_url") && !g_str_has_suffix(k, "_models_url")) {
gsize klen = strlen(k);
if (klen > 4) {
g_hash_table_add(to_migrate, g_strndup(k, klen - 4));
}
}
}
GList* provider_names = g_hash_table_get_keys(to_migrate);
for (GList* curr = provider_names; curr; curr = g_list_next(curr)) {
const gchar* name = (const gchar*)curr->data;
auto_gchar gchar* group = g_strconcat(PREF_GROUP_AI_PREFIX, name, NULL);
const struct
{
const gchar* flat_suffix;
const gchar* new_key;
} map[] = {
{ "_url", "url" },
{ "_models", "models" },
{ "_default_model", "default_model" },
{ NULL, NULL },
};
for (int j = 0; map[j].flat_suffix; j++) {
auto_gchar gchar* flat_key = g_strconcat(name, map[j].flat_suffix, NULL);
if (g_key_file_has_key(prefs, PREF_GROUP_AI, flat_key, NULL)) {
auto_gchar gchar* val = g_key_file_get_string(prefs, PREF_GROUP_AI, flat_key, NULL);
if (val) {
g_key_file_set_string(prefs, group, map[j].new_key, val);
}
g_key_file_remove_key(prefs, PREF_GROUP_AI, flat_key, NULL);
}
}
/* Token was stored as the bare provider name in [ai]. */
if (g_key_file_has_key(prefs, PREF_GROUP_AI, name, NULL)) {
auto_gchar gchar* val = g_key_file_get_string(prefs, PREF_GROUP_AI, name, NULL);
if (val) {
g_key_file_set_string(prefs, group, "key", val);
}
g_key_file_remove_key(prefs, PREF_GROUP_AI, name, NULL);
}
}
g_list_free(provider_names);
g_hash_table_destroy(to_migrate);
}
_save_prefs();
boolean_choice_ac = autocomplete_new();
@@ -1773,103 +1708,73 @@ _save_prefs(void)
}
/* ========================================================================
* AI provider preferences
*
* Each provider lives in its own [ai/<name>] subsection of the keyfile with:
* url=...
* key=<api token>
* default_model=...
* models=model1;model2;...
* setting.<custom>=value (e.g. setting.tools=enabled)
* The bare [ai] section keeps only the global "defaults_initialized" sentinel.
* Legacy flat-key layouts are migrated on first load in _prefs_load().
* AI Token Management
* ======================================================================== */
static gchar*
_ai_section(const char* const provider)
{
return g_strconcat(PREF_GROUP_AI_PREFIX, provider, NULL);
}
static gboolean
_ai_set_string(const char* const provider, const char* const key, const char* const value)
{
if (!provider || !key || !value || !prefs)
return FALSE;
auto_gchar gchar* group = _ai_section(provider);
g_key_file_set_string(prefs, group, key, value);
_save_prefs();
return TRUE;
}
static char*
_ai_get_string(const char* const provider, const char* const key)
{
if (!provider || !key || !prefs)
return NULL;
auto_gchar gchar* group = _ai_section(provider);
if (!g_key_file_has_key(prefs, group, key, NULL))
return NULL;
return g_key_file_get_string(prefs, group, key, NULL);
}
static gboolean
_ai_remove_string(const char* const provider, const char* const key)
{
if (!provider || !key || !prefs)
return FALSE;
auto_gchar gchar* group = _ai_section(provider);
if (!g_key_file_has_key(prefs, group, key, NULL))
return FALSE;
g_key_file_remove_key(prefs, group, key, NULL);
_save_prefs();
return TRUE;
}
/* --- Token --- */
gboolean
prefs_ai_set_token(const char* const provider, const char* const token)
{
if (!provider || !prefs)
if (!provider)
return FALSE;
if (!token)
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);
return _ai_set_string(provider, "key", token);
}
}
gboolean
prefs_ai_remove_token(const char* const provider)
{
return _ai_remove_string(provider, "key");
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)
{
return _ai_get_string(provider, "key");
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)
if (!prefs || !g_key_file_has_group(prefs, PREF_GROUP_AI)) {
return NULL;
}
GList* result = NULL;
GList* providers = prefs_ai_list_providers();
for (GList* curr = providers; curr; curr = g_list_next(curr)) {
const gchar* name = (const gchar*)curr->data;
auto_gchar gchar* tok = prefs_ai_get_token(name);
if (tok && tok[0] != '\0') {
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));
}
}
prefs_free_ai_providers(providers);
return result;
}
@@ -1881,45 +1786,72 @@ prefs_free_ai_tokens(GList* tokens)
}
}
/* --- Models cache --- */
gboolean
prefs_ai_set_models(const char* const provider, const gchar* const* models, gint count)
{
if (!provider || count <= 0 || !prefs)
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)
if (i > 0) {
g_string_append_c(model_str, ';');
}
g_string_append(model_str, models[i]);
}
gboolean ok = _ai_set_string(provider, "models", model_str->str);
g_key_file_set_string(prefs, PREF_GROUP_AI, g_strconcat(provider, "_models", NULL), model_str->str);
g_string_free(model_str, TRUE);
return ok;
_save_prefs();
return TRUE;
}
gboolean
prefs_ai_remove_models(const char* const provider)
{
return _ai_remove_string(provider, "models");
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)
{
auto_gchar gchar* model_str = _ai_get_string(provider, "models");
if (!model_str || model_str[0] == '\0')
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;
}
@@ -1931,89 +1863,78 @@ prefs_free_ai_models(GList* models)
}
}
/* --- Default model --- */
gboolean
prefs_ai_set_default_model(const char* const provider, const char* const model)
{
if (!provider || !model || !prefs)
return FALSE;
if (model[0] == '\0')
return prefs_ai_remove_default_model(provider);
return _ai_set_string(provider, "default_model", model);
}
gboolean
prefs_ai_remove_default_model(const char* const provider)
{
return _ai_remove_string(provider, "default_model");
}
char*
prefs_ai_get_default_model(const char* const provider)
{
return _ai_get_string(provider, "default_model");
}
/* --- Provider URL --- */
/* ========================================================================
* AI Provider Management
* ======================================================================== */
gboolean
prefs_ai_set_provider(const char* const provider, const char* const url)
{
if (!provider || !url)
return FALSE;
return _ai_set_string(provider, "url", url);
if (!prefs)
return FALSE;
/* Store URL with "_url" suffix to avoid collision with API key storage */
g_key_file_set_string(prefs, PREF_GROUP_AI, g_strconcat(provider, "_url", NULL), url);
_save_prefs();
return TRUE;
}
gboolean
prefs_ai_remove_provider(const char* const provider)
{
/* Provider presence is identified by the url key; removing it is what
* makes prefs_ai_list_providers stop returning this name. The remaining
* sub-keys are wiped through prefs_ai_remove_section(). */
return _ai_remove_string(provider, "url");
if (!provider)
return FALSE;
if (!prefs)
return FALSE;
auto_gchar gchar* url_key = g_strconcat(provider, "_url", NULL);
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, url_key, NULL)) {
return FALSE;
}
g_key_file_remove_key(prefs, PREF_GROUP_AI, url_key, NULL);
_save_prefs();
return TRUE;
}
char*
prefs_ai_get_provider_url(const char* const provider)
{
return _ai_get_string(provider, "url");
}
gboolean
prefs_ai_remove_section(const char* const provider)
{
if (!provider || !prefs)
return FALSE;
return NULL;
auto_gchar gchar* group = _ai_section(provider);
if (!g_key_file_has_group(prefs, group))
return FALSE;
auto_gchar gchar* key = g_strconcat(provider, "_url", NULL);
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, key, NULL)) {
return NULL;
}
g_key_file_remove_group(prefs, group, NULL);
_save_prefs();
return TRUE;
return g_key_file_get_string(prefs, PREF_GROUP_AI, key, NULL);
}
GList*
prefs_ai_list_providers(void)
{
if (!prefs)
if (!prefs || !g_key_file_has_group(prefs, PREF_GROUP_AI)) {
return NULL;
}
GList* result = NULL;
gsize len = 0;
auto_gcharv gchar** groups = g_key_file_get_groups(prefs, &len);
if (!groups)
return NULL;
auto_gcharv gchar** keys = g_key_file_get_keys(prefs, PREF_GROUP_AI, &len, NULL);
gsize prefix_len = strlen(PREF_GROUP_AI_PREFIX);
for (gsize i = 0; i < len; i++) {
const gchar* g = groups[i];
if (g_str_has_prefix(g, PREF_GROUP_AI_PREFIX) && strlen(g) > prefix_len) {
/* Only count groups that actually identify a provider (have url). */
if (g_key_file_has_key(prefs, g, "url", NULL)) {
result = g_list_append(result, g_strdup(g + prefix_len));
char* key = keys[i];
/* Match keys ending with "_url" that are not "_models" suffix */
if (g_str_has_suffix(key, "_url") && !g_str_has_suffix(key, "_models_url")) {
/* Extract provider name by removing "_url" suffix */
gsize key_len = strlen(key);
if (key_len > 4) {
gchar* provider_name = g_strndup(key, key_len - 4);
result = g_list_append(result, provider_name);
}
}
}
@@ -2032,106 +1953,38 @@ prefs_free_ai_providers(GList* providers)
GList*
prefs_ai_get_providers(void)
{
/* If user has configured any providers, return those */
GList* configured = prefs_ai_list_providers();
GList* result = NULL;
for (GList* curr = configured; curr; curr = g_list_next(curr)) {
const gchar* name = (const gchar*)curr->data;
auto_gchar gchar* url = prefs_ai_get_provider_url(name);
if (url && strlen(url) > 0) {
result = g_list_append(result, g_strdup(name));
result = g_list_append(result, g_strdup(url));
if (configured && g_list_length(configured) > 0) {
/* Build result list with name, url pairs */
GList* result = NULL;
GList* curr = configured;
while (curr) {
gchar* name = (gchar*)curr->data;
gchar* url = prefs_ai_get_provider_url(name);
if (url && strlen(url) > 0) {
result = g_list_append(result, g_strdup(name));
result = g_list_append(result, g_strdup(url));
}
g_free(url);
curr = g_list_next(curr);
}
prefs_free_ai_providers(configured);
return result;
}
prefs_free_ai_providers(configured);
/* Seed openai/perplexity into the in-memory result on very first init
* only. In production the "defaults_initialized" sentinel in [ai] is what
* tells us we've already seeded — a user who removes every provider must
* not get them silently re-added on the next start. When prefs is NULL
* (e.g. unit tests that bring up ai_client without prefs_load) we still
* seed the in-memory list so behaviour matches a fresh first run, but
* skip the persistence write. */
gboolean already_seeded = prefs && g_key_file_get_boolean(prefs, PREF_GROUP_AI, "defaults_initialized", NULL);
if (!already_seeded && !result) {
if (prefs) {
prefs_ai_set_provider("openai", "https://api.openai.com/");
prefs_ai_set_provider("perplexity", "https://api.perplexity.ai/");
}
result = g_list_append(result, g_strdup("openai"));
result = g_list_append(result, g_strdup("https://api.openai.com/"));
result = g_list_append(result, g_strdup("perplexity"));
result = g_list_append(result, g_strdup("https://api.perplexity.ai/"));
}
if (prefs && !already_seeded) {
g_key_file_set_boolean(prefs, PREF_GROUP_AI, "defaults_initialized", TRUE);
_save_prefs();
}
return result;
}
/* --- Custom per-provider settings (setting.<name>) --- */
gboolean
prefs_ai_set_setting(const char* const provider, const char* const setting, const char* const value)
{
if (!provider || !setting)
return FALSE;
auto_gchar gchar* key = g_strconcat(PREF_AI_SETTING_PREFIX, setting, NULL);
if (!value)
return _ai_remove_string(provider, key);
return _ai_set_string(provider, key, value);
}
gboolean
prefs_ai_remove_setting(const char* const provider, const char* const setting)
{
if (!provider || !setting)
return FALSE;
auto_gchar gchar* key = g_strconcat(PREF_AI_SETTING_PREFIX, setting, NULL);
return _ai_remove_string(provider, key);
}
char*
prefs_ai_get_setting(const char* const provider, const char* const setting)
{
if (!provider || !setting)
return NULL;
auto_gchar gchar* key = g_strconcat(PREF_AI_SETTING_PREFIX, setting, NULL);
return _ai_get_string(provider, key);
}
GList*
prefs_ai_list_settings(const char* const provider)
{
if (!provider || !prefs)
return NULL;
auto_gchar gchar* group = _ai_section(provider);
if (!g_key_file_has_group(prefs, group))
return NULL;
prefs_ai_set_provider("openai", "https://api.openai.com/");
prefs_ai_set_provider("perplexity", "https://api.perplexity.ai/");
GList* result = NULL;
gsize len = 0;
auto_gcharv gchar** keys = g_key_file_get_keys(prefs, group, &len, NULL);
gsize prefix_len = strlen(PREF_AI_SETTING_PREFIX);
for (gsize i = 0; i < len; i++) {
if (g_str_has_prefix(keys[i], PREF_AI_SETTING_PREFIX) && strlen(keys[i]) > prefix_len) {
result = g_list_append(result, g_strdup(keys[i] + prefix_len));
}
}
result = g_list_append(result, g_strdup("openai"));
result = g_list_append(result, g_strdup("https://api.openai.com/"));
result = g_list_append(result, g_strdup("perplexity"));
result = g_list_append(result, g_strdup("https://api.perplexity.ai/"));
return result;
}
void
prefs_free_ai_settings(GList* settings)
{
if (settings) {
g_list_free_full(settings, 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.
@@ -2208,7 +2061,6 @@ _get_group(preference_t pref)
case PREF_STATUSBAR_SHOW_NAME:
case PREF_STATUSBAR_SHOW_NUMBER:
case PREF_STATUSBAR_SHOW_READ:
case PREF_STATUSBAR_SHOW_DBBACKEND:
case PREF_STATUSBAR_SELF:
case PREF_STATUSBAR_CHAT:
case PREF_STATUSBAR_ROOM_TITLE:
@@ -2536,8 +2388,6 @@ _get_key(preference_t pref)
return "statusbar.show.number";
case PREF_STATUSBAR_SHOW_READ:
return "statusbar.show.read";
case PREF_STATUSBAR_SHOW_DBBACKEND:
return "statusbar.show.dbbackend";
case PREF_STATUSBAR_SELF:
return "statusbar.self";
case PREF_STATUSBAR_CHAT:
@@ -2631,7 +2481,6 @@ _get_default_boolean(preference_t pref)
case PREF_ROOM_LIST_CACHE:
case PREF_STATUSBAR_SHOW_NUMBER:
case PREF_STATUSBAR_SHOW_READ:
case PREF_STATUSBAR_SHOW_DBBACKEND:
case PREF_REVEAL_OS:
case PREF_CORRECTION_ALLOW:
case PREF_RECEIPTS_SEND:

View File

@@ -162,7 +162,6 @@ typedef enum {
PREF_STATUSBAR_SHOW_NAME,
PREF_STATUSBAR_SHOW_NUMBER,
PREF_STATUSBAR_SHOW_READ,
PREF_STATUSBAR_SHOW_DBBACKEND,
PREF_STATUSBAR_SELF,
PREF_STATUSBAR_CHAT,
PREF_STATUSBAR_ROOM_TITLE,
@@ -375,27 +374,10 @@ GList* prefs_ai_list_providers(void);
void prefs_free_ai_providers(GList* providers);
GList* prefs_ai_get_providers(void);
/* Wipe the whole [ai/<provider>] subsection (url, key, models, default_model,
* custom settings) in one call. */
gboolean prefs_ai_remove_section(const char* const provider);
/* 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);
/* AI default model per provider */
gboolean prefs_ai_set_default_model(const char* const provider, const char* const model);
gboolean prefs_ai_remove_default_model(const char* const provider);
char* prefs_ai_get_default_model(const char* const provider);
/* AI per-provider custom settings (e.g. tools, search) — persisted as
* setting.<name>=<value> inside [ai/<provider>]. Pass NULL value to remove. */
gboolean prefs_ai_set_setting(const char* const provider, const char* const setting, const char* const value);
gboolean prefs_ai_remove_setting(const char* const provider, const char* const setting);
char* prefs_ai_get_setting(const char* const provider, const char* const setting);
GList* prefs_ai_list_settings(const char* const provider);
void prefs_free_ai_settings(GList* settings);
#endif

View File

@@ -88,8 +88,9 @@ _ff_cache_line_ids(ff_contact_state_t* state, const char* line)
return;
// Split metadata on unescaped '|'
auto_gchar gchar* meta_str = g_strndup(bracket + 1, close - bracket - 1);
char* meta_str = g_strndup(bracket + 1, close - bracket - 1);
char** parts = ff_split_meta(meta_str);
g_free(meta_str);
char* stanza_id = NULL;
char* archive_id = NULL;
@@ -140,13 +141,15 @@ _ff_maybe_index_line(ff_contact_state_t* state, const char* buf, off_t pos)
if (!space)
return;
auto_gchar gchar* ts_str = g_strndup(buf, space - buf);
char* ts_str = g_strndup(buf, space - buf);
GDateTime* dt = g_date_time_new_from_iso8601(ts_str, NULL);
if (!dt) {
log_warning("flatfile: unparsable timestamp in %s at offset %ld: %s",
state->filepath, (long)pos, ts_str);
g_free(ts_str);
return;
}
g_free(ts_str);
if (state->n_entries >= state->cap_entries) {
state->cap_entries = state->cap_entries ? state->cap_entries * 2 : 64;

View File

@@ -211,13 +211,9 @@ _first_pass(FILE* fp, const char* basename,
if (pl->stanza_id && pl->stanza_id[0] != '\0') {
if (g_hash_table_contains(seen_stanza_ids, pl->stanza_id)) {
// Older clients (Pidgin, Adium, even early Profanity)
// sometimes reuse client-generated stanza-ids across
// distinct messages, so a collision is not a real
// integrity defect — log it for diagnostics but don't
// surface it through /history verify.
log_debug("flatfile verify: duplicate stanza-id \"%s\" at %s:%d",
pl->stanza_id, basename, lineno);
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_WARNING, basename, lineno,
g_strdup_printf("Duplicate stanza-id \"%s\"", pl->stanza_id)));
} else {
g_hash_table_insert(seen_stanza_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno));
}
@@ -306,13 +302,7 @@ _verify_contact_dir(const char* cdir_path, GSList** issues)
return;
}
// Reconstruct the contact JID from the directory name so issue reports
// identify which contact the problem belongs to (every contact has its
// own history.log, so the bare filename is ambiguous).
auto_gchar gchar* dir_name = g_path_get_basename(cdir_path);
auto_gcharv gchar** parts = g_strsplit(dir_name, "_at_", -1);
auto_gchar gchar* contact_jid = g_strjoinv("@", parts);
auto_gchar gchar* basename = g_strdup_printf("%s/history.log", contact_jid);
const char* basename = "history.log";
_check_permissions(filepath, basename, issues);

View File

@@ -466,36 +466,23 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
sqlite3_finalize(stmt);
}
// Check timestamp ordering, restricted to consecutive rows belonging
// to the same conversation pair so adjacency does not span unrelated
// contacts. Adjacent rows by id from different conversations are
// legitimately out-of-order and should not be reported.
// Check timestamp ordering for a specific contact or all
const Jid* myjid = connection_get_jid();
if (myjid && myjid->barejid) {
auto_sqlite char* query = NULL;
if (contact_barejid) {
query = sqlite3_mprintf(
"SELECT A.`id`, A.`timestamp`, A.`from_jid`, A.`to_jid`, B.`id`, B.`timestamp` "
"FROM `ChatLogs` A "
"JOIN `ChatLogs` B ON B.`from_jid` = A.`from_jid` AND B.`to_jid` = A.`to_jid` "
" AND B.`id` > A.`id` "
"SELECT A.`id`, A.`timestamp`, B.`id`, B.`timestamp` FROM `ChatLogs` A "
"JOIN `ChatLogs` B ON B.`id` = A.`id` + 1 "
"WHERE A.`timestamp` > B.`timestamp` "
"AND ((A.`from_jid` = %Q AND A.`to_jid` = %Q) OR (A.`from_jid` = %Q AND A.`to_jid` = %Q)) "
"AND NOT EXISTS (SELECT 1 FROM `ChatLogs` C "
" WHERE C.`from_jid` = A.`from_jid` AND C.`to_jid` = A.`to_jid` "
" AND C.`id` > A.`id` AND C.`id` < B.`id`) "
"LIMIT 50",
contact_barejid, myjid->barejid, myjid->barejid, contact_barejid);
} else {
query = sqlite3_mprintf(
"SELECT A.`id`, A.`timestamp`, A.`from_jid`, A.`to_jid`, B.`id`, B.`timestamp` "
"FROM `ChatLogs` A "
"JOIN `ChatLogs` B ON B.`from_jid` = A.`from_jid` AND B.`to_jid` = A.`to_jid` "
" AND B.`id` > A.`id` "
"SELECT A.`id`, A.`timestamp`, B.`id`, B.`timestamp` FROM `ChatLogs` A "
"JOIN `ChatLogs` B ON B.`id` = A.`id` + 1 "
"WHERE A.`timestamp` > B.`timestamp` "
"AND NOT EXISTS (SELECT 1 FROM `ChatLogs` C "
" WHERE C.`from_jid` = A.`from_jid` AND C.`to_jid` = A.`to_jid` "
" AND C.`id` > A.`id` AND C.`id` < B.`id`) "
"LIMIT 50");
}
@@ -503,19 +490,15 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
while (sqlite3_step(stmt) == SQLITE_ROW) {
int id_a = sqlite3_column_int(stmt, 0);
const char* ts_a = (const char*)sqlite3_column_text(stmt, 1);
const char* from_a = (const char*)sqlite3_column_text(stmt, 2);
const char* to_a = (const char*)sqlite3_column_text(stmt, 3);
int id_b = sqlite3_column_int(stmt, 4);
const char* ts_b = (const char*)sqlite3_column_text(stmt, 5);
int id_b = sqlite3_column_int(stmt, 2);
const char* ts_b = (const char*)sqlite3_column_text(stmt, 3);
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
issue->level = INTEGRITY_WARNING;
issue->file = g_strdup_printf("%s↔%s/chatlog.db",
from_a ? from_a : "?", to_a ? to_a : "?");
issue->file = g_strdup("chatlog.db");
issue->line = id_a;
issue->message = g_strdup_printf("Timestamp out of order: row %d (%s) > row %d (%s)",
id_a, ts_a ? ts_a : "NULL",
id_b, ts_b ? ts_b : "NULL");
id_a, ts_a ? ts_a : "NULL", id_b, ts_b ? ts_b : "NULL");
issues = g_slist_append(issues, issue);
}
sqlite3_finalize(stmt);
@@ -523,7 +506,7 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
// Check broken LMC references
auto_sqlite char* lmc_query = sqlite3_mprintf(
"SELECT A.`id`, A.`replaces_db_id`, A.`from_jid`, A.`to_jid` FROM `ChatLogs` A "
"SELECT A.`id`, A.`replaces_db_id` FROM `ChatLogs` A "
"WHERE A.`replaces_db_id` IS NOT NULL "
"AND NOT EXISTS (SELECT 1 FROM `ChatLogs` B WHERE B.`id` = A.`replaces_db_id`) "
"LIMIT 50");
@@ -532,13 +515,10 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
while (sqlite3_step(stmt) == SQLITE_ROW) {
int id = sqlite3_column_int(stmt, 0);
int replaces_id = sqlite3_column_int(stmt, 1);
const char* from = (const char*)sqlite3_column_text(stmt, 2);
const char* to = (const char*)sqlite3_column_text(stmt, 3);
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
issue->level = INTEGRITY_ERROR;
issue->file = g_strdup_printf("%s↔%s/chatlog.db",
from ? from : "?", to ? to : "?");
issue->file = g_strdup("chatlog.db");
issue->line = id;
issue->message = g_strdup_printf("Broken LMC reference: row %d references non-existent row %d",
id, replaces_id);

View File

@@ -1884,12 +1884,6 @@ cons_statusbar_setting(void)
cons_show("Show tab with no actions (/statusbar) : OFF");
}
if (prefs_get_boolean(PREF_STATUSBAR_SHOW_DBBACKEND)) {
cons_show("Show database backend (/statusbar) : ON");
} else {
cons_show("Show database backend (/statusbar) : OFF");
}
cons_show("Max tabs (/statusbar) : %d", prefs_get_statusbartabs());
gint pref_len = prefs_get_statusbartablen();
@@ -1948,12 +1942,27 @@ cons_log_setting(void)
cons_show("Log level (/log level) : %s", level);
}
void
cons_logging_setting(void)
{
if (prefs_get_boolean(PREF_CHLOG))
cons_show("Chat logging (/logging chat) : ON");
else
cons_show("Chat logging (/logging chat) : OFF");
if (prefs_get_boolean(PREF_GRLOG))
cons_show("Groupchat logging (/logging group) : ON");
else
cons_show("Groupchat logging (/logging group) : OFF");
}
void
cons_show_log_prefs(void)
{
cons_show("Logging preferences:");
cons_show("");
cons_log_setting();
cons_logging_setting();
cons_alert(NULL);
}
@@ -2847,6 +2856,11 @@ cons_privacy_setting(void)
{
cons_show("Database logging : %s", prefs_get_string(PREF_DBLOG));
if (prefs_get_boolean(PREF_CHLOG)) {
cons_show("Chat logging (/logging chat) : ON");
} else {
cons_show("Chat logging (/logging chat) : OFF");
}
if (prefs_get_boolean(PREF_HISTORY)) {
cons_show("Chat history (/history) : ON");
} else {

View File

@@ -609,8 +609,6 @@ _status_bar_draw_maintext(int pos)
static int
_status_bar_draw_dbbackend(int pos)
{
if (!prefs_get_boolean(PREF_STATUSBAR_SHOW_DBBACKEND))
return pos;
if (!active_db_backend || !active_db_backend->name)
return pos;

View File

@@ -330,6 +330,7 @@ void cons_history_setting(void);
void cons_carbons_setting(void);
void cons_receipts_setting(void);
void cons_log_setting(void);
void cons_logging_setting(void);
void cons_autoaway_setting(void);
void cons_reconnect_setting(void);
void cons_autoping_setting(void);

View File

@@ -1600,7 +1600,7 @@ win_print_outgoing(ProfWin* window, const char* show_char, const char* const id,
GDateTime* timestamp = g_date_time_new_now_local();
const char* myjid = connection_get_fulljid();
if (!prefs_get_boolean(PREF_CORRECTION_ALLOW) || !_win_correct(window, message, id, replace_id, myjid)) {
if (!_win_correct(window, message, id, replace_id, myjid)) {
auto_gchar gchar* outgoing_str = prefs_get_string(PREF_OUTGOING_STAMP);
_win_printf(window, show_char, 0, timestamp, 0, THEME_TEXT_ME, outgoing_str, myjid, id, "%s", message);
}
@@ -1747,7 +1747,7 @@ win_print_outgoing_with_receipt(ProfWin* window, const char* show_char, const ch
receipt->received = FALSE;
const char* myjid = connection_get_fulljid();
if (prefs_get_boolean(PREF_CORRECTION_ALLOW) && _win_correct(window, message, id, replace_id, myjid)) {
if (_win_correct(window, message, id, replace_id, myjid)) {
free(receipt); // TODO: probably we should use this in _win_correct()
} else {
int y_start_pos = getcury(window->layout->win);

View File

@@ -1,4 +1,3 @@
#include "glib.h"
#include "prof_cmocka.h"
#include "common.h"
#include "ai/ai_client.h"
@@ -704,6 +703,7 @@ test_ai_parse_models_openai_format(void** state)
models = g_list_next(models);
assert_string_equal("google/gemini-3-flash-preview", models->data);
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -776,6 +776,7 @@ test_ai_parse_models_perplexity_format(void** state)
models = g_list_last(models);
assert_string_equal("xai/grok-4.3", models->data);
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -800,6 +801,7 @@ test_ai_parse_models_array_format(void** state)
models = g_list_next(models);
assert_string_equal("model3", models->data);
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -819,6 +821,7 @@ test_ai_parse_models_empty_json(void** state)
assert_null(provider->models);
assert_int_equal(0, g_list_length(provider->models));
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -834,6 +837,7 @@ test_ai_parse_models_null_handling(void** state)
ai_parse_models_from_json(provider, NULL);
assert_null(provider->models);
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -857,6 +861,7 @@ test_ai_parse_models_escaped_quotes(void** state)
GList* models = provider->models;
assert_non_null(models);
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -880,6 +885,7 @@ test_ai_parse_models_with_whitespace(void** state)
GList* models = provider->models;
assert_string_equal("model-with-spaces", models->data);
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -1136,7 +1142,7 @@ test_ai_providers_find_cycles_through_many(void** state)
ai_add_provider("alpine", "https://al.example/");
/* Three providers share prefix "alp": alpha, alphabet, alpine. */
GHashTable* seen = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
GHashTable* seen = g_hash_table_new(g_str_hash, g_str_equal);
for (int i = 0; i < 3; i++) {
auto_gchar gchar* match = ai_providers_find("alp", FALSE, NULL);

View File

@@ -69,7 +69,7 @@ cmd_otr_log_on_shows_warning_when_chlog_disabled(void** state)
prefs_set_boolean(PREF_CHLOG, FALSE);
expect_cons_show("OTR messages will be logged as plaintext.");
expect_cons_show("Chat logging is currently disabled, use '/history on' to enable.");
expect_cons_show("Chat logging is currently disabled, use '/logging chat on' to enable.");
gboolean result = cmd_otr_log(NULL, CMD_OTR, args);
assert_true(result);
@@ -115,7 +115,7 @@ cmd_otr_log_redact_shows_warning_when_chlog_disabled(void** state)
prefs_set_boolean(PREF_CHLOG, FALSE);
expect_cons_show("OTR messages will be logged as '[redacted]'.");
expect_cons_show("Chat logging is currently disabled, use '/history on' to enable.");
expect_cons_show("Chat logging is currently disabled, use '/logging chat on' to enable.");
gboolean result = cmd_otr_log(NULL, CMD_OTR, args);
assert_true(result);