v0.8.6: general mode, clipboard text, PDF text extraction

Working mode: --mode coding|general (CST_MODE, settings.json, /mode,
WebUI run settings). general swaps the default framing for a general
assistant one -- workflow, safety notes, tool wording (run_shell, the
subagents) -- and /status shows it; minimal (the paper profile) stays
byte-frozen. The prompt state carries the mode so a switch leaves a
system note in history.

Clipboard: clipboard_read / clipboard_write tools (16 MiB cap read,
default 64 KiB window; writing is a WRITE permission with a preview) on
pbcopy / wl-copy / xclip / pbpaste / wl-paste / xclip and the Win32
clipboard. In the TUI, /attach paste falls back to clipboard text when
there is no image on it.

PDF: a dependency-free text extractor (src/pdf_text.c): inflate,
Flate/ASCIIHex/ASCII85, PNG/TIFF predictors, layout-independent object
scan, object streams, page tree walk with inherited resources, content
lexer (Tj/TJ/'/" and the Td/TD/T*/Tm spacing heuristics), per-font
ToUnicode CMaps and inline images skipped. Encrypted files and pages
with no text layer are refused with the reason.

Attachments: /attach (a path or files dropped on the terminal) now take
images, PDFs and text. A PDF is stored as its text layer with the mime
still saying application/pdf and its page count; anything else has to be
UTF-8 text. Documents travel inline as text parts, so CST_NO_IMAGES and
text-only models no longer drop them, while images keep their old path.

Tests: test_pdf_text (plain, bare and zlib-framed Flate, inline image,
CID + ToUnicode, object stream, error paths), documents in test_attach,
inlined documents in test_llm_body, mode in test_config.
This commit is contained in:
WHC
2026-10-11 11:09:39 +08:00
parent 50abcf1a91
commit e0b0eddf3a
28 changed files with 3291 additions and 111 deletions
+90 -23
View File
@@ -255,28 +255,78 @@ static cJSON *image_part(const cst_llm_request_t *req,
return p;
}
/* Append every attachment of m as image parts (or a note saying why not). */
static void parts_add_images(const cst_llm_request_t *req, cJSON *parts,
const cst_message_t *m, size_t *budget) {
/* One stored document as a text part: its text, framed so the model knows
* where it came from. Documents are plain text on the wire, so a text-only
* model and CST_NO_IMAGES both still get them. */
static cJSON *document_part(const cst_llm_request_t *req,
const cst_attachment_t *a, size_t *budget,
char *why, size_t why_sz) {
why[0] = 0;
if (!req->session_path || !req->session_path[0]) {
snprintf(why, why_sz, "there is no session store to read it from");
return NULL;
}
char err[256];
size_t n = 0;
char *text = cst_attach_read(req->session_path, a, &n, err, sizeof(err));
if (!text) {
snprintf(why, why_sz, "%s", err);
return NULL;
}
int cut = 0;
if (n > *budget) {
n = *budget;
cut = 1;
}
*budget -= n;
char desc[128];
cst_attach_describe(a, desc, sizeof(desc));
cst_buf_t b = {0};
cst_buf_printf(&b, "[document \"%.120s\" (%s)%s]\n", a->name, desc,
cut ? " -- only the start is included" : "");
cst_buf_append(&b, text, n);
cst_buf_puts(&b, cut ? "\n[-- document truncated --]"
: "\n[-- end of document --]");
free(text);
cJSON *p = cJSON_CreateObject();
cJSON_AddStringToObject(p, "type", "text");
cJSON_AddStringToObject(p, "text", b.buf ? b.buf : "");
cst_buf_free(&b);
return p;
}
/* Append every attachment of m: images as image parts (or a note saying why
* not), documents inline as text. */
static void parts_add_attachments(const cst_llm_request_t *req, cJSON *parts,
const cst_message_t *m, size_t *budget,
size_t *doc_budget, const char *img_off) {
int n = m->nattach;
if (n > CST_ATTACH_MAX_COUNT) {
char note[160];
snprintf(note, sizeof(note), "[%d of %d images sent]", CST_ATTACH_MAX_COUNT,
n);
snprintf(note, sizeof(note),
"[only the first %d of %d attachments are sent]",
CST_ATTACH_MAX_COUNT, n);
parts_add_text(parts, cJSON_CreateString(note));
n = CST_ATTACH_MAX_COUNT;
}
for (int i = 0; i < n; i++) {
const cst_attachment_t *a = &m->attach[i];
int is_img = cst_attach_is_image(a);
char why[256];
cJSON *p = image_part(req, a, budget, why, sizeof(why));
cJSON *p = NULL;
if (is_img && img_off)
snprintf(why, sizeof(why), "%s", img_off);
else if (is_img)
p = image_part(req, a, budget, why, sizeof(why));
else
p = document_part(req, a, doc_budget, why, sizeof(why));
if (p) {
cJSON_AddItemToArray(parts, p);
continue;
}
char note[512];
snprintf(note, sizeof(note), "[image \"%.120s\" not sent: %.200s]",
a->name, why);
snprintf(note, sizeof(note), "[%s \"%.120s\" not sent: %.200s]",
is_img ? "image" : "document", a->name, why);
parts_add_text(parts, cJSON_CreateString(note));
}
}
@@ -284,8 +334,10 @@ static void parts_add_images(const cst_llm_request_t *req, cJSON *parts,
/* Images a tool result produced are deferred: an assistant tool_call must be
* answered by tool messages only, so they ride in one extra user message
* after the run of tool messages ends. *pending collects it. */
static void defer_images(const cst_llm_request_t *req, const cst_message_t *m,
cJSON **pending, size_t *budget) {
static void defer_attachments(const cst_llm_request_t *req,
const cst_message_t *m, cJSON **pending,
size_t *budget, size_t *doc_budget,
const char *img_off) {
cJSON *p = *pending;
if (!p) {
p = cJSON_CreateObject();
@@ -293,15 +345,18 @@ static void defer_images(const cst_llm_request_t *req, const cst_message_t *m,
cJSON_AddArrayToObject(p, "content");
*pending = p;
}
parts_add_images(req, cJSON_GetObjectItemCaseSensitive(p, "content"), m,
budget);
parts_add_attachments(req, cJSON_GetObjectItemCaseSensitive(p, "content"), m,
budget, doc_budget, img_off);
}
/* m's text plus one line per image it would have carried (malloc'd). */
/* m's text plus one line per image it would have carried (malloc'd); a
* document is not affected (it is text and is sent as such). */
static char *image_notes(const cst_message_t *m, const char *why) {
cst_buf_t b = {0};
cst_buf_puts(&b, m->content ? m->content : "");
for (int i = 0; i < m->nattach; i++) {
if (!cst_attach_is_image(&m->attach[i]))
continue;
char d[128];
cst_attach_describe(&m->attach[i], d, sizeof(d));
cst_buf_printf(&b, "%s[image \"%.120s\" (%s) not sent: %s]",
@@ -394,6 +449,7 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cJSON *prev_user = NULL; /* merge consecutive user messages */
cJSON *pending = NULL; /* images from tool results, waiting for a break */
size_t img_budget = CST_ATTACH_MAX_TOTAL;
size_t doc_budget = CST_ATTACH_MAX_TOTAL;
const char *noimg = getenv("CST_NO_IMAGES");
const char *img_off =
noimg && noimg[0] && noimg[0] != '0'
@@ -407,15 +463,26 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cst_role_t role = m->role;
if (role == CST_ROLE_TOOL && !m->tool_call_id[0])
role = CST_ROLE_USER;
int has_img = m->attach && m->nattach > 0;
int n_att = m->attach ? m->nattach : 0;
int has_img = 0, has_doc = 0;
for (int k = 0; k < n_att; k++) {
if (cst_attach_is_image(&m->attach[k]))
has_img = 1;
else
has_doc = 1;
}
char *noted = NULL;
if (has_img && img_off) {
/* plain text: text-only APIs may refuse content arrays */
noted = image_notes(m, img_off);
if (noted)
mm.content = noted;
has_img = 0;
if (!has_doc) {
/* plain text: text-only APIs may refuse content arrays */
noted = image_notes(m, img_off);
if (noted)
mm.content = noted;
}
}
/* a document needs a content array even when no image can go out */
int att_parts = has_img || has_doc;
/* Every tool_call must be answered by tool messages, so tool-produced
* images only leave the queue when the run of tool results is over. */
@@ -431,7 +498,7 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cJSON *pc = prev_user
? cJSON_GetObjectItemCaseSensitive(prev_user, "content")
: NULL;
if (!has_img && !cJSON_IsArray(pc)) {
if (!att_parts && !cJSON_IsArray(pc)) {
cJSON *c = clean_string(m->content, 0);
if (prev_user) {
cst_buf_t b = {0};
@@ -463,8 +530,8 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
}
cJSON *parts = content_parts(o);
parts_add_text(parts, clean_string(m->content, 0));
if (has_img)
parts_add_images(req, parts, m, &img_budget);
if (att_parts)
parts_add_attachments(req, parts, m, &img_budget, &doc_budget, img_off);
free(noted);
continue;
}
@@ -498,8 +565,8 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cJSON_AddTrueToObject(o, "prefix");
}
cJSON_AddItemToArray(msgs, o);
if (role == CST_ROLE_TOOL && has_img)
defer_images(req, m, &pending, &img_budget);
if (role == CST_ROLE_TOOL && n_att)
defer_attachments(req, m, &pending, &img_budget, &doc_budget, img_off);
free(noted);
}
if (pending)