19 Commits
Author SHA1 Message Date
WHC 580d533656 v0.9.6: documents, downloads, PDFs and the phone's own files
ci / tcc-check (push) Successful in 1m9s
ci / linux (aarch64, 0) (push) Failing after 1h0m2s
ci / linux (x86, 1) (push) Failing after 1h0m2s
ci / linux (x86_64, 1) (push) Failing after 1h0m2s
ci / windows (push) Failing after 10m4s
Four tools and a document writer, plus the storage half of the phone: until
now the picker's answer (a content:// URI) reached the engine as a path, and
it said "cannot read" -- the app had no way to hand a phone's file over.

- md_to_html (new, src/tools/web.c + cst_web_md_document()): markdown in, one
  self-contained HTML document out -- doctype, utf-8, viewport, escaped
  title, a small stylesheet; no script, no external request. It is written
  into the workspace and delivered as a file card (for_user, so it never
  enters a request), the way deliver_file does. The default name comes from
  the source file's stem or, for pasted markdown, from the title with what a
  path cannot carry turned into '-'. It has a preview, so an approval prompt
  says "render x -> y.html" before anything is written.
- download (src/tools/web.c): an http(s) URL into a file in the workspace,
  streamed with a 128 MB ceiling; a redirect chain, the same CA handling and
  the same refusals as web_fetch, but the bytes stay on disk (a half file is
  removed on any failure, including HTTP >= 400). The name comes from the URL
  when the caller names only a directory.
- read_pdf (src/tools/fs_tools.c): the text layer and page count of a PDF,
  which read_file refuses because the bytes are binary -- and a PDF is
  exactly what arrives from a phone. Same extractor an attachment uses, its
  own 4 MiB input ceiling, 256 KiB of text by default with a note when cut.
- session_search (new, src/tools/session_tools.c): every stored session --
  this one and all the ones before it -- searched for a substring, reporting
  the session's id, message count and title, then the matching lines with
  their number and role. Case-insensitive by default, capped at 20 matches.
- device_info (same file, perm_name "device"): version, system/arch/CPU
  count, workspace, sandbox, model and permissions, config/temp/program
  directories, pid -- so the model stops guessing where it runs.
- Tools 3 and 4 come with tests/test_tools_ext.c (also in both build files):
  the document is a whole page, a script tag and a javascript: link do not
  survive, a hostile title stays text and cannot write outside the workspace,
  the preview names the file, read_pdf/download refuse what they must (no
  network in a test), session_search finds a fixture session and says so when
  nothing matches, device_info names the workspace. README's tool table grew
  five rows.

- Android storage: the picker (SAF) answers with a content:// URI, and the
  engine opens files by path, so the app now copies what it points at into
  its own directory first -- app/android/src/xyz/whcself/cstagent/
  AttachFile.java, called through a new app::materializeAttachment()
  (ToolProvider.h: one API for every platform; a desktop strips file:// and
  refuses content:// with a reason). The copy needs no permission at all: the
  grant travels with the URI.
- "All files access" (MANAGE_EXTERNAL_STORAGE) is the other half: it is what
  lets the engine read a path in shared storage that a task names rather than
  picks. Android has no runtime dialog for it, so main.cpp opens the one
  Settings screen once (AttachFile.java's requestAllFilesAccess, with the
  all-apps screen as a fallback) and never insists: without it everything
  works, one picked file at a time. On the NX789J the screen came up, the
  app-op reads back "allow", and a relaunch no longer asks.
- Composer.qml hands a content:// URI through untouched (it is not a path,
  and decoding it would corrupt it); AgentCore::attachFile() turns whatever
  the picker gave into a path first.

Desktop: build clean, make check 36/36 (the new test included, also under
ASan), --uitest all green, --selftest and cst-app-selftest OK.
The APK builds and installs (86 MB, release-signed) with the new Java class
in its dex.

Version 0.9.6.
2026-10-11 18:33:41 +08:00
WHC 08008a5a4c v0.9.5: a session owns a directory, and the store hands out uuids
A session's transcript lived beside its attachments, both named after an id
that was a timestamp. Two things fell out of that: on a workspace two
machines share, a timestamp is not an id (the same second on both is enough
to hand out the same name), and "delete this conversation" had to chase the
jsonl, the title and a directory in a shared attachments/ tree, guessing what
belonged to whom. A phone's app data has nobody to tidy it by hand.

Now one session is one directory:

  <workspace>/.agent/sessions/
    <uuid>/session.jsonl     the transcript
    <uuid>/title             the window title, one line
    <uuid>/attachments/      images and delivered files (img-…, art-…)
    <uuid>/sub-1.jsonl       an agent role's own transcript
    CURRENT                  the id the next run opens

- src/agent_store.c: new_id() makes a uuid v4 (16 bytes from /dev/urandom,
  clock+pid only as a fallback), dir_for_id/flat_for_id/nested_for_id/
  shape_of_id decide which shape an id has, so every reader takes either.
  An id is a directory name, so it is checked as one (no "/", no "..",
  nothing starting with ".", not CURRENT or attachments). path_for_id() keeps
  the shape a session already has and makes the directory for a new id, so
  nothing had to change in the callers. store_list() lists the directories
  that hold a transcript plus the flat files, newest first. delete() is one
  rm_tree of the session's directory, plus the old shared attachments/<id>
  and CURRENT when it pointed there.
- cst_agent_store_migrate() (--migrate-store, or plain cst-agent for a
  session): moves a flat store in — <id>.jsonl to <id>/session.jsonl,
  <id>.title in, the shared attachments/<id>/ to <id>/attachments/. rename
  first, copy when the filesystem says no, and counts what it moved, so a
  second run is a no-op that says "nothing to move". On the real workspace:
  6 sessions moved, the shared directory gone, --list-sessions reads them
  back unchanged, a second run moved 0.
- cst_agent_store_id_of_path(): the id of a session path. This is the bug the
  whole change had been hiding: cst_agent_session_id() took the id from the
  file name, and the new file name is session.jsonl -- every session would
  have called itself "session" (the sidebar, CURRENT, undo, an agent role's
  directory, and the metrics file, which now also goes through the helper).
- src/attach.c: a session that owns its directory keeps its payloads inside
  it (<id>/attachments/); only the flat layout needs the shared directory and
  the id stem. Attachments behave exactly as before otherwise.
- tests/test_store.c: uuid shape and uniqueness, a small buffer refused, a
  new session's path and directory, write/list/info/title read back, the
  payload landing in <id>/attachments/, delete taking the directory with it;
  then an old-layout store: readable before the move, moved byte for byte,
  its payload readable through the new path, listed once, a second migration
  a no-op, deletion covering the migrated payload. Also in the Makefile and
  CMakeLists test lists.
- app/src/ui/UiTest.cpp: the image directory check wanted "/attachments/",
  which the new layout has no trailing slash for.

Desktop: build clean, make check 35/35, --uitest all green, --selftest and
cst-app-selftest OK, and --selftest opens a session of the migrated workspace.

Version 0.9.5.
2026-10-11 17:39:00 +08:00
WHC 7299f39245 v0.9.4: a turn keeps running when the app is not on screen
A phone freezes an app it cannot see, and the front end had nothing holding
it: a turn that took minutes -- a long shell command, a provider that thinks,
a build -- stopped quietly the moment the user pressed home, and the answer
was only there if the window happened to come back. The engine's own /attach
flow made this visible: an APK turn survived, a pocket did not.

- app/android/src/xyz/whcself/cstagent/CstService.java: a foreground service
  started with an explicit component (nothing outside the app can reach it,
  no intent filter) for as long as a turn runs. Being a foreground service
  makes the process one Android neither freezes nor kills; a partial wake
  lock (30 min ceiling) keeps the CPU awake with the screen off. Its ongoing
  notification says what is running -- "CST-agent is working" plus the first
  line of the prompt -- on the same cst-agent channel the notification_send
  tool uses, and taps back into the app. A phone that refuses the foreground
  start (Android 12+ does, from the background) loses the protection, not the
  turn: the failure is caught and the service stops itself.
- Manifest: FOREGROUND_SERVICE, FOREGROUND_SERVICE_DATA_SYNC (API 34+ wants
  the typed one) and WAKE_LOCK, plus the <service> with
  foregroundServiceType="dataSync". The first build of this failed with
  "Expected '>', but got ' '" -- a manifest comment cannot contain "--".
- Front end: app::holdForeground(text) / app::releaseForeground() in
  ToolProvider.h, one call for every platform -- Android starts and stops the
  service through JNI, a desktop has nothing to hold (its window is the app).
  AgentCore::send() calls it when the turn starts (firstLine() of the prompt
  is what the notification says) and finishTurn() releases it, so the
  notification exists exactly as long as the work does. CST_APP_NO_SERVICE=1
  keeps a test run from touching the phone.
- On the NX789J, with the app sent home and the screen off: the service read
  back isForeground=true with its notification, the wake lock cst-agent:turn
  was taken at the start of the turn and dropped at its end, run_shell
  "sleep 60" finished with the screen dark, and the service was gone after.

Desktop: build clean, --uitest all green, --selftest and cst-app-selftest OK
(the hold is a no-op there, so nothing about the Qt side changed).

Version 0.9.4.
2026-10-11 16:51:39 +08:00
WHC d4f65b17cf v0.9.3: notification_send posts on the phone, and Java helpers have a home
The tool was the last Android one that never worked on the NX789J: it would
fail with "Notification.Builder.build() failed: no exception detail" and the
shade stayed empty. The detail was the whole story -- Qt clears a Java
exception before C++ gets to look at it, so a build() that throws and a
build() that returns null look exactly the same from here, and the only way
to catch the difference is to let Java catch it.

- app/android/src/xyz/whcself/cstagent/Notifier.java: the builder, the
  PendingIntent, build() and notify() in one try/catch that returns "" or
  the throwable's class and message. androiddeployqt copies app/android/ with
  the rest of the package source and Qt's Gradle template puts its src/ on
  java.srcDirs, so a class dropped there needs no build-script change (the
  first one is this; the foreground service of the next step goes there too).
- AndroidTools.cpp keeps what it can see from C++ (the context, the channel
  on API 26+, the small icon looked up by name, the launch intent) and calls
  Notifier.post(); the result line now carries the Java failure when there is
  one. The javaErrorText() helper of the previous attempt is gone: reading a
  pending exception from C++ is exactly what does not work here.
- On the device: the tool answers "notification sent", the system holds the
  record (channel cst-agent, importance 3, the framework small icon, the tap
  intent back into the app). Earlier attempts in the same session are in the
  transcript next to it: four failures from the old path, then this one.

Docs: docs/android.md gets the Java-helper mechanism (and why the tool has to
be wrapped in Java at all) and no longer lists notification_send as missing;
app/README.md's platform table and the Android note say where a Java class
goes.

Version 0.9.3.
2026-10-11 16:40:51 +08:00
WHC 60cbce3c5c v0.9.2: files the agent delivers come back as cards, and the saved key is used
A turn could write a report and say "here it is" -- and there was nothing to
open. The engine had no way to hand a finished file to the person, and the
front end had no way to show one. This release adds the whole path: a tool to
deliver, a store that keeps the copy, a card in the transcript, and open /
share through the platform. It also fixes two bugs the phone exposed while it
was being tried there.

deliver_file (engine)
- cst_attachment_t.for_user marks a copy that belongs to the person, not to
  the model; cst_attach_import_artifact() stores any bytes up to
  CST_ARTIFACT_MAX_BYTES (64 MB) with the mime taken from the name's
  extension. Attachments are unchanged (content decides the type, 16 MB).
- It is a read-policy engine tool: path resolves inside the workspace, a
  directory is refused, title (optional) is the display name.
- The wire is what keeps it out of the conversation: parts_add_attachments()
  skips for_user entries without even a "not sent" note, and a turn that only
  delivered a file no longer invents an empty user message. tests/test_artifacts.c
  pins that down end to end, including a session-file round trip.

The card (Qt front end)
- filesText() replaced imagesText(): one line per file -- kind, stored name,
  name, origin, mime, bytes, pixels -- decoded by ImageStrip.qml. A for_user
  entry is a file card (extension badge, name, type and size, Open, Share);
  a picture stays a thumbnail and opens on tap. Both shapes are read, so
  sessions written by an older version still draw.
- AgentCore::openArtifact() / shareArtifact() accept a name only when one of
  the session's own messages carries it, and hand the display name to the
  platform layer for the status line.
- Android opens through the app's FileProvider (content:// + ACTION_VIEW,
  resolveActivity() checked, read permission granted) and shares through the
  share sheet; a desktop uses QDesktopServices / the clipboard.
  CST_APP_NO_OPEN=1 makes --uitest run without opening anything.

Two bugs the device found
- The session writer had its own copy of the attachment JSON and forgot
  `for_user`: the file survived the file, but came back as an attachment --
  no card, and put back in front of the model. It now uses the one writer
  (cst_attachments_to_json()), with the round trip covered by a test.
- engineToolImages() looked the card up with findTool(), which only finds a
  card that is still running -- and the engine reports the result *before*
  the files, so the card was already complete and the files were dropped.
  A live turn therefore showed no card while a replay of the same session
  did. It now matches on the call id.
- A tap that could not work said nothing: ImageStrip.qml's errorText was
  never drawn. The card now carries the reason ("no app on the phone opens
  report.md").

Also
- The saved provider key reaches the engine on every start: AgentCore::open()
  was handed an empty key and the engine never resolves credentials itself
  (only the CLI did), so every start asked for it again. It resolves the same
  order main.c uses (saved key, provider environment, CST_API_KEY) and wipes
  the copy it made; --selftest checks it against a scratch credentials file.
- Android: notification_send reads the Java exception text into its result,
  and keeps failing on the NX789J -- the build() returns null and Qt clears
  the exception before C++ can read it, so the call has to be wrapped in Java
  next (docs/android.md says so).
- --uitest grew the delivered-file case (card, Open, Share, a forged name
  refused), the live order of result-then-files, and the startup row count.

Version 0.9.2.
2026-10-11 16:34:47 +08:00
WHC 84f9585588 v0.9.1: the window opens on the session the engine already has open
A workspace is never empty on the second run: cst_agent_open() resumes the
last session of the store, so the engine holds a transcript before the window
exists. The window only filled its view on a session *switch*, and a start is
not a switch -- the pane came up empty next to a sidebar pointing at the very
session it belonged to (on the phone: "hello, 6 messages" highlighted, no
transcript under it).

- Main.qml: Component.onCompleted reloads the session list and replays the
  open session, the same call the sessionChanged handler makes.
- AgentCore::switchSession(): a click on the row of the session that is
  already open emits sessionChanged instead of returning quietly, so the row
  redraws its transcript rather than being a dead spot.
- --uitest: the window is now created over a seeded session (a fixture is
  written and switched to before the QML loads, which is what a restart looks
  like) and the check "the open session is on screen at startup" pins it --
  it failed with 0 rows before the fix. Two qWaits in the settings-dialog
  section, whose clicks were racing the dialog's layout: the extra startup
  work made that visible (5 failures that had nothing to do with sessions).

Verified: --uitest 79/79 (the new check among them), cst-app-selftest OK, and
on the phone -- the APK rebuilt and installed, a restart of the app draws the
whole stored session (messages, tool cards, thinking blocks, copy icons).

Version 0.9.1.

mode = coding
2026-10-11 15:26:32 +08:00
WHC c2320f946b v0.9.0: the Qt front end as an Android APK, images in the transcript
Two strands land together: the window draws pictures now, and the whole front
end builds, installs and runs on the phone with the engine linked in-process.

Images (the "make a picture actually show up" part)
- Engine, additively: cst_agent_hooks_t.on_tool_images(call, list, n, ud) fires
  when a call left attachments behind, and cst_agent_pending_rel() /
  cst_agent_pending_is_image() read that list without the app touching the
  store. tests/test_attach.c::test_agent_pending pins the three cases (an
  image, a stored name that passes cst_attach_rel_ok, out-of-range NULL).
- App: image://cstattach/<stored name> (app/src/ui/SessionImages.cpp) serves
  only the current session's attachment directory, checked with
  cst_attach_rel_ok, decodes down to the requested sourceSize and never
  upscales, and holds a mutex because requestImage() runs on the render
  thread while the directory can change underneath it. AgentCore gained
  attachmentDir(), pendingImages(), the toolImages()/promptImages() signals
  and hookToolImages(); history() gives replayed rows their images too.
- QML: ImageStrip.qml (a thumb strip, <=150 px tall, shaped by the picture,
  tooltip with name/size/origin, the file's name when it went missing),
  wired into MessageCard and ToolCard. Qt 6's ListModel cannot hold a
  list-valued role (append() reads back undefined, setProperty drops it), so
  a row carries one image per line as <stored>|<name>|<origin>|<w>x<h> and
  ImageStrip decodes that.

Android (the engine is already in the APK; this is the front end)
- app/src/main.cpp: on Android the workspace, config, tmp and HOME go under
  the app's own data directory before the engine opens anything, because
  QDir::currentPath() is "/" there and /tmp is not writable.
- app/android/: the manifest (QtApplication/QtActivity, INTERNET and
  POST_NOTIFICATIONS, adjustResize, no storage permission -- the picker will
  be the system one), strings, a vector icon; minSdk 24 and targetSdk 35 come
  from app/CMakeLists.txt through QT_ANDROID_MIN/TARGET_SDK_VERSION.
- app/src/platform/android/AndroidTools.cpp: the Android file behind
  PlatformInit(), owner "app:android" (notification, clipboard, share sheet,
  file picker are stubs for now, so the tool list does not lie about them).
- app/tools/selftest.c decides its scratch workspace with os_temp_dir(), so
  the C smoke test runs on the device too.
- scripts/build_apk_android.sh: sources to installed APK in one command
  (environment checks, qt-cmake configure, --target apk, adb install), and
  the host-side plumbing the NixOS + qt.io combination needs -- the NDK
  sysroot include dirs for Qt's FindGLESv2/FindEGL (they only look under
  <sysroot>/include), -L$PREFIX/lib on the shared/module link lines as well
  as the executables', the Android debug keystore created on first use with
  QT_ANDROID_SIGN_APK + QT_ANDROID_KEYSTORE_* (androiddeployqt copies the
  unsigned gradle output and signs it itself), and the #!/bin/bash shebang of
  the build-tools wrappers, which NixOS does not have.
- scripts/gradle-android-init.gradle (installed into ~/.gradle/init.d/): the
  release lint task is switched off -- lintVitalAnalyzeRelease resolves its
  own tool jars straight from dl.google.com, bypassing every repository the
  build configures, and fails the build after all the real work is done.
- Icons: app/icons/*.svg (Breeze's 24@3x files, the 19 names the window asks
  for) are compiled in as :/cst-icons and ThemeIcons.cpp falls back to them
  when the platform has no icon theme. On the phone the Qt theme lookup found
  nothing and the QML glyph fallback was an empty box in the phone's fonts.
- docs/android.md: "The APK (Qt front end)" -- the SDK assembled without
  sdkmanager, the host Qt's missing libraries on NixOS, the Gradle mirrors,
  lint, signing -- and a Status that says what runs where. app/README.md
  gains the images and APK lines, and stops claiming Android platform tools
  that are still stubs.

Verified: engine `make check` green; desktop `cst-app-selftest: OK` and
--uitest all checks pass; phone (NX789J, Android 16, arm64-v8a) -- the APK
installs, starts on the workspace under /data/user/0/xyz.whcself.cstagent/
files/, a turn reaches a configured provider over HTTPS from inside the ART
process and renders the reply, rotation keeps the session and docks the
sidebar, and the toolbar/card icons draw.

Version 0.9.0.

mode = coding
2026-10-11 15:22:10 +08:00
WHC 82dee65942 v0.8.15: custom providers and model parameters under Advanced options
The settings dialog could pick a preset, a model and a key and nothing else.
Two things were missing: a provider the presets do not know, and what the
model is asked to do while it answers. Both live under "Advanced options"
now, next to the entry they belong to.

Custom providers (engine)
- cst_models_user_set_provider() / cst_models_user_drop_provider() write the
  engine's own user layer, <config>/cst-agent/models.json:
  {"providers": {"myllm": {"api_base": ..., "key_env": ...,
                           "default_model": ..., "models": [...]}}}
  and reload the registry, so the provider is at once in the provider list,
  in the wire models[] and in the CLI's /provider -- the file a hand-written
  override would go into is the same one, so the GUI and an editor agree.
  The name is checked (1-31 of letters, digits, '.', '_', '-'), a preset's
  name overrides that preset (a self-hosted endpoint, a proxy), everything
  else in the file (model rules) is kept, and a file that is not a JSON
  object is never overwritten. Dropping only removes the user's own entry.
- AgentCore::providers() gained "custom" (source == "user"), and
  addProvider() / removeProvider() drive the two calls above.

Model parameters (engine + wire)
- cst_agent_config_t and cst_llm_request_t carry temperature, top_p and
  max_tokens. 0 means the field is not sent at all -- an absent field is the
  provider's own default, which "temperature": 0 is not (deterministic
  decoding is a real choice), so cst_llm_build_body() only adds what was set.
  tests/test_llm_body.c:test_gen_params pins both halves.
- settings.json keeps them per provider ("params": {...}) via
  cst_settings_remember_params(); cst_settings_remember() carries them along
  with the provider choice, and cst_agent_create() reads them, so a CLI or
  TUI run sends what the window was set to. An all-zero config (the CLI)
  leaves what the GUI saved alone.
- AgentCore::genParams() / setGenParams(), and useProvider() applies both the
  remembered parameters of the provider it lands on and a base URL that was
  edited in the dialog.

The dialog
- Rewritten around one scrolling body with a fixed footer: a long model list
  used to push the key field and the buttons out of the dialog. The model
  chips are filtered (a filter field appears past twelve) and capped at 40
  with "... +N more", which is what a router with hundreds of models needs.
- "Advanced options" folds out the provider entry (name, base URL, key
  variable, default model, models; Save provider / Remove from models.json)
  and the model parameters (temperature, top-p, max tokens; empty = not
  sent). The fields follow the picked provider and are what gets saved, so a
  hand-typed edit is never lost to a stale property.

Verified: --uitest 67/67 (15 new checks: parameters round-trip through the
engine, a provider added through the form and removed again, a bad name
refused, a preset not removable), --selftest, ctest cst-app-selftest, and
make check with the new body test.

Version 0.8.15.

mode = coding
2026-10-11 13:26:02 +08:00
WHC 3fd039cb51 v0.8.14: icons from the desktop's theme (Breeze), readable text, composer frame
The window drew its icons with unicode glyphs -- a clip emoji for attach, a
gear, a hamburger, a chevron, a multiplication sign for delete. Those render
in whichever font happens to cover them (colour emoji in a grey toolbar) and
are a box on a machine without that glyph. They now come from the icon theme,
the way every other desktop app gets them.

Icons
- app/src/ui/ThemeIcons.{h,cpp}: a QQuickImageProvider for
  image://csttheme/<name>?color=%23rrggbb -- QIcon::fromTheme() in the
  desktop's theme (whatever the platform theme set, else breeze-dark, breeze,
  hicolor), tinted with QPainter's SourceIn and rasterised at the requested
  size and the screen's device pixel ratio. Qt only builds its theme search
  paths from XDG once a theme name is set, so the provider fills them itself
  (GenericDataLocation/icons, ~/.icons and, on NixOS,
  /run/current-system/sw/share/icons, for a shell started outside the desktop
  session). An unknown name yields a null image, which QML reports as
  Image.Error.
- app/qml/ThemeIcon.qml: the one component that asks for an icon. Every use
  still names the old character as `glyph:`, so a host with no icon theme
  degrades to exactly what the window looked like before instead of going
  blank. installThemeIcons() is called by main() and by --uitest, so the test
  loads the same icons the app does.
- Replaced: nav (application-menu), settings (settings-configure), attach
  (mail-attachment), copy (edit-copy), session delete (edit-delete), new
  session (list-add), expand/collapse (arrow-right/arrow-down), jump to the end
  (go-down), the plan checklist (task-complete/box). A tool card also shows
  what kind of tool ran -- Theme.toolIcon(): pencil for patch/write, text page
  for read, folder for list/glob, magnifier for grep, braces for a shell,
  globe for web, list for a plan. Toolbar icons are 22 px (Breeze's toolbar
  size), card and row icons 14-16 px.

Colours (the "some text is unreadable" part)
- textFaint #6d757b -> #8d959c: ~3.4:1 -> ~5:1 on the card fill. It carries
  hints, provider URLs, session metadata and placeholders, so it is text, not
  decoration.
- A selected provider row and the open session row write on the accent fill;
  they now use accentText / accentTextDim / okOnAccent instead of faint grey
  and green on blue (named "accentText", not "onAccent": QML reads any `onFoo`
  property as a signal handler and refuses the assignment).
- The filled buttons (Send, Use this) take their ink from Theme.accentInk.

Composer
- The frame is one Theme.pad around its column on every side (anchors.fill,
  implicitHeight + 2*pad). With the top margin alone the last row -- the clip
  and Send -- hung over the lower border, which is what put the clip on the
  frame instead of inside it.

app/README.md gains an "Icons, colors" section (the provider, why the theme
search paths are filled by hand, the qtsvg requirement for rasterising Breeze,
the tool map, and the contrast rules).

Verified: --uitest 52/52, --selftest OK, and the 0.8.14 window relaunched in
the Plasma session (no QML warnings).

Version 0.8.14.

mode = coding
2026-10-11 12:53:49 +08:00
WHC 8694afca94 v0.8.13: tool cards paired by call id, markdown rendered while it streams
Two things the window got wrong once a real turn ran through it, plus the
switch that was invisible on a dark theme.

Tool cards (app/src/core/AgentCore.*, app/qml/Main.qml, ToolCard.qml):
- A turn that called the same tool twice left the first card on "running"
  forever and gave its result to the second card: both were paired by name.
  cst_agent_hooks_t hands the call (ctl_tool_call_t) to every callback, so the
  id travels with the signals now (toolEvent(phase, id, name, detail),
  toolResult(id, name, exitCode, output)) and findTool() matches on it, in the
  same way AgentCore::history() fills it from tool_call_id / tool_calls_json
  for a replayed session. Only a call without an id -- an old text-protocol
  session -- still falls back to the name.
- Nothing may keep spinning after a turn: closeAllRows() runs when the turn
  ends and settles every open row; a call that never came back shows "no
  result" (state "unfinished") and no longer claims an exit code.

Streaming text (app/qml/Main.qml, ChatView.qml):
- The answer was plain text while it streamed and turned into markdown only
  when the turn ended. A 120 ms timer now re-renders the open answer with the
  engine's own renderer (AgentCore::renderMarkdown -> cst_web_md_html), so a
  list, a table or a code fence fills in formatted as it arrives. A row keeps
  its own "html" field; a replayed session renders once, where its rows are
  built.

QML details:
- the Plan mode Switch got a drawn indicator: the Basic style's pill is a dark
  pill with a dark knob on this theme, which made the control look disabled.
- Every engine signal handler is a plain window function now
  (engineToolEvent, engineToolResult, engineTurnFinished, engineAssistant,
  ...), which is also what lets --uitest drive the exact path; rowJson(i)
  returns a transcript row as JSON for it (an object would not survive
  QMetaObject::invokeMethod).

Tests: 6 more checks -- a call opens a card, two calls of one tool are two
cards, the result lands on the card of its own call, the other card is
untouched, the turn end settles an open call, a delta opens a streaming answer
and markdown is rendered while it still streams. 52/52 pass, --selftest,
ctest cst-app-selftest.

Version 0.8.13.
2026-10-11 12:44:20 +08:00
WHC cbd71b83c7 v0.8.12: attachments in the composer, picker + drop + paste
A prompt can carry files now, and they come in the three ways a desktop user
expects: the 📎 button in the composer (QtQuick.Dialogs' FileDialog), files
dropped anywhere on the window (a DropArea over it, with an accent border and
"drop to attach" while something is dragged), and an image on the clipboard
(Ctrl+V). Text keeps the editor's own paste: the composer asks
AgentCore::clipboardHasImage() first and only takes the keystroke when there is
an image.

AgentCore (app/src/core/AgentCore.*):
- pending is a property now (QStringList) fed by cst_agent_pending_describe();
  attachFile() and attachClipboard() wrap cst_agent_attach_file() /
  cst_agent_attach_bytes(), clearPending() wraps cst_agent_pending_clear(), and
  seesImages() reports whether the model is shown images at all.
- the engine's description says what the model gets ("png 12.0 KB"), not where
  it came from, so the window remembers the names it attached and puts them in
  front: "note.txt — text 21 B".
- pendingChanged() fires on attach, on clear and when a turn ends (the prompt
  consumed the list), which is what makes the chips move.

Engine (src/attach.c):
- cst_attach_describe() printed every sub-megabyte size as "%.0f KB": a 21-byte
  text file came out as "text 0 KB". It is "B", "KB" with one decimal or "MB"
  now, and test_attach still passes.

QML: the composer shows one chip per pending file with "clear all"; the hint
line mentions 📎/Ctrl+V and says when the model is text-only (images then
travel as notes); Main.qml owns the DropArea and the drag feedback.

Verified: 44/44 UI checks (8 new: attach ok, one pending, the name is shown, a
missing file is refused, one chip in the composer, clear all empties it, the
clipboard image is seen and attached), --selftest, make check rc=0 (test_attach
included), and screenshots of the chip row.

Version 0.8.12.
2026-10-11 12:35:22 +08:00
WHC be4025e276 v0.8.11: theme the settings dialog's fields and buttons
The Basic style paints its own controls: the two text fields came out as white
boxes on the dark panel and the buttons looked nothing like the rest of the
window. Both fields now carry the surface, border and focus colors the chat
cards use (same shape as AskDialog's field), and the buttons follow the
composer: Close is a quiet bordered button, Use this is the accent one.

No behavior change; 36/36 UI checks still pass and the dialog was looked at
again through CST_APP_SHOT.

Version 0.8.11.
2026-10-11 12:27:44 +08:00
WHC 69842edf16 v0.8.10: settings dialog (provider, model, key), window starts on a provider
The window could be started, but it could not be pointed anywhere: a provider,
a model and a key only came in on the command line. The gear in the header now
opens a dialog that changes all three on the engine's live config.

AgentCore (app/src/core/AgentCore.*):
- providers(): the engine's preset table (cst_provider_count/at), with base
  URL, the remembered or preset model, whether a key exists and where it comes
  from (cst_creds_resolve: "saved", or the environment variable), the preset's
  key variable and the source ("built-in" / "user"). A key is read into a local
  buffer only to answer "is there one"; it never leaves the C++ side.
- modelChoices(provider): the provider's own list (cst_provider_models), the
  fetched cache (cst_ps_cached -- which writes through its out-parameters, so
  it takes a real int, not NULL: passing one cost a segfault) and the
  remembered pick. No network. modelInfo() is cst_model_info + its "200K ·
  text only" label; usage() is cst_agent_usage for the session and the turn.
- useProvider(provider, model, key) -> "" or a reason: a different provider
  goes through cst_ps_switch_provider() (the key is verified against /models
  before it is saved; offline it is kept unverified and the header says so),
  a key for the current one through cst_ps_set_key(), and the model is
  remembered in settings.json. syncConfig() takes the engine's config back
  over the app's copy, so provider/model/approval read correctly afterwards.
  The engine's session is never touched by a switch.
- open() no longer starts with an empty provider: it takes the one the engine
  used last (settings.json), else the built-in default, plus that provider's
  base URL and model. Without this the first turn could not be sent at all and
  no entry in the dialog was "in use".

QML (app/qml/SettingsDialog.qml, Main.qml):
- a modal dialog with the themed surface the other dialogs use (the Basic
  style's light panel washes out the dark theme), the provider list with its
  key state, a model field with the info label and clickable candidates, a
  password field for a new key, the session's token counters, and a note that
  reports what the last "Use this" answered.

Tests: 8 more checks -- the presets are readable, an unknown provider is
refused, the counters are readable, the gear opens the panel, the rows exist, a
model typed into the field reaches the engine, the note says so, Close puts the
dialog away. 36 checks pass. --uitest also points XDG_CONFIG_HOME at its
scratch directory now, so a run cannot rewrite the user's settings.json or
credentials, and CST_APP_SHOT writes shot.png at the end plus
shot-settings.png while the dialog is open (that is how the dialog above was
reviewed on a machine without a screen).

Verified: 36/36 UI checks, --selftest, ctest cst-app-selftest, make check rc=0,
and a screenshot of the dialog.

Version 0.8.10.
2026-10-11 12:25:33 +08:00
WHC f5d340bd2a v0.8.9: sessions in the Qt front end, transcript replay, UI test helpers
A conversation is now something the window can pick. The sidebar lists the
workspace's stored sessions and switching replays one into the view.

Sessions (app/src/core/AgentCore.*, app/qml/SessionRow.qml, Sidebar.qml):
- AgentCore::open() resolves the workspace's current session through
  cst_agent_store_resolve_default() before the engine starts. Without it the
  engine opened session.jsonl in the process's working directory, next to a
  session list that stayed empty.
- sessionId property; sessions() (id, title, firstUser, messages, mtime in ms,
  current); newSession(), switchSession(), deleteSession() (moves the engine
  off a session it has open first), renameSession(); sessionChanged and
  sessionListChanged signals. No mutation runs during a turn.
- history() turns the open session back into the rows the live signals build
  (kind/role/text/name/state/detail/output/exitCode/complete): system prompt
  skipped, notes and attachments shown, assistant reasoning as a think row,
  tool_calls_json as one card per call, and each result paired with its call
  the way findTool() pairs the live stream. A call with no result keeps state
  "unfinished" ("no result"), so a replay never shows a spinner that cannot
  stop. Main.qml replays on sessionChanged.
- SessionRow.qml is a file of its own: as an inline component it would not see
  the Repeater's modelData. Session controls are disabled while a turn runs.

Tests (app/src/ui/UiTest.cpp, app/src/main.cpp):
- the fixture session is written through the store's own API (a tool call with
  its result), then the checks click its row, assert the five replayed rows and
  the paired result, start two new sessions (the view empties), delete one by
  its button (the open one stays), and switch back. 28 checks in total.
- item() falls back to the visual tree: delegates a model owns are not always
  in the window's QObject tree, which is why the rows were invisible to
  findChild even though they were on screen.
- --uitest drives a scratch workspace unless --workspace is given; it creates
  and deletes sessions. CST_APP_SHOT=file saves the window as a PNG when the
  run ends and CST_APP_DUMP=1 prints the session rows seen; both are how the
  layout gets looked at on a machine without a screen.

QML: ThinkBlock's click target is a TapHandler on its row (a MouseArea anchored
inside a layout is undefined behavior and Qt said so).

Verified: 28/28 UI checks, --selftest, ctest cst-app-selftest, make check rc=0,
and a screenshot of a replayed session (cards, tool card with its result, the
think block, the sidebar list).

Version 0.8.9.
2026-10-11 12:14:59 +08:00
WHC da812cc7e8 v0.8.8: Qt front end redesign, responsive shell, UI input tests
The window is no longer one file pretending to be a chat box. The transcript,
the run panel, the composer and the two consent dialogs are their own QML
components over one Theme singleton, and the shell around them adapts to the
screen: docked and scrollable on a desktop, a slide-out drawer on a phone.

QML (app/qml/, new except Main.qml):
- Theme.qml: single QML singleton, dark Breeze-flavoured tokens (surface,
  border, accent, text/textDim/textFaint, radius, pad, gap, sizes, monospace);
  diffLineColor() for unified-diff rows, compactBreakpoint 880, touchTarget
  34, segmentHeight 30, headerHeight 42.
- ChatView.qml: a row-type dispatcher over the transcript model (message,
  thought, tool, note), follow-the-tail scrolling with a "jump to latest"
  button once the user scrolls up, and an empty state that says where the
  agent runs and what it can do.
- MessageCard.qml, ThinkBlock.qml, NoteLine.qml, ToolCard.qml (status pill,
  arguments, collapsible output, 500-line cut, diff colouring, spinner while
  the call runs), TodoPanel.qml.
- Sidebar.qml: workspace/provider/status, mode and approval segmented rows,
  plan switch, the engine's todo list, version footer; scrolls, so a short
  window still reaches every control.
- Composer.qml: auto-growing field, Enter sends / Shift+Enter newlines,
  red Stop while a turn runs, autocorrect hints off, finger-sized targets.
- ConsentDialog.qml (Deny / Once / Always -> CST_PERM_*) and AskDialog.qml
  (the model's options become buttons, free text stays possible).
- Main.qml: shell with header, draggable sidebar splitter (hover feedback,
  200-560 px, double-click resets), transcript, composer, the two dialogs,
  Esc to cancel and Ctrl+L to clear. Under 880 px the sidebar becomes a
  drawer with a scrim instead of squeezing the chat away.

AgentCore / main.cpp:
- provider, model, approval and planMode are properties now; approval and
  plan mode write back into the engine's config, and renderMarkdown() calls
  cst_web_md_html(), so a table or a code fence looks like the WebUI's.
- the todo hook writes m_todo on the GUI thread (it was written from the
  worker thread before: a data race on a QString member).
- --size WxH for the window (the layout tests drive it), and the Basic style
  is chosen by default: the Breeze Quick style registers its types with
  relative URLs, which floods the log and breaks the composer.

UI tests (app/src/ui/):
- --uitest drives the real window with synthesized input and exits with the
  number of failures: 15 checks over docking/drawer transitions, mouse clicks
  and touch taps reaching the engine's mode, the plan switch, the composer
  arming the send button, and a 460x900 -> 1200x820 resize round trip.
- every control the tests touch carries an objectName; the sidebar's
  implicitHeight binding loop is gone.

Tests: cst-app-selftest and --uitest pass in the standalone app build,
ctest cst-app-selftest passes in the repo build (38/38 on the host).

Version 0.8.8.
2026-10-11 11:58:23 +08:00
WHC 5ba215b36c v0.8.7: tool capability metadata, in-process engine, Qt app, Android engine
The engine can be embedded now instead of only driven from a terminal: the
same objects link into a Qt front end (app/) that owns a cst_agent_t inside
its own process -- no port, no token, no second process -- and that front end
now runs on the desktop and, engine-side, on the phone.

Capability metadata (include/cst_tools.h, src/tools/registry.c):
- cst_tool_def_t gains perm_name, platforms and available (a probe that
  returns a reason), so a permission is addressable by name and a tool can
  say why it cannot run here.
- cst_platform_name(), cst_tool_platform_ok(), cst_tool_available(),
  cst_tool_meta_reload(), cst_tool_perm_name() and CST_PLAT_DESKTOP[].
- cst_tool_register() may take over a built-in name that is not runnable on
  this platform, so a front end replaces it with its own implementation.
- the registry entries carry their class (delete_file -> "delete"), the
  desktop clipboard tools carry a clipboard probe; find/active/run gate on it.
- /api/settings.tools[] exposes perm_name, platforms, available, why and the
  declared parameters.

Engine as a library (Makefile, CMakeLists.txt):
- libcstcore.a from the CORE_OBJ set, and a CMake cstcore target shared by
  the CLI and by app/ (-DCST_BUILD_APP=ON): the engine is compiled once.
- Android branch for the vendored sashidown: a per-ABI archive built with the
  NDK's aarch64-linux-android24-clang wrapper, because CMake's compiler is
  plain clang and keeps --target/--sysroot in its own variables.

Qt front end (app/, new):
- AgentCore wraps one cst_agent_t: turns run on a worker thread, every
  cst_agent_hooks_t callback becomes a Qt signal, and the two hooks that need
  an answer (on_permission, on_ask_user) block the worker until the GUI
  replies; mode, approval and plan mode go through the engine's config.
- ToolProvider hands the front end's tools to the engine (owner "app:*"); the
  Linux one is notification_send with a probe for notify-send/D-Bus.
- Main.qml: transcript, tool blocks, consent dialog (Deny/Once/Always ->
  CST_PERM_*), ask_user, mode switch, Esc to cancel; --selftest stays
  headless and runs the embedding checks without QML.

Android (scripts/, docs/android.md, new):
- scripts/build_third_party_android.sh builds static mbedTLS + libcurl for an
  ABI (CA path /system/etc/security/cacerts); build_android.sh builds
  cst-agent, libcstcore.a and the test binaries for the phone, can push them
  and run the whole suite there.
- sandbox_exec.c keeps the plain exec path on Android: the seccomp supervisor
  compiled in and the probe reported "seccomp", but writes outside the
  workspace were not denied on the device -- claiming a sandbox that does not
  hold is worse than reporting none.
- tests/test_diff.c compares layouts only against real GNU diffutils: toybox's
  diff prints the same hunk header with its own hunk/context rules.

Tests: test_tool_meta (metadata, platform gate, probe reasons, takeover of a
built-in name) and test_embed (in-process embedding contract) are new; the
existing registry/llm_body assertions now follow availability. 38 test
binaries pass on the host, 36/36 on arm64-v8a Android 16, cst-app-selftest
passes in both the standalone app/ build and the repo build.

Version 0.8.7.
2026-10-11 11:42:04 +08:00
WHC e0b0eddf3a v0.8.6: general mode, clipboard text, PDF text extraction
ci / tcc-check (push) Successful in 59s
ci / linux (aarch64, 0) (push) Successful in 19m11s
ci / linux (x86, 1) (push) Successful in 11m31s
ci / linux (x86_64, 1) (push) Successful in 11m11s
ci / windows (push) Successful in 9m24s
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.
2026-10-11 11:09:39 +08:00
WHC 50abcf1a91 build: finish cross-platform release tooling
ci / tcc-check (push) Successful in 1m9s
ci / linux (aarch64, 0) (push) Failing after 58m44s
ci / linux (x86, 1) (push) Successful in 13m47s
ci / linux (x86_64, 1) (push) Failing after 1h0m2s
ci / windows (push) Successful in 10m46s
2026-10-10 16:21:00 +08:00
WHC 9bf7707912 v0.8.5: WebUI process timeline, Gemini-style images, aligned layout
ci / linux (clang) (push) Successful in 6m15s
ci / linux (gcc) (push) Successful in 7m14s
ci / musl (push) Successful in 7m14s
ci / windows-cross (push) Successful in 11m4s
Process timeline: a run of consecutive thinking and tool calls is one
block that folds to a single line ("思考 4 次 · 工具 6 次"; while running
it shows the current action) and opens into a compact timeline, one row
per thought or call. Visible text, a note or the next prompt closes the
block; history and the live stream follow the same rule. show_thinking
now also applies while streaming instead of forcing thoughts open.

Layout: the avatar gutter and the flex-wrap that dropped it under long
answers are gone, so answer, process blocks, notes and composer share one
left edge. The text column and composer are both 720px (--content); the
rail and run panel grow with clamp() on wide screens. A rebuild after
each turn no longer replays every entrance animation, and the turn time
survives it. Message actions are icon buttons and only appear under text.

Images: a prompt's images sit above the bubble, right-aligned (one keeps
its shape, several are 152px rounded tiles) and open in a lightbox (Esc,
arrow keys). Images uploaded from the page show as thumbnails over the
composer and appear on the prompt as soon as it is sent.

Also: ?theme= is no longer overridden by app.js, the user bubble is
visible in the light theme, and the narrow top bar no longer wraps.
2026-10-10 14:54:36 +08:00
131 changed files with 18011 additions and 646 deletions
+101 -63
View File
@@ -11,13 +11,11 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
linux: # Verification: TinyCC builds and tests everything in seconds. tcc warns on
# `#pragma GCC`, so no -Werror here; the gcc release builds below carry it.
tcc-check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
strategy:
fail-fast: false
matrix:
cc: [gcc, clang]
steps: steps:
- name: Checkout - name: Checkout
env: env:
@@ -34,21 +32,14 @@ jobs:
$SUDO bash scripts/ci_pkg_mirror.sh $SUDO bash scripts/ci_pkg_mirror.sh
$SUDO apt-get update $SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends \ $SUDO apt-get install -y --no-install-recommends \
build-essential clang cmake pkg-config python3 \ build-essential pkg-config python3 curl ca-certificates \
libcurl4-openssl-dev libncurses-dev libcurl4-openssl-dev libncurses-dev
- name: Build (warnings are errors) - name: Build TinyCC
run: make CC=${{ matrix.cc }} CFLAGS="-std=c11 -Wall -Wextra -O2 -g -Werror" -j"$(nproc)" run: bash scripts/ci_tcc.sh
- name: Build
run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" -j"$(nproc)"
- name: Unit tests - name: Unit tests
run: | run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" check
# Same -Werror as the build step so undeclared libc helpers cannot
# silently return int and truncate pointers (see test_subagent).
make CC=${{ matrix.cc }} \
CFLAGS="-std=c11 -Wall -Wextra -O2 -g -Werror" check
- name: CMake build + ctest
run: |
cmake -B build -DCMAKE_C_COMPILER=${{ matrix.cc }} -DCST_BUILD_TUI=ON
cmake --build build -j"$(nproc)"
ctest --test-dir build --output-on-failure
- name: Smoke test - name: Smoke test
run: | run: |
./cst-agent --help ./cst-agent --help
@@ -57,39 +48,22 @@ jobs:
- name: WebUI end-to-end (recorded stream, no API key) - name: WebUI end-to-end (recorded stream, no API key)
run: sh scripts/e2e_webui.sh run: sh scripts/e2e_webui.sh
musl: # Release: static musl binaries, gcc with -Werror.
# dist/cst-agent-VERSION-linux-ARCH.tar.gz
linux:
needs: tcc-check
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 60
container: alpine:3.20 strategy:
steps: fail-fast: false
- name: Install tools + mirror matrix:
run: | include:
sed -i 's#https\?://dl-cdn.alpinelinux.org/alpine#https://mirrors.tuna.tsinghua.edu.cn/alpine#g' \ - arch: x86_64
/etc/apk/repositories check: 1
apk add --no-cache curl git bash - arch: x86
- name: Checkout check: 1
env: - arch: aarch64
GITHUB_SERVER_URL: ${{ github.server_url }} check: 0
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
run: |
curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
-o /tmp/ci_checkout.sh
bash /tmp/ci_checkout.sh
- name: Install build dependencies
run: |
# linux-headers provides linux/audit.h for the seccomp probe path
apk add --no-cache build-base pkgconf curl-dev ncurses-dev \
linux-headers git nodejs bash python3
- name: Build + unit tests
run: |
make CFLAGS="-std=c11 -Wall -Wextra -O2 -Werror" -j"$(nproc)"
make check
# MinGW-w64 UCRT cross build → dist/cst-agent-*-windows-x86_64.zip
windows-cross:
runs-on: ubuntu-latest
timeout-minutes: 120
steps: steps:
- name: Checkout - name: Checkout
env: env:
@@ -105,20 +79,29 @@ jobs:
SUDO=$(command -v sudo || true) SUDO=$(command -v sudo || true)
$SUDO bash scripts/ci_pkg_mirror.sh $SUDO bash scripts/ci_pkg_mirror.sh
$SUDO apt-get update $SUDO apt-get update
# ncurses-bin/-term: tic, infocmp and the fallback terminal entries
$SUDO apt-get install -y --no-install-recommends \ $SUDO apt-get install -y --no-install-recommends \
ca-certificates curl git cmake ninja-build make pkg-config \ build-essential cmake ninja-build pkg-config python3 perl \
python3 zip unzip perl xz-utils curl ca-certificates xz-utils bzip2 file ncurses-bin ncurses-term
- name: Cross-build Windows zip if [ "${{ matrix.arch }}" = aarch64 ]; then
run: bash scripts/ci_windows_cross.sh $SUDO apt-get install -y --no-install-recommends qemu-user-static
- name: Verify zip fi
- name: Build (static, -Werror)
env:
CST_LINUX_CHECK: ${{ matrix.check }}
run: bash scripts/package_linux.sh ${{ matrix.arch }}
- name: Smoke test
run: | run: |
ls -lah dist/windows-x86_64/ d=$(ls -d dist/cst-agent-*-linux-${{ matrix.arch }})
ls -lah dist/cst-agent-*-windows-x86_64.zip file "$d/cst-agent"
cat dist/cst-agent-*-windows-x86_64.zip.sha256 run=""
unzip -l dist/cst-agent-*-windows-x86_64.zip | head -20 [ "${{ matrix.arch }}" = aarch64 ] && run=qemu-aarch64-static
# Auto-publish to Gitea Releases (v$CST_VERSION from include/cst_version.h). $run "$d/cst-agent" --version
# Prefer repo secret RELEASE_TOKEN (write:repository); fall back to the $run "$d/cst-agent" --help >/dev/null
# job token. Skipped on pull_request and when no token is available. $run "$d/cst-agent" --workspace "$PWD" --list-sessions
cat dist/cst-agent-*-linux-${{ matrix.arch }}.tar.gz.sha256
# Release vVERSION (from include/cst_version.h) on Gitea. Prefer repo
# secret RELEASE_TOKEN (write:repository); fall back to the job token.
- name: Publish Gitea Release - name: Publish Gitea Release
if: github.event_name == 'push' if: github.event_name == 'push'
env: env:
@@ -134,4 +117,59 @@ jobs:
echo "::warning::no RELEASE_TOKEN / GITHUB_TOKEN; skip auto-release" echo "::warning::no RELEASE_TOKEN / GITHUB_TOKEN; skip auto-release"
exit 0 exit 0
fi fi
bash scripts/release_windows.sh bash scripts/release.sh dist/cst-agent-*-linux-${{ matrix.arch }}.tar.gz
# Release: mingw-w64 UCRT gcc (Debian trixie) with -Werror.
# dist/cst-agent-VERSION-windows-x86_64.zip and -setup.exe (NSIS)
windows:
needs: tcc-check
runs-on: ubuntu-latest
timeout-minutes: 120
container: debian:trixie
steps:
- name: Install tools
run: |
# No CA certificates in the image yet, so the mirror is plain http.
sed -i 's#https\?://deb.debian.org#http://mirrors.tuna.tsinghua.edu.cn#g' \
/etc/apt/sources.list.d/debian.sources
apt-get update
apt-get install -y --no-install-recommends \
ca-certificates curl git build-essential cmake ninja-build make \
pkg-config python3 perl zip unzip xz-utils patch file \
gcc-mingw-w64-ucrt64 binutils-mingw-w64-ucrt64 nsis
- name: Checkout
env:
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
run: |
curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
-o /tmp/ci_checkout.sh
bash /tmp/ci_checkout.sh
- name: Cross-build zip and installer
env:
CST_WIN_CFLAGS: -Wall -Wextra -Werror
run: bash scripts/ci_windows_cross.sh
- name: Verify
run: |
ls -lah dist/windows-x86_64/
file dist/windows-x86_64/cst-agent.exe dist/cst-agent-*-windows-x86_64-setup.exe
cat dist/cst-agent-*-windows-x86_64.zip.sha256 dist/cst-agent-*-windows-x86_64-setup.exe.sha256
unzip -l dist/cst-agent-*-windows-x86_64.zip
- name: Publish Gitea Release
if: github.event_name == 'push'
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CST_GITEA: ${{ github.server_url }}
CST_REPO: ${{ github.repository }}
GITHUB_REF: ${{ github.ref }}
CI: "true"
run: |
export GITEA_TOKEN="${RELEASE_TOKEN:-${GITHUB_TOKEN:-}}"
if [[ -z "${GITEA_TOKEN}" ]]; then
echo "::warning::no RELEASE_TOKEN / GITHUB_TOKEN; skip auto-release"
exit 0
fi
bash scripts/release.sh dist/cst-agent-*-windows-x86_64.zip \
dist/cst-agent-*-windows-x86_64-setup.exe
+17
View File
@@ -26,9 +26,26 @@ third_party/ncurses-*.tar.gz
third_party/busybox-w32/ third_party/busybox-w32/
third_party/busybox-src/ third_party/busybox-src/
third_party/prefix-mingw/ third_party/prefix-mingw/
third_party/prefix-linux-*/
third_party/build-linux-*/
third_party/prefix-android-*/
third_party/build-android-*/
third_party/src/
vendor/sashidown/*.o vendor/sashidown/*.o
vendor/sashidown/src/*.o vendor/sashidown/src/*.o
vendor/sashidown/libsashidown.a vendor/sashidown/libsashidown.a
vendor/sashidown/libsashidown-*.a
vendor/sashidown/libsashidown-mingw.a vendor/sashidown/libsashidown-mingw.a
.gen/ .gen/
.build-tmp/ .build-tmp/
# Android APK builds (scripts/build_apk_android.sh) and what gradle leaves
build-apk-android/
*.apk
*.aab
app/android/.gradle/
app/android/build/
app/android/local.properties
# the engine as a static library (app/ links it; built by `make`)
libcstcore.a
+73 -15
View File
@@ -6,6 +6,7 @@ set(CMAKE_C_STANDARD_REQUIRED ON)
option(CST_BUILD_TUI "Build ncurses/PDCurses TUI" ON) option(CST_BUILD_TUI "Build ncurses/PDCurses TUI" ON)
option(CST_BUILD_WEBUI "Build the HTTP/SSE WebUI (--web)" ON) option(CST_BUILD_WEBUI "Build the HTTP/SSE WebUI (--web)" ON)
option(CST_BUILD_APP "Build the Qt front end in app/ (in-process engine, no port)" OFF)
option(CST_NATIVE_ARCH "Pass -march=native to sashidown" OFF) option(CST_NATIVE_ARCH "Pass -march=native to sashidown" OFF)
option(CST_BUNDLE_BUSYBOX "Install placeholder/scripts for busybox beside binary" ON) option(CST_BUNDLE_BUSYBOX "Install placeholder/scripts for busybox beside binary" ON)
@@ -64,6 +65,29 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
COMMAND ${CST_MAKE} -C "${SD_DIR}" clean COMMAND ${CST_MAKE} -C "${SD_DIR}" clean
COMMENT "Building libsashidown for MinGW" COMMENT "Building libsashidown for MinGW"
VERBATIM) VERBATIM)
elseif(CMAKE_SYSTEM_NAME STREQUAL "Android")
# The host archive has to stay host-built (the CLI, the tests and the
# Makefile link it), so the NDK build keeps its own copy, one per ABI.
# CMake's compiler is plain clang (the --target/--sysroot flags live in its
# own variables), so the vendor make gets the per-target wrapper instead:
# aarch64-linux-android24-clang carries both.
get_filename_component(_SD_CC_BIN "${CMAKE_C_COMPILER}" DIRECTORY)
string(REPLACE "-none" "" _SD_TRIPLE "${CMAKE_C_COMPILER_TARGET}")
set(_SD_CC "${_SD_CC_BIN}/${_SD_TRIPLE}-clang")
if(NOT EXISTS "${_SD_CC}")
set(_SD_CC "${CMAKE_C_COMPILER}")
endif()
set(SASHIDOWN_A "${SD_DIR}/libsashidown-${ANDROID_ABI}.a")
add_custom_command(
OUTPUT "${SASHIDOWN_A}"
COMMAND ${CST_MAKE} -C "${SD_DIR}" clean
COMMAND ${CST_MAKE} -C "${SD_DIR}" libsashidown.a
CC=${_SD_CC} AR=${CMAKE_AR} RANLIB=${CMAKE_RANLIB}
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${SD_DIR}/libsashidown.a" "${SASHIDOWN_A}"
COMMAND ${CST_MAKE} -C "${SD_DIR}" clean
COMMENT "Building libsashidown for Android (${ANDROID_ABI})"
VERBATIM)
else() else()
set(SASHIDOWN_A "${SD_DIR}/libsashidown.a") set(SASHIDOWN_A "${SD_DIR}/libsashidown.a")
add_custom_command( add_custom_command(
@@ -95,6 +119,7 @@ set(CST_CORE_SOURCES
src/subagent.c src/subagent.c
src/attach.c src/attach.c
src/clipboard.c src/clipboard.c
src/pdf_text.c
src/session.c src/session.c
src/tools/tools.c src/tools/tools.c
src/tools/patch.c src/tools/patch.c
@@ -102,6 +127,7 @@ set(CST_CORE_SOURCES
src/tools/fs_tools.c src/tools/fs_tools.c
src/tools/agent_tools.c src/tools/agent_tools.c
src/tools/web.c src/tools/web.c
src/tools/session_tools.c
src/tools/diff.c src/tools/diff.c
src/undo.c src/undo.c
src/permissions.c src/permissions.c
@@ -172,12 +198,42 @@ endif()
if(WIN32) if(WIN32)
# MinGW has no <regex.h>: musl's TRE engine, UTF-8 aware (vendor/tre-regex) # MinGW has no <regex.h>: musl's TRE engine, UTF-8 aware (vendor/tre-regex)
include_directories(vendor/tre-regex) include_directories(vendor/tre-regex)
list(APPEND CST_CORE_SOURCES set(_CST_TRE_SOURCES
vendor/tre-regex/regcomp.c vendor/tre-regex/regexec.c vendor/tre-regex/regcomp.c vendor/tre-regex/regexec.c
vendor/tre-regex/regerror.c vendor/tre-regex/tre-mem.c) vendor/tre-regex/regerror.c vendor/tre-regex/tre-mem.c)
list(APPEND CST_CORE_SOURCES ${_CST_TRE_SOURCES})
# Upstream code, kept as is: CST_WIN_CFLAGS=-Werror must not apply to it.
set_source_files_properties(${_CST_TRE_SOURCES} PROPERTIES COMPILE_OPTIONS "-w")
endif() endif()
set(CST_SOURCES ${CST_CORE_SOURCES} src/main.c) # --- the engine as a library ---------------------------------------------
# cstcore is the core without main()/TUI: the CLI links it and the Qt front
# end in app/ (CST_BUILD_APP) embeds the very same objects in-process, so the
# engine is compiled once per build instead of once per front end.
add_library(cstcore STATIC ${CST_CORE_SOURCES})
add_dependencies(cstcore sashidown)
target_include_directories(cstcore PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/include"
"${SD_DIR}/include"
"${CJSON_DIR}"
)
target_compile_definitions(cstcore PRIVATE _GNU_SOURCE)
if(WIN32)
target_compile_definitions(cstcore PRIVATE _WIN32_WINNT=0x0A00)
target_link_libraries(cstcore PUBLIC
"${SASHIDOWN_A}" CURL::libcurl Threads::Threads
ws2_32 crypt32 secur32 bcrypt advapi32 user32)
else()
target_compile_definitions(cstcore PRIVATE _XOPEN_SOURCE_EXTENDED)
target_link_libraries(cstcore PUBLIC
"${SASHIDOWN_A}" CURL::libcurl Threads::Threads m)
endif()
if(CST_BUILD_APP)
add_subdirectory(app)
endif()
set(CST_SOURCES src/main.c)
if(CST_BUILD_TUI) if(CST_BUILD_TUI)
list(APPEND CST_SOURCES src/textsel.c src/tui.c) list(APPEND CST_SOURCES src/textsel.c src/tui.c)
@@ -200,14 +256,8 @@ if(NOT WIN32)
target_compile_definitions(cst-agent PRIVATE _XOPEN_SOURCE_EXTENDED) target_compile_definitions(cst-agent PRIVATE _XOPEN_SOURCE_EXTENDED)
endif() endif()
target_link_libraries(cst-agent PRIVATE # cstcore carries sashidown, curl, threads and libm as usage requirements.
"${SASHIDOWN_A}" target_link_libraries(cst-agent PRIVATE cstcore)
CURL::libcurl
Threads::Threads
)
if(NOT WIN32)
target_link_libraries(cst-agent PRIVATE m)
endif()
set(CST_WIN_CURSES "ncurses" CACHE STRING "Windows TUI backend: ncurses or pdcurses") set(CST_WIN_CURSES "ncurses" CACHE STRING "Windows TUI backend: ncurses or pdcurses")
set_property(CACHE CST_WIN_CURSES PROPERTY STRINGS ncurses pdcurses) set_property(CACHE CST_WIN_CURSES PROPERTY STRINGS ncurses pdcurses)
@@ -276,7 +326,7 @@ endif()
enable_testing() enable_testing()
set(_CST_SMALL_TESTS test_ctl_frontier test_ctl_heredoc test_json_baseline set(_CST_SMALL_TESTS test_ctl_frontier test_ctl_heredoc test_json_baseline
test_replay_starvation test_replay_real test_mm_tool test_textsel test_replay_starvation test_replay_real test_mm_tool test_textsel
test_markdown) test_pdf_text test_markdown)
foreach(t ${_CST_SMALL_TESTS}) foreach(t ${_CST_SMALL_TESTS})
if(t STREQUAL test_textsel) if(t STREQUAL test_textsel)
add_executable(${t} tests/${t}.c src/textsel.c src/os_compat.c src/os_win.c add_executable(${t} tests/${t}.c src/textsel.c src/os_compat.c src/os_win.c
@@ -284,6 +334,12 @@ foreach(t ${_CST_SMALL_TESTS})
if(WIN32) if(WIN32)
target_link_libraries(${t} PRIVATE advapi32 user32) target_link_libraries(${t} PRIVATE advapi32 user32)
endif() endif()
elseif(t STREQUAL test_pdf_text)
add_executable(${t} tests/${t}.c src/pdf_text.c src/os_compat.c src/os_win.c
src/util.c)
if(WIN32)
target_link_libraries(${t} PRIVATE advapi32 user32)
endif()
elseif(t STREQUAL test_markdown) elseif(t STREQUAL test_markdown)
add_executable(${t} tests/${t}.c) add_executable(${t} tests/${t}.c)
target_include_directories(${t} PRIVATE "${SD_DIR}/include") target_include_directories(${t} PRIVATE "${SD_DIR}/include")
@@ -307,7 +363,7 @@ foreach(t ${_CST_SMALL_TESTS})
target_include_directories(${t} PRIVATE include "${CJSON_DIR}") target_include_directories(${t} PRIVATE include "${CJSON_DIR}")
target_compile_definitions(${t} PRIVATE _GNU_SOURCE) target_compile_definitions(${t} PRIVATE _GNU_SOURCE)
if(NOT WIN32) if(NOT WIN32)
target_link_libraries(${t} PRIVATE m) target_link_libraries(${t} PRIVATE m Threads::Threads)
endif() endif()
cst_win_exe(${t}) cst_win_exe(${t})
add_test(NAME ${t} COMMAND ${t} WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}") add_test(NAME ${t} COMMAND ${t} WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
@@ -315,12 +371,14 @@ endforeach()
# Tests that link the whole core (no TUI). # Tests that link the whole core (no TUI).
set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode
test_apply_patch test_session_roundtrip test_attach test_apply_patch test_session_roundtrip test_store test_attach
test_shell test_diff test_tools_fs test_registry test_artifacts
test_shell test_diff test_tools_fs test_tools_ext test_registry
test_undo test_config test_models test_model_registry test_sandbox test_undo test_config test_models test_model_registry test_sandbox
test_keymap test_shell_compact test_skills test_keymap test_shell_compact test_skills
test_plugins test_mcp test_subagent test_lowil test_plugins test_mcp test_subagent test_lowil
test_win_utf8 test_os_dir test_tmpdir) test_win_utf8 test_os_dir test_tmpdir
test_tool_meta test_embed)
if(CST_BUILD_WEBUI) if(CST_BUILD_WEBUI)
# test_webui drives the real server; the stub has nothing to test. # test_webui drives the real server; the stub has nothing to test.
list(APPEND _CST_CORE_TESTS test_webui) list(APPEND _CST_CORE_TESTS test_webui)
+63 -19
View File
@@ -7,6 +7,7 @@ PREFIX ?= /usr/local
DESTDIR ?= DESTDIR ?=
UNAME_S := $(shell uname -s 2>/dev/null) UNAME_S := $(shell uname -s 2>/dev/null)
UNAME_O := $(shell uname -o 2>/dev/null) UNAME_O := $(shell uname -o 2>/dev/null)
comma := ,
# Android (Termux): pthreads live in libc. # Android (Termux): pthreads live in libc.
PTHREAD_LIBS := -lpthread PTHREAD_LIBS := -lpthread
@@ -26,6 +27,15 @@ CPPFLAGS += -D_DARWIN_C_SOURCE
endif endif
PKGC = $(PKG_ENV) $(PKG_CONFIG) PKGC = $(PKG_ENV) $(PKG_CONFIG)
# STATIC=1: fully static link (the musl release builds).
STATIC ?= 0
LINK_STATIC :=
ifeq ($(STATIC),1)
PKGC += --static
LINK_STATIC := -static
endif
LDFLAGS += $(LINK_STATIC)
# Wide-character curses: ncursesw, else ncurses (macOS/BSD build it wide). # Wide-character curses: ncursesw, else ncurses (macOS/BSD build it wide).
CURSES_PC := $(shell for p in ncursesw ncurses; do $(PKGC) --exists $$p 2>/dev/null && { echo $$p; exit 0; }; done) CURSES_PC := $(shell for p in ncursesw ncurses; do $(PKGC) --exists $$p 2>/dev/null && { echo $$p; exit 0; }; done)
ifneq ($(CURSES_PC),) ifneq ($(CURSES_PC),)
@@ -50,9 +60,19 @@ CST_WEBUI ?= 1
PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS) PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS)
PKG_LIBS := $(CURL_LIBS) $(CURSES_LIBS) PKG_LIBS := $(CURL_LIBS) $(CURSES_LIBS)
CPPFLAGS += -Iinclude -I.gen -I$(SD_DIR)/include -I$(CJSON_DIR) -D_XOPEN_SOURCE_EXTENDED $(PKG_CFLAGS) # tcc writes no rpath of its own, so shared libs found via pkg-config -L
# (Nix store, /opt prefixes) would not load at run time.
RPATH_LIBS :=
ifneq ($(findstring tcc,$(notdir $(CC))),)
ifneq ($(STATIC),1)
RPATH_LIBS := $(patsubst -L%,-Wl$(comma)-rpath$(comma)%,$(filter -L%,$(PKG_LIBS)))
endif
endif
# Keep POSIX declarations visible for shared test helpers (notably symlink()).
CPPFLAGS += -Iinclude -I.gen -I$(SD_DIR)/include -I$(CJSON_DIR) -D_POSIX_C_SOURCE=200809L -D_XOPEN_SOURCE_EXTENDED $(PKG_CFLAGS)
LDFLAGS += -L$(SD_DIR) LDFLAGS += -L$(SD_DIR)
LDLIBS += -lsashidown -lm $(PKG_LIBS) $(PTHREAD_LIBS) LDLIBS += -lsashidown -lm $(PKG_LIBS) $(PTHREAD_LIBS) $(RPATH_LIBS)
WEB_DIR := web WEB_DIR := web
WEB_ASSETS := $(wildcard $(WEB_DIR)/*.html) $(wildcard $(WEB_DIR)/*.js) \ WEB_ASSETS := $(wildcard $(WEB_DIR)/*.html) $(wildcard $(WEB_DIR)/*.js) \
@@ -76,6 +96,7 @@ CORE_SRC = \
src/keymap.c \ src/keymap.c \
src/skills.c \ src/skills.c \
src/extensions.c src/plugins.c src/mcp.c src/subagent.c src/attach.c src/clipboard.c \ src/extensions.c src/plugins.c src/mcp.c src/subagent.c src/attach.c src/clipboard.c \
src/pdf_text.c \
src/session.c \ src/session.c \
src/tools/tools.c \ src/tools/tools.c \
src/tools/patch.c \ src/tools/patch.c \
@@ -83,6 +104,7 @@ CORE_SRC = \
src/tools/fs_tools.c \ src/tools/fs_tools.c \
src/tools/agent_tools.c \ src/tools/agent_tools.c \
src/tools/web.c \ src/tools/web.c \
src/tools/session_tools.c \
src/tools/diff.c \ src/tools/diff.c \
src/undo.c \ src/undo.c \
src/permissions.c \ src/permissions.c \
@@ -99,6 +121,10 @@ CORE_SRC = \
SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c
OBJ = $(SRC:.c=.o) OBJ = $(SRC:.c=.o)
# The core as a static library: what the Qt app embeds in-process (app/), so
# the GUI links the same engine the CLI/TUI/WebUI run. No TUI, no main.
CORE_OBJ = $(CORE_SRC:.c=.o)
CST_CORE_LIB = libcstcore.a
HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h \ HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h \
.gen/models_builtin.inc .gen/web_assets.inc .gen/models_builtin.inc .gen/web_assets.inc
@@ -114,11 +140,17 @@ TESTS = \
tests/test_sse_decode \ tests/test_sse_decode \
tests/test_apply_patch \ tests/test_apply_patch \
tests/test_session_roundtrip \ tests/test_session_roundtrip \
tests/test_store \
tests/test_attach \ tests/test_attach \
tests/test_artifacts \
tests/test_pdf_text \
tests/test_shell \ tests/test_shell \
tests/test_diff \ tests/test_diff \
tests/test_tools_fs \ tests/test_tools_fs \
tests/test_tools_ext \
tests/test_registry \ tests/test_registry \
tests/test_tool_meta \
tests/test_embed \
tests/test_undo \ tests/test_undo \
tests/test_config \ tests/test_config \
tests/test_models \ tests/test_models \
@@ -141,11 +173,19 @@ ifneq ($(CST_WEBUI),0)
TESTS += tests/test_webui TESTS += tests/test_webui
endif endif
TEST_LIBS = -L$(SD_DIR) -lsashidown -lm $(CURL_LIBS) $(PTHREAD_LIBS) TEST_SYS_LIBS = -lm $(PTHREAD_LIBS) $(LINK_STATIC)
TEST_LIBS = -L$(SD_DIR) -lsashidown $(CURL_LIBS) $(RPATH_LIBS) $(TEST_SYS_LIBS)
# cst_test_util.h has static os_* helpers; gcc drops them when unused, tcc
# keeps them, so the small tests link the OS layer too.
TEST_OS_SRC = src/os_compat.c src/os_win.c src/util.c
.PHONY: all clean check demo sashidown-lib FORCE cmake-gcc cmake-clang asan install uninstall .PHONY: all clean check demo sashidown-lib FORCE cmake-gcc cmake-clang asan install uninstall
all: sashidown-lib cst-agent all: sashidown-lib cst-agent $(CST_CORE_LIB)
$(CST_CORE_LIB): $(CORE_OBJ) FORCE
rm -f $@
$(AR) rcs $@ $(CORE_OBJ)
cmake-gcc: cmake-gcc:
cmake -B build-gcc -DCMAKE_C_COMPILER=gcc -DCST_BUILD_TUI=ON cmake -B build-gcc -DCMAKE_C_COMPILER=gcc -DCST_BUILD_TUI=ON
@@ -184,26 +224,29 @@ cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a
src/tui.o: src/tui.c $(HDRS) $(SD_DIR)/include/sashidown.h src/tui.o: src/tui.c $(HDRS) $(SD_DIR)/include/sashidown.h
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $< $(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
tests/test_ctl_frontier: tests/test_ctl_frontier.c src/ctl_parse.c include/ctl.h tests/test_ctl_frontier: tests/test_ctl_frontier.c src/ctl_parse.c include/ctl.h $(TEST_OS_SRC)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_frontier.c src/ctl_parse.c $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_frontier.c src/ctl_parse.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
tests/test_ctl_heredoc: tests/test_ctl_heredoc.c src/ctl_parse.c include/ctl.h tests/test_ctl_heredoc: tests/test_ctl_heredoc.c src/ctl_parse.c include/ctl.h $(TEST_OS_SRC)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_heredoc.c src/ctl_parse.c $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_heredoc.c src/ctl_parse.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
tests/test_json_baseline: tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(HDRS) tests/test_json_baseline: tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(HDRS) $(TEST_OS_SRC)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c -lm $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
tests/test_replay_starvation: tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c tests/test_replay_starvation: tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c -lm $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
tests/test_replay_real: tests/test_replay_real.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c tests/test_replay_real: tests/test_replay_real.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_replay_real.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c -lm $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_replay_real.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
tests/test_mm_tool: tests/test_mm_tool.c src/mm_tool.c include/mm_tool.h tests/test_mm_tool: tests/test_mm_tool.c src/mm_tool.c include/mm_tool.h $(TEST_OS_SRC)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_mm_tool.c src/mm_tool.c $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_mm_tool.c src/mm_tool.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
tests/test_textsel: tests/test_textsel.c src/textsel.c src/os_compat.c src/os_win.c src/util.c include/cst_textsel.h tests/test_textsel: tests/test_textsel.c src/textsel.c $(TEST_OS_SRC) include/cst_textsel.h
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_textsel.c src/textsel.c src/os_compat.c src/os_win.c src/util.c $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_textsel.c src/textsel.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
tests/test_pdf_text: tests/test_pdf_text.c src/pdf_text.c include/cst_pdf.h $(TEST_OS_SRC)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_pdf_text.c src/pdf_text.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
tests/test_markdown: tests/test_markdown.c $(SD_DIR)/libsashidown.a tests/test_markdown: tests/test_markdown.c $(SD_DIR)/libsashidown.a
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_markdown.c $(LDFLAGS) -lsashidown -lm $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_markdown.c $(LDFLAGS) -lsashidown -lm
@@ -228,7 +271,8 @@ demo: cst-agent
./cst-agent --demo-ctl --workspace . ./cst-agent --demo-ctl --workspace .
clean: clean:
rm -f $(OBJ) cst-agent $(TESTS) .gen/models_builtin.inc .gen/web_assets.inc rm -f $(OBJ) cst-agent $(CST_CORE_LIB) $(TESTS) .gen/models_builtin.inc \
.gen/web_assets.inc
install: cst-agent install: cst-agent
install -d "$(DESTDIR)$(PREFIX)/bin" install -d "$(DESTDIR)$(PREFIX)/bin"
+69 -9
View File
@@ -27,6 +27,10 @@ cst-agent --version
cd ~/my-project && cst-agent cd ~/my-project && cst-agent
``` ```
不想编译:[Release](https://git.whcself.xyz/WHC/CST-agent/releases) 里有静态链接的 Linux 单文件
(`cst-agent-VERSION-linux-{x86_64,x86,aarch64}.tar.gz`,musl,任何发行版都能直接跑),解压后把 `cst-agent`
放进 `PATH` 即可;每个文件旁边有 `.sha256`。
Windows 10 1903+(PowerShell,不需要管理员): Windows 10 1903+(PowerShell,不需要管理员):
```powershell ```powershell
@@ -34,7 +38,8 @@ irm https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/install_window
# 新开终端后:cd 到项目目录运行 cst-agent;升级用 cst-agent-update # 新开终端后:cd 到项目目录运行 cst-agent;升级用 cst-agent-update
``` ```
详见 [docs/README-windows.md](docs/README-windows.md)。 也可以下载 Release 里的安装包 `cst-agent-VERSION-windows-x86_64-setup.exe`(按用户安装、自动加 PATH、
开始菜单有 WebUI 入口),或便携版 zip。详见 [docs/README-windows.md](docs/README-windows.md)。
一键脚本可以重复运行:已有仓库会 `git pull --ff-only`,代码有更新时干净重编,然后重新安装; 一键脚本可以重复运行:已有仓库会 `git pull --ff-only`,代码有更新时干净重编,然后重新安装;
同时装一个 `cst-agent-update` 命令(等同重跑脚本)。NixOS 上会自动进 `nix-shell` 编译。 同时装一个 `cst-agent-update` 命令(等同重跑脚本)。NixOS 上会自动进 `nix-shell` 编译。
@@ -49,7 +54,8 @@ irm https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/install_window
| Linux | [`scripts/bootstrap_linux.sh`](scripts/bootstrap_linux.sh) | | Linux | [`scripts/bootstrap_linux.sh`](scripts/bootstrap_linux.sh) |
| Termux | [`scripts/bootstrap_termux.sh`](scripts/bootstrap_termux.sh) | | Termux | [`scripts/bootstrap_termux.sh`](scripts/bootstrap_termux.sh) |
| Windows 安装 / 升级 | [`scripts/install_windows.ps1`](scripts/install_windows.ps1)(从 Release 安装),之后 `cst-agent-update` | | Windows 安装 / 升级 | [`scripts/install_windows.ps1`](scripts/install_windows.ps1)(从 Release 安装),之后 `cst-agent-update` |
| Windows 交叉构建(在 Linux 上) | [`scripts/bootstrap_windows.sh`](scripts/bootstrap_windows.sh) → `dist/windows-x86_64/` | | Linux 静态发行包(musl 交叉) | [`scripts/package_linux.sh`](scripts/package_linux.sh) `x86_64\|x86\|aarch64` → `dist/*.tar.gz` |
| Windows 交叉构建(在 Linux 上) | [`scripts/bootstrap_windows.sh`](scripts/bootstrap_windows.sh) → `dist/windows-x86_64/`、zip 和安装包 |
| Windows 原生构建(MSYS2 UCRT64) | [`scripts/bootstrap_msys2.sh`](scripts/bootstrap_msys2.sh) → `dist/windows-x86_64/` | | Windows 原生构建(MSYS2 UCRT64) | [`scripts/bootstrap_msys2.sh`](scripts/bootstrap_msys2.sh) → `dist/windows-x86_64/` |
### 迷你验收工程(论文案例) ### 迷你验收工程(论文案例)
@@ -86,6 +92,7 @@ cd CST-agent
make # 依赖 pkg-config 找 libcurl / ncursesw(找不到时退回 ncurses) make # 依赖 pkg-config 找 libcurl / ncursesw(找不到时退回 ncurses)
make check # 单元测试 + 截断实验(含真实录制流) make check # 单元测试 + 截断实验(含真实录制流)
make asan # AddressSanitizer + UBSan 构建并跑全部测试 make asan # AddressSanitizer + UBSan 构建并跑全部测试
make CC=tcc AR=ar check # TinyCC:整套构建 + 测试十几秒(CI 的校验就是这条)
make install PREFIX=$HOME/.local # 或 sudo make install(默认 /usr/local) make install PREFIX=$HOME/.local # 或 sudo make install(默认 /usr/local)
``` ```
@@ -111,7 +118,7 @@ cmake -B build-hd -DCST_BUILD_TUI=OFF && cmake --build build-hd
./scripts/bootstrap_windows.sh ./scripts/bootstrap_windows.sh
``` ```
依赖:`gcc` 或 `clang`、`libcurl`、TUI 需 `ncursesw`(Windows 版用打过补丁的 ncurses 6.6,脚本自动编译)。 依赖:`gcc`、`clang` 或 `tcc`(0.9.27 mob 以上)、`libcurl`、TUI 需 `ncursesw`(Windows 版用打过补丁的 ncurses 6.6,脚本自动编译)。
Sashidown(`vendor/sashidown`)与 cJSON(`vendor/cjson`,MIT)已随仓库提供。 Sashidown(`vendor/sashidown`)与 cJSON(`vendor/cjson`,MIT)已随仓库提供。
API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(权限 0600);`/key` 更换,`--ask-key` 在命令行输入。 API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(权限 0600);`/key` 更换,`--ask-key` 在命令行输入。
`--api-key` 只用于本次进程、不落盘;`DEEPSEEK_API_KEY` 等环境变量和 `CST_API_KEY` 仅作为没有保存 key 时的兜底(方便 CI)。 `--api-key` 只用于本次进程、不落盘;`DEEPSEEK_API_KEY` 等环境变量和 `CST_API_KEY` 仅作为没有保存 key 时的兜底(方便 CI)。
@@ -120,7 +127,7 @@ API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(
| OS | Arch | 编译器 | Headless | TUI | Shell | | OS | Arch | 编译器 | Headless | TUI | Shell |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| Linux(glibc / musl) | x86_64 / aarch64 | GCC, Clang | yes | ncursesw | `/bin/sh` 或旁路 `busybox` | | Linux(glibc / musl) | x86_64 / x86 / aarch64 | GCC, Clang, TinyCC | yes | ncursesw | `/bin/sh` 或旁路 `busybox` |
| WSL2 | x86_64 | 同 Linux | yes | ncursesw | `/bin/sh`(工作区建议放在 Linux 文件系统内) | | WSL2 | x86_64 | 同 Linux | yes | ncursesw | `/bin/sh`(工作区建议放在 Linux 文件系统内) |
| macOS | arm64 / x86_64 | Apple Clang | yes | ncurses(Homebrew) | `/bin/sh` | | macOS | arm64 / x86_64 | Apple Clang | yes | ncurses(Homebrew) | `/bin/sh` |
| Android Termux | aarch64 / … | Clang (Termux) | yes | ncurses(Termux 包) | `$PREFIX/bin/sh`;可旁路 busybox | | Android Termux | aarch64 / … | Clang (Termux) | yes | ncurses(Termux 包) | `$PREFIX/bin/sh`;可旁路 busybox |
@@ -164,6 +171,18 @@ make && make install # 装到 Termux 的 $PREFIX/bin,之后任意目录
临时目录:依次试 `$TMPDIR`、`$PREFIX/tmp`、`/tmp`、`/var/tmp`、`~/.cache/cst-agent/tmp`,用第一个真能建文件的, 临时目录:依次试 `$TMPDIR`、`$PREFIX/tmp`、`/tmp`、`/var/tmp`、`~/.cache/cst-agent/tmp`,用第一个真能建文件的,
并写回 `TMPDIR` 给 shell 子进程和沙箱用(Termux 的 `/tmp` 常常不存在或被 SELinux 拒绝)。 并写回 `TMPDIR` 给 shell 子进程和沙箱用(Termux 的 `/tmp` 常常不存在或被 SELinux 拒绝)。
### Linux 静态发行包
```bash
scripts/package_linux.sh x86_64 # 或 x86 / aarch64;CST_LINUX_CHECK=1 顺便跑 make check
```
用 Bootlin 的 musl 交叉 gcc(固定版本、校验 sha256)静态编译 mbedTLS、curl 8.22(只开 HTTP/HTTPS)和宽字符 ncurses,
再以 `STATIC=1` 和 `-Werror` 编出 `cst-agent`,产物是 `dist/cst-agent-VERSION-linux-ARCH.tar.gz`(+ `.sha256`)。
宿主需要 `cmake ninja make perl` 以及 `tic`/`infocmp` 和终端条目(Debian:`ncurses-bin ncurses-term`)。
ncurses 会去常见目录找 terminfo,找不到时用编进去的 `xterm-256color`、`xterm`、`screen-256color`、`tmux-256color`、`linux`、`vt100`。
CA 证书:`CST_CA_BUNDLE` → `SSL_CERT_FILE` → 各发行版的常见位置(Debian/Ubuntu、Fedora/RHEL、openSUSE、Alpine)。
### Windows 发行包 ### Windows 发行包
见 [docs/README-windows.md](docs/README-windows.md)(安装、编码、沙箱、构建、已知限制)。 见 [docs/README-windows.md](docs/README-windows.md)(安装、编码、沙箱、构建、已知限制)。
@@ -172,10 +191,17 @@ make && make install # 装到 Termux 的 $PREFIX/bin,之后任意目录
./scripts/bootstrap_windows.sh # Linux 交叉编译;Windows 上在 MSYS2 UCRT64 里用 scripts/bootstrap_msys2.sh ./scripts/bootstrap_windows.sh # Linux 交叉编译;Windows 上在 MSYS2 UCRT64 里用 scripts/bootstrap_msys2.sh
``` ```
产物:`dist/windows-x86_64/`(静态链接的 `cst-agent.exe` + `busybox.exe`,无其他 DLL)和 产物:`dist/windows-x86_64/`(静态链接的 `cst-agent.exe` + `busybox.exe`,无其他 DLL)、
`dist/cst-agent-VERSION-windows-x86_64.zip`。CI 在 `main` / `v*` tag 推送且交叉构建成功后会自动上传到 `dist/cst-agent-VERSION-windows-x86_64.zip`,以及装了 `makensis` 时的安装包 `dist/cst-agent-VERSION-windows-x86_64-setup.exe`。
Gitea Release `vVERSION`(同名资源会被替换);仓库可设 `RELEASE_TOKEN`(`write:repository`),否则用任务自带 token。
本地:`GITEA_TOKEN=... scripts/release_windows.sh`。 ### CI 与 Release
CI 用 TinyCC 校验(构建、`make check`、WebUI 端到端),通过后用 gcc(`-Werror`)出发行包:
Linux x86_64 / x86 / aarch64 静态包(x86 两种跑 `make check`,aarch64 用 qemu 冒烟),
Windows x86_64 的 zip 和安装包(Debian 的 mingw-w64 UCRT gcc + NSIS)。
推送到 `main` 或 `v*` tag 时每个任务把自己的产物上传到 Gitea Release `vVERSION`(同名资源会被替换);
仓库可设 `RELEASE_TOKEN`(`write:repository`),否则用任务自带 token。
本地:`GITEA_TOKEN=... scripts/release.sh dist/cst-agent-*.tar.gz dist/cst-agent-*.zip dist/cst-agent-*-setup.exe`。
环境变量:`CST_SHELL`、`CST_EXE_DIR`、`CST_AUTO_APPROVE` 等。 环境变量:`CST_SHELL`、`CST_EXE_DIR`、`CST_AUTO_APPROVE` 等。
@@ -207,6 +233,7 @@ Gitea Release `vVERSION`(同名资源会被替换);仓库可设 `RELEASE_T
|---|---| |---|---|
| `CST_PROTOCOL` / `--protocol` | 工具调用协议(`native-eos`) | | `CST_PROTOCOL` / `--protocol` | 工具调用协议(`native-eos`) |
| `CST_TOOLS` / `--tools` | 工具集:`default`(全部)/ `minimal`(论文 4 件套) | | `CST_TOOLS` / `--tools` | 工具集:`default`(全部)/ `minimal`(论文 4 件套) |
| `CST_MODE` / `--mode` | 工作模式:`coding`(默认,代码口径)/ `general`(通用助手口径) |
| `CST_EFFORT` / `--effort` | 推理强度 `low` / `medium` / `high` / `max` | | `CST_EFFORT` / `--effort` | 推理强度 `low` / `medium` / `high` / `max` |
| `CST_CONFIRM` / `--confirm` | 哪些类别要确认:`shell,write,net`(默认)或 `none` | | `CST_CONFIRM` / `--confirm` | 哪些类别要确认:`shell,write,net`(默认)或 `none` |
| `CST_MAX_STEPS` / `--max-steps` | 每个用户回合最多调用模型几次(30) | | `CST_MAX_STEPS` / `--max-steps` | 每个用户回合最多调用模型几次(30) |
@@ -219,6 +246,7 @@ Gitea Release `vVERSION`(同名资源会被替换);仓库可设 `RELEASE_T
| `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL(默认 `.agent/metrics/<session>.jsonl`) | | `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL(默认 `.agent/metrics/<session>.jsonl`) |
| `CST_RECORD_SSE=DIR` / `CST_REPLAY_SSE=DIR` | 录制 / 回放原始 SSE 流(`NNNN.sse` + `NNNN.req.json`) | | `CST_RECORD_SSE=DIR` / `CST_REPLAY_SSE=DIR` | 录制 / 回放原始 SSE 流(`NNNN.sse` + `NNNN.req.json`) |
| `CST_APPROVAL=ask\|auto\|yolo` / `CST_SANDBOX=off\|landlock\|seccomp` | 审批模式 / 关闭沙箱(回落到只读白名单规则)或只用指定沙箱 | | `CST_APPROVAL=ask\|auto\|yolo` / `CST_SANDBOX=off\|landlock\|seccomp` | 审批模式 / 关闭沙箱(回落到只读白名单规则)或只用指定沙箱 |
| `CST_CA_BUNDLE` | HTTPS 用的 CA 证书文件(默认:`SSL_CERT_FILE`、libcurl 自带路径、常见发行版位置;Windows 用系统证书库) |
| `CST_MODELS`、`CST_AUTO_APPROVE=1`、`CST_SHELL`、`CST_EXE_DIR` | 模型列表、自动批准(= yolo)、shell、旁路 busybox | | `CST_MODELS`、`CST_AUTO_APPROVE=1`、`CST_SHELL`、`CST_EXE_DIR` | 模型列表、自动批准(= yolo)、shell、旁路 busybox |
## Run ## Run
@@ -245,16 +273,18 @@ cst-agent --headless --prompt 'List files'
| `-N` / `--new` | 开新会话 | | `-N` / `--new` | 开新会话 |
| `-R ID` / `--resume ID` | 打开指定会话(完整 id 或唯一前缀) | | `-R ID` / `--resume ID` | 打开指定会话(完整 id 或唯一前缀) |
| `-L` / `--list-sessions` | 列出会话(时间、条数、标题)后退出 | | `-L` / `--list-sessions` | 列出会话(时间、条数、标题)后退出 |
| `--migrate-store` | 把旧版本的会话(扁平 `<id>.jsonl`)搬进新布局后退出 |
| `-A MODE` / `--approval MODE` | 审批模式 `ask` / `auto`(默认)/ `yolo`,见下文 | | `-A MODE` / `--approval MODE` | 审批模式 `ask` / `auto`(默认)/ `yolo`,见下文 |
| `-y` / `--yolo` | 等同 `--approval yolo`,只对本次运行有效 | | `-y` / `--yolo` | 等同 `--approval yolo`,只对本次运行有效 |
| `-C LIST` / `--confirm LIST` | 需要确认的类别:`shell,write,net` 的任意组合或 `none` | | `-C LIST` / `--confirm LIST` | 需要确认的类别:`shell,write,net` 的任意组合或 `none` |
| `-T PROFILE` / `--tools PROFILE` | `default` 或 `minimal` | | `-T PROFILE` / `--tools PROFILE` | `default` 或 `minimal` |
| `-M MODE` / `--mode MODE` | 工作模式 `coding`(默认)/ `general`,也读 `CST_MODE`,并记进 `settings.json` |
| `-P NAME` / `-m NAME` / `-e LEVEL` | 本次使用的 provider / 模型 / 推理强度(不传则用上次的) | | `-P NAME` / `-m NAME` / `-e LEVEL` | 本次使用的 provider / 模型 / 推理强度(不传则用上次的) |
| `-a` / `--ask-key` | 在命令行输入并保存当前 provider 的 key | | `-a` / `--ask-key` | 在命令行输入并保存当前 provider 的 key |
工作目录默认就是当前目录(`-w DIR` 可指定别的目录)。 工作目录默认就是当前目录(`-w DIR` 可指定别的目录)。
会话落盘:`<workspace>/.agent/sessions/<id>.jsonl` + `CURRENT`;`.agent/` 自带 `.gitignore`,不会被误提交。 会话落盘:一个会话一个目录 `<workspace>/.agent/sessions/<id>/`(`session.jsonl` 转录、`title`、`attachments/`、子 agent 的 `sub-N.jsonl`)+ `CURRENT` 指向下次打开的会话;id 是 uuid,两台机器共用一份工作目录也不会撞。旧版本写的扁平会话仍然可读,`--migrate-store` 一次搬进新布局(幂等)。`.agent/` 自带 `.gitignore`,不会被误提交。
### 内置工具 ### 内置工具
@@ -270,10 +300,18 @@ cst-agent --headless --prompt 'List files'
| `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 | | `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 |
| `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 | | `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 |
| `web_fetch` | 网络 | 抓取 URL,HTML 转纯文本 | | `web_fetch` | 网络 | 抓取 URL,HTML 转纯文本 |
| `download` | 网络 | 把 URL 抓成工作区里的文件(≤128 MB,超限删掉残file);字节落在磁盘上,不进模型上下文 |
| `read_pdf` | 读 | 读 PDF 的文本层(`read_file` 因二进制拒收的缺口),带页数,文本默认截到 256 KiB |
| `clipboard_read` | 读 | 读系统剪贴板里的文本(`max_bytes` 默认 64 KiB,超出截断并说明) |
| `clipboard_write` | 写 | 把文本放进系统剪贴板(按写类权限确认;本机走 pbcopy / wl-copy / xclip / Win32 API) |
| `todo_write` | 元 | 维护任务清单(TUI 右侧面板,Ctrl+T 开关) | | `todo_write` | 元 | 维护任务清单(TUI 右侧面板,Ctrl+T 开关) |
| `ask_user` | 询问 | 向用户提问(可给选项);headless 下返回错误让模型自行决定 | | `ask_user` | 询问 | 向用户提问(可给选项);headless 下返回错误让模型自行决定 |
| `skill` | 元 | 加载一个 skill 的说明(`SKILL.md`)或其中的文件;有 skill 时才出现 | | `skill` | 元 | 加载一个 skill 的说明(`SKILL.md`)或其中的文件;有 skill 时才出现 |
| `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 | | `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 |
| `deliver_file` | 交付 | 把工作区里的成品文件复制进会话库并交给用户(任意字节,≤64 MB):前端画成文件卡,可打开 / 分享;带 `for_user` 标记,永不进模型上下文 |
| `md_to_html` | 文档 | 把 markdown(文本或工作区 `.md`)渲染成一份自包含 HTML 文档写进工作区并交付成文件卡:utf-8、标题、内联样式,无脚本无外链 |
| `session_search` | 读 | 在过去和当前的所有会话转录里搜子串,给会话 id、条数、标题和匹配行(行号 + 角色 + 上下文) |
| `device_info` | 读 | 报告运行环境:系统 / 架构 / CPU 数、工作区、沙箱、模型与审批模式、config / temp / 程序目录、pid(审批策略名 `device`) |
插件工具和 MCP 工具(`mcp__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。 插件工具和 MCP 工具(`mcp__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。
同一步里的多个只读调用(读文件、搜索、只读子 agent)会并行执行(最多 4 个)。 同一步里的多个只读调用(读文件、搜索、只读子 agent)会并行执行(最多 4 个)。
@@ -281,6 +319,26 @@ cst-agent --headless --prompt 'List files'
`--tools minimal` 只保留 `read_file` / `run_shell` / `write_file` / `apply_patch`(论文实验口径)。 `--tools minimal` 只保留 `read_file` / `run_shell` / `write_file` / `apply_patch`(论文实验口径)。
TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。 TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
### 工作模式(coding / general)
`--mode general`(或 TUI 里 `/mode general`、WebUI 运行设置里的「工作模式」、`CST_MODE=general`)把默认口径
从"代码助手"换成"通用助手":工作流从改代码/跑测试换成"先弄清要什么、再动手、给出处和不确定的地方",
工具说明从"仓库"改成"文件与数据",并说明 `web_fetch` 可以拿外部资料;`/status` 第一行显示当前模式,
切换后 history 里会留一条系统提示。默认仍是 `coding`,选择记在 `settings.json`;
`--tools minimal`(论文口径)的提示词按字节冻结,不受模式影响。
### 剪贴板与文档附件
- **剪贴板**:`clipboard_read` / `clipboard_write` 两个工具(写剪贴板是不可逆发射,要确认);
TUI 里 `/attach paste` 在剪贴板里没有图片时,把剪贴板的**文本**放进输入框(丢掉 CR 与控制字节,尊重输入长度上限)。
- **附件**(`/attach <path>`、拖入终端、剪贴板图片都算):图片按原样存进会话目录,发给模型时是 image part;
**PDF 在摄取时就抽出文本层**(零依赖解析器:Flate / ASCIIHex / ASCII85、对象流、CID 字体 ToUnicode、
内联图像跳过),存的是文本、`mime` 仍是 `application/pdf`;其余文件必须是 UTF-8 文本。
文档以 **text part 内联**发出,所以不受 `CST_NO_IMAGES` 或非视觉模型影响;图片照旧会被换成一行"未发送"说明。
上限:单个附件 ≤16 MB(文档文本 ≤4 MiB),每条消息最多 8 个。
加密 PDF、扫描件(无文本层)会被明确拒绝并说明原因;
`view_image` 仍只收图片,文本/PDF 走 `/attach`。
### 权限、撤销与项目说明 ### 权限、撤销与项目说明
- **审批模式**(Shift+Tab 循环切换,`/approval`,`--approval`,`CST_APPROVAL`): - **审批模式**(Shift+Tab 循环切换,`/approval`,`--approval`,`CST_APPROVAL`):
@@ -399,6 +457,7 @@ Thought(推理过程)默认折叠成一行“Thought · 3.2s”,生成中
| `/provider [name]`、`/model [name]`、`/effort [level]` | 切换 provider(缺 key 时当场输入并用 `/models` 校验)、模型(列表优先显示 API 实际返回的模型,标 `(api)`)、推理强度;都会记住 | | `/provider [name]`、`/model [name]`、`/effort [level]` | 切换 provider(缺 key 时当场输入并用 `/models` 校验)、模型(列表优先显示 API 实际返回的模型,标 `(api)`)、推理强度;都会记住 |
| `/key` | 输入或更换当前 provider 的 API key(遮罩输入并保存) | | `/key` | 输入或更换当前 provider 的 API key(遮罩输入并保存) |
| `/protocol [name]` | 切换工具调用协议 | | `/protocol [name]` | 切换工具调用协议 |
| `/mode [coding\|general]` | 工作模式(通用助手 / 代码助手口径) |
| `/tools [on\|off NAME\|minimal\|default]` | 查看或开关工具 | | `/tools [on\|off NAME\|minimal\|default]` | 查看或开关工具 |
| `/approval [ask\|auto\|yolo]` *、`/yolo` * | 审批模式;本次开关 yolo | | `/approval [ask\|auto\|yolo]` *、`/yolo` * | 审批模式;本次开关 yolo |
| `/permissions [reset]` *、`/max-steps [N]` * | 权限规则、每轮步数 | | `/permissions [reset]` *、`/max-steps [N]` * | 权限规则、每轮步数 |
@@ -408,6 +467,7 @@ Thought(推理过程)默认折叠成一行“Thought · 3.2s”,生成中
| `/diff [path]` * | `git status` + `git diff` | | `/diff [path]` * | `git status` + `git diff` |
| `/compact` | 手动压缩上下文 | | `/compact` | 手动压缩上下文 |
| `/export [file]` *、`/copy` * | 导出 Markdown 记录;复制上一条回复(OSC 52,需终端支持) | | `/export [file]` *、`/copy` * | 导出 Markdown 记录;复制上一条回复(OSC 52,需终端支持) |
| `/attach [path\|paste\|list\|clear]` | 给下一条消息带附件:图片、PDF(自动取文本层)、文本文件;`paste` 优先取剪贴板图片,没有就把剪贴板文本放进输入框 |
| `/web` * | 后台起一个 WebUI 进程(同 workspace、新会话,日志写到 `.agent/webui.log`) | | `/web` * | 后台起一个 WebUI 进程(同 workspace、新会话,日志写到 `.agent/webui.log`) |
| `/init` | 让 agent 写 `AGENTS.md` | | `/init` | 让 agent 写 `AGENTS.md` |
| `/details` *、`/thinking [on\|off]`、`/keys` * | 展开全部 tool 输出;Thought 默认展开/折叠;快捷键列表 | | `/details` *、`/thinking [on\|off]`、`/keys` * | 展开全部 tool 输出;Thought 默认展开/折叠;快捷键列表 |
+220
View File
@@ -0,0 +1,220 @@
cmake_minimum_required(VERSION 3.16)
project(cst-agent-app LANGUAGES C CXX VERSION 0.1.0)
# The Qt front end (app/) embeds the C engine in-process: it links libcstcore
# and never opens a port. Two targets:
#
# cst-app-selftest pure C smoke test of the embedding path (no Qt), so the
# C side is still guarded on machines without Qt Quick
# cst-agent-app the GUI (Qt 6 Quick + QML), built when the Qt modules
# are there
#
# Standalone build (builds the engine from ../ too):
# cmake -S app -B build-app -DCMAKE_BUILD_TYPE=Debug && cmake --build build-app -j
# Inside the repo build (the parent defines the cstcore target, shared with the CLI):
# cmake -B build -DCST_BUILD_APP=ON && cmake --build build --target cst-agent-app
#
# Extra QML import paths, e.g. a Kirigami installed away from the Qt prefix:
# -DCST_APP_QML_IMPORT=... (added to QML2_IMPORT_PATH / the app's runtime)
option(CST_APP_GUI "build the Qt GUI (needs Qt6 Quick, Qml, QuickControls2)" ON)
option(CST_APP_SELFTEST "build the C embedding smoke test" ON)
# --- the engine ----------------------------------------------------------
# Used as a subproject (../CMakeLists.txt already defined cstcore, engine
# compiled once for CLI and app) or built here from the sources next door.
if(NOT TARGET cstcore)
set(CST_APP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/..")
include_directories("${CST_APP_ROOT}/include" "${CST_APP_ROOT}/vendor/cjson")
file(GLOB CST_APP_CORE_SRC
"${CST_APP_ROOT}/src/*.c"
"${CST_APP_ROOT}/src/tools/*.c"
"${CST_APP_ROOT}/vendor/cjson/cJSON.c")
list(REMOVE_ITEM CST_APP_CORE_SRC
"${CST_APP_ROOT}/src/main.c"
"${CST_APP_ROOT}/src/tui.c"
"${CST_APP_ROOT}/src/tui_stub.c"
"${CST_APP_ROOT}/src/textsel.c")
# The HTTP front end is not part of an in-process app: no server, no port.
list(REMOVE_ITEM CST_APP_CORE_SRC
"${CST_APP_ROOT}/src/net.c"
"${CST_APP_ROOT}/src/http_srv.c"
"${CST_APP_ROOT}/src/webui.c"
"${CST_APP_ROOT}/src/web_md.c")
if(EXISTS "${CST_APP_ROOT}/.gen/models_builtin.inc")
include_directories("${CST_APP_ROOT}/.gen")
endif()
add_library(cstcore STATIC ${CST_APP_CORE_SRC})
target_compile_definitions(cstcore PRIVATE CST_NO_WEBUI=1)
find_package(CURL REQUIRED)
find_package(Threads REQUIRED)
target_link_libraries(cstcore PUBLIC ${CURL_LIBRARIES} Threads::Threads m)
message(STATUS "app: building the engine from ${CST_APP_ROOT}")
else()
message(STATUS "app: linking the cstcore target of the parent build")
endif()
# --- the C smoke test ----------------------------------------------------
if(CST_APP_SELFTEST)
add_executable(cst-app-selftest tools/selftest.c)
target_link_libraries(cst-app-selftest PRIVATE cstcore)
enable_testing()
add_test(NAME cst-app-selftest COMMAND cst-app-selftest)
endif()
# --- the GUI -------------------------------------------------------------
if(CST_APP_GUI)
# Android's platform file talks to the platform through QtAndroidPrivate
# (the POST_NOTIFICATIONS probe), whose include path comes with CorePrivate.
if(ANDROID)
find_package(Qt6 6.4 COMPONENTS Core Gui Qml Quick QuickControls2 Test
CorePrivate QUIET)
else()
find_package(Qt6 6.4 COMPONENTS Core Gui Qml Quick QuickControls2 Test QUIET)
endif()
if(NOT Qt6_FOUND)
message(WARNING
"app: Qt 6 Quick/Qml not found, skipping cst-agent-app "
"(the C selftest still builds; install e.g. qt6.qtdeclarative / "
"qt6.qtquickcontrols2 and reconfigure)")
else()
qt_standard_project_setup()
# Qt 6.5+ policies: keep the module resource at :/qt/qml/<URI>/ (what
# main.cpp loads) and let CMake generate the qmldir of qml/ for us.
qt_policy(SET QTP0001 NEW)
qt_policy(SET QTP0004 NEW)
# The front end's platform tools: one file per platform, the same split
# core/ToolProvider.cpp makes. Android brings its own (JNI, owner
# "app:android"); the other platforms still use the Linux file, whose
# registration entry point is simply not called there.
if(ANDROID)
set(CST_APP_PLATFORM_SRC src/platform/android/AndroidTools.cpp)
else()
set(CST_APP_PLATFORM_SRC src/platform/linux/NotifyTool.cpp)
endif()
qt_add_executable(cst-agent-app
src/main.cpp
src/core/AgentCore.cpp
src/core/ToolProvider.cpp
src/ui/UiTest.cpp
src/ui/ThemeIcons.cpp
src/ui/SessionImages.cpp
${CST_APP_PLATFORM_SRC})
target_include_directories(cst-agent-app PRIVATE
src "${CMAKE_CURRENT_SOURCE_DIR}/../include")
target_compile_definitions(cst-agent-app PRIVATE CST_APP_VERSION="${PROJECT_VERSION}")
if(CST_APP_QML_IMPORT)
target_compile_definitions(cst-agent-app PRIVATE
CST_APP_QML_IMPORT="${CST_APP_QML_IMPORT}")
endif()
target_link_libraries(cst-agent-app PRIVATE
Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2 Qt6::Test cstcore)
if(ANDROID)
# app/src/platform/android/AndroidTools.cpp talks to the platform
# through QtAndroidPrivate (permissions) -- a private header, hence the
# private module for its include path.
target_link_libraries(cst-agent-app PRIVATE Qt6::CorePrivate)
endif()
if(WIN32)
set_target_properties(cst-agent-app PROPERTIES WIN32_EXECUTABLE TRUE)
endif()
# --- Android (APK) -----------------------------------------------------
# The engine is compiled in (top-level CMakeLists: -DCST_BUILD_TUI=OFF,
# -DCST_BUILD_WEBUI=OFF, so src/webui_stub.c stands in for the server and
# nothing listens on a port). The manifest, icons and strings live in
# app/android/, which androiddeployqt copies next to its own gradle
# project. minSdk 24 is the engine's own API_LEVEL (scripts/
# build_third_party_android.sh builds curl/mbedTLS for android-24).
if(ANDROID)
file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/../include/cst_version.h"
_cst_ver_lines REGEX "#define CST_VERSION_(MAJOR|MINOR|PATCH) ")
foreach(_line ${_cst_ver_lines})
if(_line MATCHES "CST_VERSION_MAJOR ([0-9]+)")
set(_cst_vmaj "${CMAKE_MATCH_1}")
elseif(_line MATCHES "CST_VERSION_MINOR ([0-9]+)")
set(_cst_vmin "${CMAKE_MATCH_1}")
elseif(_line MATCHES "CST_VERSION_PATCH ([0-9]+)")
set(_cst_vpat "${CMAKE_MATCH_1}")
endif()
endforeach()
set_target_properties(cst-agent-app PROPERTIES
QT_ANDROID_PACKAGE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/android"
QT_ANDROID_MIN_SDK_VERSION 24
QT_ANDROID_TARGET_SDK_VERSION 35
QT_ANDROID_VERSION_NAME "${_cst_vmaj}.${_cst_vmin}.${_cst_vpat}"
QT_ANDROID_VERSION_CODE 1
QT_ANDROID_ABIS "${ANDROID_ABI}")
message(STATUS "app: Android package "
"${_cst_vmaj}.${_cst_vmin}.${_cst_vpat}, ABI ${ANDROID_ABI}, "
"minSdk 24, targetSdk 35")
# Signing: androiddeployqt signs the APK itself and refuses a signed
# gradle output ("Could not copy unsigned APK"), so the keystore goes
# here, not into gradle. The path, alias and passwords arrive through
# the QT_ANDROID_KEYSTORE_* environment variables;
# scripts/build_apk_android.sh points them at the standard Android debug
# key it creates on first use, and CST_ANDROID_KEYSTORE overrides it.
if(DEFINED ENV{QT_ANDROID_KEYSTORE_PATH} AND NOT "$ENV{QT_ANDROID_KEYSTORE_PATH}" STREQUAL "")
set(QT_ANDROID_SIGN_APK TRUE)
message(STATUS "app: signing the APK with "
"$ENV{QT_ANDROID_KEYSTORE_PATH} ($ENV{QT_ANDROID_KEYSTORE_ALIAS})")
else()
message(STATUS "app: QT_ANDROID_KEYSTORE_PATH unset -- the APK stays "
"unsigned and 'adb install' will refuse it (INSTALL_PARSE_FAILED_NO_CERTIFICATES)")
endif()
endif()
# Theme is read by every component, so it is a QML singleton (one instance,
# no object needed in the tree).
set_source_files_properties(qml/Theme.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
# Keep the module flat in the resource tree (:qt/qml/CstAgent/Main.qml).
# Sources live in qml/, but a module whose files sit in a subdirectory of
# the URI makes Qt register the types with relative URLs ("qmlRegisterType
# requires absolute URLs" on every start).
foreach(_qml Main ChatView Sidebar SessionRow MessageCard ToolCard ThinkBlock
NoteLine TodoPanel Composer ConsentDialog AskDialog SettingsDialog
ThemeIcon Theme ImageStrip)
set_source_files_properties(qml/${_qml}.qml PROPERTIES QT_RESOURCE_ALIAS ${_qml}.qml)
endforeach()
qt_add_qml_module(cst-agent-app
URI CstAgent
VERSION 1.0
QML_FILES
qml/Main.qml
qml/ChatView.qml
qml/Sidebar.qml
qml/SessionRow.qml
qml/MessageCard.qml
qml/ToolCard.qml
qml/ThinkBlock.qml
qml/NoteLine.qml
qml/TodoPanel.qml
qml/Composer.qml
qml/ConsentDialog.qml
qml/AskDialog.qml
qml/SettingsDialog.qml
qml/ThemeIcon.qml
qml/ImageStrip.qml
qml/Theme.qml)
# The icons the window asks for, compiled in (app/icons, Breeze's scalable
# names copied at 24@3x). The desktop finds them in the platform's icon
# theme and only falls back to these (ThemeIcons.cpp); a machine without
# any theme -- Android -- has nothing else, and before this the QML glyph
# fallback was all that showed, which the phone's fonts cannot render.
set(CST_APP_ICONS
go-down mail-attachment application-menu settings-configure edit-copy
edit-delete arrow-down arrow-right list-add task-complete box
document-edit text-x-generic folder-open edit-find code-context
internet-services format-list-unordered help-hint)
set(_cst_icon_files "")
foreach(_icon ${CST_APP_ICONS})
list(APPEND _cst_icon_files "${CMAKE_CURRENT_SOURCE_DIR}/icons/${_icon}.svg")
endforeach()
qt_add_resources(cst-agent-app "cst_icons"
PREFIX "/cst-icons"
BASE "${CMAKE_CURRENT_SOURCE_DIR}/icons"
FILES ${_cst_icon_files})
endif()
endif()
+414
View File
@@ -0,0 +1,414 @@
# app/ — the Qt front end
CST-agent's GUI: **Qt 6 + QML + the C engine, in one process**. No HTTP
server, no port, no token, no second process — the app creates a `cst_agent_t`
directly and drives it through `cst_agent.h`.
```
app/ the GUI
src/main.cpp entry point, --workspace / --selftest
src/core/AgentCore.* QObject around cst_agent_t: turns, hooks, consent
src/core/ToolProvider.h how the front end hands tools to the engine
src/platform/<os>/ platform tools the app implements itself
qml/Main.qml the window (chat, tool runs, consent dialog)
tools/selftest.c C smoke test of the embedding path (no Qt)
```
## Build
```sh
# engine + GUI + C smoke test
cmake -S app -B build-app -DCMAKE_BUILD_TYPE=Debug
cmake --build build-app -j
QT_QPA_PLATFORM=offscreen ctest --test-dir build-app # cst-app-selftest
# the app alone
./build-app/cst-agent-app --workspace ~/notes
./build-app/cst-agent-app --size 480x900 # the narrow (drawer) layout
./build-app/cst-agent-app --uitest # drive the window, exit code = failures
./build-app/cst-agent-app --selftest # same checks as the C test
```
The standalone build is the supported one: `app/CMakeLists.txt` uses the
`cstcore` target when the parent project defines one, and otherwise builds
the engine itself from `../src` (TUI, `main.c` and the HTTP front end
excluded). Wiring it into the repository's `CMakeLists.txt` as
`-DCST_APP=ON` is on the list below.
Needs Qt 6.4+ with **Quick / Qml / QuickControls2** (`qt6.qtdeclarative`,
`qt6.qtquickcontrols2` on Nix; the online installer's "Qt 6.x Desktop" on
Windows/macOS). Without them the configure step warns and builds only the C
smoke test — that is deliberate: the embedding path stays verifiable
everywhere. Kirigami is not required yet; `qml/Main.qml` uses Qt Quick
Controls and keeps the layout in Kirigami's shape (single column, cards,
navigation drawer) so swapping in `org.kde.kirigami` later is a QML-only
change. Point the app at a Kirigami outside the Qt prefix with
`-DCST_APP_QML_IMPORT=/path/to/qml`.
## Why in one process
The engine is a library, not a daemon: `libcstcore.a` (Makefile) / the
`cstcore` target (CMake). The app owns one `cst_agent_t` per window, so
there is no protocol to version, no socket to authenticate and no second
process to babysit — and the CLI, TUI and WebUI keep working off the same
source.
- `AgentCore::send()` runs `cst_agent_run_turn_ex()` on a worker thread;
the `cst_agent_hooks_t` callbacks turn into Qt signals (assistant deltas,
tool events, tool results, todo, turn end).
- Consent is a blocking hook: the worker waits on a `QWaitCondition`, the
GUI answers, the turn goes on. `CST_PERM_DENY / ONCE / ALWAYS_TOOL /
ALWAYS_PREFIX` map 1:1 onto the buttons, and `ALWAYS_*` still lands in
`.agent/permissions.json`, so a desktop decision is visible to the TUI.
- `cancel()` is `cst_agent_request_cancel()`; the turn unwinds as a normal
result, not an exception.
## Layout and input
One shell, three sizes (everything is styled from `qml/Theme.qml`):
- **Desktop**: the sidebar is docked and its divider can be dragged (double
click restores the auto width). Hover states, tooltips, selectable text and
a wheel-scrolled transcript.
- **Narrow window** (below `Theme.compactBreakpoint`, 880 px): the sidebar
folds away, the header grows a `☰` button, and the settings slide in over
the transcript with a scrim; the sidebar itself scrolls, because a phone
window cannot show it all.
- **Touch**: controls keep a `Theme.touchTarget` (34 px) hit area, buttons do
not depend on hover, the composer asks the platform keyboard not to
autocorrect (`ImhNoPredictiveText`), and Enter/Shift+Enter still behave.
`--uitest` checks this without a human: it loads the window, clicks with
synthesized **mouse** events, taps with a synthesized **touch** device, types
into the composer, drives the session list, then narrows the window to 460 px
to assert the dock → drawer switch and opens/closes the drawer. One line per
check, non-zero exit on failure:
```sh
QT_QPA_PLATFORM=offscreen ./build-app/cst-agent-app --uitest
# ok wide window: sidebar docked
# ok mouse click on "general" reaches the engine
# ok touch tap on "coding" reaches the engine
# ...
```
## Sessions
A GUI session is a directory in the workspace's store
(`.agent/sessions/<id>/session.jsonl`, with its `title`, its `attachments/` and
its sub-agent transcripts beside it), the same one the CLI, the TUI and the
WebUI use — `AgentCore::open()` resolves the workspace's current session before
the engine starts, so nothing is written next to the process's working
directory. A store an older version wrote (flat `<id>.jsonl` files) still
reads; `cst-agent --migrate-store` moves it in.
- The sidebar lists the store (newest first): title or first prompt, message
count, "3h ago"; the open one is highlighted. `+ new` starts a session, `×`
deletes one (the engine first moves off a session it has open, so it never
sits on a file that is gone), and a click switches.
- Switching **replays** the transcript: `AgentCore::history()` walks the stored
messages and returns the same rows the live signals build — messages,
thoughts, notes, and one card per tool call, with its result paired by name
(a call with no result keeps state `unfinished`, so no spinner runs
forever). A reopened session therefore renders like the turn that wrote it.
- Session controls are disabled while a turn runs: switching would move the
engine out from under it.
`--uitest` covers the flow end to end: `writeFixtureSession()` writes a small
session through the store API (a tool call with its result), the checks click
its row, assert the five replayed rows and the paired result, start two new
sessions (the view empties), delete one by its `×` (the open one stays), and
switch back.
`--uitest` uses a scratch workspace unless `--workspace` is given, since it
creates and deletes sessions. On a machine without a screen,
`CST_APP_SHOT=shot.png` makes the run save the window as a PNG when it is
done; `CST_APP_DUMP=1` prints how many session rows the visual tree holds.
## Provider, model, key
The sidebar shows where the engine points (`provider · model`); the gear in the
header opens the dialog that changes it.
- The provider list is the engine's own preset table (`cst_provider_count()`),
with each entry's base URL and where its key would come from: "key saved"
(`.agent`-independent, `<config>/cst-agent/credentials`), "key from
OPENAI_API_KEY", or "no key". A key itself is never sent to the QML side —
the dialog only ever describes it. The window starts the engine without a
key (`AgentCore::open()` is passed an empty one), so `open()` resolves it
for the provider it starts on: saved key, then the provider's env variable,
then `CST_API_KEY` — the same order the CLI uses in `main.c`. Without that
the key sat in the file but never in the turn, and every start asked for it
again.
- "Use this" calls `AgentCore::useProvider()`, which runs
`cst_ps_switch_provider()` / `cst_ps_set_key()` on the engine's live config:
a pasted key is verified against the provider's `/models` before it is saved
(offline it is kept unverified and the header says so), the model is
remembered in `settings.json`, and the **session is not touched**. Model
candidates come from the provider entry, the fetched cache
(`cst_ps_cached`, no network) and the remembered pick; the label next to the
field is `cst_model_info` ("200K · text only").
- The dialog also reports the session's token use (`cst_agent_usage`).
`--uitest` checks it end to end: the presets are readable, an unknown provider
is refused, the gear opens the panel, the provider rows exist, a model typed
into the field reaches the engine when "Use this" is clicked, the note says so,
and Close puts the dialog away. The run points `XDG_CONFIG_HOME` at its scratch
directory, so settings and keys of the real user are never rewritten.
## Custom providers, model parameters
The dialog's **Advanced options** covers the two things the presets cannot:
a provider of your own, and what the model should do.
- A custom provider is an entry the engine keeps in
`<config>/cst-agent/models.json` (layer 1 of the model registry, see
`include/cst_models.h`) — the same file a hand-written override would go
into, so the GUI and a text editor are interchangeable:
```json
{ "providers": { "myllm": { "api_base": "https://api.example.com/v1",
"key_env": "MYLLM_API_KEY",
"default_model": "m1",
"models": ["m1", "m2"] } } }
```
`AgentCore::addProvider()` / `removeProvider()` write it
(`cst_models_user_set_provider()` / `cst_models_user_drop_provider()`) and
reload the registry, so the provider is in the list, in `models[]` on the
wire and in `/provider` of the CLI at once. A name a preset uses overrides
that preset (a self-hosted endpoint, a proxy); the other content of the file
(model rules) is kept, and a file that is not a JSON object is never
overwritten. The key is still a key: it is verified against `GET /models` of
the new base URL before it is saved, and stored per provider name as before.
- The generation parameters — **temperature**, **top-p**, **max tokens** — are
remembered per provider in `settings.json` (`"params": {...}`,
`cst_settings_remember_params()`), and applied to the engine's config by
`AgentCore::setGenParams()`, `AgentCore::useProvider()` and
`cst_agent_create()` (so a CLI or TUI run sends what the window was set to).
An **empty field is not sent at all** — that is not the same as sending 0,
which is a real temperature; `cst_llm_build_body()` only adds a field that
was set (`tests/test_llm_body.c:test_gen_params`).
- A long model list (a router lists hundreds) is filtered and capped at 40
chips with "… +N more", and the whole dialog body scrolls: the buttons stay
put and nothing can spill out of the frame.
## Attachments
The next prompt can carry files, and they arrive three ways:
- **📎 in the composer** opens a file picker (`QtQuick.Dialogs`).
- **Dropping files on the window** — a `DropArea` covers it; while something is
dragged over, the border turns accent and says "drop to attach".
- **Ctrl+V** with an image on the clipboard. Text is left to the editor's own
paste: the composer asks `AgentCore::clipboardHasImage()` first and only
intercepts the keystroke when there is an image.
`AgentCore::attachFile()` / `attachClipboard()` call `cst_agent_attach_file()` /
`cst_agent_attach_bytes()`: the engine validates the file, copies it into the
session's own directory (`.agent/sessions/<id>/attachments/`), and the next
`run_turn` carries it — the
window only shows what the engine's `pending` list says, one chip per file in
the engine's own words ("note.txt (text/plain, 21 B)"), with "clear all"
(`cst_agent_pending_clear`). A prompt consumes the list, so the chips go away
with the turn. When the model cannot be shown images
(`cst_agent_sees_images() == 0`), the composer says so and the engine sends
them as text notes instead.
`--uitest` writes a file into its scratch workspace and checks the whole path:
`attachFile` succeeds, the engine reports one pending attachment, a missing file
is refused with a reason, the composer shows one chip, "clear all" empties the
list, and — with an 8×8 image put on the clipboard — `clipboardHasImage()` and
`attachClipboard()` behave.
## Images
What a session keeps as a picture is shown, not only stored: a file
`view_image` returned, an image an MCP server sent, a screenshot the user
attached or pasted. Each one appears as a thumbnail in the transcript -- a
prompt's pictures under the message, a tool's under its card -- with the name,
size and origin in the tooltip. The height is capped so a 4K screenshot cannot
push the conversation off the screen, and a file the store no longer has says
which image it was instead of leaving a hole.
The view never touches the filesystem on its own authority: the thumbnails
come from `image://cstattach/<stored name>` (`app/src/ui/SessionImages.cpp`),
where the name is the one the engine wrote next to the session and is checked
with the engine's own `cst_attach_rel_ok()`, so only the session's attachment
directory can be read. The picture is decoded at the size of its thumbnail
rather than at its full resolution, and the provider's directory follows the
session: `AgentCore::attachmentDir()` is re-read on every switch.
A document (PDF, UTF-8 text) still arrives as its name in the message text --
those are sent to the model as text, not as a picture.
## Files the agent delivers
The opposite direction: the engine's `deliver_file` tool (registered in
`src/tools/registry.c`, run in `src/tools/fs_tools.c`) copies a finished file
out of the workspace into the session's own store, and the transcript shows it
as a **file card** -- an extension badge, the file's name, its type and size,
and two buttons.
- The copy is marked `for_user` (`cst_attachment_t.for_user`,
`cst_attach_import_artifact()`): any bytes up to `CST_ARTIFACT_MAX_BYTES`
(64 MB), the name's extension decides the mime, and **no request ever
contains it** -- `src/llm_openai.c` skips those entries when it builds the
body, which is what keeps a delivered report from being pasted back into the
context. `tests/test_artifacts.c` holds that down at the wire level.
- A card never invents a path. The row carries the stored file's name, and
`AgentCore::openArtifact()` / `shareArtifact()` accept it only when one of
the session's own messages carries that name (`artifactPathOf()`, plus the
engine's `cst_attach_rel_ok()`), so a forged name is refused with a reason.
- **Open** hands the file to the system: Android builds a `content://` URI
through the app's FileProvider, checks `resolveActivity()`, starts
`ACTION_VIEW` and grants the reader permission; a desktop calls
`QDesktopServices::openUrl`. **Share** uses the phone's share sheet; a
desktop has none, so the path goes to the clipboard.
- `CST_APP_NO_OPEN=1` makes both do nothing but report the line they would
write. `--uitest` sets it, so a test run never opens anything for real: it
replays a session with a delivered `report.md`, checks the card and its two
buttons, clicks Open (the header must say `opened report.md`), clicks Share
and checks a name the session does not own is refused.
## Transcript, tool calls, streaming text
- A tool call is a card of its own, and the call **id** is what pairs it with
its result: the same tool may run twice in a turn, and pairing by name (what
the window did before) hung the first card on "running" forever while the
second one took its result. `AgentCore::toolEvent()` and `toolResult()` carry
the id (`ctl_tool_call_t.id`), `AgentCore::history()` fills it from the
session (`tool_call_id`, `tool_calls_json`), and only an id-less call from an
old text-protocol session still falls back to the name.
- Nothing may keep spinning after a turn: `closeAllRows()` settles every open
row when the turn ends, and a call with no result becomes "no result"
(`unfinished`) instead of a spinner that runs forever.
- The answer is rendered **while it streams**: a 120 ms timer re-renders the
open answer with the engine's own markdown renderer (`cst_web_md_html`, via
`AgentCore::renderMarkdown()`), so a list or a code fence fills in formatted
instead of turning into markdown when the turn is over. A replayed session
renders once, when its rows are built.
- Every signal handler is a plain function on the window
(`engineToolEvent`, `engineToolResult`, `engineTurnFinished`,
`engineAssistant`, ...) so `--uitest` can drive the same path: it opens two
calls of one tool, delivers one result, and asserts that the result landed on
its own card and that the turn end settled the other one. `rowJson(i)` exists
for that (a row as JSON — an object would not survive
`QMetaObject::invokeMethod`).
## Icons, colors
- Icons come from the desktop's **icon theme** (Breeze on KDE), through
`image://csttheme/<name>?color=%23rrggbb` (`app/src/ui/ThemeIcons.{h,cpp}`).
Qt Quick has no icon provider of its own without Kirigami, so the window used
unicode glyphs before (a clip emoji, a gear, a hamburger): those render in
whichever font covers them — colour emoji in a grey toolbar — and are a box
on a machine without that glyph. `app/qml/ThemeIcon.qml` is the one place
that asks for an icon, and every use still names the old character as
`glyph:`: a host with no icon theme degrades to what it looked like before
rather than going blank.
- The provider fills Qt's theme search paths itself (XDG data dirs, `~/.icons`
and, on NixOS, `/run/current-system/sw/share/icons`): Qt only builds them
from XDG once a theme name is set. It then tries the theme the desktop chose,
else `breeze-dark` (this window is dark), `breeze`, `hicolor`. Rasterising
Breeze needs the Qt SVG image format plugin (`qt6.qtsvg` on Nix, part of a
desktop Qt install) — without it the icons fall back to the glyphs instead of
disappearing.
- `Theme.toolIcon(name)` maps a tool onto its icon: patch/write → pencil,
read → text page, list/glob → folder, grep → magnifier, shell → braces,
web → globe, plan → list, ask → question mark. Toolbar icons are 22 px
(Breeze's toolbar size), card and row icons 14–16 px.
- `textFaint` is readable grey, not decoration (~5:1 on the card fill): it
carries hints, secondary lines and provider URLs. Anything written *on* the
accent fill (a selected provider row, the open session) uses `accentText`,
`accentTextDim` and `okOnAccent` — green or faint grey on `accentDim` is
unreadable. The names are not `onAccent…`: QML reads every `onFoo` property
as a signal handler and refuses the assignment.
- The composer's frame is one `Theme.pad` around its column on **all** sides;
with the top margin alone the clip and Send hung over the lower border.
## Tools the app provides
A tool implemented in the front end is registered with `cst_tool_register()`
and the owner `app:<name>`, exactly like a plugin or MCP server would:
```c
cst_tool_def_t def = {0};
def.name = "notification_send";
def.desc = "Send a system notification.";
def.perm = CST_TP_ASK; /* class: drives the consent dialog */
def.perm_name = "notify"; /* policy: what the user is really granting */
def.platforms = ANDROID_ONLY; /* NULL = everywhere */
def.available = probe; /* 0 / -1 with a reason; cached */
def.run_ex = my_run;
def.ud = my_state;
def.owner = "app:android";
cst_tool_register(&def);
```
- `platforms` + `available` decide *whether* the tool exists here: an
Android-only tool never reaches the prompt or `tools[]` on Linux, and
`cst_tool_run()` refuses it with the reason if a model asks anyway.
- A front-end tool may take over the **name** of a built-in that cannot run
on this host (the Android clipboard replacing the desktop `xclip` one);
it may not shadow one that works.
- `cst_tool_unregister_owner("app:android")` drops them again — do it when a
platform service goes away, then `cst_tool_meta_reload()`.
- `setenv("CST_PLATFORM", ...)` overrides the detected host name (tests, a
launcher that knows better).
Note `run_ex` runs on the worker thread while the turn is going: touch the
GUI only through queued signals.
## Platform notes
| Platform | How the engine gets built | Platform tools |
|---|---|---|
| Linux | `cmake -S app -B build-app` (libcurl via pkg-config) | clipboard (shell helpers), notification (`notify-send` / D-Bus) |
| Windows | Qt's MinGW kit, `cmake -G Ninja -DCMAKE_PREFIX_PATH=<Qt>/lib/cmake`; the engine needs libcurl for Windows (see `docs/README-windows.md`) | notification via the WinRT/tray API, clipboard through the core's Win32 path |
| Android | engine compiled with the NDK inside the APK (`scripts/build_apk_android.sh`, `-DCST_TUI=0`, `CST_NO_WEBUI=1`); no port, QML talks to `AgentCore` in-process | notification (`Notifier.java`: channel + permission probe), clipboard (`QClipboard` under the engine's own tool names), share sheet (`ACTION_SEND` + chooser), and the file card's open/share through the app's FileProvider (`AndroidTools.cpp`) |
| macOS | Qt from the installer, libcurl from the system | notification (`osascript`), clipboard (`pbpaste`/`pbcopy`) |
Android's long-term shape: the core lives in the APK (no Termux needed), the
platform tools are Kotlin/JNI behind the same `cst_tool_register()` contract.
The build flag that keeps Unix helpers out of the picture is
`CST_PLATFORM=android` (or the `__ANDROID__` detection inside the engine).
A Java class is added by dropping it in `app/android/src/<package>/`:
androiddeployqt copies `app/android/` with the rest of the package source and
Qt's Gradle template puts its `src/` on `java.srcDirs`, so no build-script
change is needed (`Notifier.java` is the first one; a Java helper is the only
way to catch a throwable where the platform throws instead of returning).
`CstService.java` (the foreground service that holds a phone awake while a
turn runs) went in the same way; it is started by `app::holdForeground()`
when a turn begins and stopped by `app::releaseForeground()` when it ends,
so nothing about it is visible when the app is idle.
## Status
- [x] engine as a library (`libcstcore.a` / `cstcore`), in-process embed API
- [x] tool metadata: `perm_name`, `platforms`, `available`, shadowing rule
- [x] `AgentCore` (turns, cancel, consent, todo) and the C smoke test
- [x] QML shell: transcript cards, tool cards with diff coloring, thinking
blocks, consent/ask dialogs, sidebar, responsive dock/drawer layout,
`--uitest` for mouse, touch and small-screen checks
- [x] session list in the sidebar: new / switch / delete, transcript replay
- [x] settings dialog: provider, model, key (`cst_ps_*`), token counters
- [x] attachments: picker, drop on the window, Ctrl+V image paste
- [x] icons from the desktop's icon theme (Breeze), with a glyph fallback
- [x] contrast pass: faint text and selected rows are readable
- [x] images in the transcript: tool results and attachments are drawn as
thumbnails (`image://cstattach/<stored name>`, see the Images section)
- [x] Android APK: the engine is linked in (no port), builds, installs under
`xyz.whcself.cstagent` and runs on the device (see `docs/android.md`)
- [x] the saved provider key reaches the engine on every start (`AgentCore::open()`
resolves it: saved key, provider environment, `CST_API_KEY`)
- [x] delivered files: `deliver_file` stores the copy next to the session and the
transcript draws a file card with Open and Share (see the Files section)
- [x] Android platform tools (JNI: notification, clipboard, share sheet) and the
file card's open/share through the app's FileProvider
- [x] a turn keeps running with the app off screen (Android foreground service
+ wake lock; nothing to do on a desktop)
- [ ] a real turn driven through the window (needs a provider key)
- [ ] SAF file picker, the back gesture, the icon glyphs on the phone
- [ ] Kirigami (the rest of the Breeze look: colors, controls), tray,
global shortcuts
+113
View File
@@ -0,0 +1,113 @@
<?xml version="1.0"?>
<!-- The APK's manifest (QT_ANDROID_PACKAGE_SOURCE_DIR in app/CMakeLists.txt):
androiddeployqt copies this file and res/ into its gradle project and
fills in the INSERT_* placeholders below (version code and name, library
name, app arguments). An XML comment may not contain a double hyphen, so
the placeholder syntax is only spelled out in the attributes themselves.
The SDK levels are not here on purpose: they come from
QT_ANDROID_MIN_SDK_VERSION and QT_ANDROID_TARGET_SDK_VERSION in
app/CMakeLists.txt, and androiddeployqt writes them into the generated
gradle.properties. The app name and icon come from res/ next to this
file (values/strings.xml, drawable/ic_launcher.xml).
WHAT THIS APP IS: the engine runs in this process (no server, no port) and
writes everything (workspace, session store, settings, credentials) under
the app's own data directory, which main.cpp points the engine at (see
androidRoot() there).
Permissions: INTERNET for the providers the user configures,
POST_NOTIFICATIONS for the app's notification_send tool (Android 13+
asks at runtime; the tool stays hidden until it is granted), the
foreground-service permissions with WAKE_LOCK for the service that keeps
a turn going while the phone is in a pocket (CstService.java), and
MANAGE_EXTERNAL_STORAGE ("all files access", Android 11+) so the engine
can read a path in shared storage that a task names rather than picks:
Android only offers the Settings screen for it, so main.cpp opens that
once and everything still works without it: what the system picker (SAF)
hands over is copied into the app's own directory (AttachFile.java). No
cleartext traffic: every provider URL is https. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="xyz.whcself.cstagent"
android:installLocation="auto"
android:versionCode="-- %%INSERT_VERSION_CODE%% --"
android:versionName="-- %%INSERT_VERSION_NAME%% --">
<!-- %%INSERT_PERMISSIONS -->
<!-- %%INSERT_FEATURES -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- "All files access": without it the engine can only read the app's own
directory, so a task that names a file in shared storage cannot be
answered. Android 11+ has no runtime dialog for it, only the Settings
screen (AttachFile.java opens it once). Files the user picks work
either way: their content:// grant is copied into the app first. -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<supports-screens
android:anyDensity="true"
android:largeScreens="true"
android:normalScreens="true"
android:smallScreens="true" />
<application
android:name="org.qtproject.qt.android.bindings.QtApplication"
android:label="@string/app_name"
android:icon="@drawable/ic_launcher"
android:allowBackup="true"
android:hardwareAccelerated="true"
android:enableOnBackInvokedCallback="false">
<activity
android:name="org.qtproject.qt.android.bindings.QtActivity"
android:label="@string/app_name"
android:configChanges="orientation|uiMode|screenLayout|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|keyboard|keyboardHidden|navigation|density|mcc|mnc"
android:launchMode="singleTop"
android:screenOrientation="unspecified"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.lib_name"
android:value="-- %%INSERT_APP_LIB_NAME%% --" />
<meta-data
android:name="android.app.arguments"
android:value="-- %%INSERT_APP_ARGUMENTS%% --" />
</activity>
<!-- The share tool hands a file out of the workspace, and a file
crossing an app boundary has to travel as a content:// uri. This
is Qt's own provider (androidx.core.content.FileProvider, paths
from res/xml/qtprovider_paths.xml): its files-path is the app's
files directory, which is where the workspace lives. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.qtprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/qtprovider_paths" />
</provider>
<!-- A turn can outlive the screen: while one is running the front end
starts this service (AgentCore::send -> app::holdForeground), which
makes the process a foreground one, so Android neither freezes nor
kills it in the background, and takes a partial wake lock so the
CPU keeps going with the phone in a pocket. dataSync is the type
that fits "the app is finishing some work". -->
<service
android:name="xyz.whcself.cstagent.CstService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Launcher icon: the terminal prompt the app is about ("&gt;_"), the window's
own colors (bg #12161a, accent #5cc0f0, faint #8d959c). A vector drawable
works from API 24 up; a proper adaptive icon (mipmap-anydpi-v26 + a PNG
fallback) is polish for later. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#12161a"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:strokeColor="#5cc0f0"
android:strokeWidth="7"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M32,38 L52,54 L32,70" />
<path
android:fillColor="#00000000"
android:strokeColor="#8d959c"
android:strokeWidth="7"
android:strokeLineCap="round"
android:pathData="M58,74 L78,74" />
</vector>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The name under the launcher icon. -->
<resources>
<string name="app_name">CST-agent</string>
</resources>
@@ -0,0 +1,189 @@
package xyz.whcself.cstagent;
import android.content.ActivityNotFoundException;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.OpenableColumns;
import android.provider.Settings;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
/* What the user picks in the system file chooser comes back as a content://
* URI, and the engine opens files by path -- so the bytes are copied into the
* app's own directory first. That copy needs no permission at all (the grant
* travels with the URI), which is what makes picking work on a phone that
* grants nothing else.
*
* The "all files access" below is a different thing: it is what lets the
* engine read /sdcard by path afterwards (a model that opens a file the user
* names rather than picks). Android 11+ has no runtime dialog for it -- only
* the Settings screen -- so it is a request, not a demand: everything works
* without it, one file at a time.
*
* Qt swallows a Java exception (it comes back as a null object), so every
* method catches its own and answers with a reason instead.
*/
public final class AttachFile {
private AttachFile() {}
private static final long MAX_BYTES = 256L * 1024 * 1024;
public static boolean isContent(String s) {
return s != null && s.startsWith("content:");
}
/* content://<uri> -> the path of a copy inside destDir; "" when the string
* is already a path; "error: ..." when the copy failed. */
public static String materialize(Context ctx, String pathOrUri, String destDir) {
if (pathOrUri == null || pathOrUri.isEmpty())
return "error: no file was picked";
if (!isContent(pathOrUri))
return "";
try {
if (ctx == null)
return "error: no Android context";
final Uri uri = Uri.parse(pathOrUri);
String name = displayName(ctx, uri);
if (name == null || name.trim().isEmpty())
name = uri.getLastPathSegment();
name = sanitize(name);
File dir = new File(destDir);
if (!dir.isDirectory() && !dir.mkdirs())
return "error: cannot use " + destDir;
File out = unique(dir, name);
final ContentResolver cr = ctx.getContentResolver();
final InputStream in = cr.openInputStream(uri);
if (in == null)
return "error: the picker gave no data for that file";
long total = 0;
try {
final OutputStream os = new FileOutputStream(out);
try {
final byte[] buf = new byte[64 * 1024];
int n;
while ((n = in.read(buf)) > 0) {
total += n;
if (total > MAX_BYTES) {
os.close();
in.close();
out.delete();
return "error: that file is larger than 256 MB";
}
os.write(buf, 0, n);
}
} finally {
os.close();
}
} finally {
in.close();
}
if (total == 0) {
out.delete();
return "error: that file is empty";
}
return out.getAbsolutePath();
} catch (Throwable t) {
return "error: cannot read the picked file: " + t;
}
}
/* Android 11+ keeps "all files access" in a special app-op; older systems
* have no such gate. */
public static boolean hasAllFilesAccess(Context ctx) {
try {
if (Build.VERSION.SDK_INT < 30)
return true;
return Environment.isExternalStorageManager();
} catch (Throwable t) {
return false;
}
}
/* The Settings screen Android requires for it. "" when it was opened. */
public static String requestAllFilesAccess(Context ctx) {
try {
if (Build.VERSION.SDK_INT < 30 || ctx == null)
return "";
if (Environment.isExternalStorageManager())
return "";
final Intent intent = new Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:" + ctx.getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try {
ctx.startActivity(intent);
} catch (ActivityNotFoundException notFound) {
final Intent all = new Intent(
Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
all.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
ctx.startActivity(all);
}
return "";
} catch (Throwable t) {
return "cannot open the storage settings: " + t;
}
}
private static String displayName(Context ctx, Uri uri) {
Cursor c = null;
try {
c = ctx.getContentResolver().query(uri,
new String[] { OpenableColumns.DISPLAY_NAME }, null, null,
null);
if (c != null && c.moveToFirst()) {
final int i = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (i >= 0)
return c.getString(i);
}
} catch (Throwable ignored) {
/* fall back to the last path segment */
} finally {
if (c != null)
c.close();
}
return null;
}
/* A plain file name: no separators, no traversal, no control bytes. */
private static String sanitize(String name) {
if (name == null || name.isEmpty())
return "picked.bin";
final StringBuilder b = new StringBuilder();
for (int i = 0; i < name.length() && b.length() < 120; i++) {
final char ch = name.charAt(i);
if (ch == '/' || ch == '\\' || ch < 0x20)
b.append('_');
else
b.append(ch);
}
String s = b.toString();
while (s.startsWith("."))
s = s.substring(1);
if (s.isEmpty())
return "picked.bin";
return s;
}
private static File unique(File dir, String name) {
File f = new File(dir, name);
if (!f.exists())
return f;
final int dot = name.lastIndexOf('.');
final String stem = dot > 0 ? name.substring(0, dot) : name;
final String ext = dot > 0 ? name.substring(dot) : "";
for (int i = 2; i < 1000; i++) {
f = new File(dir, stem + "-" + i + ext);
if (!f.exists())
return f;
}
return f;
}
}
@@ -0,0 +1,143 @@
package xyz.whcself.cstagent;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.IBinder;
import android.os.PowerManager;
/**
* The service that keeps a turn running while the app is not on screen.
*
* A phone freezes an app it cannot see (and may kill it), so a turn that takes
* minutes -- a build, a long web fetch, a provider that thinks -- dies in the
* pocket. While one is going, AgentCore::send() starts this service through
* app::holdForeground(); being a foreground service makes the process one
* Android does not touch, and the partial wake lock keeps the CPU awake with
* the screen off. turnFinished() stops it again (app::releaseForeground()), so
* the permanent notification exists exactly as long as the work does.
*
* Started with an explicit component (AndroidTools.cpp builds the Intent), no
* intent filter: nothing outside the app can start it.
*/
public class CstService extends Service {
/** Same channel the notification_send tool uses: one place in settings. */
private static final String CHANNEL_ID = "cst-agent";
private static final int NOTE_ID = 2;
private static final String ACTION_STOP = "xyz.whcself.cstagent.STOP";
/** A turn that runs longer than this is stuck; the lock must not outlive it. */
private static final long WAKE_LOCK_TIMEOUT_MS = 30 * 60 * 1000L;
private PowerManager.WakeLock wake;
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
stopEverything();
return START_NOT_STICKY;
}
String text = intent == null ? null : intent.getStringExtra("text");
try {
startForeground(NOTE_ID, buildNote(text));
} catch (Throwable t) {
/* Android 12+ refuses a foreground start from the background: the
* turn then simply runs without the protection rather than not at
* all. Nothing to show the user here -- the turn's own result is
* what matters. */
stopSelf();
return START_NOT_STICKY;
}
acquireWake();
return START_NOT_STICKY;
}
@Override
public void onDestroy() {
releaseWake();
super.onDestroy();
}
private void stopEverything() {
releaseWake();
if (Build.VERSION.SDK_INT >= 24)
stopForeground(STOP_FOREGROUND_REMOVE);
else
stopForeground(true);
stopSelf();
}
private void acquireWake() {
try {
if (wake == null) {
PowerManager pm =
(PowerManager) getSystemService(Context.POWER_SERVICE);
if (pm == null)
return;
wake = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
"cst-agent:turn");
wake.setReferenceCounted(false);
}
wake.acquire(WAKE_LOCK_TIMEOUT_MS);
} catch (Throwable ignored) {
/* no wake lock: the turn still runs while the screen is on */
}
}
private void releaseWake() {
try {
if (wake != null && wake.isHeld())
wake.release();
} catch (Throwable ignored) {
}
}
/** "the agent is working: <first line of the prompt>", tapping opens it. */
private Notification buildNote(String text) {
Context ctx = getApplicationContext();
NotificationManager manager =
(NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
if (manager != null && Build.VERSION.SDK_INT >= 26) {
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID, "CST-agent", NotificationManager.IMPORTANCE_LOW);
channel.setDescription("A turn is running");
manager.createNotificationChannel(channel);
}
Notification.Builder builder = Build.VERSION.SDK_INT >= 26
? new Notification.Builder(ctx, CHANNEL_ID)
: new Notification.Builder(ctx);
builder.setContentTitle("CST-agent is working")
.setSmallIcon(smallIcon())
.setOngoing(true)
.setShowWhen(false);
if (text != null && !text.isEmpty())
builder.setContentText(text);
Intent open = ctx.getPackageManager().getLaunchIntentForPackage(ctx.getPackageName());
if (open != null) {
open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
builder.setContentIntent(PendingIntent.getActivity(ctx, 0, open,
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT));
}
return builder.build();
}
/** A framework drawable first (the same order the tool uses), then the app's. */
private int smallIcon() {
int icon = getResources().getIdentifier("ic_dialog_info", "drawable", "android");
if (icon == 0)
icon = getResources().getIdentifier("stat_notify_chat", "drawable", "android");
if (icon == 0)
icon = getApplicationInfo().icon;
return icon;
}
}
@@ -0,0 +1,60 @@
package xyz.whcself.cstagent;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
/**
* The notification behind the engine's notification_send tool.
*
* It lives in Java because a Java exception is invisible to C++ by the time
* Qt's JNI wrapper is done with the call: Notification.Builder.build() can
* throw (a small icon Android refuses, a channel it does not know) and the
* JNI call then simply returns null -- the tool could only say "failed, no
* exception detail" on a device where the phone's own shade never saw
* anything. Catching it here turns the failure into the line the tool shows.
*
* Called from app/src/platform/android/AndroidTools.cpp through QJniObject.
* androiddeployqt copies this file with the rest of the package source
* (QT_ANDROID_PACKAGE_SOURCE_DIR, app/android/) and Qt's Gradle template adds
* "src" to java.srcDirs, so no build script change is needed for a file
* added here.
*/
public final class Notifier {
private Notifier() {}
/**
* Post one notification. An empty channelId means an API below 26, which
* has no channels. Returns "" when it went out, the Java failure
* otherwise (class name and message), so the tool result can name it.
*/
public static String post(Context ctx, String channelId, String title,
String text, int iconId, Intent tap, int id) {
try {
Notification.Builder builder = channelId == null || channelId.isEmpty()
? new Notification.Builder(ctx)
: new Notification.Builder(ctx, channelId);
builder.setContentTitle(title).setContentText(text).setSmallIcon(iconId);
if (tap != null) {
tap.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent pending = PendingIntent.getActivity(
ctx, 0, tap,
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
builder.setContentIntent(pending);
}
NotificationManager manager = (NotificationManager)
ctx.getSystemService(Context.NOTIFICATION_SERVICE);
if (manager == null)
return "no NotificationManager";
manager.notify(id, builder.build());
return "";
} catch (Throwable t) {
String message = t.getMessage();
return t.getClass().getSimpleName()
+ (message == null || message.isEmpty() ? "" : ": " + message);
}
}
}
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m3 5v2h16v-2h-16m0 5v2h16v-2h-16m0 5v2h16v-2h-16" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 446 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m3.707031 7l-.707031.707031 6.125 6.125 1.875 1.875 1.875-1.875 6.125-6.125-.707031-.707031-6.125 6.125-1.167969 1.167969-1.167969-1.167969-6.125-6.125" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 549 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m7.707031 3l-.707031.707031 6.125 6.125 1.167969 1.167969-1.167969 1.167969-6.125 6.125.707031.707031 6.125-6.125 1.875-1.875-1.875-1.875-6.125-6.125" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 547 B

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 24 24" width="24" height="24">
<defs>
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path class="ColorScheme-Text" transform="translate(-.99999-.99999)" d="m4 4v16h16v-16h-16m1 1h14v14h-14v-14" fill="currentColor"/>
<path d="m3.5056345 10.977883v-7.5052069h14.878582v15.010415h-14.878582z" class="ColorScheme-Text" fill="currentColor" fill-opacity=".50196078"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 589 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m10.04492 3c-1.77683 0-2.521484.706446-2.521484 2.509766v2.441406c0 1.11382-.034195 2.404297-1.015625 2.404297h-.507813v1.289062h.507813c.98148 0 1.015625 1.290477 1.015625 2.404297v2.441406c0 1.80332.744654 2.509766 2.521484 2.509766v-1.183594c-1.18455 0-1.201172-.478327-1.201172-2.210937v-2.01563c0-.83093-.204705-2.067378-1.203125-2.580078.99842-.51271 1.203125-1.751092 1.203125-2.582032v-2.033203c0-1.7326.016592-2.210937 1.201172-2.210937v-1.183594m1.894531 0v1.183594c1.18456 0 1.216797.478337 1.216797 2.210937v2.033203c0 .83094.204705 2.069322 1.203125 2.582032-.99842.5127-1.203125 1.749148-1.203125 2.580078v2.01563c0 1.73261-.032187 2.210937-1.216797 2.210937v1.183594c1.77684 0 2.537109-.706446 2.537109-2.509766v-2.441406c0-1.11382.068644-2.404297 1.033204-2.404297h.490234v-1.289062h-.490234c-.96457 0-1.033204-1.290477-1.033204-2.404297v-2.441406c0-1.80332-.760269-2.509766-2.537109-2.509766" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m14.996094 3l-11.992188 11.992188h-.003906v4.00781h1 2 1.00781v-.003906l11.992188-11.992188-.001953-.001953.001953-.001953-4-4-.001953.001953-.001953-.001953m-1.998047 3.412109l2.589844 2.589844-7.587891 7.587891v-1.589844h-1-1v-1-.589844l6.998047-6.998047m-7.998047 7.998047v1.589844h1 1v1 .589844l-.410156.410156h-1.589844l-1-1v-1.589844l1-1" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 741 B

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" version="1.1" width="24" height="24">
<defs>
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path class="ColorScheme-Text" style="fill:currentColor; fill-opacity:1; stroke:none" d="M 3 3 L 3 17 L 7 17 L 7 19 L 17 19 L 17 10 L 13 6 L 12 6 L 9 3 L 3 3 Z M 4 4 L 8 4 L 8 6 L 7 6 L 7 16 L 4 16 L 4 4 Z M 8 7 L 12 7 L 12 11 L 16 11 L 16 18 L 8 18 L 8 7 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 583 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-NegativeText { color: #da4453; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" class="ColorScheme-NegativeText" d="M8 3v2h1V4h4v1h1V3H8M4 6v1h14V6H4m2 2v11h10V8h-1v10H7V8H6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 471 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 9 3 C 5.6759952 3 3 5.6759952 3 9 C 3 12.324005 5.6759952 15 9 15 C 10.481205 15 11.830584 14.465318 12.875 13.582031 L 18.292969 19 L 19 18.292969 L 13.582031 12.875 C 14.465318 11.830584 15 10.481205 15 9 C 15 5.6759952 12.324005 3 9 3 z M 9 4 C 11.770005 4 14 6.2299952 14 9 C 14 11.770005 11.770005 14 9 14 C 6.2299952 14 4 11.770005 4 9 C 4 6.2299952 6.2299952 4 9 4 z " class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 774 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor" d="M 3 3 L 3 4 L 3 19 L 4 19 L 19 19 L 19 18 L 19 5 L 12. 5 L 10. 3 L 10 3 L 10 3 L 4 3 L 3 3 z M 9 8 L 18 8 L 18 18 L 4 18 L 4 10 L 7 10 L 7 10 L 7 10 L 9 8 z " class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 528 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 4 3 C 3.4477153 3 3 3.4477153 3 4 C 3 4.5522847 3.4477153 5 4 5 C 4.5522847 5 5 4.5522847 5 4 C 5 3.4477153 4.5522847 3 4 3 z M 10 3 L 10 4 L 19 4 L 19 3 L 10 3 z M 10 5 L 10 6 L 16 6 L 16 5 L 10 5 z M 4 9 C 3.4477153 9 3 9.4477153 3 10 C 3 10.552285 3.4477153 11 4 11 C 4.5522847 11 5 10.552285 5 10 C 5 9.4477153 4.5522847 9 4 9 z M 10 9 L 10 10 L 19 10 L 19 9 L 10 9 z M 10 11 L 10 12 L 12 12 L 12 11 L 10 11 z M 4 15 C 3.4477153 15 3 15.447715 3 16 C 3 16.552285 3.4477153 17 4 17 C 4.5522847 17 5 16.552285 5 16 C 5 15.447715 4.5522847 15 4 15 z M 10 15 L 10 16 L 19 16 L 19 15 L 10 15 z M 10 17 L 10 18 L 16 18 L 16 17 L 10 17 z M 17 18 L 17 19 L 19 19 L 19 18 L 17 18 z " class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m3.707031 7l-.707031.707031 6.125 6.125 1.875 1.875 1.875-1.875 6.125-6.125-.707031-.707031-6.125 6.125-1.167969 1.167969-1.167969-1.167969-6.125-6.125" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 549 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 11 3 C 6.568 3 3 6.568 3 11 C 3 15.432 6.568 19 11 19 C 15.432 19 19 15.432 19 11 C 19 6.568 15.432 3 11 3 z M 11 4 C 14.878 4 18 7.122 18 11 C 18 14.878 14.878 18 11 18 C 7.122 18 4 14.878 4 11 C 4 7.122 7.122 4 11 4 z M 10 6 L 10 8 L 12 8 L 12 6 L 10 6 z M 10 9 L 10 16 L 12 16 L 12 9 L 10 9 z " class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 696 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 10 4 L 10 11 L 3 11 L 3 12 L 10 12 L 10 19 L 11 19 L 11 12 L 18 12 L 18 11 L 11 11 L 11 4 L 10 4 z " class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 499 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 12.902344 3 L 3.734375 12.167969 C 2.167415 13.734929 2.167415 16.257279 3.734375 17.824219 C 5.301315 19.391169 7.823675 19.391169 9.390625 17.824219 L 17.851562 9.3632812 L 18.558594 8.65625 C 19.733804 7.48104 19.733794 5.5892625 18.558594 4.4140625 C 17.383384 3.2388525 15.491616 3.2388525 14.316406 4.4140625 L 13.609375 5.1210938 L 5.1484375 13.582031 C 4.3649575 14.365511 4.3649675 15.626686 5.1484375 16.410156 C 5.9319175 17.193636 7.1930825 17.193646 7.9765625 16.410156 L 16.4375 7.9492188 L 15.730469 7.2421875 L 7.9765625 14.996094 L 7.2695312 15.703125 C 6.8777912 16.094865 6.2471888 16.094865 5.8554688 15.703125 C 5.4637287 15.311405 5.4637187 14.680813 5.8554688 14.289062 L 14.316406 5.828125 L 15.023438 5.1210938 C 15.806917 4.3376037 17.068092 4.3376037 17.851562 5.1210938 C 18.635042 5.9045538 18.635042 7.1657387 17.851562 7.9492188 L 8.6835938 17.117188 C 7.5083838 18.292397 5.6166162 18.292397 4.4414062 17.117188 C 3.2661862 15.941977 3.2661862 14.05021 4.4414062 12.875 L 13.609375 3.7070312 L 12.902344 3 z " class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 11.5 3 C 10.286139 3 9.2809778 3.8559279 9.0507812 5 L 3 5 L 3 6 L 9.0507812 6 C 9.2809778 7.1440721 10.286139 8 11.5 8 C 12.713861 8 13.719022 7.1440721 13.949219 6 L 19 6 L 19 5 L 13.949219 5 C 13.719022 3.8559279 12.713861 3 11.5 3 z M 5.5 14 C 4.1149999 14 3 15.115 3 16.5 C 3 17.885 4.1149999 19 5.5 19 C 6.7138604 19 7.7190223 18.144072 7.9492188 17 L 19 17 L 19 16 L 7.9492188 16 C 7.7190223 14.855928 6.7138604 14 5.5 14 z M 5.5 15 C 6.3310001 15 7 15.669 7 16.5 C 7 17.331 6.3310001 18 5.5 18 C 4.6689999 18 4 17.331 4 16.5 C 4 15.669 4.6689999 15 5.5 15 z " class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 966 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style id="current-color-scheme" type="text/css">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path class="ColorScheme-Text" d="M 19,9 12,16 8,12 9.4896719,10.543135 12,13 17.482382,7.5492102 Z M 4,3 V 5 H 3 V 6 H 4 V 7 H 3 v 1 h 1 v 6 H 3 v 1 h 1 v 1 H 3 v 1 h 1 v 2 h 3 12 v -1 -7 h -1 v 1 1 2 1 2 H 10 9 7 V 16 15 13 12 10 9 7 6 4 h 2 1 8 v 2 h 1 V 3 H 7 Z m 4,3 v 2 h 2 V 6 Z m 3,0 v 1 h 6 V 6 Z" style="fill:currentColor;fill-opacity:1;stroke:none"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 669 B

+14
View File
@@ -0,0 +1,14 @@
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="24" version="1.1" height="24" viewBox="0 0 24 24">
<defs id="defs3871"/>
<metadata id="metadata3874"/>
<g transform="translate(1,1)">
<g inkscape:label="Capa 1" inkscape:groupmode="layer" id="layer1" transform="matrix(1 0 0 1 -326 -534.3622)">
<rect width="8" x="329" y="537.36218" height="1.00002" style="fill:#6c7a89" id="rect4279"/>
<rect width="16" x="329" y="541.36218" height="1" style="fill:#6c7a89" id="rect4281"/>
<rect width="16" x="329" y="548.36218" height="1" style="fill:#6c7a89" id="rect4283"/>
<rect width="8" x="329" y="545.36218" height="1" style="fill:#6c7a89" id="rect4285"/>
<rect width="12" x="329" y="552.36218" height="0.999983" style="fill:#6c7a89" id="rect4287"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 919 B

+108
View File
@@ -0,0 +1,108 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// on_ask_user: the agent stopped and wants an answer from the human. Options
// (when the engine sends any) become buttons; the field is always there.
Dialog {
id: dialog
property string question: ""
property string optionText: ""
signal answered(string text)
readonly property var options: {
const raw = optionText.split(/[|\n]/).map(s => s.trim()).filter(s => s.length > 0 && s.length < 60)
return raw.length > 1 && raw.length <= 8 ? raw : []
}
modal: true
anchors.centerIn: parent
width: Math.min(620, parent ? parent.width - 60 : 620)
padding: Theme.pad
background: Rectangle {
color: Theme.surface
border.width: 1
border.color: Theme.border
radius: Theme.radius
}
function send(text) {
const t = text.trim()
if (!t.length)
return
answered(t)
close()
}
contentItem: ColumnLayout {
spacing: Theme.gap
RowLayout {
spacing: Theme.gap
Rectangle { width: 8; height: 8; radius: 4; color: Theme.accent }
Text {
text: "The agent asks"
color: Theme.text
font.pixelSize: Theme.bodySize + 1
font.bold: true
}
}
Text {
Layout.fillWidth: true
text: dialog.question
color: Theme.text
font.pixelSize: Theme.bodySize
wrapMode: Text.Wrap
}
Text {
Layout.fillWidth: true
visible: dialog.optionText.length > 0 && dialog.options.length === 0
text: dialog.optionText
color: Theme.textDim
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
Flow {
Layout.fillWidth: true
visible: dialog.options.length > 0
spacing: Theme.gap
Repeater {
model: dialog.options
Button {
text: modelData
onClicked: dialog.send(modelData)
}
}
}
TextField {
id: answer
Layout.fillWidth: true
placeholderText: "your answer"
placeholderTextColor: Theme.textFaint
color: Theme.text
font.pixelSize: Theme.bodySize
selectByMouse: true
onAccepted: dialog.send(text)
background: Rectangle {
radius: Theme.radiusSm
color: Theme.bg
border.width: 1
border.color: answer.activeFocus ? Theme.accentDim : Theme.border
}
}
}
footer: DialogButtonBox {
padding: Theme.pad
Button {
text: "Send"
highlighted: true
enabled: answer.text.trim().length > 0
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
onClicked: dialog.send(answer.text)
}
}
onOpened: answer.forceActiveFocus()
}
+152
View File
@@ -0,0 +1,152 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The transcript: cards for the messages, a card per tool call, folded
// thinking blocks and one-line notes. Follows the tail of the conversation
// until the user scrolls away from it.
Item {
id: view
property var model: null
property bool running: false
ListView {
id: list
objectName: "transcript"
anchors.fill: parent
anchors.leftMargin: Theme.gapL
anchors.rightMargin: Theme.gapL
model: view.model
clip: true
spacing: Theme.gap
topMargin: Theme.gapL
bottomMargin: Theme.gapL
cacheBuffer: 2000
boundsBehavior: Flickable.StopAtBounds
// The delegate picks the card by row kind; the components below read
// the ListModel roles directly, which is why they are declared in the
// delegate's scope.
delegate: Loader {
id: rowLoader
width: list.width
sourceComponent: model.kind === "tool" ? toolComp
: model.kind === "think" ? thinkComp
: model.kind === "note" ? noteComp : msgComp
Component {
id: msgComp
MessageCard {
role: model.role
text: model.text
complete: model.complete
images: model.images !== undefined ? model.images : ""
/* html comes from the streaming renderer (Main.qml) or,
* for a replayed session, from here once. */
html: model.html.length > 0 ? model.html
: (model.role === "agent" || model.role === "error") &&
model.complete && model.text.length > 0
? agent.renderMarkdown(model.text) : ""
}
}
Component {
id: thinkComp
ThinkBlock {
text: model.text
complete: model.complete
}
}
Component {
id: toolComp
ToolCard {
objectName: "toolCard"
name: model.name
state: model.state
detail: model.detail
output: model.output
exitCode: model.exitCode
hasResult: model.complete
images: model.images !== undefined ? model.images : ""
}
}
Component {
id: noteComp
NoteLine {
text: model.text
level: model.state === "warn" || model.state === "error" ? model.state : "info"
}
}
}
onMovementEnded: view.stickBottom = atYEnd
onCountChanged: if (view.stickBottom) Qt.callLater(list.positionViewAtEnd)
onContentHeightChanged: if (view.stickBottom) Qt.callLater(list.positionViewAtEnd)
}
// Follow the tail unless the user asked otherwise.
property bool stickBottom: true
// Empty state: what this window is and what to do with it.
ColumnLayout {
anchors.centerIn: parent
width: Math.min(parent.width - 80, 520)
spacing: Theme.gap
visible: view.model ? view.model.count === 0 : true
Text {
Layout.alignment: Qt.AlignHCenter
text: "CST-agent"
color: Theme.text
font.pixelSize: 22
font.bold: true
}
Text {
Layout.alignment: Qt.AlignHCenter
text: "The engine runs in this window — no server, no second process."
color: Theme.textDim
font.pixelSize: Theme.bodySize
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
Layout.fillWidth: true
}
Text {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: Theme.gap
text: "Ask for a change, a command to run, a file to explain.\n" +
"Every write, delete and command asks first: Deny / Once / Always.\n" +
"Enter sends, Shift+Enter adds a line, Esc stops a running turn."
color: Theme.textFaint
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
Layout.fillWidth: true
}
}
// Jump back to the live end of the conversation.
RoundButton {
id: jump
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Theme.padL
width: Theme.jumpButton
height: Theme.jumpButton
visible: !view.stickBottom && list.contentHeight > list.height
contentItem: ThemeIcon {
name: "go-down"
glyph: "↓"
size: 20
color: Theme.text
}
background: Rectangle {
radius: width / 2
color: jump.hovered ? Theme.surfaceHi : Theme.surfaceAlt
border.width: 1
border.color: Theme.border
}
onClicked: {
view.stickBottom = true
list.positionViewAtEnd()
}
}
}
+252
View File
@@ -0,0 +1,252 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Dialogs
import QtQuick.Layouts
// The input line: multiline, Enter sends, the button changes with the turn
// state. Sized for a thumb as much as for a mouse, and it asks the platform
// keyboard not to autocorrect what is usually code or a path.
//
// Files arrive three ways -- the clip picker, a drop on the window (Main.qml)
// and an image on the clipboard (Ctrl+V) -- and all of them end up in the
// engine's pending list, which the next prompt carries.
Rectangle {
id: root
objectName: "composer"
property bool running: false
property bool compact: false
property string note: "" // a failure, or what was just attached
property var attachments: [] // AgentCore::pending, "name (mime, 12 KB)"
property alias text: input.text
signal submitted(string text)
function submit() {
const t = input.text.trim()
if (!t.length || running)
return
input.text = ""
submitted(t)
}
/* A file: from the picker, or a URL dropped on the window. */
function localPath(url) {
let s = String(url)
// A phone's picker (SAF) hands over content:// URIs: they are not
// paths and must not be decoded -- the app copies what they point at
// into its own directory before the engine sees it (see
// app::materializeAttachment).
if (s.startsWith("content://"))
return s
if (s.startsWith("file://"))
s = s.substring(7)
/* Windows keeps its drive letter after the slash. */
if (/^\/[A-Za-z]:/.test(s))
s = s.substring(1)
return decodeURIComponent(s)
}
function attachPath(path) {
const r = agent.attachFile(path)
note = r.length ? r : ""
}
radius: Theme.radius
color: Theme.surface
border.width: 1
border.color: input.activeFocus ? Theme.accentDim : Theme.border
/* One pad of frame on every side of the column: with the top margin
* alone the last row (the clip and Send) hung over the lower border,
* which is what put the clip on the frame instead of inside it. */
implicitHeight: layout.implicitHeight + Theme.pad * 2
ColumnLayout {
id: layout
anchors {
fill: parent
margins: Theme.pad
}
spacing: 6
// What the next prompt will carry, in the engine's own words.
Flow {
Layout.fillWidth: true
visible: root.attachments.length > 0
spacing: 6
Repeater {
model: root.attachments
delegate: Rectangle {
id: chip
required property var modelData
required property int index
objectName: "attachChip" + chip.index
implicitWidth: chipText.implicitWidth + 14
implicitHeight: 24
radius: Theme.radiusSm
color: Theme.surfaceAlt
border.width: 1
border.color: Theme.border
Text {
id: chipText
anchors.centerIn: parent
text: String(chip.modelData)
color: Theme.textDim
font.pixelSize: Theme.smallSize
}
}
}
Button {
id: clearAtt
objectName: "clearAttachments"
text: "clear all"
implicitHeight: 24
onClicked: {
agent.clearPending()
root.note = ""
}
background: Rectangle {
radius: Theme.radiusSm
color: clearAtt.hovered ? Theme.surfaceHi : "transparent"
border.width: 1
border.color: Theme.border
}
contentItem: Text {
text: clearAtt.text
color: clearAtt.hovered ? Theme.text : Theme.textFaint
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
Text {
Layout.fillWidth: true
visible: root.note.length > 0
text: root.note
color: Theme.warn
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
TextArea {
id: input
objectName: "composerInput"
Layout.fillWidth: true
Layout.preferredHeight: Math.min(root.compact ? 120 : 170,
Math.max(56, contentHeight + 12))
placeholderText: root.running
? "the turn is running — Esc stops it"
: (root.compact
? "Ask for something…"
: "Ask for something (Enter sends, Shift+Enter for a new line)")
placeholderTextColor: Theme.textFaint
color: Theme.text
font.pixelSize: Theme.bodySize
wrapMode: TextArea.Wrap
selectByMouse: true
selectionColor: Theme.accentDim
inputMethodHints: Qt.ImhMultiLine | Qt.ImhNoPredictiveText |
Qt.ImhNoAutoUppercase
background: Rectangle { color: "transparent" }
enabled: !root.running
Keys.onPressed: function (ev) {
if (ev.key === Qt.Key_Return || ev.key === Qt.Key_Enter) {
if (!(ev.modifiers & Qt.ShiftModifier)) {
root.submit()
ev.accepted = true
}
return
}
/* An image has nothing the editor could paste, so Ctrl+V
* becomes an attachment there; text stays with the
* editor's own paste. */
if ((ev.modifiers & Qt.ControlModifier) && ev.key === Qt.Key_V &&
agent.clipboardHasImage()) {
root.note = agent.attachClipboard()
ev.accepted = true
}
}
}
ToolButton {
id: attach
objectName: "attachButton"
Layout.alignment: Qt.AlignBottom
implicitWidth: root.compact ? Theme.touchTarget : 40
implicitHeight: Theme.touchTarget + 4
enabled: !root.running
ToolTip.visible: hovered
ToolTip.text: "Attach a file — or drop one on the window, or paste an image"
contentItem: ThemeIcon {
name: "mail-attachment"
glyph: "📎"
size: 20
color: attach.enabled ? (attach.hovered ? Theme.text : Theme.textDim)
: Theme.textFaint
}
background: Rectangle {
radius: Theme.radiusSm
color: attach.hovered && attach.enabled ? Theme.surfaceHi
: "transparent"
}
onClicked: picker.open()
}
Button {
id: action
objectName: "sendButton"
Layout.alignment: Qt.AlignBottom
implicitWidth: root.compact ? 68 : 88
implicitHeight: Theme.touchTarget + 4
text: root.running ? "Stop" : "Send"
enabled: root.running || input.text.trim().length > 0
onClicked: root.running ? agent.cancel() : root.submit()
background: Rectangle {
radius: Theme.radiusSm
color: !action.enabled ? Theme.surfaceAlt
: root.running ? (action.hovered ? "#e05666" : Theme.err)
: (action.hovered ? "#5cc0f0" : Theme.accent)
}
contentItem: Text {
text: action.text
color: action.enabled ? Theme.accentInk : Theme.textFaint
font.pixelSize: Theme.bodySize
font.bold: true
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
// On a narrow window the hint would wrap into the editor's space.
Text {
Layout.fillWidth: true
visible: !root.compact
text: {
if (root.running)
return "working — Esc cancels the turn"
let s = "Enter sends · Shift+Enter newline · Esc stops · the clip attaches a file, Ctrl+V an image"
if (root.attachments.length > 0 && agent.seesImages() === 0)
s += " · this model is text-only, images arrive as notes"
return s
}
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
FileDialog {
id: picker
title: "Attach a file"
fileMode: FileDialog.OpenFile
onAccepted: root.attachPath(root.localPath(selectedFile))
}
}
+122
View File
@@ -0,0 +1,122 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The engine's on_permission hook is blocked on this dialog: nothing runs
// until one of the three answers goes back.
Dialog {
id: dialog
property string tool: ""
property string detail: ""
property string preview: ""
signal answered(int choice)
modal: true
anchors.centerIn: parent
width: Math.min(760, parent ? parent.width - 60 : 760)
padding: Theme.pad
title: ""
background: Rectangle {
color: Theme.surface
border.width: 1
border.color: Theme.border
radius: Theme.radius
}
contentItem: ColumnLayout {
spacing: Theme.gap
RowLayout {
spacing: Theme.gap
Rectangle {
width: 8
height: 8
radius: 4
color: Theme.warn
}
Text {
text: "Permission requested"
color: Theme.text
font.pixelSize: Theme.bodySize + 1
font.bold: true
}
Text {
Layout.fillWidth: true
elide: Text.ElideRight
text: dialog.tool
color: Theme.warn
font.family: Theme.mono
font.pixelSize: Theme.monoSize
}
}
Text {
Layout.fillWidth: true
visible: dialog.detail.length > 0
text: dialog.detail
color: Theme.textDim
font.pixelSize: Theme.bodySize
wrapMode: Text.Wrap
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Math.min(320, previewText.implicitHeight + Theme.pad * 2)
visible: dialog.preview.length > 0
color: Theme.bg
radius: Theme.radiusSm
border.width: 1
border.color: Theme.border
Flickable {
id: flick
anchors.fill: parent
anchors.margins: 8
contentWidth: previewText.width
contentHeight: previewText.height
clip: true
ScrollBar.vertical: ScrollBar {}
Text {
id: previewText
width: Math.max(flick.width, implicitWidth)
text: dialog.preview
color: Theme.text
font.family: Theme.mono
font.pixelSize: Theme.monoSize
textFormat: Text.PlainText
}
}
}
Text {
Layout.fillWidth: true
text: "“Always” is remembered for this tool in .agent/permissions.json — the TUI and the CLI see the same decision."
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
}
footer: DialogButtonBox {
spacing: Theme.gap
padding: Theme.pad
Button {
text: "Deny"
DialogButtonBox.buttonRole: DialogButtonBox.RejectRole
onClicked: { dialog.answered(0); dialog.close() }
}
Button {
text: "Once"
highlighted: true
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
onClicked: { dialog.answered(1); dialog.close() }
}
Button {
text: "Always"
DialogButtonBox.buttonRole: DialogButtonBox.ActionRole
onClicked: { dialog.answered(2); dialog.close() }
}
}
}
+269
View File
@@ -0,0 +1,269 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The files a message or a tool call carries.
//
// A picture is a thumbnail from the session's own store (image://cstattach/,
// app/src/ui/SessionImages.cpp) and opens with whatever the system uses for
// images when it is tapped. A file the agent delivered (deliver_file) is a card
// with the name, the type, the size and two buttons: open and share. The names
// are the ones the engine wrote next to the session -- a card never invents a
// path.
//
// One line per file, written by AgentCore.cpp (filesText):
// <kind>|<stored name>|<name>|<origin>|<mime>|<bytes>|<w>x<h>
// A session saved by an older version wrote "<stored name>|<name>|<origin>|
// <w>x<h>" for pictures; both shapes are read here.
Flow {
id: strip
objectName: "imageStrip"
/* A row carries this as text: a ListModel role cannot hold a list. */
property string text: ""
property var files: decode(text)
/* The last refusal, so a tap that cannot work says why (the header's own
* status line is the engine's). */
property string errorText: ""
spacing: 6
function decode(t) {
const out = []
const lines = String(t).split("\n")
for (let i = 0; i < lines.length; i++) {
if (!lines[i].length)
continue
const f = lines[i].split("|")
if (!f[0].length)
continue
const old = f.length < 7 // a picture line from an old session
const dim = String(old ? f[3] : f[6]).split("x")
let w = 0, h = 0
if (dim.length === 2) {
w = parseInt(dim[0], 10) || 0
h = parseInt(dim[1], 10) || 0
}
const rel = old ? f[0] : f[1]
out.push({ kind: old ? "img" : f[0],
rel: rel,
name: (old ? f[1] : f[2]) || rel,
origin: (old ? f[2] : f[3]) || "",
mime: old ? "image/*" : (f[4] || ""),
bytes: old ? 0 : (parseInt(f[5], 10) || 0),
w: w, h: h,
url: "image://cstattach/" + rel })
}
return out
}
function humanSize(n) {
if (!n || n <= 0)
return ""
if (n >= 1024 * 1024)
return (n / (1024 * 1024)).toFixed(1) + " MB"
if (n >= 1024)
return Math.round(n / 1024) + " KB"
return n + " B"
}
/* The two things a file card does. The engine answers with "" when it
* worked (it puts the line in the header itself) or a reason. */
function openFile(rel) {
errorText = agent.openArtifact(rel)
}
function shareFile(rel) {
errorText = agent.shareArtifact(rel)
}
Repeater {
model: strip.files
delegate: Rectangle {
id: cell
required property var modelData
required property int index
readonly property bool isFile: String(modelData.kind) !== "img"
readonly property bool hovered: mouse.containsMouse
/* Images keep their own shape, capped; a file card is a fixed
* width so a long name cannot stretch the transcript. */
readonly property real ratio: {
const w = Number(modelData.w), h = Number(modelData.h)
return (w > 0 && h > 0) ? w / h : 1.5
}
readonly property int cap: 150
objectName: isFile ? ("fileCard" + index) : ("imageThumb" + index)
width: isFile ? 300
: Math.max(56, Math.min(Math.round(cap * ratio), 320))
height: isFile ? 52
: Math.max(40, Math.min(cap, Math.round(width / ratio)))
radius: Theme.radiusSm
color: Theme.bg
border.width: 1
border.color: hovered ? Theme.accentDim : Theme.border
clip: true
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
}
// --- a picture: tap it to look at it ---------------------------
Image {
id: pic
anchors.fill: parent
anchors.margins: 2
visible: !cell.isFile && status === Image.Ready
source: String(cell.modelData.url)
sourceSize.width: 512
sourceSize.height: 512
fillMode: Image.PreserveAspectFit
asynchronous: false
}
Text {
anchors.centerIn: parent
width: parent.width - 8
visible: !cell.isFile && pic.status === Image.Error
text: String(cell.modelData.name)
elide: Text.ElideMiddle
horizontalAlignment: Text.AlignHCenter
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
MouseArea {
anchors.fill: parent
enabled: !cell.isFile
cursorShape: Qt.PointingHandCursor
onClicked: strip.openFile(String(cell.modelData.rel))
}
// --- a delivered file: name, type, open, share -----------------
RowLayout {
id: row
visible: cell.isFile
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 6
spacing: 6
/* The extension stands in for an icon: no theme lookup, and a
* ZIP that says ZIP is more use than a generic sheet. */
Rectangle {
implicitWidth: 40
implicitHeight: 30
radius: Theme.radiusSm
color: Theme.surfaceHi
Text {
anchors.centerIn: parent
text: {
const n = String(cell.modelData.name)
const dot = n.lastIndexOf(".")
if (dot < 0 || dot === n.length - 1)
return "FILE"
return n.slice(dot + 1, dot + 5).toUpperCase()
}
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
Text {
Layout.fillWidth: true
text: String(cell.modelData.name)
color: Theme.text
font.pixelSize: Theme.bodySize
elide: Text.ElideMiddle
}
Text {
Layout.fillWidth: true
text: {
const m = cell.modelData
let s = m.mime ? String(m.mime) : ""
const size = strip.humanSize(m.bytes)
if (size)
s += (s ? " · " : "") + size
if (m.origin && m.origin !== "user" && m.origin !== "tool")
s += (s ? " · " : "") + m.origin
return s
}
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
}
}
ToolButton {
objectName: "fileOpen" + cell.index
implicitWidth: Theme.touchTarget
implicitHeight: Theme.touchTarget
onClicked: strip.openFile(String(cell.modelData.rel))
contentItem: Text {
text: "Open"
color: parent.hovered ? Theme.text : Theme.textDim
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
border.width: 1
border.color: Theme.border
}
}
ToolButton {
objectName: "fileShare" + cell.index
implicitWidth: Theme.touchTarget
implicitHeight: Theme.touchTarget
onClicked: strip.shareFile(String(cell.modelData.rel))
contentItem: Text {
text: "Share"
color: parent.hovered ? Theme.text : Theme.textDim
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
border.width: 1
border.color: Theme.border
}
}
}
ToolTip.visible: hovered
ToolTip.delay: 400
ToolTip.text: {
const m = cell.modelData
let s = String(m.name)
if (m.w > 0 && m.h > 0)
s += " · " + m.w + "×" + m.h
if (m.mime && m.kind !== "img")
s += " · " + m.mime
return s
}
}
}
/* A tap that could not work says why, right where the card is: a phone
* with nothing installed for the type is the common case ("no app on the
* phone opens report.md"), and a silence there looks like a dead button. */
Text {
objectName: "fileError"
visible: strip.errorText.length > 0
width: strip.width
wrapMode: Text.WordWrap
text: strip.errorText
color: Theme.err
font.pixelSize: Theme.smallSize
}
}
+660
View File
@@ -0,0 +1,660 @@
// The window: a sidebar with the engine's settings, the transcript, the
// composer, and the two dialogs the engine can block on.
//
// One shell, three sizes: the sidebar is docked on a desktop (and can be
// dragged), slides over the transcript when the window is narrow, and every
// control keeps a finger-sized hit area for touch screens. Qt Quick Controls
// only, laid out the way Kirigami expects (sidebar + page, cards, header), so
// swapping in org.kde.kirigami later stays a QML change.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
ApplicationWindow {
id: win
width: initialWidth > 0 ? initialWidth : 1180
height: initialHeight > 0 ? initialHeight : 800
minimumWidth: 360
minimumHeight: 420
visible: true
color: Theme.bg
title: "CST-agent — " + (agent.workspace.length ? agent.workspace : "no workspace")
property string statusText: ""
property var sessionsView: [] // the store, for the sidebar
property bool navOpen: false // the sliding sidebar, compact only
property int sidebarWidth: 0 // 0 = follow the window width
readonly property bool compact: width < Theme.compactBreakpoint
readonly property int sidebarWide: sidebarWidth > 0
? sidebarWidth
: Math.max(228, Math.min(360, Math.round(width * 0.26)))
// The settings panel, used docked and in the sliding panel: one definition,
// two places.
component ShellSidebar: Sidebar {
workspace: agent.workspace
provider: agent.provider
model: agent.model
mode: agent.mode
approval: agent.approval
planMode: agent.planMode
running: agent.running
status: win.statusText
todo: agent.todo
sessions: win.sessionsView
sessionId: agent.sessionId
onModeRequested: function (m) { agent.setMode(m) }
onSessionRequested: function (id) { win.switchSession(id) }
onSessionNewRequested: win.newSession()
onSessionDeleteRequested: function (id) { win.deleteSession(id) }
onApprovalRequested: function (a) { agent.setApproval(a) }
onPlanModeRequested: function (on) { agent.setPlanMode(on) }
}
// ---- the transcript --------------------------------------------------
ListModel { id: transcript }
function lastIndex() { return transcript.count - 1 }
function lastRow() { return transcript.count ? transcript.get(lastIndex()) : null }
function addRow(kind, role, state) {
transcript.append({ kind: kind, role: role, text: "", html: "", complete: false,
name: "", callId: "", state: state, detail: "", output: "",
exitCode: 0, images: "" })
}
// The engine streams one kind at a time: close whatever was open before.
function closeOpenRows(exceptKind) {
const last = lastRow()
if (last && !last.complete && last.kind !== exceptKind)
transcript.setProperty(lastIndex(), "complete", true)
}
function appendDelta(kind, role, text) {
closeOpenRows(kind)
if (!lastRow() || lastRow().kind !== kind || lastRow().complete)
addRow(kind, role, "running")
transcript.setProperty(lastIndex(), "text", lastRow().text + text)
}
// The open card of this call, -1 when none. The id is what tells two calls
// of the same tool apart (pairing by name left the first card running
// forever); a call without one -- an old text-protocol session -- falls
// back to the name.
function findTool(callId, name) {
for (let i = transcript.count - 1; i >= 0; i--) {
const r = transcript.get(i)
if (r.kind !== "tool" || r.complete)
continue
if (callId.length) {
if (r.callId === callId)
return i
continue /* another call: its card is further back */
}
if (r.callId.length)
continue
if (r.name === name)
return i
return -1
}
return -1
}
function openTool(callId, name, state) {
closeOpenRows("tool")
addRow("tool", "tool", state)
transcript.setProperty(lastIndex(), "name", name)
transcript.setProperty(lastIndex(), "callId", callId)
return lastIndex()
}
function submit(text) {
transcript.append({ kind: "message", role: "user", text: text, html: "", complete: true,
name: "", callId: "", state: "", detail: "", output: "",
exitCode: 0, images: "" })
agent.send(text)
}
// A turn is over: nothing else is coming, so nothing may keep spinning.
function closeAllRows() {
for (let i = 0; i < transcript.count; i++) {
const r = transcript.get(i)
if (r.complete)
continue
if (r.kind === "tool" && (r.state === "pending" || r.state === "ready" ||
r.state === "running"))
transcript.setProperty(i, "state", "unfinished")
transcript.setProperty(i, "complete", true)
}
}
// Markdown while it streams: the open answer is re-rendered with the
// engine's own renderer (the timer below), so the text fills in formatted
// instead of turning into markdown when the turn ends.
function renderStreaming() {
for (let i = transcript.count - 1; i >= 0; i--) {
const r = transcript.get(i)
if (r.kind !== "message" || r.role !== "agent" || r.complete)
continue
const html = agent.renderMarkdown(r.text)
if (html !== r.html)
transcript.setProperty(i, "html", html)
return
}
}
// ---- sessions --------------------------------------------------------
function reloadSessions() { win.sessionsView = agent.sessions() }
// A stored session comes back as rows: the engine holds the transcript
// (AgentCore::history()), these rows only mirror it.
function replay() {
transcript.clear()
const rows = agent.history()
for (let i = 0; i < rows.length; i++)
transcript.append(rows[i])
chat.stickBottom = true
}
function switchSession(id) {
if (agent.switchSession(id))
win.reloadSessions()
}
function newSession() {
if (agent.newSession()) {
transcript.clear()
win.reloadSessions()
}
}
function deleteSession(id) {
if (agent.deleteSession(id))
win.reloadSessions()
}
// ---- provider, model, key --------------------------------------------
function openSettings() {
settings.load(agent.providers(), agent.provider, agent.model)
settings.open()
}
function applySettings(provider, model, key, temperature, topP, maxTokens) {
/* The parameters are stored first: useProvider() then picks up what
* was remembered for whichever provider it ends on. */
const perr = agent.setGenParams(provider, temperature, topP, maxTokens)
const err = perr.length ? perr : agent.useProvider(provider, model, key)
settings.note = err.length ? err
: "using " + agent.provider + " / " + agent.model
settings.choices = agent.modelChoices(provider)
settings.syncPick()
}
/* The engine opens the workspace's last session (cst_agent_open()), so the
* window is created with a transcript already in it: draw it, or the pane
* starts empty while the sidebar points at the very session it belongs to.
* A switch draws it the same way (the sessionChanged handler below). */
Component.onCompleted: {
win.reloadSessions()
win.replay()
}
// ---- the shell -------------------------------------------------------
header: ToolBar {
id: appBar
implicitHeight: Theme.headerHeight
background: Rectangle {
color: Theme.surface
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: 1
color: Theme.border
}
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.pad
anchors.rightMargin: Theme.gap
spacing: Theme.gap
// The docked sidebar has its own title: only the sliding one needs
// a way in.
ToolButton {
objectName: "navButton"
visible: win.compact
implicitWidth: Theme.touchTarget
implicitHeight: Theme.touchTarget
ToolTip.visible: hovered && !win.compact
ToolTip.text: "Settings and plan"
contentItem: ThemeIcon {
name: "application-menu"
glyph: "☰"
size: 22
color: parent.hovered ? Theme.text : Theme.textDim
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: win.navOpen = !win.navOpen
}
Text {
visible: !win.compact
text: "CST-agent"
color: Theme.text
font.pixelSize: Theme.bodySize
font.bold: true
}
Text {
Layout.fillWidth: true
elide: Text.ElideMiddle
text: agent.workspace.length ? agent.workspace : "(no workspace)"
color: Theme.textDim
font.pixelSize: Theme.smallSize
}
Text {
objectName: "statusText"
visible: win.statusText.length > 0 && !win.compact
text: win.statusText
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
Layout.maximumWidth: 260
}
BusyIndicator {
running: agent.running
visible: agent.running
implicitWidth: 22
implicitHeight: 22
}
ToolButton {
objectName: "settingsButton"
implicitWidth: Theme.touchTarget
implicitHeight: Theme.touchTarget
ToolTip.visible: hovered
ToolTip.text: "Provider, model and key"
contentItem: ThemeIcon {
name: "settings-configure"
glyph: "⚙"
size: 22
color: parent.hovered ? Theme.text : Theme.textDim
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: win.openSettings()
}
ToolButton {
objectName: "clearButton"
enabled: transcript.count > 0 && !agent.running
implicitWidth: win.compact ? Theme.touchTarget : 56
implicitHeight: Theme.touchTarget
ToolTip.visible: hovered
ToolTip.text: "Clear the transcript (Ctrl+L) — the session file keeps its history"
contentItem: Text {
text: "Clear"
color: parent.enabled ? (parent.hovered ? Theme.text : Theme.textDim) : Theme.textFaint
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered && parent.enabled ? Theme.surfaceHi : "transparent"
}
onClicked: {
transcript.clear()
win.statusText = ""
}
}
}
}
RowLayout {
anchors.fill: parent
spacing: 0
ShellSidebar {
id: dockedSidebar
objectName: "dockedSidebar"
visible: !win.compact
Layout.fillHeight: true
Layout.preferredWidth: win.sidebarWide
}
// Drag the divider: a mouse affordance that costs nothing on touch
// (the handle is only there while the sidebar is docked).
Item {
id: divider
visible: !win.compact
Layout.fillHeight: true
implicitWidth: 5
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
width: 1
height: parent.height
color: dividerMa.pressed ? Theme.accentDim
: dividerMa.containsMouse ? Theme.surfaceHi : Theme.border
}
MouseArea {
id: dividerMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.SplitHCursor
property real startX: 0
property int startW: 0
onPressed: function (mouse) {
startX = mouse.x
startW = win.sidebarWide
}
onPositionChanged: function (mouse) {
if (pressed)
win.sidebarWidth = Math.max(200, Math.min(560, startW + Math.round(mouse.x - startX)))
}
onDoubleClicked: win.sidebarWidth = 0
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 0
ChatView {
id: chat
Layout.fillWidth: true
Layout.fillHeight: true
model: transcript
running: agent.running
}
Composer {
id: composer
Layout.fillWidth: true
Layout.leftMargin: win.compact ? Theme.gap : Theme.gapL
Layout.rightMargin: win.compact ? Theme.gap : Theme.gapL
Layout.bottomMargin: win.compact ? Theme.gap : Theme.gapL
running: agent.running
compact: win.compact
attachments: agent.pending
onSubmitted: function (text) { win.submit(text) }
}
}
}
// ---- compact: the sidebar slides in over the transcript --------------
/* Files dropped anywhere on the window: the engine copies them next to
* the session and the next prompt carries them. The picker and Ctrl+V in
* the composer land in the same list. */
DropArea {
id: dropZone
objectName: "dropZone"
anchors.fill: parent
onDropped: function (drop) {
if (!drop.hasUrls)
return
let note = ""
let n = 0
for (let i = 0; i < drop.urls.length; i++) {
const r = agent.attachFile(composer.localPath(drop.urls[i]))
if (r.length)
note = r
else
n++
}
composer.note = note.length ? note
: n + (n === 1 ? " file attached" : " files attached")
drop.accept()
}
}
// While something is dragged over the window, say what will happen.
Rectangle {
anchors.fill: parent
visible: dropZone.containsDrag
color: "transparent"
border.width: 2
border.color: Theme.accent
Text {
anchors.centerIn: parent
text: "drop to attach"
color: Theme.accent
font.pixelSize: Theme.bodySize
font.bold: true
}
}
Rectangle {
id: scrim
objectName: "navScrim"
visible: win.navOpen && win.compact
anchors.fill: parent
color: "#000000"
opacity: 0.45
MouseArea {
anchors.fill: parent
onClicked: win.navOpen = false
onWheel: function (wheel) { wheel.accepted = true }
}
}
Rectangle {
id: navPanel
objectName: "navPanel"
visible: win.navOpen && win.compact
anchors {
left: parent.left
top: parent.top
bottom: parent.bottom
}
width: Math.min(320, win.width * 0.88)
color: Theme.surface
border.width: 1
border.color: Theme.border
x: win.navOpen && win.compact ? 0 : -width
Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } }
Flickable {
anchors.fill: parent
contentHeight: panelSidebar.implicitHeight
clip: true
ScrollBar.vertical: ScrollBar {}
ShellSidebar {
id: panelSidebar
width: parent.width
border.width: 0
}
}
}
ConsentDialog {
id: consent
onAnswered: function (choice) { agent.answerPermission(choice) }
}
SettingsDialog {
id: settings
onApplyRequested: function (provider, model, key, temperature, topP, maxTokens) {
win.applySettings(provider, model, key, temperature, topP, maxTokens)
}
}
AskDialog {
id: ask
onAnswered: function (text) { agent.answerQuestion(text) }
}
Shortcut { sequence: "Escape"; enabled: agent.running; onActivated: agent.cancel() }
Shortcut {
sequence: "Ctrl+L"
enabled: !agent.running
onActivated: {
transcript.clear()
win.statusText = ""
}
}
// ---- engine -> UI ----------------------------------------------------
/* The signal handlers only forward: every one of them has a function
* here, so the same path can be driven from --uitest (which is how the
* two-calls-of-one-tool case stays fixed). */
function engineAssistant(text) { win.appendDelta("message", "agent", text) }
function engineThink(text) { win.appendDelta("think", "agent", text) }
function engineToolEvent(phase, id, name, detail) {
win.closeOpenRows("tool")
let i = win.findTool(id, name)
if (phase === "open" || phase === "arg") {
if (i < 0)
i = win.openTool(id, name, "pending")
if (detail.length)
transcript.setProperty(i, "detail", transcript.get(i).detail + detail)
return
}
if (phase === "ready" || phase === "run") {
if (i < 0)
i = win.openTool(id, name, phase)
transcript.setProperty(i, "state", phase === "run" ? "running" : "queued")
transcript.setProperty(i, "detail", detail)
return
}
// done | denied
if (i < 0)
i = win.openTool(id, name, phase)
transcript.setProperty(i, "state", phase)
if (detail.length && transcript.get(i).output.length === 0)
transcript.setProperty(i, "output", detail)
}
function engineToolResult(id, name, exitCode, output) {
let i = win.findTool(id, name)
if (i < 0) /* a result whose call is not in the view: its own card */
i = win.openTool(id, name, "done")
transcript.setProperty(i, "output", output)
transcript.setProperty(i, "exitCode", exitCode)
transcript.setProperty(i, "state", "done")
transcript.setProperty(i, "complete", true)
}
function engineTurnFinished(rc, err) {
win.renderStreaming(); /* one last render while the row is still open */
win.closeAllRows()
if (err.length)
transcript.append({ kind: "note", role: "error", text: err, html: "",
complete: true, name: "", callId: "", state: "error",
detail: "", output: "", exitCode: 0 })
win.statusText = ""
}
/* The images the prompt just carried (AgentCore::promptImages): the
* composer has appended the user row, so they attach to it. */
function enginePromptImages(images) {
if (!images.length)
return
for (let i = transcript.count - 1; i >= 0; i--) {
const r = transcript.get(i)
if (r.kind === "message" && r.role === "user") {
transcript.setProperty(i, "images", images)
return
}
}
}
/* Files a tool produced, on the card of the call that made them. The
* engine reports the result first and the files after it, so the card is
* normally already complete by the time they arrive -- findTool() only
* looks at cards still running, which is why this looks for the call id
* itself (and falls back to findTool for a call that has no id). */
function engineToolImages(callId, images) {
if (!images.length)
return
let i = -1
if (callId.length) {
for (let k = transcript.count - 1; k >= 0; k--) {
const r = transcript.get(k)
if (r.kind === "tool" && r.callId === callId) {
i = k
break
}
}
} else {
i = win.findTool("", "")
}
if (i >= 0)
transcript.setProperty(i, "images", images)
}
/* For --uitest: one transcript row, or null. */
function rowAt(i) {
return (i >= 0 && i < transcript.count) ? transcript.get(i) : null
}
/* For --uitest: the same row as JSON (a QObject's return value does not
* survive QMetaObject::invokeMethod, a string does). */
function rowJson(i) {
return JSON.stringify(rowAt(i))
}
/* While a turn runs the open answer is re-rendered a few times a second:
* markdown text fills in formatted rather than at the end of the turn. */
Timer {
interval: 120
repeat: true
running: agent.running
onTriggered: win.renderStreaming()
}
Connections {
target: agent
function onSessionChanged() { win.reloadSessions(); win.replay() }
function onSessionListChanged() { win.reloadSessions() }
function onAssistantDelta(text) { win.engineAssistant(text) }
function onThinkDelta(text) { win.engineThink(text) }
function onStatusChanged(text) { win.statusText = text }
function onToolEvent(phase, id, name, detail) {
win.engineToolEvent(phase, id, name, detail)
}
function onToolResult(id, name, exitCode, output) {
win.engineToolResult(id, name, exitCode, output)
}
function onToolImages(callId, images) {
win.engineToolImages(callId, images)
}
function onPromptImages(images) {
win.enginePromptImages(images)
}
function onPermissionRequested(tool, detail, preview) {
consent.tool = tool
consent.detail = detail
consent.preview = preview
consent.open()
}
function onQuestionAsked(question, options) {
ask.question = question
ask.optionText = options
ask.open()
}
function onTurnFinished(rc, err) { win.engineTurnFinished(rc, err) }
function onRunningChanged() {
if (!agent.running)
win.closeAllRows()
}
}
}
+117
View File
@@ -0,0 +1,117 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// One message in the transcript: the prompt, the answer, or an error. The
// answer arrives as plain text while it streams and is rendered from markdown
// (by the engine, through AgentCore::renderMarkdown) once it is complete.
Rectangle {
id: card
property string role: "agent" // user | agent | error
property string text: ""
property string html: ""
property bool complete: true
property string images: ""
radius: Theme.radius
color: role === "user" ? Theme.surfaceAlt : Theme.surface
border.width: 1
border.color: role === "error" ? Theme.err
: role === "user" ? Theme.border : Theme.border
implicitHeight: layout.implicitHeight + Theme.pad * 2
ColumnLayout {
id: layout
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: Theme.pad
}
spacing: Theme.gap
RowLayout {
spacing: Theme.gap
Rectangle {
width: 6
height: 6
radius: 3
color: card.role === "user" ? Theme.accent
: card.role === "error" ? Theme.err : Theme.ok
}
Text {
text: card.role === "user" ? "You"
: card.role === "error" ? "Error" : "CST-agent"
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
Text {
visible: !card.complete
text: "streaming…"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
Item { Layout.fillWidth: true }
ToolButton {
implicitWidth: 26
implicitHeight: 22
visible: card.text.length > 0
ToolTip.visible: hovered
ToolTip.text: "Copy"
contentItem: ThemeIcon {
name: "edit-copy"
glyph: "⧉"
size: 14
color: parent.hovered ? Theme.text : Theme.textFaint
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: {
plain.text = card.text
plain.selectAll()
plain.copy()
}
}
}
// Hidden holder so "copy" always has the plain text, rtf or not.
TextEdit {
id: plain
visible: false
}
TextEdit {
id: streamText
visible: card.html.length === 0
Layout.fillWidth: true
text: card.text
readOnly: true
selectByMouse: true
wrapMode: TextEdit.Wrap
color: card.role === "error" ? Theme.err : Theme.text
font.pixelSize: Theme.bodySize
selectionColor: Theme.accentDim
}
Text {
visible: card.html.length > 0
Layout.fillWidth: true
text: card.html
textFormat: Text.RichText
color: Theme.text
font.pixelSize: Theme.bodySize
wrapMode: Text.Wrap
onLinkActivated: function (link) { Qt.openUrlExternally(link) }
}
// What the prompt carried: pictures from the store, not their names.
ImageStrip {
Layout.fillWidth: true
visible: card.images.length > 0
text: card.images
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import QtQuick
import QtQuick.Layouts
// A one-line note from the engine (status text, a refusal, a hint).
RowLayout {
id: note
property string text: ""
property string level: "info" // info | warn | error
spacing: Theme.gap
Rectangle {
Layout.alignment: Qt.AlignTop
Layout.topMargin: 6
width: 5
height: 5
radius: 3
color: note.level === "error" ? Theme.err
: note.level === "warn" ? Theme.warn : Theme.textFaint
}
Text {
Layout.fillWidth: true
text: note.text
color: note.level === "error" ? Theme.err
: note.level === "warn" ? Theme.warn : Theme.textDim
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
textFormat: Text.PlainText
}
}
+106
View File
@@ -0,0 +1,106 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// One stored session in the sidebar: its name (a rename, else its first
// prompt), how much is in it and when it was last written, and a way to drop
// it. The open one is marked. A file of its own because the sidebar lists them
// through a Repeater -- an inline component would not see modelData there.
Rectangle {
id: srow
property var entry: null
readonly property bool open: entry !== null && entry.current === true
objectName: "sessionRow:" + (entry !== null ? entry.id : "")
implicitHeight: rowCol.implicitHeight + 8
radius: Theme.radiusSm
color: srow.open ? Theme.accentDim
: rowHover.hovered ? Theme.surfaceAlt : "transparent"
border.width: 1
border.color: srow.open ? Theme.accent : "transparent"
signal activated(string id)
signal removeRequested(string id)
// "3h ago"; the entries carry ms since the epoch.
function ago(ms) {
if (!ms)
return "never"
const s = Math.max(0, (Date.now() - ms) / 1000)
if (s < 60)
return "just now"
if (s < 3600)
return Math.floor(s / 60) + "m ago"
if (s < 86400)
return Math.floor(s / 3600) + "h ago"
return Math.floor(s / 86400) + "d ago"
}
HoverHandler { id: rowHover }
MouseArea {
anchors.fill: parent
onClicked: srow.activated(srow.entry.id)
}
RowLayout {
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: 4
}
spacing: 2
ColumnLayout {
id: rowCol
Layout.fillWidth: true
Layout.leftMargin: 4
spacing: 0
Text {
Layout.fillWidth: true
elide: Text.ElideRight
text: srow.entry !== null && srow.entry.title.length ? srow.entry.title
: srow.entry !== null && srow.entry.firstUser.length ? srow.entry.firstUser
: "(no prompt yet)"
color: srow.open ? Theme.accentText : Theme.textDim
font.pixelSize: Theme.smallSize
}
Text {
Layout.fillWidth: true
elide: Text.ElideRight
text: {
if (srow.entry === null)
return ""
const n = srow.entry.messages
const when = srow.ago(srow.entry.mtime)
return n > 0 ? n + (n === 1 ? " message · " : " messages · ") + when
: when
}
color: srow.open ? Theme.accentTextDim : Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
ToolButton {
objectName: "sessionDelete:" + (srow.entry !== null ? srow.entry.id : "")
implicitWidth: 26
implicitHeight: 26
ToolTip.visible: hovered
ToolTip.text: "Delete this session and its transcript"
contentItem: ThemeIcon {
name: "edit-delete"
glyph: "×"
size: 14
color: parent.hovered ? Theme.err
: srow.open ? Theme.accentTextDim : Theme.textFaint
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: srow.removeRequested(srow.entry.id)
}
}
}
+739
View File
@@ -0,0 +1,739 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// Provider, model and key: what the engine needs before it can talk to a model
// at all. Everything here goes through AgentCore, which moves the engine's own
// config (its session stays where it is) and remembers the choice. A key is
// verified against the provider before it is saved and is never read back into
// this window -- the dialog only ever says where the key it will use comes
// from.
//
// Three things live under "Advanced options", because they are for the odd
// provider or the odd model: the provider's own entry (name, base URL, key
// variable, models -- the engine keeps those in <config>/models.json, which is
// how a provider the presets do not know is added or an existing one is
// pointed somewhere else) and the generation parameters (temperature, top-p,
// max tokens), which are remembered per provider.
Dialog {
id: dlg
objectName: "settingsDialog"
parent: Overlay.overlay
anchors.centerIn: parent
modal: true
closePolicy: Popup.CloseOnEscape
padding: Theme.pad
property var providers: [] // AgentCore::providers()
property var choices: [] // AgentCore::modelChoices(picked)
property string picked: ""
property string model: ""
property string note: "" // what the last action answered
property string keyEnv: ""
property string keyFrom: ""
property bool hasKey: false
property bool advanced: false
property string modelFilter: "" // narrows a long model list
/* The advanced form: the picked provider's entry, editable. */
property string advName: ""
property string advBase: ""
property string advKeyEnv: ""
property string advModel: ""
property string advModels: ""
property string tempText: ""
property string topPText: ""
property string maxTokText: ""
signal applyRequested(string provider, string model, string key,
real temperature, real topP, int maxTokens)
width: Math.min(660, parent ? parent.width - 32 : 660)
height: Math.min(640, parent ? parent.height - 40 : 640)
/* The Basic style paints a light panel; this window is dark, so the
* dialog brings its own surface like every other one here. */
background: Rectangle {
color: Theme.surface
radius: Theme.radius
border.width: 1
border.color: Theme.border
}
// A long provider list (a router lists hundreds) is a wall of chips: the
// filter narrows it and the rest is one line ("+N more").
readonly property int chipCap: 40
readonly property var chips: {
const f = modelFilter.trim().toLowerCase()
const out = []
for (let i = 0; i < choices.length; i++) {
const c = String(choices[i])
if (!f.length || c.toLowerCase().indexOf(f) >= 0)
out.push(c)
}
return out
}
readonly property int chipsHidden: Math.max(0, chips.length - chipCap)
/* "0.2" -> 0.2; anything empty or unreadable -> 0 (the field is not sent,
* which is not the same as sending 0). */
function num(text) {
const v = parseFloat(String(text).replace(",", "."))
return isFinite(v) && v > 0 ? v : 0
}
function pickedEntry() {
for (let i = 0; i < providers.length; i++)
if (providers[i].name === dlg.picked)
return providers[i]
return null
}
/* Fill it in: the presets, the provider the engine is on now, and the
* model that provider last used. */
function load(list, provider, currentModel) {
providers = list
picked = provider
model = currentModel
note = ""
for (let i = 0; i < list.length; i++)
if (list[i].current === true)
picked = list[i].name
choices = picked.length ? agent.modelChoices(picked) : []
modelFilter = ""
syncPick()
}
/* Where the picked provider's key would come from and what it is set to.
* From the entries -- a key itself never reaches the QML side. */
function syncPick() {
hasKey = false
keyFrom = ""
keyEnv = ""
const p = pickedEntry()
if (p) {
hasKey = p.hasKey === true
keyFrom = p.keyFrom
keyEnv = p.keyEnv
/* The advanced form describes the picked provider: edit it, or
* type another name to add one. */
advName = p.name
advBase = p.base
advKeyEnv = p.keyEnv
advModel = p.model
advModels = p.custom === true ? agent.modelChoices(p.name).join(", ") : ""
}
const g = picked.length ? agent.genParams(picked) : null
tempText = g && g.temperature > 0 ? String(g.temperature) : ""
topPText = g && g.top_p > 0 ? String(g.top_p) : ""
maxTokText = g && g.max_tokens > 0 ? String(g.max_tokens) : ""
}
function keyState() {
if (hasKey)
return keyFrom === "saved" ? "a key is saved · paste another to replace it"
: "key from the environment: " + keyFrom
return keyEnv.length ? "no key yet — set " + keyEnv + ", or paste one here"
: "no key yet — paste one here"
}
function usageLine() {
const u = agent.usage()
if (!u.requests)
return "no tokens spent in this session yet"
let s = u.requests + (u.requests === 1 ? " request" : " requests") +
" · " + u.prompt + " in · " + u.out + " out"
if (u.priced)
s += " · " + u.cost.toFixed(4) + " " + u.currency
return s
}
/* Write the form's entry into the engine's user file and list it again. */
function saveProvider() {
/* The fields are the truth here, not the properties: a hand-typed
* edit is what the user wants saved. */
const name = advNameField.text.trim()
const err = agent.addProvider(name, advBaseField.text.trim(),
advKeyEnvField.text.trim(),
advModelField.text.trim(),
advModelsField.text)
if (err.length) {
note = err
return
}
providers = agent.providers()
picked = name
choices = agent.modelChoices(name)
syncPick()
note = "saved " + name + " to models.json — it is in the list above now"
}
function removeProvider() {
const typed = advNameField.text.trim()
const name = typed.length ? typed : picked
const rc = agent.removeProvider(name)
if (rc < 0) {
note = "could not remove " + name
return
}
providers = agent.providers()
note = rc === 0
? name + " is not in models.json (a preset of that name stays)"
: "removed " + name + " from models.json"
if (rc === 1 && providers.length > 0)
picked = providers[0].name
choices = picked.length ? agent.modelChoices(picked) : []
syncPick()
}
/* The form's fields follow the picked provider (and a user edit breaks
* the binding, so the push is imperative). */
onAdvNameChanged: if (advNameField && advNameField.text !== advName) advNameField.text = advName
onAdvBaseChanged: if (advBaseField && advBaseField.text !== advBase) advBaseField.text = advBase
onAdvKeyEnvChanged: if (advKeyEnvField && advKeyEnvField.text !== advKeyEnv) advKeyEnvField.text = advKeyEnv
onAdvModelChanged: if (advModelField && advModelField.text !== advModel) advModelField.text = advModel
onAdvModelsChanged: if (advModelsField && advModelsField.text !== advModels) advModelsField.text = advModels
onTempTextChanged: if (tempField && tempField.text !== tempText) tempField.text = tempText
onTopPTextChanged: if (topPField && topPField.text !== topPText) topPField.text = topPText
onMaxTokTextChanged: if (maxTokField && maxTokField.text !== maxTokText) maxTokField.text = maxTokText
// One labelled field of the advanced form.
component AdvField: ColumnLayout {
property string label: ""
property alias text: field.text
property alias placeholder: field.placeholderText
property int hintWidth: 0
Layout.fillWidth: true
spacing: 2
Text {
text: parent.label
color: Theme.textDim
font.pixelSize: Theme.smallSize
}
TextField {
id: field
Layout.fillWidth: true
implicitHeight: Theme.touchTarget
selectByMouse: true
color: Theme.text
placeholderTextColor: Theme.textFaint
selectionColor: Theme.accentDim
inputMethodHints: Qt.ImhNoPredictiveText
background: Rectangle {
radius: Theme.radiusSm
color: Theme.bg
border.width: 1
border.color: field.activeFocus ? Theme.accentDim : Theme.border
}
}
}
contentItem: ColumnLayout {
objectName: "settingsPanel"
spacing: Theme.gap
Text {
Layout.fillWidth: true
text: "Provider, model, key"
color: Theme.text
font.pixelSize: Theme.bodySize
font.bold: true
}
Text {
Layout.fillWidth: true
text: "Keys are stored under <config>/cst-agent/credentials (mode 0600) and are never shown again."
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
/* Everything scrolls, so the buttons stay where they are and a long
* model list cannot push them out of the dialog. */
Flickable {
id: body
objectName: "settingsBody"
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 120
clip: true
contentWidth: width
contentHeight: bodyCol.implicitHeight
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar {}
ColumnLayout {
id: bodyCol
width: parent.width
spacing: Theme.gap
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Repeater {
model: dlg.providers
delegate: Rectangle {
id: prow
required property var modelData
readonly property bool selected: dlg.picked === modelData.name
objectName: "providerRow:" + modelData.name
Layout.fillWidth: true
implicitHeight: prowCol.implicitHeight + 8
radius: Theme.radiusSm
color: prow.selected ? Theme.accentDim
: prowHover.hovered ? Theme.surfaceAlt
: "transparent"
border.width: 1
border.color: prow.selected ? Theme.accent : "transparent"
HoverHandler { id: prowHover }
MouseArea {
anchors.fill: parent
onClicked: {
dlg.picked = prow.modelData.name
modelField.text = prow.modelData.model
dlg.choices = agent.modelChoices(prow.modelData.name)
dlg.modelFilter = ""
dlg.syncPick()
}
}
ColumnLayout {
id: prowCol
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: 4
}
spacing: 0
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
Text {
text: prow.modelData.name
color: prow.selected ? Theme.accentText : Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: prow.selected
}
Text {
visible: prow.modelData.current === true
text: "in use"
color: prow.selected ? Theme.okOnAccent : Theme.ok
font.pixelSize: Theme.smallSize
}
Item { Layout.fillWidth: true }
Text {
text: prow.modelData.custom === true
? "custom" : prow.modelData.source
color: prow.selected ? Theme.accentTextDim : Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
Text {
Layout.fillWidth: true
elide: Text.ElideMiddle
text: prow.modelData.base + " · " +
(prow.modelData.hasKey === true
? (prow.modelData.keyFrom === "saved"
? "key saved"
: "key from " + prow.modelData.keyFrom)
: (prow.modelData.keyEnv.length
? "no key (" + prow.modelData.keyEnv + ")"
: "no key"))
color: prow.selected ? Theme.accentTextDim : Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
}
}
}
Text {
Layout.fillWidth: true
text: "Model"
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
TextField {
id: modelField
objectName: "settingsModel"
Layout.fillWidth: true
text: dlg.model
implicitHeight: Theme.touchTarget
selectByMouse: true
color: Theme.text
placeholderText: "model name"
placeholderTextColor: Theme.textFaint
selectionColor: Theme.accentDim
inputMethodHints: Qt.ImhNoPredictiveText
background: Rectangle {
radius: Theme.radiusSm
color: Theme.bg
border.width: 1
border.color: modelField.activeFocus ? Theme.accentDim
: Theme.border
}
}
Text {
Layout.preferredWidth: 90
elide: Text.ElideRight
text: modelField.text.length ? agent.modelInfo(modelField.text) : ""
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
/* A router can list hundreds of models: filter, cap, say how
* many are left instead of filling the dialog. */
RowLayout {
Layout.fillWidth: true
visible: dlg.choices.length > 12
spacing: Theme.gap
TextField {
id: filterField
objectName: "settingsFilter"
Layout.fillWidth: true
text: dlg.modelFilter
implicitHeight: Theme.segmentHeight
selectByMouse: true
color: Theme.text
placeholderText: "filter " + dlg.choices.length + " models"
placeholderTextColor: Theme.textFaint
selectionColor: Theme.accentDim
inputMethodHints: Qt.ImhNoPredictiveText
onTextChanged: dlg.modelFilter = text
background: Rectangle {
radius: Theme.radiusSm
color: Theme.bg
border.width: 1
border.color: filterField.activeFocus ? Theme.accentDim
: Theme.border
}
}
Text {
Layout.preferredWidth: 110
elide: Text.ElideRight
text: dlg.chips.length + " shown"
+ (dlg.chipsHidden ? " · +" + dlg.chipsHidden + " more" : "")
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
Flow {
Layout.fillWidth: true
spacing: 6
visible: dlg.choices.length > 0
Repeater {
model: dlg.chips.slice(0, dlg.chipCap)
delegate: Rectangle {
id: chip
required property var modelData
readonly property string name: String(chip.modelData)
objectName: "modelChoice:" + chip.name
implicitWidth: chipText.implicitWidth + 14
implicitHeight: 26
radius: Theme.radiusSm
color: chipHover.hovered ? Theme.surfaceHi : "transparent"
border.width: 1
border.color: modelField.text === chip.name ? Theme.accent
: Theme.border
HoverHandler { id: chipHover }
Text {
id: chipText
anchors.centerIn: parent
text: chip.name
color: modelField.text === chip.name ? Theme.text
: Theme.textDim
font.pixelSize: Theme.smallSize
}
TapHandler { onTapped: modelField.text = chip.name }
}
}
}
TextField {
id: keyField
objectName: "settingsKey"
Layout.fillWidth: true
implicitHeight: Theme.touchTarget
echoMode: TextInput.Password
selectByMouse: true
color: Theme.text
placeholderText: "API key (leave empty to keep what is there)"
placeholderTextColor: Theme.textFaint
selectionColor: Theme.accentDim
inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhSensitiveData
onAccepted: dlg.apply()
background: Rectangle {
radius: Theme.radiusSm
color: Theme.bg
border.width: 1
border.color: keyField.activeFocus ? Theme.accentDim : Theme.border
}
}
Text {
Layout.fillWidth: true
text: dlg.keyState()
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
/* --- advanced ------------------------------------------- */
ToolButton {
id: advToggle
objectName: "settingsAdvanced"
Layout.fillWidth: true
implicitHeight: Theme.segmentHeight
onClicked: dlg.advanced = !dlg.advanced
contentItem: RowLayout {
spacing: 6
ThemeIcon {
name: dlg.advanced ? "arrow-down" : "arrow-right"
glyph: dlg.advanced ? "▾" : "▸"
size: 14
color: Theme.textDim
}
Text {
text: "Advanced options"
color: advToggle.hovered ? Theme.text : Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
Item { Layout.fillWidth: true }
Text {
text: "custom provider · model parameters"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
background: Rectangle {
radius: Theme.radiusSm
color: advToggle.hovered ? Theme.surfaceAlt : "transparent"
}
}
ColumnLayout {
Layout.fillWidth: true
visible: dlg.advanced
spacing: 6
Text {
Layout.fillWidth: true
text: "Provider entry — saved in <config>/cst-agent/models.json. " +
"A name the presets know is overridden, a new one is added."
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
GridLayout {
Layout.fillWidth: true
columns: 2
columnSpacing: Theme.gap
rowSpacing: 4
AdvField {
id: advNameField
objectName: "advName"
label: "Name"
placeholder: "myllm"
Layout.preferredWidth: 180
}
AdvField {
id: advBaseField
objectName: "advBase"
label: "Base URL"
placeholder: "https://api.example.com/v1"
}
AdvField {
id: advKeyEnvField
objectName: "advKeyEnv"
label: "Key variable"
placeholder: "MYLLM_API_KEY"
}
AdvField {
id: advModelField
objectName: "advModel"
label: "Default model"
placeholder: "the model to start with"
}
AdvField {
id: advModelsField
objectName: "advModels"
label: "Models it offers (comma separated, optional)"
placeholder: "m1, m2"
Layout.columnSpan: 2
}
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
Button {
id: saveProviderBtn
objectName: "settingsSaveProvider"
text: "Save provider"
implicitHeight: Theme.touchTarget
enabled: advNameField.text.trim().length > 0 &&
advBaseField.text.trim().length > 0
onClicked: dlg.saveProvider()
background: Rectangle {
radius: Theme.radiusSm
color: saveProviderBtn.hovered ? Theme.surfaceHi : Theme.surfaceAlt
border.width: 1
border.color: Theme.border
}
contentItem: Text {
text: saveProviderBtn.text
color: saveProviderBtn.enabled ? Theme.textDim : Theme.textFaint
font.pixelSize: Theme.bodySize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Button {
id: removeProviderBtn
objectName: "settingsRemoveProvider"
text: "Remove from models.json"
implicitHeight: Theme.touchTarget
readonly property var entry: dlg.pickedEntry()
enabled: entry !== null && entry.custom === true
ToolTip.visible: hovered
ToolTip.text: "Only the user's own entry goes: a built-in preset of the same name stays"
onClicked: dlg.removeProvider()
background: Rectangle {
radius: Theme.radiusSm
color: removeProviderBtn.hovered && removeProviderBtn.enabled
? Theme.surfaceHi : Theme.surfaceAlt
border.width: 1
border.color: Theme.border
}
contentItem: Text {
text: removeProviderBtn.text
color: removeProviderBtn.enabled ? Theme.textDim
: Theme.textFaint
font.pixelSize: Theme.bodySize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Item { Layout.fillWidth: true }
}
Text {
Layout.fillWidth: true
Layout.topMargin: 4
text: "Model parameters — remembered for " +
(dlg.picked.length ? dlg.picked : "this provider") +
", sent with every request. Empty = the provider's own default."
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
AdvField {
id: tempField
objectName: "advTemperature"
label: "Temperature"
placeholder: "not sent"
Layout.preferredWidth: 130
}
AdvField {
id: topPField
objectName: "advTopP"
label: "Top-p"
placeholder: "not sent"
Layout.preferredWidth: 130
}
AdvField {
id: maxTokField
objectName: "advMaxTokens"
label: "Max tokens"
placeholder: "not sent"
Layout.preferredWidth: 150
}
Item { Layout.fillWidth: true }
}
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
Text {
objectName: "settingsNote"
Layout.fillWidth: true
text: dlg.note.length ? dlg.note : dlg.usageLine()
color: dlg.note.length ? Theme.warn : Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
maximumLineCount: 2
elide: Text.ElideRight
}
Button {
id: closeBtn
objectName: "settingsClose"
text: "Close"
implicitHeight: Theme.touchTarget
onClicked: dlg.close()
background: Rectangle {
radius: Theme.radiusSm
color: closeBtn.hovered ? Theme.surfaceHi : Theme.surfaceAlt
border.width: 1
border.color: Theme.border
}
contentItem: Text {
text: closeBtn.text
color: Theme.textDim
font.pixelSize: Theme.bodySize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Button {
id: applyBtn
objectName: "settingsApply"
text: "Use this"
implicitHeight: Theme.touchTarget
onClicked: dlg.apply()
background: Rectangle {
radius: Theme.radiusSm
color: applyBtn.hovered ? "#5cc0f0" : Theme.accent
}
contentItem: Text {
text: applyBtn.text
color: Theme.accentInk
font.pixelSize: Theme.bodySize
font.bold: true
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
/* The model parameters belong to the provider that is picked, so they are
* applied first: useProvider() then takes over what was remembered for
* whichever provider it ends on. */
function apply() {
dlg.applyRequested(dlg.picked, modelField.text, keyField.text,
dlg.num(tempField.text), dlg.num(topPField.text),
Math.round(dlg.num(maxTokField.text)))
}
onOpened: {
keyField.text = ""
modelField.forceActiveFocus()
}
}
+345
View File
@@ -0,0 +1,345 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// What the engine is set to: where it runs, how it frames itself, when it
// asks, whether it is planning. Everything here writes straight into the
// engine's config (AgentCore), never into a copy the engine cannot see.
//
// The panel scrolls (a phone-sized window cannot show it all) and every
// control keeps a finger-sized hit area.
Rectangle {
id: side
property string workspace: ""
property string provider: ""
property string model: ""
property string mode: "coding"
property int approval: 1 // cst_approval_t
property bool planMode: false
property bool running: false
property string status: ""
property string todo: ""
property var sessions: [] // what the store holds (AgentCore::sessions)
property string sessionId: ""
signal modeRequested(string mode)
signal approvalRequested(int approval)
signal planModeRequested(bool on)
signal sessionRequested(string id)
signal sessionNewRequested()
signal sessionDeleteRequested(string id)
readonly property alias contentColumn: col
color: Theme.surface
border.width: 1
border.color: Theme.border
implicitHeight: col.implicitHeight + Theme.pad * 2
// One selectable row of a segmented control.
component Seg: Button {
id: seg
property bool current: false
flat: true
implicitHeight: Theme.segmentHeight
padding: 0
background: Rectangle {
radius: Theme.radiusSm
color: seg.current ? Theme.accentDim : (seg.hovered ? Theme.surfaceAlt : "transparent")
border.width: 1
border.color: seg.current ? Theme.accent : Theme.border
}
contentItem: Text {
text: seg.text
color: seg.current ? Theme.text : Theme.textDim
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Flickable {
anchors.fill: parent
contentWidth: width
contentHeight: col.implicitHeight + Theme.pad * 2
clip: true
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar {}
ColumnLayout {
id: col
x: Theme.pad
y: Theme.pad
width: parent.width - Theme.pad * 2
spacing: Theme.gapL
// --- where ---
ColumnLayout {
Layout.fillWidth: true
spacing: 4
RowLayout {
spacing: Theme.gap
Rectangle { width: 8; height: 8; radius: 4; color: Theme.accent }
Text {
text: "CST-agent"
color: Theme.text
font.pixelSize: Theme.bodySize
font.bold: true
}
Item { Layout.fillWidth: true }
Text {
visible: side.running
text: "turn running"
color: Theme.warn
font.pixelSize: Theme.smallSize
}
}
Text {
Layout.fillWidth: true
text: side.workspace.length ? side.workspace : "(no workspace)"
color: Theme.textDim
font.pixelSize: Theme.smallSize
elide: Text.ElideMiddle
ToolTip.visible: wsHover.hovered
ToolTip.text: side.workspace
HoverHandler { id: wsHover }
}
Text {
Layout.fillWidth: true
visible: side.provider.length > 0 || side.model.length > 0
text: (side.provider.length ? side.provider : "?") +
(side.model.length ? " · " + side.model : "")
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
visible: side.sessionId.length > 0
text: "session " + side.sessionId
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideMiddle
}
Text {
Layout.fillWidth: true
visible: side.status.length > 0
text: side.status
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
}
}
// --- which conversation ---
ColumnLayout {
Layout.fillWidth: true
spacing: 4
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
Text {
text: "Sessions"
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
Item { Layout.fillWidth: true }
// Starting a session while a turn runs would move the
// engine out from under it: the whole block is off then.
ToolButton {
id: newSession
objectName: "newSessionButton"
implicitWidth: 68
implicitHeight: Theme.segmentHeight
enabled: !side.running
ToolTip.visible: hovered
ToolTip.text: "Start a new session"
contentItem: RowLayout {
spacing: 4
ThemeIcon {
name: "list-add"
glyph: "+"
size: 14
color: newSession.enabled
? (newSession.hovered ? Theme.text : Theme.textDim)
: Theme.textFaint
}
Text {
text: "new"
color: newSession.enabled
? (newSession.hovered ? Theme.text : Theme.textDim)
: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered && parent.enabled ? Theme.surfaceHi
: "transparent"
border.width: 1
border.color: Theme.border
}
onClicked: side.sessionNewRequested()
}
}
Repeater {
model: side.sessions
delegate: SessionRow {
Layout.fillWidth: true
entry: modelData
onActivated: function (id) { side.sessionRequested(id) }
onRemoveRequested: function (id) { side.sessionDeleteRequested(id) }
}
}
Text {
Layout.fillWidth: true
visible: side.sessions.length === 0
text: "nothing stored here yet"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
}
// --- how it frames itself ---
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Mode"; color: Theme.textDim; font.pixelSize: Theme.smallSize; font.bold: true }
RowLayout {
Layout.fillWidth: true
spacing: 4
Seg {
objectName: "modeCoding"
Layout.fillWidth: true
text: "coding"
current: side.mode !== "general"
onClicked: side.modeRequested("coding")
}
Seg {
objectName: "modeGeneral"
Layout.fillWidth: true
text: "general"
current: side.mode === "general"
onClicked: side.modeRequested("general")
}
}
Text {
Layout.fillWidth: true
text: side.mode === "general" ? "documents, data, everyday files"
: "code framed: repos, builds, tests"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
}
// --- when it asks ---
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Approval"; color: Theme.textDim; font.pixelSize: Theme.smallSize; font.bold: true }
RowLayout {
Layout.fillWidth: true
spacing: 4
Seg {
Layout.fillWidth: true
text: "ask"
current: side.approval === 0
onClicked: side.approvalRequested(0)
}
Seg {
Layout.fillWidth: true
text: "auto"
current: side.approval === 1
onClicked: side.approvalRequested(1)
}
Seg {
Layout.fillWidth: true
text: "yolo"
current: side.approval === 2
onClicked: side.approvalRequested(2)
}
}
Text {
Layout.fillWidth: true
text: side.approval === 0 ? "every tool asks first"
: side.approval === 2 ? "never asks — writes go through"
: "sandbox and rules decide, asks on doubt"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
RowLayout {
Layout.fillWidth: true
Layout.topMargin: 2
spacing: Theme.gap
Switch {
id: planSwitch
objectName: "planSwitch"
checked: side.planMode
implicitHeight: Theme.touchTarget
onClicked: side.planModeRequested(checked)
/* The Basic style's indicator is a dark pill with a dark
* knob here, so it is drawn: thumb-sized, accent when
* on, clearly off when not. */
indicator: Rectangle {
implicitWidth: 44
implicitHeight: 22
x: planSwitch.leftPadding
y: parent.height / 2 - height / 2
radius: 11
color: planSwitch.checked ? Theme.accentDim : Theme.surfaceAlt
border.width: 1
border.color: planSwitch.checked ? Theme.accent : Theme.border
Rectangle {
width: 16
height: 16
y: 2
x: planSwitch.checked ? parent.width - width - 3 : 3
radius: 8
color: planSwitch.checked ? Theme.text : Theme.textDim
Behavior on x {
NumberAnimation { duration: 120; easing.type: Easing.OutCubic }
}
}
}
}
Text {
text: "Plan mode"
color: Theme.textDim
font.pixelSize: Theme.smallSize
}
Item { Layout.fillWidth: true }
}
Text {
Layout.fillWidth: true
visible: side.planMode
text: "read-only tools and a planning note"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
}
// --- the plan the engine keeps ---
TodoPanel {
Layout.fillWidth: true
text: side.todo
}
Text {
Layout.fillWidth: true
Layout.topMargin: Theme.gap
text: "engine in-process · v" + Qt.application.version
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
}
}
}
}
+98
View File
@@ -0,0 +1,98 @@
// One place for the look: colors, spacing, fonts. The window, the cards and
// the dialogs all read from here, so re-theming (or swapping in Kirigami's
// Kirigami.Theme) is a single file.
pragma Singleton
import QtQuick
QtObject {
// Breeze-dark-ish (the desktop the app is built for), no hard contrast.
readonly property color bg: "#15181a"
readonly property color surface: "#1e2225"
readonly property color surfaceAlt: "#262b2f"
readonly property color surfaceHi: "#313840"
readonly property color border: "#343b41"
readonly property color text: "#eff0f1"
readonly property color textDim: "#9aa2a8"
/* Faint is still text: small print (hints, secondary lines, a selected
* row's detail) has to stay readable on both the background and the
* accent fill, so it is a step lighter than it looks in a mockup. */
readonly property color textFaint: "#8d959c"
readonly property color accent: "#3daee9"
readonly property color accentDim: "#1d6f95"
/* What is written *on* the accent fill (a selection, the open session).
* Named "accentText...", not "onAccent...": QML reads any `onFoo` as a
* signal handler and refuses the assignment. */
readonly property color accentText: "#ffffff"
readonly property color accentTextDim: "#dceefb"
/* The dark ink a filled button carries (accent, ok, err are all light). */
readonly property color accentInk: "#12161a"
readonly property color ok: "#27ae60"
readonly property color warn: "#f67400"
readonly property color err: "#da4453"
/* Green on the accent fill is mud: the "in use" mark needs its own ink. */
readonly property color okOnAccent: "#d8f5e3"
readonly property int radius: 6
readonly property int radiusSm: 3
readonly property int gap: 8
readonly property int gapL: 14
readonly property int pad: 12
readonly property int padL: 16
readonly property int bodySize: 14
readonly property int smallSize: 12
readonly property int monoSize: 13
readonly property string mono: "monospace"
readonly property int sidebarWidth: 268
readonly property int contentMax: 1100
// Layout: below the breakpoint the sidebar slides over the transcript
// instead of taking a column, and controls stay thumb-sized above it
// (a finger needs ~34-40 px, a mouse is happy with 26).
readonly property int compactBreakpoint: 880
readonly property int touchTarget: 34
readonly property int segmentHeight: 30
readonly property int headerHeight: 42
readonly property int jumpButton: 40
// Which Breeze icon stands for a tool: the card shows at a glance what
// kind of thing ran. Unknown names still get one (no glyph fallbacks for
// tools -- a missing icon just leaves the name alone).
function toolIcon(name) {
const n = String(name).toLowerCase()
if (n.indexOf("patch") >= 0 || n.indexOf("write") >= 0 || n.indexOf("edit") >= 0)
return "document-edit"
if (n.indexOf("read") >= 0 || n.indexOf("cat") >= 0)
return "text-x-generic"
if (n.indexOf("list_dir") >= 0 || n.indexOf("glob") >= 0 ||
n.indexOf("find") >= 0 || n.indexOf("dir") >= 0 || n.indexOf("file") >= 0)
return "folder-open"
if (n.indexOf("grep") >= 0 || n.indexOf("search") >= 0)
return "edit-find"
if (n.indexOf("bash") >= 0 || n.indexOf("shell") >= 0 ||
n.indexOf("command") >= 0 || n.indexOf("run") >= 0)
return "code-context"
if (n.indexOf("web") >= 0 || n.indexOf("http") >= 0 || n.indexOf("fetch") >= 0)
return "internet-services"
if (n.indexOf("todo") >= 0 || n.indexOf("plan") >= 0)
return "format-list-unordered"
if (n.indexOf("ask") >= 0 || n.indexOf("question") >= 0 || n.indexOf("notif") >= 0)
return "help-hint"
return "code-context"
}
// The tool output / diff line colors, used by ToolCard.
function lineColor(line) {
if (line.startsWith("@@"))
return accent
if (line.startsWith("+++") || line.startsWith("---"))
return textDim
if (line.startsWith("+"))
return ok
if (line.startsWith("-"))
return err
return text
}
}
+50
View File
@@ -0,0 +1,50 @@
import QtQuick
// One icon, from the desktop's icon theme (image://csttheme/, see
// app/src/ui/ThemeIcons.cpp), tinted with a color of the window's own.
//
// `glyph` is the last resort: a machine with no icon theme at all gets the
// unicode character the app used before, rather than an empty hole. Every
// place that shows an icon names its glyph, so nothing can go blank.
Item {
id: root
property string name: ""
property string glyph: ""
property color color: Theme.textDim
property int size: 16
implicitWidth: size
implicitHeight: size
/* "#rrggbb": a color typed to a string in QML carries alpha and a
* leading '#', which the provider's query would choke on. */
function hex(c) {
function two(v) {
const s = Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16)
return s.length < 2 ? "0" + s : s
}
return "#" + two(c.r) + two(c.g) + two(c.b)
}
Image {
id: img
anchors.fill: parent
source: root.name.length
? "image://csttheme/" + root.name + "?color=" + encodeURIComponent(root.hex(root.color))
: ""
sourceSize.width: root.size
sourceSize.height: root.size
fillMode: Image.PreserveAspectFit
smooth: true
visible: status === Image.Ready
}
Text {
anchors.fill: parent
visible: img.status === Image.Error && root.glyph.length > 0
text: root.glyph
color: root.color
font.pixelSize: Math.round(root.size * 0.85)
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
+73
View File
@@ -0,0 +1,73 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The model's reasoning stream, collapsed by default: it is context, not the
// answer.
Rectangle {
id: block
property string text: ""
property bool complete: false
property bool expanded: false
radius: Theme.radius
color: Theme.surfaceAlt
border.width: 1
border.color: Theme.border
implicitHeight: layout.implicitHeight + Theme.pad
opacity: 0.9
ColumnLayout {
id: layout
anchors {
left: parent.left
right: parent.right
top: parent.top
leftMargin: Theme.pad
rightMargin: Theme.pad
topMargin: Theme.pad / 2
}
spacing: 2
RowLayout {
spacing: Theme.gap
/* A handler, not a MouseArea: a layout decides the geometry of its
* children, and anchoring one of them is undefined behavior. */
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: block.expanded = !block.expanded
}
ThemeIcon {
name: block.expanded ? "arrow-down" : "arrow-right"
glyph: block.expanded ? "▾" : "▸"
size: Theme.smallSize + 2
color: Theme.textDim
}
Text {
text: block.complete ? "thinking" : "thinking…"
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
Text {
Layout.fillWidth: true
elide: Text.ElideRight
visible: !block.expanded
text: block.text.replace(/\s+/g, " ").trim()
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
Text {
Layout.fillWidth: true
Layout.topMargin: 4
visible: block.expanded
text: block.text
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.italic: true
wrapMode: Text.Wrap
textFormat: Text.PlainText
}
}
}
+61
View File
@@ -0,0 +1,61 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The engine's todo list (on_todo), rendered as a small checklist. The text
// is markdown-ish: "- [x] thing" lines.
ColumnLayout {
id: panel
property string text: ""
function entries() {
return text.split("\n")
.map(function (l) { return l.trim() })
.filter(function (l) { return l.length > 0 })
.map(function (l) {
const done = /^[-*]\s*\[[xX]\]/.test(l) || /^\[[xX]\]/.test(l)
const open = /^[-*]\s*\[\s*\]/.test(l) || /^\[\s*\]/.test(l)
return { done: done, todo: open, label: l.replace(/^[-*]?\s*\[[xX ]?\]\s*/, "").replace(/^[-*]\s*/, "") }
})
}
spacing: 6
Text {
text: "Plan"
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
Repeater {
model: panel.entries()
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
ThemeIcon {
Layout.alignment: Qt.AlignTop
name: modelData.done ? "task-complete" : "box"
glyph: modelData.done ? "✓" : "○"
size: Theme.smallSize + 2
color: modelData.done ? Theme.ok : Theme.textFaint
}
Text {
Layout.fillWidth: true
text: modelData.label
color: modelData.done ? Theme.textFaint : Theme.textDim
font.pixelSize: Theme.smallSize
font.strikeout: modelData.done
wrapMode: Text.Wrap
}
}
}
Text {
Layout.fillWidth: true
visible: panel.entries().length === 0
text: "no plan yet"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
+177
View File
@@ -0,0 +1,177 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// A tool call: what ran, whether it is still running, and what came back.
// Long output stays folded until asked for, diff lines are colored.
Rectangle {
id: card
property string name: ""
property string state: "pending" // pending | ready | running | done | denied
// | unfinished (replayed, no result)
property string detail: ""
property string output: ""
property int exitCode: 0
property bool hasResult: false
property bool expanded: false
property string images: ""
readonly property bool running: state === "pending" || state === "ready" || state === "running"
readonly property color stateColor: state === "denied" ? Theme.err
: state === "unfinished" ? Theme.textFaint
: running ? Theme.warn
: hasResult && exitCode !== 0 ? Theme.err
: Theme.ok
readonly property string stateText: state === "pending" ? "reading"
: state === "ready" ? "queued"
: state === "running" ? "running"
: state === "denied" ? "denied"
: state === "unfinished" ? "no result"
: hasResult && exitCode !== 0 ? "failed" : "done"
function lines() {
const all = output.split("\n")
return all.length > 500 ? all.slice(0, 500).concat(["… " + (all.length - 500) + " more lines"]) : all
}
radius: Theme.radius
color: Theme.surfaceAlt
border.width: 1
border.color: expanded ? Theme.stateColor : Theme.border
implicitHeight: layout.implicitHeight + Theme.pad
ColumnLayout {
id: layout
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: Theme.pad
}
spacing: 6
RowLayout {
spacing: Theme.gap
BusyIndicator {
visible: card.running
running: card.running
implicitWidth: 16
implicitHeight: 16
Layout.alignment: Qt.AlignVCenter
}
Rectangle {
visible: !card.running
Layout.alignment: Qt.AlignVCenter
width: 6
height: 6
radius: 3
color: card.stateColor
}
/* What kind of tool this was, from the icon theme (Theme.toolIcon
* maps the engine's names onto Breeze ones). */
ThemeIcon {
Layout.alignment: Qt.AlignVCenter
name: Theme.toolIcon(card.name)
size: 16
color: Theme.textDim
}
Text {
text: card.name
color: Theme.text
font.family: Theme.mono
font.pixelSize: Theme.monoSize
font.bold: true
}
Rectangle {
Layout.alignment: Qt.AlignVCenter
radius: Theme.radiusSm
color: "transparent"
border.width: 1
border.color: card.stateColor
implicitWidth: chip.implicitWidth + 10
implicitHeight: chip.implicitHeight + 2
Text {
id: chip
anchors.centerIn: parent
/* "no result" is not an outcome: no exit code belongs on it. */
text: card.stateText + (card.hasResult && card.state !== "unfinished"
? " · exit " + card.exitCode : "")
color: card.stateColor
font.pixelSize: Theme.smallSize
}
}
Text {
Layout.fillWidth: true
elide: Text.ElideRight
text: card.detail.replace(/\s+/g, " ").trim()
color: Theme.textFaint
font.family: Theme.mono
font.pixelSize: Theme.smallSize
}
ToolButton {
implicitWidth: 26
implicitHeight: 22
visible: card.output.length > 0
contentItem: ThemeIcon {
name: card.expanded ? "arrow-down" : "arrow-right"
glyph: card.expanded ? "▾" : "▸"
size: 14
color: parent.hovered ? Theme.text : Theme.textDim
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: card.expanded = !card.expanded
}
}
// What the tool made (view_image, an MCP image): shown as it is, with
// the output left folded -- the picture is the result.
ImageStrip {
Layout.fillWidth: true
visible: card.images.length > 0
text: card.images
}
// Folded view: the first line of the result, so a done card still says
// something without being opened.
Text {
Layout.fillWidth: true
visible: !card.expanded && card.output.length > 0
text: {
const l = card.output.split("\n")
return l[0] + (l.length > 1 ? " …" : "")
}
elide: Text.ElideRight
color: Theme.textFaint
font.family: Theme.mono
font.pixelSize: Theme.smallSize
}
ColumnLayout {
Layout.fillWidth: true
visible: card.expanded && card.output.length > 0
spacing: 0
Repeater {
model: card.expanded ? card.lines() : []
Text {
Layout.fillWidth: true
text: modelData.length ? modelData : " "
color: Theme.lineColor(modelData)
font.family: Theme.mono
font.pixelSize: Theme.monoSize
wrapMode: Text.NoWrap
textFormat: Text.PlainText
}
}
Text {
Layout.topMargin: 4
visible: card.output.split("\n").length > 500
text: "output truncated for the view"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
}
}
File diff suppressed because it is too large Load Diff
+266
View File
@@ -0,0 +1,266 @@
/* AgentCore: the Qt wrapper around one cst_agent_t.
*
* The engine runs turns on a worker thread; every cst_agent_hooks_t callback
* is turned into a Qt signal, and the two hooks that need an answer
* (on_permission, on_ask_user) block the worker until the GUI replies. No
* HTTP, no socket: the GUI owns the engine in its own process. */
#ifndef CST_APP_AGENTCORE_H
#define CST_APP_AGENTCORE_H
#include <QByteArray>
#include <QMutex>
#include <QObject>
#include <QString>
#include <QVariantList>
#include <QVariantMap>
#include <QWaitCondition>
#include "cst_agent.h"
#include "cst_tools.h"
class QThread;
class AgentCore : public QObject {
Q_OBJECT
Q_PROPERTY(bool running READ running NOTIFY runningChanged)
Q_PROPERTY(QString workspace READ workspace NOTIFY workspaceChanged)
Q_PROPERTY(QString mode READ mode NOTIFY modeChanged)
Q_PROPERTY(QString provider READ provider NOTIFY configChanged)
Q_PROPERTY(QString model READ model NOTIFY configChanged)
Q_PROPERTY(int approval READ approval WRITE setApproval NOTIFY approvalChanged)
Q_PROPERTY(bool planMode READ planMode WRITE setPlanMode NOTIFY planModeChanged)
Q_PROPERTY(QString todo READ todo NOTIFY todoChanged)
Q_PROPERTY(QString sessionId READ sessionId NOTIFY sessionChanged)
public:
explicit AgentCore(QObject *parent = nullptr);
~AgentCore() override;
/* Start the engine on a workspace. provider/model/apiKey may be empty to
* fall back to the saved credentials and the provider's default model. */
bool open(const QString &workspace, const QString &provider,
const QString &model, const QString &apiKey, QString *err);
void close();
bool running() const { return m_running; }
QString workspace() const { return m_workspace; }
QString mode() const { return QString::fromUtf8(m_cfg.mode); }
QString provider() const { return QString::fromUtf8(m_cfg.provider); }
QString model() const { return QString::fromUtf8(m_cfg.model); }
int approval() const { return m_cfg.approval; }
bool planMode() const { return m_cfg.plan_mode != 0; }
QString todo() const { return m_todo; }
QString sessionId() const;
/* Markdown -> HTML with the engine's own renderer (cst_web_md_html), so a
* message looks the same here as it does in the WebUI. */
Q_INVOKABLE QString renderMarkdown(const QString &text) const;
/* --- sessions (the workspace's .agent/sessions store) ------------------- */
/* Stored sessions, newest first; one map each: id, title (a rename, else
* empty), firstUser, messages, mtime (ms since the epoch), current. */
Q_INVOKABLE QVariantList sessions() const;
/* The open session as transcript rows, in exactly the shape the live
* signals build (kind, role, text, name, state, detail, output, exitCode,
* complete, images), so a reopened session renders like the turn that wrote
* it. */
Q_INVOKABLE QVariantList history() const;
/* None of these run during a turn; all return false when one does. */
Q_INVOKABLE bool newSession();
Q_INVOKABLE bool switchSession(const QString &id);
Q_INVOKABLE bool deleteSession(const QString &id);
Q_INVOKABLE bool renameSession(const QString &id, const QString &title);
/* --- provider, model, key (see cst_provider_switch.h) ------------------- */
/* The presets: name, base, model (remembered, else the preset's), hasKey
* and keyFrom ("saved", else the environment variable), keyEnv, source and
* current, custom (the user's own provider, kept in the engine's user
* model file). Never a key: the engine keeps those out of the UI's reach. */
Q_INVOKABLE QVariantList providers() const;
/* A provider the engine's presets do not know: it goes into the user model
* file (<config>/cst-agent/models.json), which is where the registry keeps
* them, and the registry is reloaded, so the next providers() call lists
* it. `modelsCsv` is a comma-separated model list (may be empty).
* "" when it worked, else the reason. */
Q_INVOKABLE QString addProvider(const QString &name, const QString &base,
const QString &keyEnv,
const QString &defaultModel,
const QString &modelsCsv);
/* Drop a provider that came from that file (1 = it was there, 0 = it was
* not, -1 = with a reason). A preset of the same name stays. */
Q_INVOKABLE int removeProvider(const QString &name);
/* The generation parameters remembered for a provider (0 = not set, the
* field is not sent at all). */
Q_INVOKABLE QVariantMap genParams(const QString &provider) const;
/* Remember temperature / top-p / max tokens (0 = unset) for a provider and
* apply them to the engine when that provider is the live one.
* "" when it worked, else the reason. */
Q_INVOKABLE QString setGenParams(const QString &provider, double temperature,
double topP, int maxTokens);
/* Model candidates for a provider: its own list, what was fetched into the
* cache, and the remembered pick. No network. */
Q_INVOKABLE QStringList modelChoices(const QString &provider) const;
/* "1M · vision" for the model picker, "" when nothing is known. */
Q_INVOKABLE QString modelInfo(const QString &model) const;
/* Switch provider, model and key in one call. The engine's own config moves
* (its session does not), the choice is remembered, and an empty return
* means it worked; anything else is a reason to show. A key is verified
* against the provider's /models when one is given. */
Q_INVOKABLE QString useProvider(const QString &provider, const QString &model,
const QString &key);
/* Tokens of this session and of the last turn, plus the cost when priced. */
Q_INVOKABLE QVariantMap usage() const;
/* --- attachments (see cst_attach.h) ------------------------------------- */
Q_PROPERTY(QStringList pending READ pendingAttachments NOTIFY pendingChanged)
/* What the next prompt will carry: "<name> (<mime>, 12 KB)" per line. */
QStringList pendingAttachments() const;
/* Copy a file next to the session and give it to the next prompt. "" when
* it worked, else why not. */
Q_INVOKABLE QString attachFile(const QString &path);
/* The same for an image on the clipboard (Ctrl+V). "" when it worked; a
* reason -- "no image on the clipboard" -- when the editor should have the
* keystroke instead. */
Q_INVOKABLE QString attachClipboard();
/* Whether Ctrl+V should become an attachment at all (an image is on the
* clipboard) or stay with the editor (text). */
Q_INVOKABLE bool clipboardHasImage() const;
Q_INVOKABLE void clearPending();
/* The images among the pending attachments, in the engine's order, in the
* same text history() and toolImages() carry (see imagesText()): one line
* per picture, "<stored name>|<name>|<origin>|<w>x<h>". Empty for a PDF or
* a text file, and for a picture whose stored name the engine did not
* report. */
Q_INVOKABLE QString pendingImages() const;
/* Where the current session keeps its stored images: the directory
* image://cstattach/ serves (SessionImages.cpp). "" until the engine is
* open. It follows the session. */
Q_INVOKABLE QString attachmentDir() const;
/* A file card's two buttons: the stored file goes to the system -- a phone
* opens it with ACTION_VIEW or hands it to the share sheet, a desktop gives
* it to its own application or to the clipboard. "" when it went through
* (the header gets the line from statusChanged), else what to say. The name
* is the stored one the card carries; anything else is refused. */
Q_INVOKABLE QString openArtifact(const QString &rel);
Q_INVOKABLE QString shareArtifact(const QString &rel);
/* 1 the model is shown images, 0 it is not (they go as notes), -1 unknown. */
Q_INVOKABLE int seesImages() const;
/* The engine, for the parts that need it (tool registration). */
cst_agent_t *agent() const { return m_agent; }
const cst_agent_config_t *config() const { return &m_cfg; }
/* Ask a question; emits the streaming signals, then turnFinished(). */
Q_INVOKABLE void send(const QString &prompt);
Q_INVOKABLE void cancel();
/* Consent: answers map to CST_PERM_*. */
Q_INVOKABLE void answerPermission(int choice);
Q_INVOKABLE void answerQuestion(const QString &answer);
Q_INVOKABLE void setMode(const QString &mode); /* coding | general */
Q_INVOKABLE void setApproval(int approval); /* cst_approval_t */
Q_INVOKABLE void setPlanMode(bool on);
/* Tools the front end implements itself. The engine copies the definition
* (strings included), so a stack value is fine; ud must outlive it.
* owner is what unregisterOwner() later matches. */
bool registerTool(const cst_tool_def_t *def);
void unregisterOwner(const QString &owner);
/* Re-probe platform tools after a service appeared or went away. */
void reloadToolMeta() { cst_tool_meta_reload(); }
/* Answer every consent question with "once" (the C smoke test, headless
* runs, a --yes flag). */
void setAutoApprove(bool on) { m_autoApprove = on; }
signals:
void configChanged();
void approvalChanged();
void planModeChanged();
void sessionChanged(); /* another session is open now */
void sessionListChanged(); /* the store gained, lost or renamed one */
void pendingChanged(); /* attachments for the next prompt changed */
void statusChanged(const QString &text);
void assistantDelta(const QString &text);
void thinkDelta(const QString &text);
/* A call is identified by its id: the same tool can run twice in one turn,
* and only the id tells the two cards apart. Text protocols carry ids too
* (cst_tool_call_from_native, the ctl parser), so this is never empty. */
void toolEvent(const QString &phase, const QString &id, const QString &name,
const QString &detail);
void toolResult(const QString &id, const QString &name, int exitCode,
const QString &output);
/* Images a tool produced (view_image, an MCP image), right after its
* result, as the text ImageStrip.qml decodes. */
void toolImages(const QString &callId, const QString &images);
/* The images the prompt that was just sent carries, in the same shape,
* fired before the turn's first delta. */
void promptImages(const QString &images);
void todoChanged(const QString &text);
void permissionRequested(const QString &tool, const QString &detail,
const QString &preview);
void questionAsked(const QString &question, const QString &options);
void turnFinished(int rc, const QString &err);
void runningChanged();
void workspaceChanged();
void modeChanged();
private:
friend class Turn;
void finishTurn(int rc, const QString &err);
void setRunning(bool on);
/* Take the engine's config over m_cfg again (a switch changed it) and say
* so. */
void syncConfig();
/* Re-read what a session switch changed (id, todo) and say so. */
void afterSessionChange();
/* Hook trampolines (C, run on the worker thread). */
static void hookStatus(const char *msg, void *ud);
static void hookAssistant(const char *text, size_t len, void *ud);
static void hookThink(const char *text, size_t len, void *ud);
static void hookToolEvent(const char *phase, const ctl_tool_call_t *call,
const char *detail, void *ud);
static void hookToolResult(const ctl_tool_call_t *call, const char *out,
size_t len, int exit_code, void *ud);
static void hookToolImages(const ctl_tool_call_t *call,
const cst_attachment_t *list, int n, void *ud);
static void hookTodo(const char *text, void *ud);
static int hookPermission(const char *tool, const char *detail,
const char *preview, void *ud);
static int hookAskUser(const char *question, const char *options, char *ans,
size_t ans_sz, void *ud);
/* Post a lambda onto the GUI thread. */
template <typename F>
void post(F f) {
QMetaObject::invokeMethod(this, f, Qt::QueuedConnection);
}
cst_agent_t *m_agent = nullptr;
cst_agent_config_t m_cfg;
cst_agent_hooks_t m_hooks;
QString m_workspace;
QThread *m_turn = nullptr;
bool m_running = false;
bool m_closing = false;
/* consent handshake between the worker and the GUI thread */
QMutex m_askMu;
QWaitCondition m_askCv;
int m_permAnswer = CST_PERM_DENY;
bool m_permPending = false;
QString m_question;
QString m_todo;
/* Names of the files this window attached, in the engine's pending order:
* the engine's own description says what it is, not where it came from. */
QStringList m_attachNames;
QByteArray m_answer;
bool m_questionPending = false;
bool m_autoApprove = false;
};
#endif /* CST_APP_AGENTCORE_H */
+40
View File
@@ -0,0 +1,40 @@
#include "ToolProvider.h"
#include "AgentCore.h"
namespace app {
/* Keep these in one place: every platform tool of the front end uses one of
* them, so the engine's availability gate and the policy UI agree. */
const char *const kDesktopOnly[] = {"desktop", NULL};
const char *const kAndroidOnly[] = {"android", NULL};
int registerPlatformTools(AgentCore *core) {
if (!core)
return 0;
int n = 0;
#if defined(__ANDROID__)
n += platformRegisterAndroid(core);
#elif defined(__APPLE__)
n += platformRegisterMac(core);
#elif defined(_WIN32)
n += platformRegisterWindows(core);
#elif defined(__linux__)
n += platformRegisterLinux(core);
#endif
/* A service may have appeared or gone since the last probe (a desktop
* session starting, the notification daemon coming up). */
cst_tool_meta_reload();
return n;
}
void unregisterPlatformTools(AgentCore *core) {
if (!core)
return;
core->unregisterOwner(QStringLiteral("app:linux"));
core->unregisterOwner(QStringLiteral("app:windows"));
core->unregisterOwner(QStringLiteral("app:macos"));
core->unregisterOwner(QStringLiteral("app:android"));
}
} // namespace app
+99
View File
@@ -0,0 +1,99 @@
/* How the front end hands a tool to the engine.
*
* A tool implemented in C++ is an ordinary cst_tool_def_t: the engine owns a
* copy of the definition, so a static (or stack) value is enough, and
* `ud`/`run_ex` keep pointing at the front end's state. The callback runs on
* the turn's worker thread -- touch Qt objects only through queued signals.
*
* See app/src/platform/linux/NotifyTool.cpp for a worked example, and
* tests/test_embed.c for the same contract without Qt.
*/
#ifndef CST_APP_TOOLPROVIDER_H
#define CST_APP_TOOLPROVIDER_H
#include <QString>
#include "cst_tools.h"
/* The engine wrapper of the front end (core/AgentCore.h). Only a pointer
* travels through this header, so the class stays incomplete here. */
class AgentCore;
namespace app {
/* Fill a definition in one place, so every front-end tool carries the same
* metadata (class + policy name + platform list + probe). */
inline void setMeta(cst_tool_def_t *def, const char *name, const char *desc,
cst_tool_perm_t perm, const char *perm_name,
const char *const *platforms,
int (*available)(char *, size_t)) {
def->name = name;
def->desc = desc;
def->perm = perm;
def->perm_name = perm_name;
def->platforms = platforms;
def->available = available;
}
/* Tools that only work on the phone. */
extern const char *const kAndroidOnly[];
/* Tools that only work on a desktop (linux / win / macos). */
extern const char *const kDesktopOnly[];
/* The platform tools this app provides. Called once the engine exists;
* returns how many were registered. */
int registerPlatformTools(AgentCore *core);
/* Drop them again (they belong to the owners "app:*"). */
void unregisterPlatformTools(AgentCore *core);
/* One entry point per platform, implemented by the file under
* src/platform/<os>/; ToolProvider.cpp picks one at compile time. */
int platformRegisterLinux(AgentCore *core);
int platformRegisterMac(AgentCore *core);
int platformRegisterWindows(AgentCore *core);
int platformRegisterAndroid(AgentCore *core);
/* Hand a file of the session's store to the system: a phone opens it with
* ACTION_VIEW through the app's FileProvider, a desktop gives it to whatever
* the desktop uses. `title` is the name a person knows the file by (the
* stored copy is named by its id, which means nothing outside), used for the
* status line. Both return "" on success and a reason to show the user
* otherwise; *status (optional) gets the line to put in the header.
* CST_APP_NO_OPEN=1 makes both do nothing (--uitest drives the window without
* opening anything for real). */
QString openLocalFile(const QString &path, const QString &title, QString *status);
/* The same file through the share sheet where there is one (Android); a
* desktop has no share sheet, so the path goes to the clipboard. */
QString shareLocalFile(const QString &path, const QString &title,
QString *status);
/* Hold the machine while a turn runs, so it survives the app leaving the
* screen: a phone starts a foreground service with a partial wake lock (the
* process is one Android will not freeze or kill, and the CPU keeps going
* with the screen off) and shows a notification naming the work; a desktop
* does nothing -- the window *is* the app there. Returns "" when the hold is
* in place (or when the platform has nothing to do), a reason otherwise; the
* turn runs either way. `text` is what the phone's notification says. */
QString holdForeground(const QString &text);
/* Drop it again, at the end of the turn (a phone: the notification goes). */
void releaseForeground();
/* Turn what a file chooser handed over into a path the engine can open. A
* desktop picker gives a path and nothing happens; a phone's is a SAF
* content:// URI, which the app copies into its own directory first (the
* grant travels with the URI, so no permission is involved -- and without
* the copy the engine, which opens files by path, would fail with "cannot
* read"). Returns the path to read, or "" with *err filled. */
QString materializeAttachment(const QString &pathOrUri, QString *err);
/* Does this app have "all files access" (Android 11+)? A desktop always
* does. Without it the app still attaches whatever the picker handed it; what
* it cannot do is read shared storage by path. */
bool hasAllFilesAccess();
/* Ask for it, through the one screen Android offers (there is no runtime
* dialog for it). A desktop, a phone that already has it or one too old to
* know about it returns "" and nothing opens. */
QString requestAllFilesAccess();
} // namespace app
#endif /* CST_APP_TOOLPROVIDER_H */
+330
View File
@@ -0,0 +1,330 @@
/* cst-agent-app: the Qt front end.
*
* cst-agent-app --workspace DIR [--provider p] [--model m] [--api-key k]
* cst-agent-app --size WxH window geometry (480x900, 1180x800, ...)
* cst-agent-app --selftest embedding checks, no window, no network
*
* The engine runs in this process (see app/README.md); nothing listens on a
* port and no second process is started. */
#include <QCommandLineParser>
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQuickStyle>
#include <QStandardPaths>
#include <QTemporaryDir>
#include <QTextStream>
#include <QUrl>
#if defined(Q_OS_ANDROID)
#include <QJniObject>
#include <QtCore/private/qandroidextras_p.h>
#endif
#include <cstdio>
#include <cstring>
#include <memory>
#include "core/AgentCore.h"
#include "core/ToolProvider.h"
#include "ui/UiTest.h"
#include "ui/SessionImages.h"
#include "ui/ThemeIcons.h"
#include "cst_version.h"
#if defined(Q_OS_ANDROID)
/* Where an Android app is allowed to write: its own data directory. There is
* no usable $HOME, no writable "/" (QDir::currentPath()) and no /tmp, while
* the engine wants a config dir ($XDG_CONFIG_HOME/cst-agent, os_config_dir())
* and a scratch dir (the first writable entry of os_temp_dir()'s list). All
* of them are pointed here -- once, before the engine exists. */
QString androidRoot() {
const QString root =
QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
return root.isEmpty() ? QDir::tempPath() + QStringLiteral("/cst-agent")
: root;
}
#endif
namespace {
int runSelftest() {
QTextStream out(stdout);
int bad = 0;
QTemporaryDir tmp, cfg;
if (!tmp.isValid() || !cfg.isValid()) {
out << "selftest: cannot create a scratch dir\n";
return 1;
}
/* The window passes no --api-key, so the key the user saved for the provider
* has to come back from <config>/cst-agent/credentials -- the file the
* settings dialog writes. Planted in a scratch config dir, never in the
* real one. */
const QString cfgdir = cfg.path() + QStringLiteral("/cst-agent");
QDir().mkpath(cfgdir);
QFile creds(cfgdir + QStringLiteral("/credentials"));
if (!creds.open(QIODevice::WriteOnly | QIODevice::Truncate) ||
creds.write("deepseek.api_key=selftest-saved\n") < 0) {
out << "selftest: cannot write a scratch credentials file\n";
return 1;
}
creds.close();
qputenv("XDG_CONFIG_HOME", cfg.path().toUtf8());
AgentCore core;
QString err;
if (!core.open(tmp.path(), QStringLiteral("deepseek"), QString(), QString(),
&err)) {
out << "selftest: engine did not start: " << err << "\n";
return 1;
}
const cst_agent_config_t *live =
core.agent() ? cst_agent_config(core.agent()) : nullptr;
if (!live || strcmp(live->api_key, "selftest-saved") != 0) {
out << "selftest: the saved key did not reach the engine config\n";
bad++;
} else {
out << "selftest: the saved key is in the engine config\n";
}
if (!core.open(tmp.path(), QStringLiteral("deepseek"),
QStringLiteral("deepseek-flash"), QStringLiteral("test-key"),
&err)) {
out << "selftest: engine did not start: " << err << "\n";
return 1;
}
out << "selftest: engine started in-process\n";
if (core.agent() == nullptr) {
out << "selftest: no agent handle\n";
return 1;
}
/* the front end's own tools go through the engine's registry */
const int n = app::registerPlatformTools(&core);
out << "selftest: platform tools registered: " << n << "\n";
const cst_tool_def_t *t = cst_tool_find("notification_send");
if (!t) {
out << "selftest: notification_send is not in the registry\n";
bad++;
} else {
if (t->owner == nullptr || strncmp(t->owner, "app:", 4) != 0) {
out << "selftest: notification_send has no app owner\n";
bad++;
}
char why[160] = "";
const bool ok = cst_tool_available(t, why, sizeof(why)) == 0;
out << "selftest: notification_send available=" << (ok ? "yes" : "no")
<< (ok ? "" : QStringLiteral(" (%1)").arg(QString::fromUtf8(why)))
<< "\n";
}
/* The engine's own deliver_file: the file a tool hands to the person. */
const cst_tool_def_t *dv = cst_tool_find("deliver_file");
if (!dv) {
out << "selftest: deliver_file is not in the registry\n";
bad++;
} else {
char why[160] = "";
const bool ok = cst_tool_available(dv, why, sizeof(why)) == 0;
out << "selftest: deliver_file available=" << (ok ? "yes" : "no")
<< (ok ? "" : QStringLiteral(" (%1)").arg(QString::fromUtf8(why)))
<< "\n";
}
/* the mode comes from the engine, not from the UI */
core.setMode(QStringLiteral("general"));
if (core.mode() != QLatin1String("general")) {
out << "selftest: mode did not stick\n";
bad++;
}
app::unregisterPlatformTools(&core);
if (cst_tool_find("notification_send") != nullptr) {
out << "selftest: platform tools were not dropped\n";
bad++;
}
core.close();
out << (bad ? "selftest: FAILED\n" : "selftest: OK\n");
out.flush();
return bad ? 1 : 0;
}
} // namespace
int main(int argc, char **argv) {
/* --selftest stays headless: no window, no QML, no display needed. */
bool selftest = false;
for (int i = 1; i < argc; i++)
if (strcmp(argv[i], "--selftest") == 0)
selftest = true;
if (selftest) {
QCoreApplication app(argc, argv);
return runSelftest();
}
QGuiApplication app(argc, argv);
/* The window styles its own controls; the platform style only adds noise
* here (the KDE Breeze plugin logs a line per registered type and trips
* over the composer's TextArea). An explicit choice still wins. */
if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE"))
QQuickStyle::setStyle(QStringLiteral("Basic"));
QGuiApplication::setApplicationName(QStringLiteral("cst-agent-app"));
QGuiApplication::setApplicationVersion(QStringLiteral(CST_VERSION));
QGuiApplication::setOrganizationName(QStringLiteral("cst-agent"));
QCommandLineParser parser;
parser.setApplicationDescription(
QStringLiteral("CST-agent desktop front end (engine in-process)"));
parser.addHelpOption();
parser.addVersionOption();
QCommandLineOption wsOpt({QStringLiteral("w"), QStringLiteral("workspace")},
QStringLiteral("workspace directory"),
QStringLiteral("dir"));
QCommandLineOption providerOpt(QStringLiteral("provider"),
QStringLiteral("provider name"),
QStringLiteral("name"));
QCommandLineOption modelOpt(QStringLiteral("model"),
QStringLiteral("model name"), QStringLiteral("name"));
QCommandLineOption planOpt(QStringLiteral("plan"),
QStringLiteral("start in plan mode"));
QCommandLineOption uitestOpt(
QStringLiteral("uitest"),
QStringLiteral("drive the window with synthesized mouse/touch input and exit"));
QCommandLineOption sizeOpt(QStringLiteral("size"),
QStringLiteral("initial window size, e.g. 480x900"),
QStringLiteral("WxH"));
parser.addOption(wsOpt);
parser.addOption(providerOpt);
parser.addOption(modelOpt);
parser.addOption(planOpt);
parser.addOption(uitestOpt);
parser.addOption(sizeOpt);
parser.process(app);
QString ws = parser.value(wsOpt);
if (ws.isEmpty()) {
#if defined(Q_OS_ANDROID)
ws = androidRoot() + QStringLiteral("/workspace");
#else
ws = QDir::currentPath();
#endif
}
ws = QFileInfo(ws).absoluteFilePath();
/* --uitest drives a scratch workspace when none was given: the session
* checks create and delete sessions, which is not something to do in
* whatever directory the app happened to be started from. */
std::unique_ptr<QTemporaryDir> scratch;
if (parser.isSet(uitestOpt) && parser.value(wsOpt).isEmpty()) {
scratch.reset(new QTemporaryDir);
if (scratch->isValid()) {
ws = scratch->path();
/* settings.json and credentials live in <config>/cst-agent: point them
* at the scratch dir too, so a test run never rewrites the user's own
* provider choice or key. */
qputenv("XDG_CONFIG_HOME", scratch->path().toUtf8());
}
}
#if defined(Q_OS_ANDROID)
/* Before the engine is created: its settings, credentials and session store
* all resolve their paths from the environment. */
const QString root = androidRoot();
for (const QString &sub : {QStringLiteral("config"), QStringLiteral("tmp")})
QDir().mkpath(root + QLatin1Char('/') + sub);
qputenv("XDG_CONFIG_HOME", (root + QStringLiteral("/config")).toUtf8());
qputenv("TMPDIR", (root + QStringLiteral("/tmp")).toUtf8());
qputenv("HOME", root.toUtf8());
#endif
AgentCore core;
QString err;
if (!core.open(ws, parser.value(providerOpt), parser.value(modelOpt),
QString(), &err)) {
QTextStream(stderr) << "cannot start the engine: " << err << "\n";
return 1;
}
app::registerPlatformTools(&core); /* the app's own tools */
#if defined(Q_OS_ANDROID)
/* notification_send sits behind POST_NOTIFICATIONS from Android 13 on, and
* the tool is hidden while it is not granted (its probe says so): ask once,
* here, so the tool is there for the first turn rather than a prompt that
* shows up mid-answer. Denying it only costs the tool. */
if (QNativeInterface::QAndroidApplication::context().isValid() &&
QtAndroidPrivate::checkPermission(
QStringLiteral("android.permission.POST_NOTIFICATIONS"))
.result() != QtAndroidPrivate::Authorized) {
QtAndroidPrivate::requestPermission(
QStringLiteral("android.permission.POST_NOTIFICATIONS"));
}
/* "All files access" is the other half of storage: it is what lets the
* engine read a path in shared storage (a file the user names, not one the
* picker hands over -- that one is copied into the app either way, see
* AttachFile.java). Android has no runtime dialog for it, only the Settings
* screen, so it is opened once (a marker file remembers) and never
* insisted on: without it everything still works, one picked file at a
* time. The line below is what logcat shows when a device behaves oddly. */
const bool allFiles = app::hasAllFilesAccess();
qInfo("storage: all files access = %s", allFiles ? "yes" : "no");
if (!allFiles) {
const QString marker = androidRoot() + QStringLiteral("/.all-files-asked");
if (!QFileInfo::exists(marker)) {
const QString why = app::requestAllFilesAccess();
if (!why.isEmpty())
qWarning("all files access: %s", qUtf8Printable(why));
QFile asked(marker);
if (asked.open(QIODevice::WriteOnly))
asked.write("1\n");
}
}
#endif
if (parser.isSet(planOpt))
core.setPlanMode(true);
/* --uitest: same window, driven by synthesized input (app/src/ui/UiTest.cpp). */
if (parser.isSet(uitestOpt))
return app::runUiTest(core, parser.value(sizeOpt));
/* The layout is responsive; --size is how it gets exercised without a
* different screen (see app/README.md). */
int winW = 0, winH = 0;
const QString size = parser.value(sizeOpt);
if (!size.isEmpty()) {
const int x = size.indexOf(QLatin1Char('x'));
bool okW = false, okH = false;
if (x > 0) {
winW = size.left(x).toInt(&okW);
winH = size.mid(x + 1).toInt(&okH);
}
if (!okW || !okH || winW < 320 || winH < 360) {
QTextStream(stderr) << "--size wants WxH (at least 320x360)\n";
return 2;
}
}
QQmlApplicationEngine engine;
/* image://csttheme/ plus the desktop's icon theme behind it. */
app::installThemeIcons(&engine);
/* image://cstattach/: the session's own store, so the pictures a tool made
* (view_image, an MCP image) and the ones the user attached are shown, not
* only named. The directory follows the session. */
app::SessionImages *images = app::installSessionImages(&engine);
images->setDir(core.attachmentDir());
QObject::connect(&core, &AgentCore::sessionChanged, images,
[images, &core] { images->setDir(core.attachmentDir()); });
engine.rootContext()->setContextProperty(QStringLiteral("agent"), &core);
engine.rootContext()->setContextProperty(QStringLiteral("initialWidth"), winW);
engine.rootContext()->setContextProperty(QStringLiteral("initialHeight"), winH);
const QUrl url(QStringLiteral("qrc:/qt/qml/CstAgent/Main.qml"));
QObject::connect(
&engine, &QQmlApplicationEngine::objectCreated, &app,
[url](QObject *obj, const QUrl &objUrl) {
if (!obj && url == objUrl)
QCoreApplication::exit(-1);
},
Qt::QueuedConnection);
engine.load(url);
if (engine.rootObjects().isEmpty())
return 1;
return app.exec();
}
+829
View File
@@ -0,0 +1,829 @@
/* Platform tools on Android, implemented by the app: the same contract as
* app/src/platform/linux/NotifyTool.cpp, with Android APIs behind it.
*
* The engine only ever sees the capability: `platforms = kAndroidOnly` (the
* engine reports "android" when it was compiled with the NDK, see
* src/tools/registry.c: cst_platform_name()) plus an `available` probe decide
* whether a tool reaches the prompt and tools[] at all -- a Termux build on
* the same device reports "termux" and gets the android tools too, an APK
* never gets the desktop ones. `owner = "app:android"` is what
* unregisterPlatformTools() drops again.
*
* WHERE THE CODE RUNS: `run_ex` is called on the turn's worker thread, never
* on the GUI thread. JNI calls are fine from any thread (Qt attaches it),
* Qt GUI objects are not -- a tool that needs the widget tree
* has to hop back with a blocking queued call (onGuiThread() below).
*
* The clipboard tools keep the names of the engine's built-ins: those are
* gated to CST_PLAT_DESKTOP and the registry lets an app tool of the same
* name shadow the built-in (src/tools/registry.c: cst_tool_find()), so the
* model sees one capability either way. */
#include <QByteArray>
#include <QClipboard>
#include <QCoreApplication>
#include <QDir>
#include <QFileInfo>
#include <QFuture>
#include <QGuiApplication>
#include <QJniObject>
#include <QMetaObject>
#include <QStandardPaths>
#include <QString>
#include <QStringList>
#include <QThread>
#include <QtCore/private/qandroidextras_p.h>
#include <cstdio>
#include <cstring>
#include <utility>
#include "core/AgentCore.h"
#include "core/ToolProvider.h"
namespace app {
namespace {
/* --- small helpers ------------------------------------------------------- */
const char *argOf(const ctl_tool_call_t *call, const char *key) {
for (int i = 0; i < call->nargs; i++)
if (strcmp(call->args[i].key, key) == 0)
return call->args[i].value;
return nullptr;
}
void setOut(cst_tool_result_t *r, const QByteArray &text, int rc) {
r->out = strdup(text.constData());
r->out_len = r->out ? strlen(r->out) : 0;
r->exit_code = rc;
}
/* Anything that touches a Qt GUI object (the clipboard) or an Activity has to
* run on the GUI thread; the turn's worker thread waits for it. */
template <typename Fn>
void onGuiThread(Fn &&fn) {
if (!qApp || QThread::currentThread() == qApp->thread()) {
fn();
return;
}
QMetaObject::invokeMethod(qApp, std::forward<Fn>(fn),
Qt::BlockingQueuedConnection);
}
jint sdkInt() {
static const jint sdk = QJniObject::getStaticField<jint>(
"android/os/Build$VERSION", "SDK_INT");
return sdk;
}
QJniObject appContext() {
return QNativeInterface::QAndroidApplication::context();
}
/* --- notification_send --------------------------------------------------- */
/* Android 13 (API 33) put notifications behind a runtime permission; before
* that the permission does not exist and they always work. main.cpp asks for
* it once at startup, so the probe is normally green on a device where the
* user said yes. */
int notifyProbe(char *why, size_t why_sz) {
if (sdkInt() < 33)
return 0;
const QFuture<QtAndroidPrivate::PermissionResult> f =
QtAndroidPrivate::checkPermission(
QStringLiteral("android.permission.POST_NOTIFICATIONS"));
if (f.result() == QtAndroidPrivate::Authorized)
return 0;
snprintf(why, why_sz,
"POST_NOTIFICATIONS is not granted (Android 13+ asks the user the "
"first time the app runs)");
return -1;
}
bool postNotification(const QString &title, const QString &message,
QString *err) {
QJniObject ctx = appContext();
if (!ctx.isValid()) {
*err = QStringLiteral("no Android context");
return false;
}
QJniObject manager = ctx.callObjectMethod(
"getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;",
QJniObject::fromString(QStringLiteral("notification")).object<jstring>());
if (!manager.isValid()) {
*err = QStringLiteral("no NotificationManager");
return false;
}
const QString channelId = QStringLiteral("cst-agent");
if (sdkInt() >= 26) {
QJniObject channel(
"android/app/NotificationChannel",
"(Ljava/lang/String;Ljava/lang/CharSequence;I)V",
QJniObject::fromString(channelId).object<jstring>(),
QJniObject::fromString(QStringLiteral("CST-agent")).object<jstring>(),
static_cast<jint>(3) /* IMPORTANCE_DEFAULT */);
manager.callMethod<void>("createNotificationChannel",
"(Landroid/app/NotificationChannel;)V",
channel.object<jobject>());
}
/* The small icon. The R classes are not reachable from here, so the id is
* looked up by name, and build() throws IllegalArgumentException ("Invalid
* notification (no valid small icon)") when there is none at all: every
* candidate that can be there is tried -- the framework drawables first,
* then the launcher icon the APK itself carries (AGP puts it in mipmap, a
* hand made APK in drawable/icon). */
QJniObject res = ctx.callObjectMethod("getResources",
"()Landroid/content/res/Resources;");
QJniObject pkg = ctx.callObjectMethod("getPackageName",
"()Ljava/lang/String;");
jint icon = 0;
if (res.isValid()) {
auto idOf = [&res, &pkg](const char *name, const char *type,
const char *owner) {
return res.callMethod<jint>(
"getIdentifier", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I",
QJniObject::fromString(QString::fromLatin1(name)).object<jstring>(),
QJniObject::fromString(QString::fromLatin1(type)).object<jstring>(),
owner ? QJniObject::fromString(QString::fromLatin1(owner)).object<jstring>()
: pkg.object<jstring>());
};
for (const char *n : {"ic_dialog_info", "ic_popup_reminder",
"stat_notify_chat", "sym_def_app_icon"}) {
icon = idOf(n, "drawable", "android");
if (icon != 0)
break;
}
for (const char *n : {"ic_launcher", "icon", "app_icon", "qt_icon"}) {
if (icon == 0)
icon = idOf(n, "mipmap", nullptr);
if (icon == 0)
icon = idOf(n, "drawable", nullptr);
if (icon != 0)
break;
}
}
if (icon == 0) {
*err = QStringLiteral("no small icon: it is in neither the framework "
"resources nor the APK's own");
return false;
}
/* Tapping it opens the app again (the launch intent of the package). */
QJniObject pm = ctx.callObjectMethod(
"getPackageManager", "()Landroid/content/pm/PackageManager;");
/* The builder, the build and the notify happen in Java (Notifier.java):
* a Java exception is invisible to C++ once Qt's JNI wrapper has cleared
* it, so on the phone this only ever said "failed, no exception detail"
* while the shade stayed empty. Java catches it and hands back the line to
* show. */
QJniObject launch;
if (pm.isValid() && pkg.isValid())
launch = pm.callObjectMethod(
"getLaunchIntentForPackage",
"(Ljava/lang/String;)Landroid/content/Intent;", pkg.object<jstring>());
QJniObject posted = QJniObject::callStaticObjectMethod(
"xyz/whcself/cstagent/Notifier", "post",
"(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;"
"Ljava/lang/String;ILandroid/content/Intent;I)Ljava/lang/String;",
ctx.object<jobject>(),
QJniObject::fromString(channelId).object<jstring>(),
QJniObject::fromString(title).object<jstring>(),
QJniObject::fromString(message).object<jstring>(), icon,
launch.isValid() ? launch.object<jobject>() : nullptr,
static_cast<jint>(1));
const QString java =
posted.isValid() ? posted.toString()
: QStringLiteral("the Notifier helper is not in this APK");
if (!java.isEmpty()) {
*err = QStringLiteral("cannot post the notification: %1").arg(java);
return false;
}
return true;
}
/* Runs on the turn's worker thread: no GUI objects here. */
int notifyRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
cst_tool_result_t *r, void *ud) {
(void)ctx;
(void)ud;
const char *message = argOf(call, "message");
const char *title = argOf(call, "title");
if (!message || !message[0]) {
setOut(r, QByteArrayLiteral("error: notification_send needs a message"), 1);
return 0;
}
const QString t = title && title[0] ? QString::fromUtf8(title)
: QStringLiteral("CST-agent");
const QString m = QString::fromUtf8(message);
QString err;
bool ok = false;
onGuiThread([&] { ok = postNotification(t, m, &err); });
if (ok)
setOut(r, QByteArrayLiteral("notification sent"), 0);
else
setOut(r, QByteArrayLiteral("error: ") + err.toUtf8(), 1);
return 0;
}
/* --- clipboard_read / clipboard_write ----------------------------------- */
int clipboardProbe(char *why, size_t why_sz) {
if (QGuiApplication::clipboard())
return 0;
snprintf(why, why_sz, "no QClipboard (no GUI application)");
return -1;
}
int clipboardReadRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
cst_tool_result_t *r, void *ud) {
(void)ctx;
(void)ud;
const char *cap = argOf(call, "max_bytes");
const long max =
cap && cap[0] ? strtol(cap, nullptr, 10) : 65536L;
QString text;
bool have = false;
onGuiThread([&] {
if (QClipboard *cb = QGuiApplication::clipboard()) {
text = cb->text();
have = true;
}
});
if (!have) {
setOut(r, QByteArrayLiteral("error: no clipboard"), 1);
return 0;
}
QByteArray body = text.toUtf8();
const bool cut = max > 0 && body.size() > max;
if (cut)
body.truncate(static_cast<int>(max));
QByteArray out =
QByteArrayLiteral("clipboard text (") + QByteArray::number(body.size()) +
QByteArrayLiteral(" bytes") +
(cut ? QByteArrayLiteral(", truncated") : QByteArray()) +
QByteArrayLiteral("):\n") + body + QByteArrayLiteral("\n");
setOut(r, out, 0);
return 0;
}
int clipboardWriteRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
cst_tool_result_t *r, void *ud) {
(void)ctx;
(void)ud;
const char *text = argOf(call, "text");
if (!text) {
setOut(r, QByteArrayLiteral("error: clipboard_write needs text"), 1);
return 0;
}
const QString t = QString::fromUtf8(text);
bool ok = false;
onGuiThread([&] {
if (QClipboard *cb = QGuiApplication::clipboard()) {
cb->setText(t);
ok = true;
}
});
if (ok)
setOut(r, QByteArrayLiteral("clipboard: replaced with ") +
QByteArray::number(t.toUtf8().size()) +
QByteArrayLiteral(" bytes"),
0);
else
setOut(r, QByteArrayLiteral("error: no clipboard"), 1);
return 0;
}
/* --- share --------------------------------------------------------------- */
/* A path inside the workspace, resolved the way the engine's file tools do:
* relative to the workspace and never outside it. */
QString resolveInWorkspace(const cst_tool_ctx_t *ctx, const char *rel) {
if (!ctx || !ctx->workspace_root || !rel || !rel[0])
return QString();
const QString root = QDir::cleanPath(QString::fromUtf8(ctx->workspace_root));
const QString full = QDir::cleanPath(root + QLatin1Char('/') +
QString::fromUtf8(rel));
if (full != root && !full.startsWith(root + QLatin1Char('/')))
return QString();
return full;
}
QString mimeOf(const QString &path) {
const QString ext = QFileInfo(path).suffix().toLower();
if (ext == QLatin1String("png")) return QStringLiteral("image/png");
if (ext == QLatin1String("jpg") || ext == QLatin1String("jpeg"))
return QStringLiteral("image/jpeg");
if (ext == QLatin1String("webp")) return QStringLiteral("image/webp");
if (ext == QLatin1String("gif")) return QStringLiteral("image/gif");
if (ext == QLatin1String("pdf")) return QStringLiteral("application/pdf");
if (ext == QLatin1String("json")) return QStringLiteral("application/json");
if (ext == QLatin1String("txt") || ext == QLatin1String("md") ||
ext == QLatin1String("log"))
return QStringLiteral("text/plain");
return QStringLiteral("*/*");
}
/* ACTION_SEND through the system share sheet. `text` shares a sentence (the
* usual case: the user takes it to another app), `file` shares one file out of
* the workspace by way of the FileProvider Qt's manifest template declares
* (androidx.core.content.FileProvider, authority <package>.qtprovider). */
bool shareText(const QString &text, const QString &subject, QString *err) {
QJniObject ctx = appContext();
if (!ctx.isValid()) {
*err = QStringLiteral("no Android context");
return false;
}
QJniObject send = QJniObject::fromString(QStringLiteral("android.intent.action.SEND"));
QJniObject intent("android/content/Intent", "(Ljava/lang/String;)V",
send.object<jstring>());
intent.callObjectMethod("setType",
"(Ljava/lang/String;)Landroid/content/Intent;",
QJniObject::fromString(QStringLiteral("text/plain"))
.object<jstring>());
intent.callObjectMethod(
"putExtra",
"(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;",
QJniObject::fromString(QStringLiteral("android.intent.extra.TEXT"))
.object<jstring>(),
QJniObject::fromString(text).object<jstring>());
if (!subject.isEmpty())
intent.callObjectMethod(
"putExtra",
"(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;",
QJniObject::fromString(QStringLiteral("android.intent.extra.SUBJECT"))
.object<jstring>(),
QJniObject::fromString(subject).object<jstring>());
QJniObject chooser("android/content/Intent", "(Ljava/lang/String;)V",
QJniObject::fromString(QStringLiteral("android.intent.action.CHOOSER"))
.object<jstring>());
/* Intent.createChooser(Intent, CharSequence) -- the share sheet itself. */
QJniObject sheet = QJniObject::callStaticObjectMethod(
"android/content/Intent", "createChooser",
"(Landroid/content/Intent;Ljava/lang/CharSequence;)Landroid/content/Intent;",
intent.object<jobject>(),
QJniObject::fromString(QStringLiteral("Share with")).object<jstring>());
QJniObject toStart = sheet.isValid() ? sheet : intent;
toStart.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
static_cast<jint>(0x10000000) /* NEW_TASK */);
ctx.callMethod<void>("startActivity", "(Landroid/content/Intent;)V",
toStart.object<jobject>());
return true;
}
/* The content:// uri of a file inside the app's own data directory. The
* FileProvider and its paths xml come from Qt's manifest template
* (authority <package>.qtprovider, files-path "/"), which covers the whole
* workspace and the session store. */
QJniObject contentUriFor(const QString &path, QString *err) {
QJniObject ctx = appContext();
if (!ctx.isValid()) {
*err = QStringLiteral("no Android context");
return QJniObject();
}
QJniObject pkg = ctx.callObjectMethod("getPackageName", "()Ljava/lang/String;");
const QString authority = pkg.toString() + QStringLiteral(".qtprovider");
QJniObject file("java/io/File", "(Ljava/lang/String;)V",
QJniObject::fromString(path).object<jstring>());
QJniObject uri = QJniObject::callStaticObjectMethod(
"androidx/core/content/FileProvider", "getUriForFile",
"(Landroid/content/Context;Ljava/lang/String;Ljava/io/File;)Landroid/net/Uri;",
ctx.object<jobject>(),
QJniObject::fromString(authority).object<jstring>(),
file.object<jobject>());
if (!uri.isValid())
*err = QStringLiteral("the FileProvider did not hand out a uri for ") + path;
return uri;
}
bool shareFile(const QString &path, const QString &text, QString *err) {
QJniObject ctx = appContext();
if (!ctx.isValid()) {
*err = QStringLiteral("no Android context");
return false;
}
QJniObject uri = contentUriFor(path, err);
if (!uri.isValid())
return false;
QJniObject action = QJniObject::fromString(QStringLiteral("android.intent.action.SEND"));
QJniObject intent("android/content/Intent", "(Ljava/lang/String;)V",
action.object<jstring>());
intent.callObjectMethod("setType",
"(Ljava/lang/String;)Landroid/content/Intent;",
QJniObject::fromString(mimeOf(path)).object<jstring>());
intent.callObjectMethod(
"putExtra",
"(Ljava/lang/String;Landroid/os/Parcelable;)Landroid/content/Intent;",
QJniObject::fromString(QStringLiteral("android.intent.extra.STREAM"))
.object<jstring>(),
uri.object<jobject>());
if (!text.isEmpty())
intent.callObjectMethod(
"putExtra",
"(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;",
QJniObject::fromString(QStringLiteral("android.intent.extra.TEXT"))
.object<jstring>(),
QJniObject::fromString(text).object<jstring>());
intent.callObjectMethod(
"addFlags", "(I)Landroid/content/Intent;",
static_cast<jint>(0x10000000 /* NEW_TASK */ | 0x00000001 /* GRANT_READ_URI_PERMISSION */));
QJniObject sheet = QJniObject::callStaticObjectMethod(
"android/content/Intent", "createChooser",
"(Landroid/content/Intent;Ljava/lang/CharSequence;)Landroid/content/Intent;",
intent.object<jobject>(),
QJniObject::fromString(QStringLiteral("Share with")).object<jstring>());
QJniObject toStart = sheet.isValid() ? sheet : intent;
toStart.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
static_cast<jint>(0x10000000));
ctx.callMethod<void>("startActivity", "(Landroid/content/Intent;)V",
toStart.object<jobject>());
return true;
}
int shareRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
cst_tool_result_t *r, void *ud) {
(void)ud;
const char *text = argOf(call, "text");
const char *file = argOf(call, "file");
const char *subject = argOf(call, "subject");
if ((!text || !text[0]) && (!file || !file[0])) {
setOut(r, QByteArrayLiteral("error: share needs text or file"), 1);
return 0;
}
QString path;
if (file && file[0]) {
path = resolveInWorkspace(ctx, file);
if (path.isEmpty() || !QFileInfo::exists(path)) {
setOut(r, QByteArrayLiteral("error: ") + QByteArray(file) +
QByteArrayLiteral(" is not inside the workspace"),
1);
return 0;
}
}
const QString body = text ? QString::fromUtf8(text) : QString();
const QString subjectLine = subject ? QString::fromUtf8(subject) : QString();
QString err;
bool ok = false;
onGuiThread([&] {
ok = path.isEmpty() ? shareText(body, subjectLine, &err)
: shareFile(path, body, &err);
});
if (!ok) {
setOut(r, QByteArrayLiteral("error: ") + err.toUtf8(), 1);
return 0;
}
setOut(r, path.isEmpty()
? QByteArrayLiteral("the share sheet is open with the text")
: QByteArrayLiteral("the share sheet is open with ") +
path.toUtf8(),
0);
return 0;
}
int shareProbe(char *why, size_t why_sz) {
if (appContext().isValid())
return 0;
snprintf(why, why_sz, "no Android activity to share from");
return -1;
}
/* Registered once; the engine keeps a copy until the owner is unregistered. */
cst_tool_def_t g_notify;
cst_tool_def_t g_clip_read;
cst_tool_def_t g_clip_write;
cst_tool_def_t g_share;
} // namespace
/* A file card's "open": whatever app the phone has for the type, with the read
* permission granted to the app that picks it up. */
QString openLocalFile(const QString &path, const QString &title,
QString *status) {
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
if (status)
*status = QStringLiteral("opened %1 (test mode)").arg(name);
return QString();
}
if (!QFileInfo::exists(path))
return QStringLiteral("the file is gone: %1").arg(name);
QString err;
bool ok = false;
onGuiThread([&] {
QJniObject ctx = appContext();
if (!ctx.isValid()) {
err = QStringLiteral("no Android context");
return;
}
QJniObject uri = contentUriFor(path, &err);
if (!uri.isValid())
return;
QJniObject action =
QJniObject::fromString(QStringLiteral("android.intent.action.VIEW"));
QJniObject intent("android/content/Intent", "(Ljava/lang/String;)V",
action.object<jstring>());
intent.callObjectMethod(
"setDataAndType",
"(Landroid/net/Uri;Ljava/lang/String;)Landroid/content/Intent;",
uri.object<jobject>(),
QJniObject::fromString(mimeOf(path)).object<jstring>());
intent.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
static_cast<jint>(0x10000000 /* NEW_TASK */ |
0x00000001 /* GRANT_READ_URI */));
/* Nothing installed for the type: say so rather than fail silently. */
QJniObject pm = ctx.callObjectMethod(
"getPackageManager", "()Landroid/content/pm/PackageManager;");
QJniObject found =
pm.isValid()
? pm.callObjectMethod(
"resolveActivity",
"(Landroid/content/Intent;I)Landroid/content/pm/ResolveInfo;",
intent.object<jobject>(),
static_cast<jint>(0x00010000) /* MATCH_DEFAULT_ONLY */)
: QJniObject();
if (!found.isValid()) {
err = QStringLiteral("no app on the phone opens %1").arg(name);
return;
}
ctx.callMethod<void>("startActivity", "(Landroid/content/Intent;)V",
intent.object<jobject>());
ok = true;
});
if (!ok)
return err.isEmpty() ? QStringLiteral("cannot open %1").arg(name) : err;
if (status)
*status = QStringLiteral("opened %1").arg(name);
return QString();
}
/* The share sheet, the same way the share tool opens it. */
QString shareLocalFile(const QString &path, const QString &title,
QString *status) {
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
if (status)
*status = QStringLiteral("shared %1 (test mode)").arg(name);
return QString();
}
if (!QFileInfo::exists(path))
return QStringLiteral("the file is gone: %1").arg(name);
QString err;
bool ok = false;
onGuiThread([&] { ok = shareFile(path, QString(), &err); });
if (!ok)
return err.isEmpty() ? QStringLiteral("cannot share %1").arg(name) : err;
if (status)
*status = QStringLiteral("share sheet: %1").arg(name);
return QString();
}
/* An Intent aimed at one of our own classes, by name: the Java side is
* reached through an explicit component, so no other app can call it (and no
* intent filter has to exist). Empty action means "start". */
QJniObject serviceIntent(const QJniObject &ctx, const QString &action) {
QJniObject pkg = ctx.callObjectMethod("getPackageName",
"()Ljava/lang/String;");
if (!pkg.isValid())
return QJniObject();
QJniObject comp(
"android/content/ComponentName", "(Ljava/lang/String;Ljava/lang/String;)V",
pkg.object<jstring>(),
QJniObject::fromString(
QStringLiteral("xyz.whcself.cstagent.CstService"))
.object<jstring>());
if (!comp.isValid())
return QJniObject();
QJniObject intent("android/content/Intent");
intent.callObjectMethod(
"setComponent",
"(Landroid/content/ComponentName;)Landroid/content/Intent;",
comp.object<jobject>());
if (!action.isEmpty())
intent.callObjectMethod("setAction",
"(Ljava/lang/String;)Landroid/content/Intent;",
QJniObject::fromString(action).object<jstring>());
return intent;
}
/* A turn can outlive the screen, and a phone freezes or kills an app it
* cannot see: CstService (app/android/src/xyz/whcself/cstagent/CstService.java)
* is started as a foreground service for as long as one runs, and it takes the
* partial wake lock that keeps the CPU awake with the screen off. The explicit
* component means nothing outside the app can start it. */
QString holdForeground(const QString &text) {
if (qEnvironmentVariableIsSet("CST_APP_NO_SERVICE"))
return QString(); /* a test run leaves the phone alone */
QString err;
onGuiThread([&] {
QJniObject ctx = appContext();
if (!ctx.isValid()) {
err = QStringLiteral("no Android context");
return;
}
QJniObject intent = serviceIntent(ctx, QString());
if (!intent.isValid()) {
err = QStringLiteral("no ComponentName for CstService");
return;
}
intent.callObjectMethod(
"putExtra",
"(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;",
QJniObject::fromString(QStringLiteral("text")).object<jstring>(),
QJniObject::fromString(text).object<jstring>());
QJniObject started =
sdkInt() >= 26
? ctx.callObjectMethod(
"startForegroundService",
"(Landroid/content/Intent;)Landroid/content/ComponentName;",
intent.object<jobject>())
: ctx.callObjectMethod(
"startService",
"(Landroid/content/Intent;)Landroid/content/ComponentName;",
intent.object<jobject>());
if (!started.isValid())
err = QStringLiteral("the phone refused the foreground service");
});
return err;
}
void releaseForeground() {
if (qEnvironmentVariableIsSet("CST_APP_NO_SERVICE"))
return;
onGuiThread([&] {
QJniObject ctx = appContext();
if (!ctx.isValid())
return;
QJniObject intent = serviceIntent(
ctx, QStringLiteral("xyz.whcself.cstagent.STOP"));
if (intent.isValid())
ctx.callMethod<jboolean>("stopService",
"(Landroid/content/Intent;)Z",
intent.object<jobject>());
});
}
/* What the picker gave us -> a path the engine can open. A SAF URI is copied
* into the app's own cache (the URI's grant covers the read; no permission is
* involved), a plain path is left alone. */
QString materializeAttachment(const QString &pathOrUri, QString *err) {
if (err)
err->clear();
if (pathOrUri.isEmpty())
return QString();
if (!pathOrUri.startsWith(QLatin1String("content://"))) {
QString p = pathOrUri;
if (p.startsWith(QLatin1String("file://")))
p = p.mid(7);
return p;
}
const QString dest = QStandardPaths::writableLocation(
QStandardPaths::CacheLocation) +
QStringLiteral("/picked");
QString result, failure;
onGuiThread([&] {
QJniObject ctx = appContext();
if (!ctx.isValid()) {
failure = QStringLiteral("no Android context to copy the file with");
return;
}
QJniObject out = QJniObject::callStaticObjectMethod(
"xyz/whcself/cstagent/AttachFile", "materialize",
"(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)"
"Ljava/lang/String;",
ctx.object<jobject>(), QJniObject::fromString(pathOrUri).object<jstring>(),
QJniObject::fromString(dest).object<jstring>());
if (!out.isValid()) {
failure = QStringLiteral("the phone could not hand that file over");
return;
}
const QString s = out.toString();
if (s.startsWith(QLatin1String("error: ")))
failure = s.mid(7);
else if (!s.isEmpty())
result = s;
/* "" means it was a path all along; the caller keeps what it had */
});
if (!failure.isEmpty()) {
if (err)
*err = failure;
return QString();
}
return result.isEmpty() ? pathOrUri : result;
}
bool hasAllFilesAccess() {
bool granted = true;
onGuiThread([&] {
QJniObject ctx = appContext();
if (!ctx.isValid())
return;
granted = QJniObject::callStaticMethod<jboolean>(
"xyz/whcself/cstagent/AttachFile", "hasAllFilesAccess",
"(Landroid/content/Context;)Z", ctx.object<jobject>());
});
return granted;
}
QString requestAllFilesAccess() {
if (qEnvironmentVariableIsSet("CST_APP_NO_PERM"))
return QString(); /* a test run touches no settings */
QString err;
onGuiThread([&] {
QJniObject ctx = appContext();
if (!ctx.isValid())
return;
QJniObject out = QJniObject::callStaticObjectMethod(
"xyz/whcself/cstagent/AttachFile", "requestAllFilesAccess",
"(Landroid/content/Context;)Ljava/lang/String;",
ctx.object<jobject>());
if (out.isValid()) {
const QString s = out.toString();
if (!s.isEmpty())
err = s;
}
});
return err;
}
int platformRegisterAndroid(AgentCore *core) {
if (!core)
return 0;
int n = 0;
{
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
{"message", "string", 1, "text of the notification"},
{"title", "string", 0, "title line (default: CST-agent)"},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr}};
memset(&g_notify, 0, sizeof(g_notify));
setMeta(&g_notify, "notification_send",
"Show a notification in the phone's shade (what the user sees "
"outside the app).",
CST_TP_ASK, "notify", kAndroidOnly, notifyProbe);
memcpy(g_notify.params, params, sizeof(params));
g_notify.run_ex = notifyRun;
g_notify.owner = "app:android";
n += core->registerTool(&g_notify) ? 1 : 0;
}
{
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
{"max_bytes", "integer", 0, "cap on the returned text (default 65536)"},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr}};
memset(&g_clip_read, 0, sizeof(g_clip_read));
setMeta(&g_clip_read, "clipboard_read",
"Read the text on the phone's clipboard — what the user copied "
"outside the workspace. Returns UTF-8 text.",
CST_TP_READ, "read", kAndroidOnly, clipboardProbe);
memcpy(g_clip_read.params, params, sizeof(params));
g_clip_read.run_ex = clipboardReadRun;
g_clip_read.owner = "app:android";
n += core->registerTool(&g_clip_read) ? 1 : 0;
}
{
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
{"text", "string", 1, "text to put on the clipboard"},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr}};
memset(&g_clip_write, 0, sizeof(g_clip_write));
setMeta(&g_clip_write, "clipboard_write",
"Replace the phone's clipboard with text, so the user can paste it "
"into another app. Overwrites what they had copied.",
CST_TP_WRITE, "write", kAndroidOnly, clipboardProbe);
memcpy(g_clip_write.params, params, sizeof(params));
g_clip_write.run_ex = clipboardWriteRun;
g_clip_write.owner = "app:android";
n += core->registerTool(&g_clip_write) ? 1 : 0;
}
{
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
{"text", "string", 0, "text to share"},
{"file", "string", 0, "workspace-relative file to share"},
{"subject", "string", 0, "subject line of the shared item"},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr}};
memset(&g_share, 0, sizeof(g_share));
setMeta(&g_share, "share",
"Hand text or a workspace file to the system share sheet, so the "
"user can send it to another app.",
CST_TP_ASK, "share", kAndroidOnly, shareProbe);
memcpy(g_share.params, params, sizeof(params));
g_share.run_ex = shareRun;
g_share.owner = "app:android";
n += core->registerTool(&g_share) ? 1 : 0;
}
return n;
}
} // namespace app
+180
View File
@@ -0,0 +1,180 @@
/* Platform tools on Linux, implemented by the app (not by the engine): they
* exist exactly where the desktop service does, and the engine only ever sees
* the capability -- platforms + probe decide whether the tool is offered. */
#include <QByteArray>
#include <QClipboard>
#include <QDesktopServices>
#include <QFileInfo>
#include <QGuiApplication>
#include <QProcess>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <cstdio>
#include <cstring>
#include "core/AgentCore.h"
#include "core/ToolProvider.h"
namespace app {
namespace {
/* whereis-style check without a shell: the same rule the engine uses for the
* clipboard helpers. */
bool haveProgram(const char *name) {
QByteArray path = qgetenv("PATH");
const QList<QByteArray> dirs = path.split(':');
for (const QByteArray &d : dirs) {
QByteArray dir = d.isEmpty() ? QByteArray(".") : d;
QByteArray cand = dir + "/" + name;
if (QFileInfo::exists(QString::fromLocal8Bit(cand)) &&
QFileInfo(QString::fromLocal8Bit(cand)).isExecutable())
return true;
}
return false;
}
/* 0 when notifications can be shown; -1 with the reason otherwise. */
int notifyProbe(char *why, size_t why_sz) {
if (haveProgram("notify-send"))
return 0;
if (haveProgram("gdbus") && !qgetenv("DBUS_SESSION_BUS_ADDRESS").isEmpty())
return 0;
snprintf(why, why_sz,
"no notification service (no notify-send, no D-Bus session bus)");
return -1;
}
const char *argOf(const ctl_tool_call_t *call, const char *key) {
for (int i = 0; i < call->nargs; i++)
if (strcmp(call->args[i].key, key) == 0)
return call->args[i].value;
return nullptr;
}
/* Runs on the turn's worker thread: no GUI objects here. */
int notifyRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
cst_tool_result_t *r, void *ud) {
(void)ctx;
(void)ud;
const char *title = argOf(call, "title");
const char *message = argOf(call, "message");
if (!message || !message[0]) {
const char *msg = "error: notification_send needs a message";
r->out = strdup(msg);
r->out_len = r->out ? strlen(msg) : 0;
r->exit_code = 1;
return 0;
}
QStringList args;
if (title && title[0])
args << QString::fromUtf8(title);
else
args << QStringLiteral("CST-agent");
args << QString::fromUtf8(message);
const int rc = QProcess::execute(QStringLiteral("notify-send"), args);
const QByteArray text =
(rc == 0 ? QByteArrayLiteral("notification sent")
: QByteArrayLiteral("notify-send failed (rc=") +
QByteArray::number(rc) + QByteArrayLiteral(")"));
r->out = strdup(text.constData());
r->out_len = r->out ? strlen(r->out) : 0;
r->exit_code = rc == 0 ? 0 : 1;
return 0;
}
/* Registered once; the engine keeps a copy until the owner is unregistered. */
cst_tool_def_t g_notify;
} // namespace
/* A file card's "open": xdg-open through QDesktopServices, so the desktop's
* own application for the type comes up. */
QString openLocalFile(const QString &path, const QString &title,
QString *status) {
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
if (status)
*status = QStringLiteral("opened %1 (test mode)").arg(name);
return QString();
}
if (!QFileInfo::exists(path))
return QStringLiteral("the file is gone: %1").arg(name);
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(path)))
return QStringLiteral("no application on this desktop opens %1").arg(name);
if (status)
*status = QStringLiteral("opened %1").arg(name);
return QString();
}
/* No share sheet here: the path is what another program (or another window)
* needs, so it goes to the clipboard. */
QString shareLocalFile(const QString &path, const QString &title,
QString *status) {
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
if (status)
*status = QStringLiteral("shared %1 (test mode)").arg(name);
return QString();
}
if (!QFileInfo::exists(path))
return QStringLiteral("the file is gone: %1").arg(name);
QGuiApplication::clipboard()->setText(path);
if (status)
*status = QStringLiteral("%1: the path is on the clipboard").arg(name);
return QString();
}
/* A desktop has nothing to hold: the window is the app, and a turn runs as
* long as it is open -- the machine does not freeze a process it can see.
* Kept here so the front end calls one API on every platform. */
QString holdForeground(const QString &text) {
(void)text;
return QString();
}
void releaseForeground() {}
/* A desktop picker hands over a path, so there is nothing to turn into one:
* a file:// URL loses its scheme and a content:// URI (a portal that never
* gave us a path) is refused with what the user should do instead. */
QString materializeAttachment(const QString &pathOrUri, QString *err) {
QString s = pathOrUri;
if (s.startsWith(QLatin1String("file://")))
s = s.mid(7);
if (s.startsWith(QLatin1String("content://"))) {
if (err)
*err = QStringLiteral("that picker handed over a content:// URI this "
"desktop cannot open; pick the file by path "
"instead");
return QString();
}
return s;
}
/* Every desktop process reads the files its user can. */
bool hasAllFilesAccess() { return true; }
QString requestAllFilesAccess() { return QString(); }
int platformRegisterLinux(AgentCore *core) {
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
{"message", "string", 1, "text of the notification"},
{"title", "string", 0, "title line (default: CST-agent)"},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr}};
memset(&g_notify, 0, sizeof(g_notify));
setMeta(&g_notify, "notification_send",
"Show a desktop notification (what the user sees outside the app).",
CST_TP_ASK, "notify", kDesktopOnly, notifyProbe);
memcpy(g_notify.params, params, sizeof(params));
g_notify.run = nullptr;
g_notify.run_ex = notifyRun;
g_notify.ud = nullptr;
g_notify.owner = "app:linux";
return core->registerTool(&g_notify) ? 1 : 0;
}
} // namespace app
+81
View File
@@ -0,0 +1,81 @@
#include "ui/SessionImages.h"
#include <QFileInfo>
#include <QImageReader>
#include <QQmlApplicationEngine>
#include "cst_attach.h"
namespace app {
namespace {
/* "img-1a2b3c4d.png?w=160": the stored name, then what the view asked for.
* The size comes from the QML side as sourceSize, which Qt passes in as
* requestedSize; the query string is accepted too, so a plain URL can ask. */
QString relOf(const QString &id) {
const int q = id.indexOf(QLatin1Char('?'));
return q < 0 ? id : id.left(q);
}
} // namespace
SessionImages::SessionImages()
: QQuickImageProvider(QQuickImageProvider::Image) {}
void SessionImages::setDir(const QString &dir) {
QMutexLocker lock(&m_mu);
m_dir = dir;
}
QImage SessionImages::requestImage(const QString &id, QSize *size,
const QSize &requestedSize) {
const QString rel = relOf(id);
if (!cst_attach_rel_ok(rel.toUtf8().constData()))
return QImage(); /* QML sees Image.Error and falls back to the name */
QString dir;
{
QMutexLocker lock(&m_mu);
dir = m_dir;
}
if (dir.isEmpty())
return QImage();
const QString path = dir + QLatin1Char('/') + rel;
if (!QFileInfo::exists(path))
return QImage();
QImageReader reader(path);
reader.setAutoTransform(true);
const QSize full = reader.size();
if (!full.isValid()) {
/* No size in the header (or not a picture at all): hand back what Qt
* makes of it and let the view scale it. */
const QImage img = reader.read();
if (size)
*size = img.size();
return img;
}
/* Shrink to what the view shows, never up: a screenshot is decoded at the
* size of its thumbnail instead of at 4K. */
const bool smaller = requestedSize.isValid() && requestedSize.width() > 0 &&
requestedSize.height() > 0 &&
(full.width() > requestedSize.width() ||
full.height() > requestedSize.height());
if (smaller) {
const QSize scaled = full.scaled(requestedSize, Qt::KeepAspectRatio);
reader.setScaledSize(scaled);
if (size)
*size = scaled;
} else if (size) {
*size = full;
}
return reader.read();
}
SessionImages *installSessionImages(QQmlApplicationEngine *engine) {
SessionImages *images = new SessionImages;
if (engine)
engine->addImageProvider(QStringLiteral("cstattach"), images);
return images;
}
} // namespace app
+45
View File
@@ -0,0 +1,45 @@
/* The session's stored images as a QML image provider:
*
* image://cstattach/<stored name> (see cst_attach.h: "img-1a2b3c4d.png")
*
* The files live in the current session's attachment directory, which is what
* AgentCore::attachmentDir() names. Only that directory is served, and only
* names that pass the engine's own cst_attach_rel_ok() -- a transcript can
* carry hand-edited JSON, and nothing in it may reach outside the store.
*
* requestImage() runs on Quick's render thread while setDir() comes from the
* window's thread, hence the mutex. */
#ifndef CST_APP_SESSION_IMAGES_H
#define CST_APP_SESSION_IMAGES_H
#include <QImage>
#include <QMutex>
#include <QQuickImageProvider>
#include <QString>
class QQmlApplicationEngine;
namespace app {
class SessionImages : public QQuickImageProvider {
public:
SessionImages();
/* The directory holding the current session's images; "" disables serving. */
void setDir(const QString &dir);
QImage requestImage(const QString &id, QSize *size,
const QSize &requestedSize) override;
private:
mutable QMutex m_mu;
QString m_dir;
};
/* Registers image://cstattach/ on the engine and hands the provider back, so
* the caller can keep its directory in step with the agent's session. */
SessionImages *installSessionImages(QQmlApplicationEngine *engine);
} // namespace app
#endif // CST_APP_SESSION_IMAGES_H
+136
View File
@@ -0,0 +1,136 @@
#include "ui/ThemeIcons.h"
#include <QDir>
#include <QGuiApplication>
#include <QIcon>
#include <QPainter>
#include <QQmlApplicationEngine>
#include <QScreen>
#include <QStandardPaths>
#include <QStringList>
#include <QUrl>
namespace app {
namespace {
/* What a usable theme has to have; every window in the app needs it. */
const char kProbe[] = "mail-attachment";
/* The desktop's own theme first (whatever the platform theme set), then
* Breeze: this window is dark, so the dark variant, and Breeze proper as
* Qt's fallback for the odd name that only lives there. Before any of that
* the search paths have to be filled in -- Qt only builds them from XDG once
* a theme name was set, and a shell that was started outside the desktop
* session (nix-shell) has no XDG_DATA_DIRS pointing at the icon packages,
* hence the system profile. */
void adoptIconTheme() {
const QStringList known = QStandardPaths::standardLocations(
QStandardPaths::GenericDataLocation);
QStringList paths;
for (const QString &d : known)
paths << d + QStringLiteral("/icons");
paths << QDir::homePath() + QStringLiteral("/.icons");
const QString system = QStringLiteral("/run/current-system/sw/share/icons");
if (!paths.contains(system) && QDir(system).exists())
paths << system;
paths << QStringLiteral(":/icons");
QIcon::setThemeSearchPaths(paths);
QStringList candidates;
if (!QIcon::themeName().isEmpty())
candidates << QIcon::themeName();
candidates << QStringLiteral("breeze-dark") << QStringLiteral("breeze")
<< QStringLiteral("hicolor");
for (const QString &name : candidates) {
QIcon::setThemeName(name);
if (QIcon::hasThemeIcon(QLatin1String(kProbe)))
return;
}
}
/* The bundled set: app/icons/*.svg, compiled in as :/cst-icons/<name>.svg by
* app/CMakeLists.txt. A machine without an icon theme -- Android, where the
* Qt theme lookup finds nothing and QML was left showing its glyph fallback,
* which the phone fonts cannot draw -- gets the same icons from here. */
QIcon bundledIcon(const QString &name) {
return QIcon(QStringLiteral(":/cst-icons/") + name + QStringLiteral(".svg"));
}
QIcon iconForName(const QString &name) {
const QIcon themed = QIcon::fromTheme(name);
if (!themed.isNull())
return themed;
return bundledIcon(name);
}
/* "?color=%23eff0f1": the tint, and nothing else so far. */
QColor colorOption(const QString &id, QString *name) {
QColor color;
*name = id;
const int q = id.indexOf(QLatin1Char('?'));
if (q < 0)
return color;
*name = id.left(q);
const QStringList options = id.mid(q + 1).split(QLatin1Char('&'));
for (const QString &opt : options) {
if (!opt.startsWith(QLatin1String("color=")))
continue;
const QColor c(QUrl::fromPercentEncoding(opt.mid(6).toUtf8()));
if (c.isValid())
color = c;
}
return color;
}
qreal targetRatio() {
if (const QScreen *s = QGuiApplication::primaryScreen())
return qMax<qreal>(1.0, s->devicePixelRatio());
return 1.0;
}
} // namespace
ThemeIconProvider::ThemeIconProvider()
: QQuickImageProvider(QQuickImageProvider::Image) {}
QImage ThemeIconProvider::requestImage(const QString &id, QSize *size,
const QSize &requestedSize) {
QString name;
const QColor color = colorOption(id, &name);
if (name.isEmpty())
return QImage();
const QIcon icon = iconForName(name);
if (icon.isNull())
return QImage(); /* QML shows its fallback glyph */
const int px = requestedSize.isValid() && requestedSize.width() > 0
? requestedSize.width()
: 22;
const qreal ratio = targetRatio();
QPixmap pm = icon.pixmap(QSize(px, px), ratio, QIcon::Normal, QIcon::Off);
if (pm.isNull())
return QImage();
if (color.isValid()) {
QPainter p(&pm);
p.setCompositionMode(QPainter::CompositionMode_SourceIn);
p.fillRect(pm.rect(), color);
}
QImage img = pm.toImage();
img.setDevicePixelRatio(ratio);
if (size)
*size = QSize(px, px);
return img;
}
void installThemeIcons(QQmlApplicationEngine *engine) {
adoptIconTheme();
if (engine)
engine->addImageProvider(QStringLiteral("csttheme"), new ThemeIconProvider);
}
QString iconThemeName() { return QIcon::themeName(); }
bool themeIconAvailable(const QString &name) { return !iconForName(name).isNull(); }
} // namespace app
+47
View File
@@ -0,0 +1,47 @@
/* Icons from the desktop's icon theme (Breeze on KDE).
*
* The window did its icons with unicode glyphs (a clip emoji for attach, a
* gear, a hamburger): they render in whatever font happens to cover them --
* colour emoji in a grey toolbar -- and on a machine without that glyph they
* are a box. Every other desktop app asks the icon theme instead, and Qt
* Quick has no image provider for it without Kirigami, so this is one:
*
* image://csttheme/<icon-name>?color=%23rrggbb
*
* The icon is looked up with QIcon::fromTheme() in the desktop's theme
* (breeze-dark on a KDE desktop, Breeze as fallback), tinted to the asked-for
* color and rendered at the requested size. An unknown name yields a null
* image, which QML sees as Image.Error -- that is the hook for a glyph
* fallback, used on a machine with no icon theme at all. */
#ifndef CST_APP_THEMEICONS_H
#define CST_APP_THEMEICONS_H
#include <QImage>
#include <QQuickImageProvider>
#include <QString>
class QQmlApplicationEngine;
namespace app {
class ThemeIconProvider : public QQuickImageProvider {
public:
ThemeIconProvider();
QImage requestImage(const QString &id, QSize *size,
const QSize &requestedSize) override;
};
/* Points Qt at the desktop's icon theme and registers image://csttheme/.
* Call it on every engine that loads the window (main.cpp and --uitest). */
void installThemeIcons(QQmlApplicationEngine *engine);
/* The theme Qt was left on, for --selftest and the README. */
QString iconThemeName();
/* Whether the theme has this icon (what the fallback glyphs are for). */
bool themeIconAvailable(const QString &name);
} // namespace app
#endif /* CST_APP_THEMEICONS_H */
+860
View File
@@ -0,0 +1,860 @@
/* --uitest: drive the window with synthesized input.
*
* The GUI is checked the way a person uses it -- a mouse click on a segment,
* a finger tap on another, a narrowed window that has to collapse the
* sidebar -- instead of only loading the QML and hoping. Qt's test event
* synthesis does the pointing, so this runs on any platform, with or without
* a touchscreen, and can be scripted (see app/README.md). */
#include "UiTest.h"
#include <QGuiApplication>
#include <QClipboard>
#include <QBuffer>
#include <QFile>
#include <QImage>
#include <QJsonDocument>
#include <QJsonObject>
#include <QPainter>
#include <QPointingDevice>
#include <QQuickItem>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQuickWindow>
#include <QTest>
#include <QVariantMap>
#include <cstdio>
#include <cstring>
#include "cst_agent.h"
#include "cst_attach.h"
#include "ui/SessionImages.h"
#include "ui/ThemeIcons.h"
namespace app {
namespace {
int g_fail;
void check(bool ok, const QString &what) {
std::printf("%s %s\n", ok ? "ok " : "FAIL", qPrintable(what));
std::fflush(stdout);
if (!ok)
g_fail++;
}
/* Every item with this objectName in the visual tree. A view that has just
* been replayed can still hold the delegates it is about to drop, so a name
* can match more than one item for a moment. */
void collectNamed(QQuickItem *root, const QString &name,
QList<QQuickItem *> *out) {
if (!root)
return;
if (root->objectName() == name)
out->append(root);
const QList<QQuickItem *> kids = root->childItems();
for (QQuickItem *c : kids)
collectNamed(c, name, out);
}
int countNamed(QQuickItem *root, const QString &prefix) {
if (!root)
return 0;
int n = root->objectName().startsWith(prefix) ? 1 : 0;
const QList<QQuickItem *> kids = root->childItems();
for (QQuickItem *c : kids)
n += countNamed(c, prefix);
return n;
}
/* An item by objectName. The QObject tree is tried first (it holds the named
* controls); a view's delegates can be owned by the model instead, so the
* visual tree -- what a finger can actually reach -- is walked as well. */
QQuickItem *item(QQuickWindow *win, const QString &name) {
QList<QQuickItem *> hits;
collectNamed(win->contentItem(), name, &hits);
/* What a finger can reach is the one the test means: a delegate on its way
* out is still in the tree but invisible. */
for (QQuickItem *it : hits)
if (it->isVisible())
return it;
if (QQuickItem *it = win->findChild<QQuickItem *>(name))
return it;
return hits.isEmpty() ? nullptr : hits.first();
}
/* The centre of an item, in window coordinates. */
QPoint centre(QQuickItem *it) {
const QPointF p = it->mapToScene(QPointF(it->width() / 2, it->height() / 2));
return p.toPoint();
}
void clickMouse(QQuickWindow *win, const QString &name) {
QQuickItem *it = item(win, name);
if (!it || !it->isVisible()) {
check(false, QStringLiteral("mouse click %1: not there").arg(name));
return;
}
QTest::mouseClick(win, Qt::LeftButton, Qt::NoModifier, centre(it));
}
void tapTouch(QQuickWindow *win, QPointingDevice *dev, const QString &name) {
QQuickItem *it = item(win, name);
if (!it || !it->isVisible()) {
check(false, QStringLiteral("touch tap %1: not there").arg(name));
return;
}
const QPoint p = centre(it);
QTest::touchEvent(win, dev).press(0, p, win);
QTest::touchEvent(win, dev).release(0, p, win);
}
void resizeTo(QQuickWindow *win, int w, int h) {
win->resize(w, h);
QTest::qWait(250); /* let the layouts settle */
}
/* A stored session with a tool call in it, written through the store's own
* API, so the file is exactly what the engine reads back. */
QString writeFixtureSession(const QString &ws, const QString &id) {
const QByteArray w = ws.toUtf8();
char path[4096] = "";
if (cst_agent_store_path_for_id(w.constData(), id.toUtf8().constData(), path,
sizeof(path)) != 0)
return QString();
cst_session_t *s = cst_session_open(path);
if (!s)
return QString();
cst_message_t m;
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_USER;
m.content = strdup("what is here?");
/* The prompt carries a picture, stored exactly the way the engine stores
* one (the file next to the session, the message naming it). */
{
QImage pic(320, 200, QImage::Format_RGB32);
pic.fill(Qt::darkCyan);
QPainter painter(&pic);
painter.fillRect(QRect(0, 0, 160, 100), Qt::darkYellow);
painter.end();
QByteArray png;
QBuffer buf(&png);
buf.open(QIODevice::WriteOnly);
pic.save(&buf, "PNG");
char aerr[256] = "";
m.attach = cst_attach_import_bytes(path, "shot.png", png.constData(),
static_cast<size_t>(png.size()), aerr,
sizeof(aerr));
m.nattach = m.attach ? 1 : 0;
}
cst_session_append(s, &m);
cst_attachments_free(m.attach, m.nattach);
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_ASSISTANT;
m.content = strdup("let me look. ");
m.tool_calls_json = strdup(
"[{\"id\":\"call_1\",\"type\":\"function\",\"function\":"
"{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":"
"\\\"README.md\\\"}\"}}]");
cst_session_append(s, &m);
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_TOOL;
m.content = strdup("hello\n");
snprintf(m.tool_call_id, sizeof(m.tool_call_id), "%s", "call_1");
snprintf(m.tool_name, sizeof(m.tool_name), "%s", "read_file");
cst_session_append(s, &m);
/* A delivered file (deliver_file): the copy the agent hands to the person,
* marked for_user -- the row a file card is drawn from. */
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_ASSISTANT;
m.tool_calls_json = strdup(
"[{\"id\":\"call_2\",\"type\":\"function\",\"function\":"
"{\"name\":\"deliver_file\",\"arguments\":\"{\\\"path\\\":"
"\\\"report.md\\\"}\"}}]");
cst_session_append(s, &m);
memset(&m, 0, sizeof(m));
{
const QString src = ws + QStringLiteral("/report.md");
QFile rf(src);
if (rf.open(QIODevice::WriteOnly))
rf.write("# report\n");
rf.close();
char aerr[256] = "";
m.attach = cst_attach_import_artifact(path, src.toUtf8().constData(), NULL,
aerr, sizeof(aerr));
m.nattach = m.attach ? 1 : 0;
}
m.role = CST_ROLE_TOOL;
m.content = strdup("delivered report.md\n");
snprintf(m.tool_call_id, sizeof(m.tool_call_id), "%s", "call_2");
snprintf(m.tool_name, sizeof(m.tool_name), "%s", "deliver_file");
cst_session_append(s, &m);
cst_attachments_free(m.attach, m.nattach);
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_ASSISTANT;
m.reasoning = strdup("checked the file");
m.content = strdup("a repo.");
cst_session_append(s, &m);
cst_session_close(s);
return id;
}
/* A picture of an interesting state on request: the only way to look at the
* layout on a machine without a screen (CST_APP_SHOT=shot.png makes the run
* write shot.png at the end and shot-settings.png while the settings dialog
* is open). */
void maybeShot(QQuickWindow *win, const QString &suffix) {
if (!qEnvironmentVariableIsSet("CST_APP_SHOT"))
return;
const QString base = qEnvironmentVariable("CST_APP_SHOT");
const QString path =
suffix.isEmpty() ? base
: base.endsWith(QLatin1String(".png"))
? base.left(base.size() - 4) + suffix + QStringLiteral(".png")
: base + suffix + QStringLiteral(".png");
std::printf("%s %s\n",
win->grabWindow().save(path) ? "note wrote" : "note cannot write",
qPrintable(path));
}
} // namespace
int runUiTest(AgentCore &core, const QString &size) {
int width = 1000, height = 720;
const int x = size.indexOf(QLatin1Char('x'));
if (x > 0) {
const int w = size.left(x).toInt(), h = size.mid(x + 1).toInt();
if (w > 0 && h > 0) {
width = w;
height = h;
}
}
/* A workspace is never empty on a second run: the engine opens the last
* session of the store (cst_agent_open()), so the window is created with a
* transcript already in the engine. Put one there before the QML loads,
* which is exactly what a restart looks like. */
/* A file card hands its file to the system: in a test run nothing may open
* for real, and the platform layer itself honours this (see ToolProvider.h). */
qputenv("CST_APP_NO_OPEN", "1");
const QString resumed =
writeFixtureSession(core.workspace(), QStringLiteral("20260101-115959-on"));
check(!resumed.isEmpty() && core.switchSession(resumed),
QStringLiteral("the engine has a session open before the window exists"));
QQmlApplicationEngine engine;
installThemeIcons(&engine); /* the window's icons, same as main() */
/* image://cstattach/: the session's stored images, pointed at the session
* the engine has open (and re-pointed on every switch). */
SessionImages *images = installSessionImages(&engine);
images->setDir(core.attachmentDir());
QObject::connect(&core, &AgentCore::sessionChanged, images,
[images, &core] { images->setDir(core.attachmentDir()); });
engine.rootContext()->setContextProperty(QStringLiteral("agent"), &core);
engine.rootContext()->setContextProperty(QStringLiteral("initialWidth"), width);
engine.rootContext()->setContextProperty(QStringLiteral("initialHeight"), height);
engine.load(QUrl(QStringLiteral("qrc:/qt/qml/CstAgent/Main.qml")));
if (engine.rootObjects().isEmpty()) {
std::printf("FAIL the window did not load\n");
return 1;
}
QQuickWindow *win = qobject_cast<QQuickWindow *>(engine.rootObjects().value(0));
if (!win) {
std::printf("FAIL the root object is not a window\n");
return 1;
}
QTest::qWait(250);
/* --- the window opens on the session the engine already had ----------- */
{
QQuickItem *tr = item(win, QStringLiteral("transcript"));
const int rows = tr ? tr->property("count").toInt() : -1;
check(rows == 6,
QStringLiteral("the open session is on screen at startup (%1 rows)")
.arg(rows));
}
QQuickItem *docked = item(win, QStringLiteral("dockedSidebar"));
QQuickItem *navButton = item(win, QStringLiteral("navButton"));
QQuickItem *navPanel = item(win, QStringLiteral("navPanel"));
QQuickItem *composer = item(win, QStringLiteral("composerInput"));
QPointingDevice *touch = QTest::createTouchDevice();
/* --- a desktop window: the sidebar is docked, the drawer is gone --- */
check(docked && docked->isVisible(), QStringLiteral("wide window: sidebar docked"));
check(navButton && !navButton->isVisible(),
QStringLiteral("wide window: no drawer button"));
check(core.mode() == QLatin1String("coding"),
QStringLiteral("engine starts in coding mode"));
/* --- mouse: pick the other mode --- */
clickMouse(win, QStringLiteral("modeGeneral"));
check(core.mode() == QLatin1String("general"),
QStringLiteral("mouse click on \"general\" reaches the engine"));
check(item(win, QStringLiteral("modeGeneral"))->property("current").toBool(),
QStringLiteral("mouse click marks \"general\" as current"));
/* --- touch: tap the first mode back --- */
tapTouch(win, touch, QStringLiteral("modeCoding"));
check(core.mode() == QLatin1String("coding"),
QStringLiteral("touch tap on \"coding\" reaches the engine"));
/* --- mouse: the plan switch is a two-way control --- */
clickMouse(win, QStringLiteral("planSwitch"));
check(core.planMode(), QStringLiteral("plan switch turns plan mode on"));
clickMouse(win, QStringLiteral("planSwitch"));
check(!core.planMode(), QStringLiteral("plan switch turns it off again"));
/* --- keyboard: typing in the composer arms Send --- */
QQuickItem *send = item(win, QStringLiteral("sendButton"));
if (composer && send) {
composer->forceActiveFocus();
/* QTest::keyClicks() is QWidget-only; a window takes one key at a time. */
for (const QChar &c : QStringLiteral("hello"))
QTest::keyClick(win, c.toLatin1());
check(send->property("enabled").toBool(),
QStringLiteral("typing arms the send button"));
QTest::keyClick(win, Qt::Key_A, Qt::ControlModifier);
QTest::keyClick(win, Qt::Key_Backspace);
check(!send->property("enabled").toBool(),
QStringLiteral("an empty composer disarms it"));
} else {
check(false, QStringLiteral("composer and send button are reachable"));
}
/* --- sessions: the sidebar is the store's face ------------------------ */
QQuickItem *list = item(win, QStringLiteral("transcript"));
const QString fixture =
writeFixtureSession(core.workspace(), QStringLiteral("20260101-120000-fix"));
check(!fixture.isEmpty() && list != nullptr,
QStringLiteral("a session fixture with a tool call is in the store"));
QMetaObject::invokeMethod(win, "reloadSessions");
QTest::qWait(150);
int rowsSeen = countNamed(win->contentItem(), QStringLiteral("sessionRow:"));
if (qEnvironmentVariableIsSet("CST_APP_DUMP"))
std::printf("note: %d session rows in the visual tree\n", rowsSeen);
const int listed = win->property("sessionsView").toList().size();
check(item(win, QStringLiteral("sessionRow:") + fixture) != nullptr,
QStringLiteral("the stored session shows up in the sidebar (%1 rows, %2 listed)")
.arg(rowsSeen)
.arg(listed));
check(core.sessions().size() >= 2,
QStringLiteral("the store lists it next to the open one"));
/* the fixture is: user, agent + call, result, agent + call, delivered
* file, thought, agent */
clickMouse(win, QStringLiteral("sessionRow:") + fixture);
check(core.sessionId() == fixture,
QStringLiteral("a click on a row switches the engine"));
check(list && list->property("count").toInt() == 6,
QStringLiteral("the reopened transcript is replayed into the view"));
{
bool toolOk = false;
const QVariantList rows = core.history();
for (const QVariant &v : rows) {
const QVariantMap r = v.toMap();
if (r.value(QStringLiteral("kind")).toString() == QLatin1String("tool") &&
r.value(QStringLiteral("name")).toString() == QLatin1String("read_file"))
toolOk = r.value(QStringLiteral("complete")).toBool() &&
r.value(QStringLiteral("output")).toString() ==
QLatin1String("hello\n");
}
check(toolOk, QStringLiteral("a tool result is paired with its call"));
}
/* --- a delivered file: a card with open and share --------------------- */
{
/* The view is re-laid out off the click; give it the frame it needs. */
QTest::qWait(150);
const int cards =
countNamed(win->contentItem(), QStringLiteral("fileCard"));
check(cards == 1,
QStringLiteral("a delivered file is a card in the tool row (%1)")
.arg(cards));
check(item(win, QStringLiteral("fileOpen0")) != nullptr &&
item(win, QStringLiteral("fileShare0")) != nullptr,
QStringLiteral("the card carries open and share"));
win->setProperty("statusText", QString());
clickMouse(win, QStringLiteral("fileOpen0"));
const QString opened = win->property("statusText").toString();
check(opened.startsWith(QLatin1String("opened ")),
QStringLiteral("Open hands the file to the system (%1)").arg(opened));
clickMouse(win, QStringLiteral("fileShare0"));
const QString shared = win->property("statusText").toString();
check(shared.contains(QLatin1String("report.md")),
QStringLiteral("Share names the file (%1)").arg(shared));
const QString refused =
core.openArtifact(QStringLiteral("art-not-one-of-ours.md"));
check(refused.contains(QLatin1String("no such file")),
QStringLiteral("a name this session does not own is refused (%1)")
.arg(refused));
/* And the card says it: a tap that cannot work must not look dead. */
QQuickItem *strip = nullptr;
{
QList<QQuickItem *> hits;
collectNamed(win->contentItem(), QStringLiteral("imageStrip"), &hits);
for (QQuickItem *it : hits)
if (it->isVisible())
strip = it;
}
bool called = false;
if (strip)
called = QMetaObject::invokeMethod(
strip, "openFile",
Q_ARG(QVariant, QStringLiteral("art-not-one-of-ours.md")));
const QVariant err = strip ? strip->property("errorText") : QVariant();
check(called && err.toString().contains(QLatin1String("no such file")),
QStringLiteral("the card shows why it could not open it (%1)")
.arg(err.toString()));
QTest::qWait(150);
QQuickItem *errText = item(win, QStringLiteral("fileError"));
check(errText && errText->isVisible() &&
errText->property("text").toString().contains(
QLatin1String("no such file")),
QStringLiteral("and the line is where the card is"));
}
/* new sessions: the engine moves, the view empties, the store grows */
const int before = core.sessions().size();
clickMouse(win, QStringLiteral("newSessionButton"));
const QString first = core.sessionId();
check(first != fixture, QStringLiteral("New starts another session"));
clickMouse(win, QStringLiteral("newSessionButton"));
const QString second = core.sessionId();
check(second != first, QStringLiteral("New starts another one each time"));
check(core.sessions().size() == before + 2,
QStringLiteral("both new sessions are in the store"));
check(list && list->property("count").toInt() == 0,
QStringLiteral("a fresh session starts with an empty view"));
/* the x drops a stored session and leaves the open one alone */
QTest::qWait(150);
clickMouse(win, QStringLiteral("sessionDelete:") + first);
check(core.sessions().size() == before + 1,
QStringLiteral("the x drops a stored session"));
check(core.sessionId() == second,
QStringLiteral("deleting another session leaves the open one alone"));
/* and back to the fixture, which still has its six rows */
QTest::qWait(150);
clickMouse(win, QStringLiteral("sessionRow:") + fixture);
check(core.sessionId() == fixture && list &&
list->property("count").toInt() == 6,
QStringLiteral("switching back replays it again"));
{
bool promptImage = false;
const QVariantList rows = core.history();
for (const QVariant &v : rows) {
const QVariantMap r = v.toMap();
if (r.value(QStringLiteral("role")).toString() == QLatin1String("user") &&
r.value(QStringLiteral("images")).toString().contains(
QLatin1String("|")))
promptImage = true;
}
check(promptImage,
QStringLiteral("a stored prompt image comes back with its row"));
/* The rows are in the model by now; the cards -- and the thumbnails in
* them -- are built on the view's next layout pass. A frame forces it,
* and that frame is also the picture a person looks at. */
maybeShot(win, QStringLiteral("-images"));
QTest::qWait(150);
if (qEnvironmentVariableIsSet("CST_APP_DUMP")) {
QVariant v;
QMetaObject::invokeMethod(win, "rowJson", Q_RETURN_ARG(QVariant, v),
Q_ARG(QVariant, 0));
std::printf("note: replayed row 0: %s\n", qPrintable(v.toString()));
std::printf("note: %d strips and %d thumbs in the tree\n",
countNamed(win->contentItem(), QStringLiteral("imageStrip")),
countNamed(win->contentItem(), QStringLiteral("imageThumb")));
}
check(countNamed(win->contentItem(), QStringLiteral("imageThumb")) >= 1,
QStringLiteral("the replayed transcript shows it as a thumbnail (%1)")
.arg(countNamed(win->contentItem(), QStringLiteral("imageThumb"))));
}
/* --- provider, model, key: the settings dialog ------------------------ */
check(core.providers().size() > 0 &&
!core.providers().first().toMap().value(QStringLiteral("name"))
.toString()
.isEmpty(),
QStringLiteral("the provider presets are readable"));
check(core.useProvider(QStringLiteral("no-such-provider"), QString(), QString())
.contains(QLatin1String("no such provider")),
QStringLiteral("switching to an unknown provider is refused"));
check(core.usage().contains(QStringLiteral("requests")),
QStringLiteral("the token counters are readable"));
clickMouse(win, QStringLiteral("settingsButton"));
QQuickItem *panel = item(win, QStringLiteral("settingsPanel"));
check(panel && panel->isVisible(), QStringLiteral("the gear opens the settings dialog"));
check(countNamed(win->contentItem(), QStringLiteral("providerRow:")) > 0,
QStringLiteral("the dialog lists the providers"));
maybeShot(win, QStringLiteral("-settings"));
QQuickItem *mfield = item(win, QStringLiteral("settingsModel"));
if (mfield && panel) {
mfield->setProperty("text", QStringLiteral("cst-test-model"));
clickMouse(win, QStringLiteral("settingsApply"));
check(core.model() == QLatin1String("cst-test-model"),
QStringLiteral("the model picked in the dialog reaches the engine"));
QQuickItem *note = item(win, QStringLiteral("settingsNote"));
check(note && note->property("text").toString().contains(
QLatin1String("cst-test-model")),
QStringLiteral("the dialog says what it is using now"));
clickMouse(win, QStringLiteral("settingsClose"));
check(!panel->isVisible(), QStringLiteral("Close puts the dialog away"));
} else {
check(false, QStringLiteral("the dialog has a model field"));
}
/* --- advanced: a custom provider, added and removed ------------------- */
/* The run has a scratch config dir (main.cpp points XDG_CONFIG_HOME at it),
* so the engine's user model file is this run's own. */
check(core.genParams(QStringLiteral("deepseek")).value(QStringLiteral("temperature"))
.toDouble() == 0.0,
QStringLiteral("no generation parameters are saved to begin with"));
check(core.setGenParams(QStringLiteral("deepseek"), 0.2, 0.9, 4096).isEmpty(),
QStringLiteral("generation parameters are accepted"));
{
const QVariantMap g = core.genParams(QStringLiteral("deepseek"));
check(g.value(QStringLiteral("temperature")).toDouble() > 0.19 &&
g.value(QStringLiteral("max_tokens")).toInt() == 4096,
QStringLiteral("they are remembered per provider (%1 / %2 / %3)")
.arg(g.value(QStringLiteral("temperature")).toDouble())
.arg(g.value(QStringLiteral("top_p")).toDouble())
.arg(g.value(QStringLiteral("max_tokens")).toInt()));
}
check(core.addProvider(QStringLiteral("cst-scratch"), QStringLiteral("https://api.example.com/v1"),
QStringLiteral("CST_SCRATCH_KEY"), QStringLiteral("scratch-1"),
QStringLiteral("scratch-1, scratch-2"))
.isEmpty(),
QStringLiteral("a provider the presets do not know is added"));
{
bool found = false, custom = false;
QString base, models;
const QVariantList list = core.providers();
for (const QVariant &v : list) {
const QVariantMap p = v.toMap();
if (p.value(QStringLiteral("name")).toString() != QLatin1String("cst-scratch"))
continue;
found = true;
custom = p.value(QStringLiteral("custom")).toBool();
base = p.value(QStringLiteral("base")).toString();
models = core.modelChoices(QStringLiteral("cst-scratch")).join(QLatin1Char(','));
}
check(found && custom && base.contains(QLatin1String("api.example.com")),
QStringLiteral("it shows up in the provider list as a custom one"));
check(models.contains(QLatin1String("scratch-1")) &&
models.contains(QLatin1String("scratch-2")),
QStringLiteral("the models it lists are the picker's (%1)").arg(models));
}
check(!core.addProvider(QStringLiteral("bad name"), QStringLiteral("https://x/v1"),
QString(), QString(), QString())
.isEmpty(),
QStringLiteral("a name that is not a provider name is refused"));
check(core.removeProvider(QStringLiteral("cst-scratch")) == 1,
QStringLiteral("the custom provider can be removed again"));
{
bool gone = true;
const QVariantList list = core.providers();
for (const QVariant &v : list)
if (v.toMap().value(QStringLiteral("name")).toString() ==
QLatin1String("cst-scratch"))
gone = false;
check(gone, QStringLiteral("and it is gone from the list"));
}
check(core.removeProvider(QStringLiteral("deepseek")) == 0,
QStringLiteral("a preset cannot be removed (there is no user entry to drop)"));
check(core.setGenParams(QStringLiteral("deepseek"), 0, 0, 0).isEmpty() &&
core.genParams(QStringLiteral("deepseek"))
.value(QStringLiteral("temperature"))
.toDouble() == 0.0,
QStringLiteral("parameters can be cleared again"));
/* The dialog drives the same two engine calls: open it, fold the advanced
* section out and put a provider in through the form. */
clickMouse(win, QStringLiteral("settingsButton"));
QTest::qWait(150); /* the dialog's layout settles before it is clicked into */
if (panel && mfield) {
clickMouse(win, QStringLiteral("settingsAdvanced"));
QTest::qWait(150); /* the fold-out re-lays the body out */
QQuickItem *nfield = item(win, QStringLiteral("advName"));
QQuickItem *bfield = item(win, QStringLiteral("advBase"));
check(nfield && nfield->isVisible(), QStringLiteral("Advanced options opens in the dialog"));
maybeShot(win, QStringLiteral("-advanced"));
if (nfield && bfield) {
nfield->setProperty("text", QStringLiteral("cst-form"));
bfield->setProperty("text", QStringLiteral("https://form.example.com/v1"));
/* The form sits at the end of the dialog's scrolling body: scroll it
* into view so the click lands on the button, not on the frame. */
QQuickItem *scroller = item(win, QStringLiteral("settingsBody"));
if (scroller) {
scroller->setProperty("contentY", scroller->property("contentHeight").toReal() -
scroller->height());
QTest::qWait(120); /* the flickable settles */
}
clickMouse(win, QStringLiteral("settingsSaveProvider"));
bool found = false;
const QVariantList list = core.providers();
for (const QVariant &v : list)
if (v.toMap().value(QStringLiteral("name")).toString() ==
QLatin1String("cst-form"))
found = true;
check(found, QStringLiteral("the form in the dialog adds a provider"));
QQuickItem *rm = item(win, QStringLiteral("settingsRemoveProvider"));
check(rm && rm->property("enabled").toBool(),
QStringLiteral("its entry can be removed from the form"));
clickMouse(win, QStringLiteral("settingsRemoveProvider"));
check(core.removeProvider(QStringLiteral("cst-form")) == 0,
QStringLiteral("the form's provider is gone again"));
} else {
check(false, QStringLiteral("the advanced form has name and base URL fields"));
}
clickMouse(win, QStringLiteral("settingsClose"));
} else {
check(false, QStringLiteral("the settings dialog can be reopened"));
}
/* --- attachments: a file goes next to the session -------------------- */
const QString att = core.workspace() + QStringLiteral("/note.txt");
{
QFile f(att);
f.open(QIODevice::WriteOnly);
f.write("hello from a fixture\n");
}
check(core.attachFile(att).isEmpty(),
QStringLiteral("a file on disk can be attached"));
check(core.pendingAttachments().size() == 1,
QStringLiteral("the engine reports one attachment for the next prompt"));
check(core.pendingAttachments().first().contains(QLatin1String("note.txt")),
QStringLiteral("the attachment names the file it came from"));
check(!core.attachFile(QStringLiteral("/no/such/file-here")).isEmpty(),
QStringLiteral("a missing file is refused with a reason"));
QTest::qWait(120);
check(countNamed(win->contentItem(), QStringLiteral("attachChip")) == 1,
QStringLiteral("the composer lists it"));
maybeShot(win, QStringLiteral("-attach"));
clickMouse(win, QStringLiteral("clearAttachments"));
check(core.pendingAttachments().isEmpty(),
QStringLiteral("clear all drops them"));
/* the paste path: an image on the clipboard becomes an attachment */
QGuiApplication *gui = qobject_cast<QGuiApplication *>(QCoreApplication::instance());
if (gui && gui->clipboard()) {
QImage img(8, 8, QImage::Format_RGB32);
img.fill(Qt::red);
gui->clipboard()->setImage(img);
check(core.clipboardHasImage(), QStringLiteral("the clipboard image is seen"));
check(core.attachClipboard().isEmpty() &&
core.pendingAttachments().size() == 1,
QStringLiteral("an image on the clipboard is attached"));
core.clearPending();
} else {
check(false, QStringLiteral("there is a clipboard to paste from"));
}
/* --- tool calls: two calls of one tool are two cards ------------------ */
const int rows0 = list ? list->property("count").toInt() : -1;
auto rowAt = [&](int i) {
QVariant v;
QVariantMap r;
if (!QMetaObject::invokeMethod(win, "rowJson", Q_RETURN_ARG(QVariant, v),
Q_ARG(QVariant, i)))
return r;
return QJsonDocument::fromJson(v.toString().toUtf8()).object().toVariantMap();
};
auto event4 = [&](const char *fn, const QVariant &a, const QVariant &b,
const QVariant &c, const QVariant &d) {
return QMetaObject::invokeMethod(win, fn, Q_ARG(QVariant, a),
Q_ARG(QVariant, b), Q_ARG(QVariant, c),
Q_ARG(QVariant, d));
};
check(event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-1"),
QStringLiteral("list_dir"), QStringLiteral("{\"path\":\"vendor\"}")) &&
list->property("count").toInt() == rows0 + 1,
QStringLiteral("a tool call opens a card"));
event4("engineToolEvent", QStringLiteral("run"), QStringLiteral("call-1"),
QStringLiteral("list_dir"), QString());
/* the same tool a second time, while the first call is still open: pairing
by name is what left the first card on "running" forever */
event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-2"),
QStringLiteral("list_dir"), QStringLiteral("{\"path\":\"src\"}"));
event4("engineToolResult", QStringLiteral("call-1"), QStringLiteral("list_dir"),
QVariant(0), QStringLiteral("exit=0 bytes=532"));
check(list->property("count").toInt() == rows0 + 2,
QStringLiteral("two calls of one tool are two cards (%1 -> %2)")
.arg(rows0)
.arg(list->property("count").toInt()));
{
const QVariantMap first = rowAt(rows0), second = rowAt(rows0 + 1);
check(first.value(QStringLiteral("state")).toString() == QLatin1String("done") &&
first.value(QStringLiteral("output")).toString() ==
QLatin1String("exit=0 bytes=532"),
QStringLiteral("the result lands on the card of its own call "
"(state '%1', output '%2', keys [%3])")
.arg(first.value(QStringLiteral("state")).toString(),
first.value(QStringLiteral("output")).toString(),
first.keys().join(QLatin1Char(','))));
check(second.value(QStringLiteral("state")).toString() == QLatin1String("pending") &&
second.value(QStringLiteral("detail")).toString().contains(
QLatin1String("\"src\"")) &&
second.value(QStringLiteral("output")).toString().isEmpty(),
QStringLiteral("the other card is untouched (state '%1')")
.arg(second.value(QStringLiteral("state")).toString()));
}
/* and a turn that ends with the call still open settles it */
check(QMetaObject::invokeMethod(win, "engineTurnFinished", Q_ARG(QVariant, 0),
Q_ARG(QVariant, QString())),
QStringLiteral("the turn end is reachable too"));
{
const QVariantMap second = rowAt(rows0 + 1);
check(second.value(QStringLiteral("complete")).toBool() &&
second.value(QStringLiteral("state")).toString() ==
QLatin1String("unfinished"),
QStringLiteral("an open call is settled when the turn ends"));
}
/* --- the answer is rendered while it streams -------------------------- */
if (QMetaObject::invokeMethod(win, "engineAssistant", Q_ARG(QVariant, QStringLiteral("**bold** text")))) {
const int last = list->property("count").toInt() - 1;
QMetaObject::invokeMethod(win, "renderStreaming");
const QVariantMap row = rowAt(last);
check(row.value(QStringLiteral("kind")).toString() == QLatin1String("message") &&
!row.value(QStringLiteral("complete")).toBool(),
QStringLiteral("a delta opens a streaming answer"));
check(row.value(QStringLiteral("html")).toString().contains(QLatin1Char('<')) &&
row.value(QStringLiteral("text")).toString().contains(QLatin1String("bold")),
QStringLiteral("markdown is rendered while the answer streams"));
} else {
check(false, QStringLiteral("assistant deltas are reachable"));
}
/* --- the image provider: only the session's own files ----------------- */
{
const QString dir = core.attachmentDir();
/* one directory per session since v0.9.5: <sessions>/<id>/attachments */
check(!dir.isEmpty() && dir.endsWith(QLatin1String("/attachments")) &&
dir.contains(QLatin1String("/sessions/")),
QStringLiteral("the agent names its image directory (%1)").arg(dir));
QImage pic(32, 16, QImage::Format_RGB32);
pic.fill(Qt::darkGreen);
QByteArray png;
QBuffer buf(&png);
buf.open(QIODevice::WriteOnly);
pic.save(&buf, "PNG");
char aerr[256] = "";
cst_attachment_t *stored = cst_attach_import_bytes(
cst_agent_session_path(core.agent()), "probe.png", png.constData(),
static_cast<size_t>(png.size()), aerr, sizeof(aerr));
check(stored != nullptr,
QStringLiteral("a picture can be stored next to the session"));
if (stored) {
QSize got;
const QImage shrunk =
images->requestImage(QString::fromUtf8(stored->rel),
&got, QSize(8, 8));
check(!shrunk.isNull() && shrunk.width() == 8 && shrunk.height() == 4,
QStringLiteral("the provider serves it, shrunk to what was asked "
"(%1x%2)")
.arg(shrunk.width())
.arg(shrunk.height()));
check(images->requestImage(QStringLiteral("../escape.png"), &got,
QSize(64, 64))
.isNull(),
QStringLiteral("a name outside the attachment directory is refused"));
check(images->requestImage(QStringLiteral("no-such-file.png"), &got,
QSize(64, 64))
.isNull(),
QStringLiteral("a file that is not there is refused"));
cst_attach_free(stored);
}
}
/* --- a tool that made a picture: its card shows it -------------------- */
{
const int before = list->property("count").toInt();
const int thumbsBefore =
countNamed(win->contentItem(), QStringLiteral("imageThumb"));
event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-img"),
QStringLiteral("view_image"), QStringLiteral("{\"path\":\"shot.png\"}"));
const QString imgs =
QStringLiteral("img-fixture.png|shot.png|view_image|24x8");
check(QMetaObject::invokeMethod(win, "engineToolImages",
Q_ARG(QVariant, QStringLiteral("call-img")),
Q_ARG(QVariant, imgs)),
QStringLiteral("images a tool made reach the view"));
QTest::qWait(150);
const QVariantMap card = rowAt(before);
check(card.value(QStringLiteral("images"))
.toString()
.contains(QLatin1String("img-fixture.png")) &&
card.value(QStringLiteral("name")).toString() ==
QLatin1String("view_image"),
QStringLiteral("the card of that call carries the image"));
check(countNamed(win->contentItem(), QStringLiteral("imageThumb")) >
thumbsBefore,
QStringLiteral("its thumbnail is in the card"));
maybeShot(win, QStringLiteral("-toolimage"));
}
/* --- the order the engine uses on a live turn: the result first, the
* files after it (a card is complete by then, which used to lose them) --- */
{
const int before = list->property("count").toInt();
const int cardsBefore =
countNamed(win->contentItem(), QStringLiteral("fileCard"));
event4("engineToolEvent", QStringLiteral("open"),
QStringLiteral("call-file"), QStringLiteral("deliver_file"),
QStringLiteral("{\"path\":\"report.md\"}"));
event4("engineToolEvent", QStringLiteral("done"),
QStringLiteral("call-file"), QStringLiteral("deliver_file"),
QStringLiteral("exit=0 bytes=120"));
event4("engineToolResult", QStringLiteral("call-file"),
QStringLiteral("deliver_file"), QVariant(0),
QStringLiteral("delivered report.md (text/markdown, 120 B)"));
const QString files =
QStringLiteral("file|art-0123456789abcdef.md|report.md|tool|"
"text/markdown|120|0x0");
check(QMetaObject::invokeMethod(win, "engineToolImages",
Q_ARG(QVariant, QStringLiteral("call-file")),
Q_ARG(QVariant, files)),
QStringLiteral("the files a tool delivered reach the view"));
QTest::qWait(150);
const QVariantMap card = rowAt(before);
check(card.value(QStringLiteral("images"))
.toString()
.contains(QLatin1String("art-0123456789abcdef.md")),
QStringLiteral("a card that is already complete still takes them"));
check(countNamed(win->contentItem(), QStringLiteral("fileCard")) >
cardsBefore,
QStringLiteral("so the delivered file is on the card (%1 -> %2)")
.arg(cardsBefore)
.arg(countNamed(win->contentItem(), QStringLiteral("fileCard"))));
}
/* --- a narrow window: dock -> drawer, and the button opens it --- */
resizeTo(win, 460, 900);
check(docked && !docked->isVisible(),
QStringLiteral("narrow window: the docked sidebar folds away"));
check(navButton && navButton->isVisible(),
QStringLiteral("narrow window: the drawer button appears"));
clickMouse(win, QStringLiteral("navButton"));
check(navPanel && navPanel->isVisible(),
QStringLiteral("drawer opens over the transcript"));
clickMouse(win, QStringLiteral("navButton"));
check(navPanel && !navPanel->isVisible(),
QStringLiteral("drawer closes again"));
resizeTo(win, 1200, 820);
check(docked && docked->isVisible(),
QStringLiteral("widening again docks the sidebar back"));
/* A picture of the last state on request (see maybeShot). */
maybeShot(win, QString());
std::printf("--- %s\n", g_fail ? "FAILED" : "all UI checks passed");
return g_fail ? 1 : 0;
}
} // namespace app
+20
View File
@@ -0,0 +1,20 @@
/* The --uitest entry point: see UiTest.cpp for what it drives. */
#ifndef CST_APP_UITEST_H
#define CST_APP_UITEST_H
#include <QString>
#include "core/AgentCore.h"
namespace app {
/* Loads the window, drives its controls with synthesized mouse/touch input
* (and a resize to the compact layout), prints one line per check and returns
* how many failed as the process exit code. `size` is the usual WxH string,
* empty for the default. */
int runUiTest(AgentCore &core, const QString &size);
} // namespace app
#endif /* CST_APP_UITEST_H */
+158
View File
@@ -0,0 +1,158 @@
/* The app's C smoke test: does the engine embed the way app/ needs it to?
*
* It links libcstcore.a (or the cstcore target) and runs without Qt, so the
* C side of the GUI stays verifiable on any machine -- including CI and the
* Android cross-build. `cst-agent-app --selftest` runs the same checks inside
* the real process. */
#define _POSIX_C_SOURCE 200809L
#include "cst_agent.h"
#include "cst_tools.h"
#include "os_compat.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#include <process.h>
#include <direct.h>
#define getpid _getpid
#else
#include <sys/stat.h>
#include <unistd.h>
#endif
static int failures;
#define CHECK(cond, what) \
do { \
if (!(cond)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, what); \
failures++; \
} \
} while (0)
/* A tool the app would implement (the Linux build uses notify-send; here it
* only has to prove the plumbing). */
static int ctx_runs;
static int app_tool_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
cst_tool_result_t *r, void *ud) {
(void)ctx;
(void)call;
(void)ud;
ctx_runs++;
r->out = strdup("app tool ran");
r->out_len = r->out ? strlen(r->out) : 0;
r->exit_code = 0;
return 0;
}
static int probe_missing(char *why, size_t why_sz) {
snprintf(why, why_sz, "the desktop service is not running");
return -1;
}
int main(void) {
/* The scratch workspace goes where this host can actually write: /tmp on a
* desktop, the app's own directory on Android (where /tmp is off limits,
* so TMPDIR is pointed at it -- see app/src/main.cpp). */
char tmp[1024] = "";
if (os_temp_dir(tmp, sizeof(tmp)) != 0 || !tmp[0])
snprintf(tmp, sizeof(tmp), "%s", "/tmp");
char ws[1200];
snprintf(ws, sizeof(ws), "%s/cst-app-selftest-%d", tmp, (int)getpid());
#ifdef _WIN32
_mkdir(ws); /* the engine wants its workspace to exist */
#else
mkdir(ws, 0700);
#endif
cst_agent_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", ws);
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/s.jsonl", ws);
snprintf(cfg.provider, sizeof(cfg.provider), "deepseek");
snprintf(cfg.model, sizeof(cfg.model), "deepseek-flash");
snprintf(cfg.api_base, sizeof(cfg.api_base), "https://api.deepseek.com/v1");
snprintf(cfg.api_key, sizeof(cfg.api_key), "test-key");
snprintf(cfg.mode, sizeof(cfg.mode), "general");
cfg.max_steps = 1;
cst_agent_t *a = cst_agent_create(&cfg);
CHECK(a != NULL, "the app can start an engine in its own process");
if (!a) {
fprintf(stderr, "cst-app-selftest: cannot start the engine\n");
return 1;
}
/* 1. a tool that exists here (no probe) is offered to the model */
cst_tool_def_t plain;
memset(&plain, 0, sizeof(plain));
plain.name = "app_ping";
plain.desc = "A tool the app provides.";
plain.perm = CST_TP_READ;
plain.run_ex = app_tool_run;
plain.owner = "app:selftest";
CHECK(cst_tool_register(&plain) == 0, "register an app tool");
const cst_tool_def_t *t = cst_tool_find("app_ping");
CHECK(t != NULL, "it is findable");
CHECK(t && cst_tool_active(cst_agent_config(a), t), "and it is offered");
ctl_tool_call_t call;
memset(&call, 0, sizeof(call));
snprintf(call.name, sizeof(call.name), "app_ping");
cst_tool_result_t res;
cst_tool_ctx_t ctx;
memset(&ctx, 0, sizeof(ctx));
ctx.workspace_root = ws;
ctx.agent = a;
cst_tool_run(&ctx, &call, &res);
CHECK(ctx_runs == 1, "the app callback ran");
CHECK(res.out && strcmp(res.out, "app tool ran") == 0, "its result came back");
cst_tool_result_free(&res);
/* 2. a tool whose platform service is missing is never offered ... */
cst_tool_def_t missing;
memset(&missing, 0, sizeof(missing));
missing.name = "app_no_service";
missing.desc = "Needs a service that is not here.";
missing.perm = CST_TP_ASK;
missing.perm_name = "notify";
missing.available = probe_missing;
missing.run_ex = app_tool_run;
missing.owner = "app:selftest";
CHECK(cst_tool_register(&missing) == 0, "register a tool with a probe");
const cst_tool_def_t *mt = cst_tool_find("app_no_service");
char why[160] = "";
CHECK(mt && cst_tool_available(mt, why, sizeof(why)) != 0,
"the probe says it cannot run");
CHECK(strstr(why, "desktop service") != NULL, "and passes its reason on");
CHECK(mt && !cst_tool_active(cst_agent_config(a), mt),
"so it stays out of tools[] and the prompt");
snprintf(call.name, sizeof(call.name), "app_no_service");
cst_tool_run(&ctx, &call, &res);
CHECK(res.out && strstr(res.out, "cannot run here") != NULL,
"asking for it anyway is answered with the reason");
CHECK(ctx_runs == 1, "and its callback never ran");
cst_tool_result_free(&res);
/* 3. the app can drop its tools again */
CHECK(cst_tool_unregister_owner("app:selftest") == 2, "unregister both");
CHECK(cst_tool_find("app_ping") == NULL, "app_ping is gone");
CHECK(cst_tool_find("read_file") != NULL, "the built-ins are untouched");
cst_agent_destroy(a);
{
char cmd[1200];
snprintf(cmd, sizeof(cmd), "rm -rf %s", ws);
if (system(cmd) != 0)
fprintf(stderr, "note: could not clean %s\n", ws);
}
if (failures) {
fprintf(stderr, "cst-app-selftest: %d failure(s)\n", failures);
return 1;
}
printf("cst-app-selftest: OK\n");
return 0;
}
+12 -4
View File
@@ -2,17 +2,25 @@
# ./scripts/build_third_party_mingw.sh curl # ./scripts/build_third_party_mingw.sh curl
# cmake -B build-win -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \ # cmake -B build-win -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
# -DCST_BUILD_TUI=OFF # -DCST_BUILD_TUI=OFF
# $MINGW_HOST picks the tool prefix (default x86_64-w64-mingw32; Debian's UCRT
# cross gcc is x86_64-w64-mingw32ucrt).
set(CMAKE_SYSTEM_NAME Windows) set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR x86_64) set(CMAKE_SYSTEM_PROCESSOR x86_64)
set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc) if(DEFINED ENV{MINGW_HOST} AND NOT "$ENV{MINGW_HOST}" STREQUAL "")
set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++) set(_MINGW_HOST "$ENV{MINGW_HOST}")
set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres) else()
set(_MINGW_HOST x86_64-w64-mingw32)
endif()
set(CMAKE_C_COMPILER ${_MINGW_HOST}-gcc)
set(CMAKE_CXX_COMPILER ${_MINGW_HOST}-g++)
set(CMAKE_RC_COMPILER ${_MINGW_HOST}-windres)
get_filename_component(_AGENT_ROOT "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE) get_filename_component(_AGENT_ROOT "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE)
set(_MINGW_PREFIX "${_AGENT_ROOT}/third_party/prefix-mingw") set(_MINGW_PREFIX "${_AGENT_ROOT}/third_party/prefix-mingw")
list(APPEND CMAKE_PREFIX_PATH "${_MINGW_PREFIX}") list(APPEND CMAKE_PREFIX_PATH "${_MINGW_PREFIX}")
set(CMAKE_FIND_ROOT_PATH "/usr/x86_64-w64-mingw32" "${_MINGW_PREFIX}") set(CMAKE_FIND_ROOT_PATH "/usr/${_MINGW_HOST}" "${_MINGW_PREFIX}")
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY BOTH) set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY BOTH)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE BOTH) set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE BOTH)
+31 -6
View File
@@ -5,6 +5,20 @@
## 安装 ## 安装
### 安装包
从 [Release](https://git.whcself.xyz/WHC/CST-agent/releases) 下载
`cst-agent-VERSION-windows-x86_64-setup.exe` 运行即可:按当前用户安装到
`%LOCALAPPDATA%\Programs\cst-agent`(不需要管理员),把这个目录加进**用户** PATH,
开始菜单里放一个“CST-Agent WebUI”(在用户目录启动 `cst-agent --web --web-open`)。
系统低于 Windows 10 1903 时安装包会直接拒绝。
卸载:设置 → 应用 → CST-Agent,或运行安装目录里的 `uninstall.exe`;会去掉 PATH 里的这一项。
API key 和设置在 `%APPDATA%\cst-agent`,卸载时保留。
安装包装的文件和 zip 一样,所以之后也能用 `cst-agent-update` 升级,或者重新运行新版安装包覆盖。
### PowerShell 脚本
在 PowerShell 里(Windows PowerShell 5.1 或 PowerShell 7 都行): 在 PowerShell 里(Windows PowerShell 5.1 或 PowerShell 7 都行):
```powershell ```powershell
@@ -123,7 +137,9 @@ windows-x86_64/
## 从源码构建 ## 从源码构建
两种方式产物一样:`dist/windows-x86_64/` 和 `dist/cst-agent-VERSION-windows-x86_64.zip`。 两种方式产物一样:`dist/windows-x86_64/` 和 `dist/cst-agent-VERSION-windows-x86_64.zip`;
装了 `makensis`(NSIS)时还有安装包 `dist/cst-agent-VERSION-windows-x86_64-setup.exe`
(脚本在 `scripts/windows/cst-agent.nsi`)。
第三方依赖(curl、ncurses、busybox-w32)都按固定版本从源码编译、静态链接。 第三方依赖(curl、ncurses、busybox-w32)都按固定版本从源码编译、静态链接。
### 在 Linux 上交叉编译 ### 在 Linux 上交叉编译
@@ -134,8 +150,16 @@ cd CST-agent
scripts/bootstrap_windows.sh scripts/bootstrap_windows.sh
``` ```
NixOS / 有 Nix 时自动进 `shell-win.nix`(`pkgsCross.ucrt64`);否则需要 UCRT 版的 NixOS / 有 Nix 时自动进 `shell-win.nix`(`pkgsCross.ucrt64`,带 NSIS);否则需要 UCRT 版的
`x86_64-w64-mingw32-gcc`(带 winpthreads)、cmake、ninja、git、perl。 mingw-w64 gcc(带 winpthreads)、cmake、ninja、git、perl。工具前缀由 `MINGW_HOST` 指定,默认
`x86_64-w64-mingw32`;Debian trixie 起的 `gcc-mingw-w64-ucrt64` 是 `x86_64-w64-mingw32ucrt`:
```bash
sudo apt install gcc-mingw-w64-ucrt64 binutils-mingw-w64-ucrt64 nsis cmake ninja-build perl
MINGW_HOST=x86_64-w64-mingw32ucrt scripts/bootstrap_windows.sh
```
`CST_WIN_CFLAGS` 给 `cst-agent.exe` 追加编译选项(CI 用 `-Wall -Wextra -Werror`)。
### 在 Windows 上用 MSYS2 原生编译 ### 在 Windows 上用 MSYS2 原生编译
@@ -159,17 +183,18 @@ scripts/bootstrap_msys2.sh
### 发布 ### 发布
CI:推 `main` 或 `v*` tag 且 `windows-cross` 成功后,自动把 zip / `.sha256` 发到 CI:推 `main` 或 `v*` tag 且 `windows` 任务成功后,自动把 zip、安装包和各自的 `.sha256` 发到
Gitea Release `vVERSION`(版本来自 `include/cst_version.h`;同名资源替换)。仓库 Secrets Gitea Release `vVERSION`(版本来自 `include/cst_version.h`;同名资源替换)。仓库 Secrets
可设 `RELEASE_TOKEN`(`write:repository`),没有则用 Actions 自带 token。 可设 `RELEASE_TOKEN`(`write:repository`),没有则用 Actions 自带 token。
本地: 本地:
```bash ```bash
GITEA_TOKEN=... scripts/release_windows.sh --build scripts/bootstrap_windows.sh
GITEA_TOKEN=... scripts/release.sh dist/cst-agent-*-windows-x86_64.zip dist/cst-agent-*-windows-x86_64-setup.exe
``` ```
创建(或复用)`vVERSION` Release,上传 zip 和 `.sha256`。token 只从环境变量读取,经 stdin 创建(或复用)`vVERSION` Release,上传给出的文件和旁边的 `.sha256`。token 只从环境变量读取,经 stdin
交给 curl,不会出现在命令行参数、磁盘或输出里。发版前先 bump `cst_version.h`;推 交给 curl,不会出现在命令行参数、磁盘或输出里。发版前先 bump `cst_version.h`;推
`vX.Y.Z` tag 时必须与头文件一致。 `vX.Y.Z` tag 时必须与头文件一致。
+202
View File
@@ -0,0 +1,202 @@
# CST-agent on Android
The engine runs on the phone as a plain bionic binary -- no Termux, no proot,
no second process for the UI to talk to. It is the same C core the CLI, the
TUI and the WebUI use, cross-compiled with the NDK; on the phone the Qt front
end (app/) will link `libcstcore.a` in-process and register its own platform
tools under the owner `app:android`.
Two scripts cover the engine side:
| script | what it does |
| --- | --- |
| `scripts/build_third_party_android.sh ABI [all\|mbedtls\|curl]` | static mbedTLS + libcurl into `third_party/prefix-android-ABI` |
| `scripts/build_android.sh [ABI] [--push] [--run] [--run-tests]` | `libcstcore.a`, `cst-agent`, the test binaries; optional push and on-device run |
## Toolchain
NixOS: nixpkgs carries the NDK (the Android SDK license makes it unfree, hence
the flag; the packaged NDK has a patched interpreter, so its clang runs here):
```sh
export ANDROID_NDK_HOME=$(NIXPKGS_ALLOW_UNFREE=1 nix build --impure --no-link \
--print-out-paths 'nixpkgs#androidenv.androidPkgs.ndk-bundle')/libexec/android-sdk/ndk-bundle
```
`adb` comes from `nix-shell -p android-tools`; the builds need cmake, ninja and
curl. End to end:
```sh
nix-shell -p cmake ninja curl android-tools --run '
JOBS=16 ./scripts/build_third_party_android.sh arm64-v8a all'
nix-shell -p cmake ninja curl android-tools --run '
JOBS=16 ./scripts/build_android.sh arm64-v8a --push --run'
```
`--run` finishes with the two smoke checks on the device (`cst-agent
--version` and `tests/test_embed`, the in-process embedding contract);
`--run-tests` builds the whole `tests/` suite, pushes it and runs every binary
on the phone, printing a pass/fail line per test.
## The APK (Qt front end)
`scripts/build_apk_android.sh [--install] [--logs] [--clean]` configures with
the Qt for Android kit, builds the `apk` target (Gradle, no port opened) and
optionally installs it. Bits it needs, all overridable through the
environment:
| variable | what it points at |
| --- | --- |
| `QT_ANDROID` | Qt for Android 6.11.2 (`aqt install-qt all_os android 6.11.2 android_arm64_v8a -O ~/Qt --archives qtbase qtdeclarative qtquickcontrols2`) |
| `QT_HOST` | the host Qt of the same version (`aqt install-qt linux desktop 6.11.2 linux_gcc_64 -O ~/Qt --archives qtbase qtdeclarative qtsvg`) |
| `ANDROID_SDK_ROOT` | SDK with `platforms/android-<n>` and `build-tools/<v>` |
| `ANDROID_NDK_HOME` | the NDK the engine was built with |
| `JAVA_HOME` | JDK 17+ for Gradle |
### The SDK without sdkmanager
`nixpkgs`'s full `androidsdk` pulls the emulator and system images (gigabytes);
the two components actually needed are cheap. The platform comes from the nix
store (unfree, so the license flag is required) and build-tools from Google's
own zip, laid out as the SDK `sdkmanager` would:
```sh
PLATFORM=$(NIXPKGS_ALLOW_UNFREE=1 NIXPKGS_ACCEPT_ANDROID_SDK_LICENSE=1 nix build \
--impure --no-link --print-out-paths --expr \
'builtins.elemAt (import <nixpkgs> {}).androidenv.androidPkgs.platforms 2') # android-35
mkdir -p ~/android-sdk/{build-tools,platforms}
ln -s $PLATFORM/libexec/android-sdk/platforms/android-35 ~/android-sdk/platforms/
```
`androiddeployqt` then picks `build-tools` and `platform-tools` from there. For
build-tools, `dl.google.com` is the only source: from here it hands out
~100 KB/s per connection, so the script downloads the zip in 16 ranged
requests (≈25x faster) and fixes the exec bits Python's `zipfile` drops.
### Host Qt on NixOS
The host Qt is built for a regular distribution: its tools (`qmlimportscanner`
first of all) look for `libgssapi_krb5.so.2`, `libglib-2.0.so.0`, `libz.so.1`,
`libfontconfig`, `libX11`, `libEGL`/`libGL`, `libzstd`, `libbrotlidec`,
`libdbus-1`, `libfreetype`, `libstdc++`, none of which exist in NixOS's search
paths. The tools carry `RUNPATH=$ORIGIN/../lib`, so symlinking those libraries
from the nix store into `<QT_HOST>/lib/` is enough:
```sh
nix build --no-link --print-out-paths nixpkgs#krb5.lib nixpkgs#dbus.lib \
nixpkgs#libglvnd nixpkgs#fontconfig.lib nixpkgs#freetype nixpkgs#glib.lib \
nixpkgs#libx11 nixpkgs#libxkbcommon nixpkgs#zlib nixpkgs#zstd \
nixpkgs#brotli.lib nixpkgs#stdenv.cc.cc.lib # then ln -s <lib> ~/Qt/6.11.2/gcc_64/lib/
```
Two more things the script does on the way:
* **GLESv2/EGL**: Qt's `FindGLESv2.cmake` only searches `CMAKE_FIND_ROOT_PATH`
prefixes (`<sysroot>/include`), but the NDK keeps the headers in
`<sysroot>/usr/include` -- without `-DGLESv2_INCLUDE_DIR`/`-DEGL_INCLUDE_DIR`
`find_package(Qt6 Gui)` fails with "dependency GLESv2 could not be found".
* **`-L$PREFIX/lib` on every link type**: the APK's payload is a *shared
module*, so `CMAKE_SHARED_LINKER_FLAGS`/`CMAKE_MODULE_LINKER_FLAGS` need the
prefix too, not just the EXE flags.
### Gradle
The generated project resolves plugins and dependencies from `google()` and
`mavenCentral()`, plus a Gradle distribution from `services.gradle.org`. From
here all three are slow (dl.google.com ~100 KB/s), so two local tweaks:
* `~/.gradle/init.d/*.gradle` (machine-local, not in the repo) prepends the
Aliyun/Tencent mirrors for `pluginManagement`, `dependencyResolutionManagement`
and every `buildscript`.
* the wrapper in `<QT_ANDROID>/src/3rdparty/gradle/gradle/wrapper/` points at
the Tencent Gradle mirror (51 MB/s vs 100 KB/s).
`scripts/gradle-android-init.gradle` (installed into `~/.gradle/init.d/` by the
build script) switches off release lint: `lintVitalAnalyzeRelease` resolves its
own tool jars straight from `dl.google.com`, bypassing every repository the
build configures, and fails the build after all the real work is done.
### Signing
`androiddeployqt` copies the **unsigned** Gradle output and signs it itself
(`QT_ANDROID_SIGN_APK` + the `QT_ANDROID_KEYSTORE_*` variables; a signed Gradle
output makes it fail with "Could not copy unsigned APK"). The script exports
those variables and creates the standard Android debug keystore
(`~/.android/debug.keystore`, alias `androiddebugkey`) on first use;
`CST_ANDROID_KEYSTORE` and `CST_ANDROID_KEY_ALIAS`/`_PASS` override it.
`apksigner` is a bash script with a `#!/bin/bash` shebang, which NixOS does not
have, so the script rewrites the shebang of the build-tools wrappers.
## What is Android-specific
* **API level 24** (`API_LEVEL=24`): bionic provides threads, `exec` and
`/system/etc/security/cacerts`, which is where libcurl is pointed
(`CURL_CA_PATH`). Bump the level if a newer syscall or CA layout is needed.
* **No TUI, no WebUI**: `-DCST_BUILD_TUI=OFF -DCST_BUILD_WEBUI=OFF` -- there is
no ncurses on Android, and the phone front end talks to the engine in-process
instead of over HTTP. `src/webui_stub.c` compiles in place of the server.
* **Sandbox**: `src/sandbox_exec.c` only enables the seccomp/Landlock paths
under `defined(__linux__) && !defined(__ANDROID__)`, so the phone build gets
the plain exec path -- the same rule the Termux build follows.
The seccomp supervisor used to compile in and the probe reported "seccomp",
but on the device a write outside the workspace was not denied
(`tests/test_sandbox.c:308`), so the phone build now reports no sandbox
instead of one that does not hold.
* **sashidown** is built per ABI (`vendor/sashidown/libsashidown-<abi>.a`) with
the NDK's `aarch64-linux-android24-clang` wrapper: CMake's compiler is plain
clang and keeps `--target`/`--sysroot` in its own variables, which the
vendored makefile cannot see. The host archive stays host-built.
* **Static libcurl** needs `-L$PREFIX/lib` on the link line, because curl's own
CMake config lists its mbedTLS dependencies as `-l` flags rather than paths.
## Status
* arm64-v8a, Android 16 (NX789J): `cst-agent` runs, `tests/test_embed` passes.
* The whole suite runs on the device (`scripts/build_android.sh arm64-v8a
--run-tests`): 36/36 after the diff test learned to tell toybox's `diff` from
GNU diffutils.
* The APK builds, installs and runs: the Qt front end links the engine
in-process (no port), `main.cpp` puts the workspace, config and tmp under
`/data/user/0/xyz.whcself.cstagent/files/`, and a turn does a real HTTPS
request from inside the ART process. The saved provider key is resolved into
the engine's config on every start (`AgentCore::open()`: saved key, the
provider's environment variable, `CST_API_KEY`), so the phone answers without
being asked for the key again. Rotation keeps the session; the sidebar
appears in landscape.
* Platform tools behind `app:android` (`app/src/platform/android/AndroidTools.cpp`):
`notification_send`, `clipboard_get` / `clipboard_set` under the engine's
own tool names and `share` (`ACTION_SEND` + chooser) all work on the device.
The file
card's **Open** builds a `content://` URI through the app's FileProvider
(`<package>.qtprovider`, Qt's `@xml/qtprovider_paths`), checks
`resolveActivity()` before starting `ACTION_VIEW` and grants the reader
permission; **Share** reuses the share sheet.
* **Java helpers**: a notification built entirely through `QJniObject` posted
nothing on the NX789J -- `Notification.Builder.build()` came back null and
the tool could only say "failed: no exception detail", because Qt clears a
Java exception before C++ gets to look at it. The one step that has to fail
visibly (a builder, a `PendingIntent`, `build()`, `notify()`) now lives in
`app/android/src/xyz/whcself/cstagent/Notifier.java`, which catches the
throwable and hands the text back for the tool result; the device then
reported `notification sent` and the shade had it. androiddeployqt copies
`app/android/` with the rest of the package source and Qt's Gradle template
puts its `src/` on `java.srcDirs`, so **a class dropped in that directory
needs no build-script change** -- the front service of the next step goes
there too.
* A session's transcript replays a delivered file (`deliver_file`) as a card
with Open and Share -- the copy lives next to the session, marked `for_user`
so no request ever contains it (see `app/README.md`, "Files the agent
delivers").
* **A turn survives the app leaving the screen**: `AgentCore::send()` calls
`app::holdForeground()`, which starts `CstService`
(`app/android/src/xyz/whcself/cstagent/CstService.java`) as a foreground
service and takes a partial wake lock; `finishTurn()` stops it again. On the
NX789J: with the app sent home and the screen off, the service showed
`isForeground=true` with its `CST-agent is working` notification (id 2, the
same `cst-agent` channel), the lock `cst-agent:turn` was acquired at the
start of the turn and released at its end, `run_shell sleep 60` finished with
the screen dark, and the service was gone once the turn was over.
`CST_APP_NO_SERVICE=1` keeps a test run from touching the phone.
* Still to come: the SAF picker for attaching a picture from another app, the
back gesture, and the icon glyphs -- `ThemeIcon` finds no icon files on the
phone and draws empty boxes where the desktop shows Breeze icons.
+5 -5
View File
@@ -21,11 +21,11 @@ The Makefile picks `ncursesw`, then `ncurses`, through pkg-config. On macOS it
adds Homebrew's keg-only `ncurses`/`curl` to `PKG_CONFIG_PATH`; without adds Homebrew's keg-only `ncurses`/`curl` to `PKG_CONFIG_PATH`; without
Homebrew it falls back to the system `-lncurses` (wide-character capable). Homebrew it falls back to the system `-lncurses` (wide-character capable).
Verified so far: NixOS x86_64 with gcc and clang (`-Werror`, `make check`, Verified so far: NixOS x86_64 with gcc, clang and TinyCC (`-Werror` except
`make asan`, ctest), and every source compiled `-fsyntax-only` with tcc, `make check`, `make asan`, ctest). CI (`.gitea/workflows/ci.yml`) checks
`x86_64-unknown-linux-musl-gcc` without warnings. Full musl, macOS and WSL with TinyCC on Debian and builds the static musl releases for x86_64, x86 and
builds run in CI (`.gitea/workflows/ci.yml`; the macOS job needs a runner and aarch64 with gcc `-Werror` (`make check` on both x86 targets, a qemu smoke
the `HAVE_MACOS_RUNNER` variable) or by hand with the checklist below. test on aarch64). macOS and WSL are checked by hand with the list below.
## Files ## Files
+21 -4
View File
@@ -116,7 +116,7 @@ completion_tokens,cached_tokens,cache_miss_tokens,cost,priced,currency}`
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|---|---|---| |---|---|---|
| GET | `/api/state` | 上面那份快照 | | GET | `/api/state` | 上面那份快照 |
| GET | `/api/session?from=N` | 会话消息(`{messages:[…]}`,形状与 `.agent/sessions/*.jsonl` 一致,另加的字段见下) | | GET | `/api/session?from=N` | 会话消息(`{messages:[…]}`,形状与转录 `.agent/sessions/<id>/session.jsonl` 的行一致,另加的字段见下) |
| GET | `/api/sessions` | `{current, sessions:[{id,title,messages,mtime}]}` | | GET | `/api/sessions` | `{current, sessions:[{id,title,messages,mtime}]}` |
| POST | `/api/sessions/new` | `{}` → `{id}` | | POST | `/api/sessions/new` | `{}` → `{id}` |
| POST | `/api/sessions/switch` | `{"id":"…"}` | | POST | `/api/sessions/switch` | `{"id":"…"}` |
@@ -209,6 +209,22 @@ Markdown 在**服务端**渲染(复用 TUI 用的 sashidown 流式 API),
对话开始后自动隐去;右栏只给三件事——任务清单、本轮文件改动行数(`+N −M`)、上下文 对话开始后自动隐去;右栏只给三件事——任务清单、本轮文件改动行数(`+N −M`)、上下文
占用与 prompt cache 命中率。布局类在页面脚本之前就写进 `<body>`,避免首帧闪一下再收起。 占用与 prompt cache 命中率。布局类在页面脚本之前就写进 `<body>`,避免首帧闪一下再收起。
对话列:正文、过程块、提示行和输入框共用同一条左边线,正文宽度上限 720px(`--content`);
左右两栏随屏宽在 `clamp()` 范围内变宽,超宽屏下中间列不会无限拉开。
过程时间线:一段**连续**的思考和工具调用合成一个过程块,折叠时只有一行
("思考 4 次 · 工具 6 次";运行中显示当前动作,比如"执行 run_shell…"),展开后是一条
细竖线串起的时间线,每次思考、每个工具调用各占一行,思考只露第一行,点开看全文。
出现可见的正文(或引擎提示、下一句提问)时过程块就此结束,后面的思考/工具另起一块——
历史重建(`/api/session`)和实时事件流用的是同一条规则,所以刷新前后看到的一样。
`/thinking on|off`(`show_thinking`)决定过程块和其中的思考默认展开还是收起,流式输出时也照此执行;
`/details` 只管工具行是否展开。
图片:提问附带的图片显示在气泡**上方**、靠右,一张保持原比例(最长 260px),多张是
152px 的圆角方块;点开是大图预览(Esc 关闭,←/→ 切换)。从本页上传/粘贴/拖入的图片在
输入框上方显示缩略图,发送后立刻出现在新提问上(本地预览,本轮结束后换成 `/api/attach/<rel>`);
从别的客户端加的待发图片仍然显示为文字条。
### 命令与快捷键 ### 命令与快捷键
输入框以 `/` 开头时是命令,在页面里执行,不发给模型:打 `/` 弹出列表(↑↓ 选,Tab 补全, 输入框以 `/` 开头时是命令,在页面里执行,不发给模型:打 `/` 弹出列表(↑↓ 选,Tab 补全,
@@ -259,10 +275,11 @@ sh scripts/e2e_webui.sh tests/fixtures/sse/deepseek-ctl-heredoc ctl-heredoc
1. 打开首页 → 顶部显示 workspace/模型/审批模式,右下状态"就绪"。 1. 打开首页 → 顶部显示 workspace/模型/审批模式,右下状态"就绪"。
2. 输入一句话 → 助手气泡逐字出现;工具卡片自动展开、结束后带 `exit=` 标记。 2. 输入一句话 → 助手气泡逐字出现;工具卡片自动展开、结束后带 `exit=` 标记。
3. 切到 `ask` 审批 → 写文件时弹窗;点"允许一次"后继续,点"拒绝"则该工具返回失败但对话继续。 3. 切到 `ask` 审批 → 写文件时弹窗;点"允许一次"后继续,点"拒绝"则该工具返回失败但对话继续。
4. `会话` 按钮:新建/切换后历史正确重建(刷新页面也一样):工具调用折叠成"本轮工具调用", 4. `会话` 按钮:新建/切换后历史正确重建(刷新页面也一样):连续的思考和工具调用折叠成一行
点开能看到参数和输出;搜索、重命名、删除可用。 过程块,展开是时间线,工具行点开能看到参数和输出;搜索、重命名、删除可用。
`/thinking off` 时流式输出的思考也保持收起。
4a. 最后一句提问上有"编辑重发",最后一个回答上有"重新生成";右栏文件行点开是真实 diff。 4a. 最后一句提问上有"编辑重发",最后一个回答上有"重新生成";右栏文件行点开是真实 diff。
5. 拖入一张图片 → 底部出现待发送提示,发送后历史里能看到图。 5. 拖入一张图片 → 输入框上方出现缩略图,发送后图片立刻出现在提问气泡上方,点开是大图预览。
6. 关掉标签页再打开 → 事件流自动续传,不丢已完成的消息。 6. 关掉标签页再打开 → 事件流自动续传,不丢已完成的消息。
## 限制与已知问题 ## 限制与已知问题
+85 -6
View File
@@ -47,17 +47,24 @@ int cst_protocol_is_text(cst_protocol_t p);
/* Eager protocols execute a call during the stream, at confirmation time. */ /* Eager protocols execute a call during the stream, at confirmation time. */
int cst_protocol_is_eager(cst_protocol_t p); int cst_protocol_is_eager(cst_protocol_t p);
/* One image carried by a message (see cst_attach.h). The bytes live beside /* One file carried by a message (see cst_attach.h): an image, or a document
* the session; this is the metadata plus the name of the stored copy. */ * whose stored payload is its text. The payload lives beside the session;
* this is the metadata plus the name of the stored copy. */
typedef struct { typedef struct {
char id[32]; /* unique inside the session ("img-1a2b3c4d") */ char id[32]; /* unique inside the session ("img-1a2b3c4d") */
char name[256]; /* display name (original file name) */ char name[256]; /* display name (original file name) */
char mime[32]; /* image/png, image/jpeg, image/gif, image/webp, image/bmp */ char mime[32]; /* image/png, ...; application/pdf for a PDF whose text
* layer the stored file holds; text/plain for text */
char rel[256]; /* file name inside the session's attachment directory */ char rel[256]; /* file name inside the session's attachment directory */
long long bytes; /* stored size */ long long bytes; /* stored size (the text, for documents) */
int width, height; /* pixels, 0 when the header could not be read */ int width, height; /* pixels, 0 when the header could not be read */
int pages; /* PDF page count, 0 for anything else */
char detail[8]; /* auto | low | high (wire hint, "" = auto) */ char detail[8]; /* auto | low | high (wire hint, "" = auto) */
char origin[32]; /* user | view_image | mcp (for display only) */ char origin[32]; /* user | view_image | mcp (for display only) */
/* A delivered file (deliver_file): the front end shows it as a card the
* person can open or share, and no request ever carries it. The stored copy
* is the file as it was, not a text reduction -- see cst_attach.h. */
int for_user;
} cst_attachment_t; } cst_attachment_t;
typedef struct { typedef struct {
@@ -82,9 +89,17 @@ typedef struct {
char model[128]; char model[128];
char provider[32]; char provider[32];
char effort[16]; /* low | medium | high | max */ char effort[16]; /* low | medium | high | max */
char mode[16]; /* coding | general (CST_DEFAULT_MODE) */
cst_protocol_t protocol; cst_protocol_t protocol;
int max_steps; /* model calls per user turn */ int max_steps; /* model calls per user turn */
size_t tool_out_cap; /* bytes of one tool output sent to the model */ size_t tool_out_cap; /* bytes of one tool output sent to the model */
/* What the model is asked to do (Chat Completions fields). 0 = the field
* is not sent at all and the provider's own default stands; that is what a
* config that was not told otherwise has, so a CLI run is unchanged.
* temperature and top_p are doubles, max_tokens the completion cap. */
double temperature;
double top_p;
int max_tokens;
int headless; int headless;
int confirm_shell; /* confirm run_shell */ int confirm_shell; /* confirm run_shell */
int confirm_write; /* confirm file-changing tools */ int confirm_write; /* confirm file-changing tools */
@@ -114,6 +129,14 @@ typedef enum {
const char *cst_approval_name(int a); const char *cst_approval_name(int a);
int cst_approval_parse(const char *s, int *out); int cst_approval_parse(const char *s, int *out);
/* Working mode: how the system prompt frames the agent. "coding" (default)
* is code framed; "general" is for tasks that are not about a codebase
* (documents, data, research, everyday files). Orthogonal to plan mode. */
#define CST_DEFAULT_MODE "coding"
int cst_mode_valid(const char *m);
/* Canonical name of m, or the default when m is unknown / empty. */
const char *cst_mode_name(const char *m);
typedef struct cst_session cst_session_t; typedef struct cst_session cst_session_t;
typedef struct cst_agent cst_agent_t; typedef struct cst_agent cst_agent_t;
@@ -144,7 +167,14 @@ void cst_session_unlock(cst_session_t *s);
/* --- workspace .agent/sessions store (OpenCode-like) --- */ /* --- workspace .agent/sessions store (OpenCode-like) --- */
int cst_agent_store_ensure(const char *workspace); int cst_agent_store_ensure(const char *workspace);
int cst_agent_store_dir(const char *workspace, char *out, size_t out_sz); int cst_agent_store_dir(const char *workspace, char *out, size_t out_sz);
/* A fresh session id: a uuid v4, so two machines sharing a workspace cannot
* hand out the same one (a timestamp can). id_sz >= 37. */
int cst_agent_store_new_id(char *id_out, size_t id_sz); int cst_agent_store_new_id(char *id_out, size_t id_sz);
/* The transcript of an id. A session owns a directory under the store
* (<id>/session.jsonl, with its attachments and sub-agent transcripts inside
* it); an id that still has the older flat file (<id>.jsonl) keeps it until
* cst_agent_store_migrate() moves it. A new id gets its directory made here,
* so the caller can open the file next. */
int cst_agent_store_path_for_id(const char *workspace, const char *id, int cst_agent_store_path_for_id(const char *workspace, const char *id,
char *out, size_t out_sz); char *out, size_t out_sz);
int cst_agent_store_set_current(const char *workspace, const char *id); int cst_agent_store_set_current(const char *workspace, const char *id);
@@ -154,6 +184,10 @@ int cst_agent_store_resolve_default(const char *workspace, char *path_out,
size_t path_sz, char *id_out, size_t path_sz, char *id_out,
size_t id_sz); size_t id_sz);
int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids); int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids);
/* The id a session path belongs to: <dir>/<id>.jsonl (the flat layout) or
* <dir>/<id>/session.jsonl (a session's own directory). 0 / -1. */
int cst_agent_store_id_of_path(const char *session_path, char *out,
size_t out_sz);
typedef struct { typedef struct {
char id[128]; char id[128];
long long mtime; long long mtime;
@@ -166,6 +200,13 @@ int cst_agent_store_info(const char *workspace, const char *id,
int cst_agent_store_title_set(const char *workspace, const char *id, int cst_agent_store_title_set(const char *workspace, const char *id,
const char *title); const char *title);
int cst_agent_store_delete(const char *workspace, const char *id); int cst_agent_store_delete(const char *workspace, const char *id);
/* Move every flat session of the store into the per-session layout: the
* transcript to <id>/session.jsonl, the title and the shared attachment
* directory (<id>.title, attachments/<id>/) along with it. Sessions that are
* already directories are left alone, so running it twice is harmless.
* *moved_out / *failed_out (optional) count what happened; 0 / -1. */
int cst_agent_store_migrate(const char *workspace, int *moved_out,
int *failed_out, char *err, size_t err_sz);
/* --- tools --- */ /* --- tools --- */
typedef struct { typedef struct {
@@ -259,6 +300,9 @@ typedef struct {
const char *cache_ttl; /* cache_control ttl (CST_CACHE_ANTHROPIC); NULL = 5m */ const char *cache_ttl; /* cache_control ttl (CST_CACHE_ANTHROPIC); NULL = 5m */
int images_off; /* the model does not take images: send a note instead */ int images_off; /* the model does not take images: send a note instead */
size_t tool_out_cap; /* wire cap for role=tool content; 0 = default */ size_t tool_out_cap; /* wire cap for role=tool content; 0 = default */
double temperature; /* 0 = not sent */
double top_p; /* 0 = not sent */
int max_tokens; /* 0 = not sent */
const char *session_path; /* where stored images live; NULL = none */ const char *session_path; /* where stored images live; NULL = none */
volatile int *cancel_flag; /* non-zero → abort (error "cancelled") */ volatile int *cancel_flag; /* non-zero → abort (error "cancelled") */
volatile int *stop_flag; /* non-zero → stop reading, not an error */ volatile int *stop_flag; /* non-zero → stop reading, not an error */
@@ -278,6 +322,8 @@ int cst_llm_list_models(const char *api_base, const char *api_key,
char *cst_llm_build_body(const cst_llm_request_t *req); char *cst_llm_build_body(const cst_llm_request_t *req);
/* Reset the CST_RECORD_SSE / CST_REPLAY_SSE sequence counter. */ /* Reset the CST_RECORD_SSE / CST_REPLAY_SSE sequence counter. */
void cst_llm_trace_reset(void); void cst_llm_trace_reset(void);
/* Set CURLOPT_CAINFO on a CURL * when a CA bundle file is known. */
void cst_curl_apply_ca(void *curl);
/* on_permission answers */ /* on_permission answers */
#define CST_PERM_DENY 0 #define CST_PERM_DENY 0
@@ -310,6 +356,12 @@ typedef struct {
/* Full tool output, fired right after execution (before it is persisted). */ /* Full tool output, fired right after execution (before it is persisted). */
void (*on_tool_result)(const ctl_tool_call_t *call, const char *out, void (*on_tool_result)(const ctl_tool_call_t *call, const char *out,
size_t len, int exit_code, void *ud); size_t len, int exit_code, void *ud);
/* Images a tool produced (view_image, MCP image content), fired right after
* the call ran and before the result is persisted. Only a call that made at
* least one image is reported, so a front end can show it at once instead of
* reading the store. */
void (*on_tool_images)(const ctl_tool_call_t *call,
const cst_attachment_t *list, int n, void *ud);
void *userdata; void *userdata;
} cst_agent_hooks_t; } cst_agent_hooks_t;
@@ -354,6 +406,12 @@ int cst_agent_pending_count(const cst_agent_t *a);
/* One line "<name> (<mime>, 12 KB)"; -1 when i is out of range. */ /* One line "<name> (<mime>, 12 KB)"; -1 when i is out of range. */
int cst_agent_pending_describe(const cst_agent_t *a, int i, char *out, int cst_agent_pending_describe(const cst_agent_t *a, int i, char *out,
size_t out_sz); size_t out_sz);
/* The stored name of pending attachment i ("img-1a2b3c4d.png"), or NULL when
* i is out of range. The file is already in the session store, so a front end
* can show the image before the message is sent. */
const char *cst_agent_pending_rel(const cst_agent_t *a, int i);
/* 1 when pending attachment i is an image, 0 otherwise (or out of range). */
int cst_agent_pending_is_image(const cst_agent_t *a, int i);
void cst_agent_pending_clear(cst_agent_t *a); void cst_agent_pending_clear(cst_agent_t *a);
/* Latest todo_write list of this session, or NULL. */ /* Latest todo_write list of this session, or NULL. */
const char *cst_agent_todo(const cst_agent_t *a); const char *cst_agent_todo(const cst_agent_t *a);
@@ -404,13 +462,24 @@ int cst_creds_resolve(const char *provider, char *out, size_t out_sz,
/* Hidden console prompt (no echo). */ /* Hidden console prompt (no echo). */
int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz); int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz);
/* Last used provider, model per provider and effort, in /* Last used provider, model per provider, effort and working mode, in
* <config>/settings.json. */ * <config>/settings.json. */
#define CST_SETTINGS_MAX_MODELS 16 #define CST_SETTINGS_MAX_MODELS 16
#define CST_SETTINGS_MAX_TRUSTED 32 #define CST_SETTINGS_MAX_TRUSTED 32
#define CST_SETTINGS_MAX_PARAMS 16
/* Generation parameters remembered per provider ("params" in settings.json):
* what the user set for that provider's models. 0 = not set, the field is
* not sent. */
typedef struct {
char provider[32];
double temperature;
double top_p;
int max_tokens;
} cst_gen_params_t;
typedef struct { typedef struct {
char provider[32]; char provider[32];
char effort[16]; char effort[16];
char mode[16]; /* coding | general, "" = not chosen yet */
char approval[8]; /* "" = not chosen yet */ char approval[8]; /* "" = not chosen yet */
int show_thinking; /* TUI: Thought blocks start expanded */ int show_thinking; /* TUI: Thought blocks start expanded */
/* workspaces whose .agent/plugins and .agent/mcp.json may run */ /* workspaces whose .agent/plugins and .agent/mcp.json may run */
@@ -421,15 +490,25 @@ typedef struct {
char provider[32]; char provider[32];
char model[128]; char model[128];
} models[CST_SETTINGS_MAX_MODELS]; } models[CST_SETTINGS_MAX_MODELS];
int nparams;
cst_gen_params_t params[CST_SETTINGS_MAX_PARAMS];
} cst_settings_t; } cst_settings_t;
int cst_settings_load(cst_settings_t *s); int cst_settings_load(cst_settings_t *s);
int cst_settings_save(const cst_settings_t *s); int cst_settings_save(const cst_settings_t *s);
const char *cst_settings_model(const cst_settings_t *s, const char *provider); const char *cst_settings_model(const cst_settings_t *s, const char *provider);
/* Record cfg's provider, model and effort as the last used ones. */ /* The remembered parameters of a provider, or NULL when it has none. */
const cst_gen_params_t *cst_settings_params(const cst_settings_t *s,
const char *provider);
/* Remember parameters for a provider; all-zero (or NULL) forgets them. */
int cst_settings_remember_params(const char *provider,
const cst_gen_params_t *p);
/* Record cfg's provider, model, effort and mode as the last used ones. */
int cst_settings_remember(const cst_agent_config_t *cfg); int cst_settings_remember(const cst_agent_config_t *cfg);
/* Record an approval mode the user picked (CST_APPROVAL_*). */ /* Record an approval mode the user picked (CST_APPROVAL_*). */
int cst_settings_remember_approval(int approval); int cst_settings_remember_approval(int approval);
int cst_settings_remember_thinking(int show_thinking); int cst_settings_remember_thinking(int show_thinking);
/* Record the working mode the user picked (coding | general). */
int cst_settings_remember_mode(const char *mode);
/* Workspace trust for project plugins / MCP servers (by real path). */ /* Workspace trust for project plugins / MCP servers (by real path). */
int cst_settings_trusted(const char *workspace); int cst_settings_trusted(const char *workspace);
int cst_settings_trust(const char *workspace, int on); int cst_settings_trust(const char *workspace, int on);
+36 -14
View File
@@ -9,16 +9,28 @@
extern "C" { extern "C" {
#endif #endif
/* --- image attachments --------------------------------------------------- /* --- attachments ---------------------------------------------------------
* An attachment is a validated copy of an image file, stored beside the * An attachment is a validated copy of a file, stored beside the session so
* session so the bytes a model is shown stay the bytes that were picked. * what a model is shown stays what was picked. Images are stored as they are
* Only the metadata travels in the JSONL; the bytes are encoded into the * and encoded into the request when it is built; a PDF is reduced to its text
* request when it is built. */ * layer and a text file is stored as is, both of which travel inline as text
* (so a text-only model still gets them). Only the metadata goes in the
* JSONL. */
/* Supported: PNG, JPEG, GIF, WebP, BMP (static images). */ /* Supported: PNG, JPEG, GIF, WebP, BMP (static images), PDF, UTF-8 text. */
#define CST_ATTACH_MAX_BYTES (16 * 1024 * 1024) /* one stored image */ #define CST_ATTACH_MAX_BYTES (16 * 1024 * 1024) /* one picked file */
#define CST_ATTACH_MAX_TOTAL (32 * 1024 * 1024) /* per request */ #define CST_ATTACH_MAX_TOTAL (32 * 1024 * 1024) /* per request */
#define CST_ATTACH_MAX_COUNT 8 /* per message */ #define CST_ATTACH_MAX_COUNT 8 /* per message */
/* Text stored and inlined per document (a PDF also caps its text here). */
#define CST_ATTACH_TEXT_MAX (4 * 1024 * 1024)
/* A delivered file (deliver_file) is the opposite of an attachment: it is
* handed to the person, not to the model, so any bytes are allowed up to this
* size, the extension names the type, and no request ever shows it. */
#define CST_ARTIFACT_MAX_BYTES (64 * 1024 * 1024)
/* Images go on the wire as image parts; everything else is inlined as text. */
int cst_attach_is_image(const cst_attachment_t *a);
int cst_attach_is_text(const cst_attachment_t *a);
/* Magic-byte test: 0 and a canonical mime when data holds a supported /* Magic-byte test: 0 and a canonical mime when data holds a supported
* image, -1 otherwise (an extension alone never decides). */ * image, -1 otherwise (an extension alone never decides). */
@@ -35,23 +47,33 @@ int cst_attach_dir(const char *session_path, char *out, size_t out_sz);
int cst_attach_full_path(const char *session_path, const cst_attachment_t *a, int cst_attach_full_path(const char *session_path, const cst_attachment_t *a,
char *out, size_t out_sz); char *out, size_t out_sz);
/* Read src_path, verify it is a supported image within limits, and copy it /* Read src_path and copy it into the session's attachment directory: an image
* into the session's attachment directory. Returns a malloc'd entry the * as it is, a PDF as its extracted text, anything else only when it is text.
* caller owns (cst_attach_free), or NULL with err filled. */ * Returns a malloc'd entry the caller owns (cst_attach_free), or NULL with
* err filled. */
cst_attachment_t *cst_attach_import_file(const char *session_path, cst_attachment_t *cst_attach_import_file(const char *session_path,
const char *src_path, char *err, const char *src_path, char *err,
size_t err_sz); size_t err_sz);
/* Store already-read bytes (clipboard, downloads); name is display-only. */ /* Store already-read image bytes (clipboard, downloads); name is
* display-only. */
cst_attachment_t *cst_attach_import_bytes(const char *session_path, cst_attachment_t *cst_attach_import_bytes(const char *session_path,
const char *name, const char *data, const char *name, const char *data,
size_t n, char *err, size_t err_sz); size_t n, char *err, size_t err_sz);
/* Copy src_path into the session's store as a finished file for the user:
* any bytes up to CST_ARTIFACT_MAX_BYTES, mime from the name's extension, and
* `for_user` set so it stays out of every request. title (optional) is the
* display name, the source's file name the default. */
cst_attachment_t *cst_attach_import_artifact(const char *session_path,
const char *src_path,
const char *title, char *err,
size_t err_sz);
/* Validate and read back a stored image, or NULL when it is gone. */ /* Validate and read back a stored payload, or NULL when it is gone. */
char *cst_attach_read(const char *session_path, const cst_attachment_t *a, char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
size_t *out_len, char *err, size_t err_sz); size_t *out_len, char *err, size_t err_sz);
/* "data:image/png;base64,...." for the wire; NULL when the file is gone. */ /* "data:image/png;base64,...." for the wire; images only, NULL otherwise. */
char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a); char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a);
/* "1920x1080 PNG 412 KB" style one-liner for prompts and the UI. */ /* "1920x1080 png 412 KB" / "pdf 1.2 MB, 12 pages" one-liner for prompts. */
void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz); void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz);
void cst_attach_free(cst_attachment_t *a); void cst_attach_free(cst_attachment_t *a);
+18
View File
@@ -13,10 +13,28 @@ extern "C" {
* ("no image on the clipboard", "install wl-clipboard", ...). */ * ("no image on the clipboard", "install wl-clipboard", ...). */
int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz); int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz);
/* Cap on text moved through the clipboard (read and write). */
#define CST_CLIPBOARD_TEXT_MAX (1024 * 1024)
/* UTF-8 text on the system clipboard: Wayland (wl-paste), X11 (xclip),
* macOS (pbpaste), Windows (CF_UNICODETEXT). Returns 0 with *data malloc'd
* and NUL-terminated, or -1 with err filled in ("no text on the clipboard",
* "reading the clipboard needs wl-clipboard", ...). */
int cst_clipboard_text(char **data, size_t *n, char *err, size_t err_sz);
/* Replace the clipboard with UTF-8 text: wl-copy / xclip / pbcopy, or the
* Windows clipboard API. Returns 0 on success, else -1 with err. */
int cst_clipboard_set_text(const char *text, size_t n, char *err,
size_t err_sz);
/* Windows CF_DIB bytes (BITMAPINFOHEADER + pixels) as an image file: a PNG /* Windows CF_DIB bytes (BITMAPINFOHEADER + pixels) as an image file: a PNG
* for uncompressed 24/32-bit bitmaps, else a BMP. NULL when malformed. */ * for uncompressed 24/32-bit bitmaps, else a BMP. NULL when malformed. */
char *cst_dib_to_image(const void *dib, size_t sz, size_t *n); char *cst_dib_to_image(const void *dib, size_t sz, size_t *n);
/* 0 when this host has a clipboard to talk to (a helper on PATH, or the
* Win32/AppKit API), -1 with a reason otherwise. Cheap: no forks. */
int cst_clipboard_available(char *why, size_t why_sz);
#define CST_PASTE_MAX_PATHS 8 #define CST_PASTE_MAX_PATHS 8
#define CST_PASTE_PATH_MAX 1024 #define CST_PASTE_PATH_MAX 1024
+16
View File
@@ -73,6 +73,22 @@ const char *cst_provider_source(const cst_provider_t *p);
/* The user file's path (it may not exist). */ /* The user file's path (it may not exist). */
int cst_models_user_path(char *out, size_t out_sz); int cst_models_user_path(char *out, size_t out_sz);
/* Add or replace a provider in the user file (layer 1 above) and reload the
* registry: this is how a provider the presets do not know becomes one.
* name is 1-31 characters of letters, digits, '.', '_' or '-'; api_base is
* required; key_env names the variable its key may come from; default_model
* and models[] are what the model picker offers (a fetched /models list still
* wins). An entry with a preset's name overrides that preset, everything else
* in the file (model rules) is kept. 0, or -1 with err filled. */
int cst_models_user_set_provider(const char *name, const char *api_base,
const char *key_env,
const char *default_model,
const char *const *models, int nmodels,
char *err, size_t err_sz);
/* Drop a provider from the user file (a preset of the same name stays).
* 1 = removed, 0 = it was not there, -1 with err. */
int cst_models_user_drop_provider(const char *name, char *err, size_t err_sz);
/* Drop everything and read the built-in data, the user file and the caches /* Drop everything and read the built-in data, the user file and the caches
* again. Provider pointers stay valid but may name another provider. */ * again. Provider pointers stay valid but may name another provider. */
void cst_models_reload(void); void cst_models_reload(void);
+27
View File
@@ -0,0 +1,27 @@
#ifndef CST_PDF_H
#define CST_PDF_H
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Text layer of a PDF, in page order, with no external dependency (no
* poppler, no zlib): classic objects and object streams, FlateDecode plus
* ASCIIHex/ASCII85, PNG/TIFF predictors, Tj/TJ/'/" text with per-font
* ToUnicode CMaps and a WinAnsi / Latin-1 fallback. Scanned pages have no
* text layer and are reported as such.
* Returns a malloc'd NUL-terminated UTF-8 string, or NULL with err filled in
* ("PDF is encrypted ...", "no extractable text ..."). *pages (optional) gets
* the page count; the result is truncated at CST_PDF_TEXT_MAX bytes. */
char *cst_pdf_text(const void *data, size_t n, int *pages, char *err,
size_t err_sz);
#define CST_PDF_TEXT_MAX (4u * 1024u * 1024u)
#ifdef __cplusplus
}
#endif
#endif
+33 -1
View File
@@ -62,6 +62,14 @@ typedef int (*cst_tool_run_ex_fn)(const cst_tool_ctx_t *ctx,
const ctl_tool_call_t *call, const ctl_tool_call_t *call,
cst_tool_result_t *r, void *ud); cst_tool_result_t *r, void *ud);
/* Platforms a tool exists on, for def.platforms (NULL-terminated).
* CST_PLAT_DESKTOP covers linux / win / macos; an app-provided tool can
* declare its own, e.g. a static {"android", NULL} in the Qt frontend. */
extern const char *const CST_PLAT_DESKTOP[];
/* Host platform: "linux" | "win" | "macos" | "android" | "termux" | "other".
* CST_PLATFORM overrides it (tests, cross checks). */
const char *cst_platform_name(void);
typedef struct { typedef struct {
const char *name; const char *name;
const char *desc; const char *desc;
@@ -81,10 +89,34 @@ typedef struct {
const char *owner; /* "plugin:<name>", "mcp:<server>", ... */ const char *owner; /* "plugin:<name>", "mcp:<server>", ... */
/* Non-READ tools that may still join a parallel batch for this call. */ /* Non-READ tools that may still join a parallel batch for this call. */
int (*parallel)(const ctl_tool_call_t *call, void *ud); int (*parallel)(const ctl_tool_call_t *call, void *ud);
/* --- capability metadata (discovery + availability) ------------------- */
/* Fine-grained policy name for permissions.json and consent UIs
* ("read","write","delete","shell","net","notify","device","ui","meta").
* NULL falls back to the class name of perm. */
const char *perm_name;
/* Platforms the tool exists on (NULL = everywhere). A tool that is not
* for this host never reaches the prompt or tools[]. */
const char *const *platforms;
/* Probe: 0 when the tool can run here, -1 with a reason in why (NULL =
* always). Keep it cheap (no forks); the answer is cached until
* cst_tool_meta_reload(). */
int (*available)(char *why, size_t why_sz);
} cst_tool_def_t; } cst_tool_def_t;
/* 0 when t exists on this platform. */
int cst_tool_platform_ok(const cst_tool_def_t *t);
/* 0 when the tool can run here, -1 with a reason in why. Cached. */
int cst_tool_available(const cst_tool_def_t *t, char *why, size_t why_sz);
/* Drop cached probes (a platform service appeared or went away). */
void cst_tool_meta_reload(void);
/* Policy name of t: perm_name, or the class default of perm. */
const char *cst_tool_perm_name(const cst_tool_def_t *t);
/* Add a tool at runtime (strings are copied, ud is not). Safe while turns /* Add a tool at runtime (strings are copied, ud is not). Safe while turns
* run; returns 0, or -1 when the name is taken or the table is full. */ * run; returns 0, or -1 when the name is taken or the table is full. An
* app-provided tool may take a name whose built-in cannot run on this host
* (the Android clipboard replacing the xclip one) and then shadows it for
* lookups: one capability, one name. */
int cst_tool_register(const cst_tool_def_t *def); int cst_tool_register(const cst_tool_def_t *def);
/* Drop every dynamic tool of owner (call only while no turn runs). */ /* Drop every dynamic tool of owner (call only while no turn runs). */
int cst_tool_unregister_owner(const char *owner); int cst_tool_unregister_owner(const char *owner);
+2 -2
View File
@@ -2,8 +2,8 @@
#define CST_VERSION_H #define CST_VERSION_H
#define CST_VERSION_MAJOR 0 #define CST_VERSION_MAJOR 0
#define CST_VERSION_MINOR 8 #define CST_VERSION_MINOR 9
#define CST_VERSION_PATCH 4 #define CST_VERSION_PATCH 6
#define CST_VERSION_STR_(x) #x #define CST_VERSION_STR_(x) #x
#define CST_VERSION_STR(x) CST_VERSION_STR_(x) #define CST_VERSION_STR(x) CST_VERSION_STR_(x)
+4
View File
@@ -38,6 +38,10 @@ void cst_web_md_free(cst_web_md_t *s);
/* One-shot: markdown -> sanitized HTML (malloc'd, never NULL on success). */ /* One-shot: markdown -> sanitized HTML (malloc'd, never NULL on success). */
char *cst_web_md_html(const char *md, size_t len); char *cst_web_md_html(const char *md, size_t len);
/* One-shot: markdown -> a whole HTML document (doctype, charset, title, a
* small stylesheet; no script and no external request), for a file handed to
* the user. title is optional; NULL or empty means "Document". */
char *cst_web_md_document(const char *md, size_t len, const char *title);
/* The sanitizer alone (tests, and any HTML that did not come from here). */ /* The sanitizer alone (tests, and any HTML that did not come from here). */
char *cst_web_md_sanitize(const char *html, size_t len); char *cst_web_md_sanitize(const char *html, size_t len);
+11 -7
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Linux host: cross-build the Windows x86_64 release (mingw-w64, UCRT, static). # Linux host: cross-build the Windows x86_64 release (mingw-w64, UCRT, static).
# scripts/bootstrap_windows.sh [repo-dir] # scripts/bootstrap_windows.sh [repo-dir]
# With Nix the toolchain comes from shell-win.nix; otherwise x86_64-w64-mingw32-gcc # With Nix the toolchain comes from shell-win.nix; otherwise $MINGW_HOST-gcc
# (UCRT flavour, with winpthreads), cmake, ninja, git and perl must be installed. # (default x86_64-w64-mingw32; UCRT flavour, with winpthreads), cmake, ninja,
# git and perl must be installed. CST_WIN_CFLAGS adds C flags (CI: -Werror).
# Output: dist/windows-x86_64/ and dist/cst-agent-VERSION-windows-x86_64.zip # Output: dist/windows-x86_64/ and dist/cst-agent-VERSION-windows-x86_64.zip
set -euo pipefail set -euo pipefail
@@ -21,15 +22,17 @@ if [[ ! -f "$DIR/CMakeLists.txt" ]]; then
fi fi
cd "$DIR" cd "$DIR"
if ! command -v x86_64-w64-mingw32-gcc >/dev/null && [[ -z "${IN_NIX_SHELL:-}" ]] && H="${MINGW_HOST:-x86_64-w64-mingw32}"
if ! command -v "$H-gcc" >/dev/null && [[ -z "${IN_NIX_SHELL:-}" ]] &&
command -v nix-shell >/dev/null; then command -v nix-shell >/dev/null; then
exec nix-shell shell-win.nix --run "$(printf '%q ' "$0" "$PWD")" exec nix-shell shell-win.nix --run "$(printf '%q ' "$0" "$PWD")"
fi fi
need() { command -v "$1" >/dev/null || { echo "missing $1 (mingw-w64 UCRT gcc, cmake, ninja, git, perl)" >&2; exit 1; }; } need() { command -v "$1" >/dev/null || { echo "missing $1 (mingw-w64 UCRT gcc, cmake, ninja, git, perl)" >&2; exit 1; }; }
need git; need cmake; need ninja; need perl; need x86_64-w64-mingw32-gcc export MINGW_HOST="$H"
if ! printf '#include <stdio.h>\n' | x86_64-w64-mingw32-gcc -dM -E - | grep -qw _UCRT; then need git; need cmake; need ninja; need perl; need "$H-gcc"
echo "x86_64-w64-mingw32-gcc targets msvcrt; the UTF-8 manifest needs a UCRT toolchain" >&2 if ! printf '#include <stdio.h>\n' | "$H-gcc" -dM -E - | grep -qw _UCRT; then
echo "$H-gcc targets msvcrt; the UTF-8 manifest needs a UCRT toolchain" >&2
exit 1 exit 1
fi fi
@@ -40,7 +43,8 @@ fi
echo "==> cmake (Windows, TUI)" echo "==> cmake (Windows, TUI)"
cmake -S . -B build-win-tui -G Ninja \ cmake -S . -B build-win-tui -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \ -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
-DCMAKE_BUILD_TYPE=Release -DCST_BUILD_TUI=ON -DCMAKE_BUILD_TYPE=Release -DCST_BUILD_TUI=ON \
-DCMAKE_C_FLAGS="${CST_WIN_CFLAGS:-}"
# Package only needs the agent binary; skip host-unrunnable Win32 tests. # Package only needs the agent binary; skip host-unrunnable Win32 tests.
cmake --build build-win-tui --target cst-agent cmake --build build-win-tui --target cst-agent
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Cross-build the engine for Android: libcstcore.a (what the APK embeds) plus
# cst-agent and tests/test_embed, which run straight from /data/local/tmp.
# No TUI (no ncurses on Android) and no WebUI (the phone app talks to the
# engine in-process; the stub compiles instead of the HTTP server).
#
# scripts/build_android.sh [arm64-v8a|x86_64] [--push] [--run] [--run-tests]
#
# --push copy the binaries to /data/local/tmp
# --run also smoke-test there: cst-agent --version, test_embed
# --run-tests build and run the whole tests/ suite on the device
#
# Needs ANDROID_NDK_HOME (see build_third_party_android.sh) and the deps from
# scripts/build_third_party_android.sh ABI all.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ABI="arm64-v8a"
DO_PUSH=0
DO_RUN=0
DO_TESTS=0
for a in "$@"; do
case "$a" in
arm64-v8a | x86_64) ABI="$a" ;;
--push) DO_PUSH=1 ;;
--run) DO_PUSH=1; DO_RUN=1 ;;
--run-tests) DO_PUSH=1; DO_TESTS=1 ;;
*) echo "usage: $0 [arm64-v8a|x86_64] [--push] [--run|--run-tests]" >&2; exit 1 ;;
esac
done
API_LEVEL="${API_LEVEL:-24}"
PREFIX="$ROOT/third_party/prefix-android-$ABI"
BUILD="$ROOT/build-android-$ABI"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
NDK="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
if [[ -z "$NDK" || ! -f "$NDK/build/cmake/android.toolchain.cmake" ]]; then
echo "set ANDROID_NDK_HOME to an NDK (see build_third_party_android.sh)" >&2
exit 1
fi
if [[ ! -f "$PREFIX/lib/libcurl.a" ]]; then
echo "missing $PREFIX/lib/libcurl.a -- run:" >&2
echo " scripts/build_third_party_android.sh $ABI all" >&2
exit 1
fi
cmake -S "$ROOT" -B "$BUILD" -G Ninja \
-DCMAKE_TOOLCHAIN_FILE="$NDK/build/cmake/android.toolchain.cmake" \
-DANDROID_ABI="$ABI" -DANDROID_PLATFORM="android-$API_LEVEL" \
-DANDROID_NDK="$NDK" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="$PREFIX" \
-DCURL_INCLUDE_DIR="$PREFIX/include" -DCURL_LIBRARY="$PREFIX/lib/libcurl.a" \
-DCMAKE_EXE_LINKER_FLAGS="-L$PREFIX/lib" \
-DCST_BUILD_TUI=OFF -DCST_BUILD_WEBUI=OFF -DCST_BUILD_APP=OFF \
-DCST_BUNDLE_BUSYBOX=OFF
if [[ "$DO_TESTS" == 1 ]]; then
cmake --build "$BUILD" -j"$JOBS" # every test target
else
cmake --build "$BUILD" -j"$JOBS" --target cst-agent test_embed
fi
echo "built in $BUILD"
[[ "$DO_PUSH" == 1 ]] || exit 0
ADB="${ADB:-adb}"
command -v "$ADB" >/dev/null || { echo "adb not found (nix-shell -p android-tools)" >&2; exit 1; }
DEV=/data/local/tmp
NAMES=(cst-agent test_embed)
FILES=("$BUILD/cst-agent" "$BUILD/test_embed")
TESTS=()
if [[ "$DO_TESTS" == 1 ]]; then
mapfile -t TESTS < <(find "$BUILD" -maxdepth 1 -type f -name 'test_*' -perm -u+x | sort)
for t in "${TESTS[@]}"; do
NAMES+=("${t##*/}")
FILES+=("$t")
done
fi
"$ADB" push "${FILES[@]}" "$DEV/" >/dev/null
"$ADB" shell chmod 755 "${NAMES[@]/#/$DEV/}"
echo "pushed ${#NAMES[@]} binaries to $DEV"
[[ "$DO_PUSH" == 1 && ( "$DO_RUN" == 1 || "$DO_TESTS" == 1 ) ]] || exit 0
echo "--- $DEV/cst-agent --version"
"$ADB" shell "$DEV/cst-agent --version"
if [[ "$DO_TESTS" == 0 ]]; then
echo "--- $DEV/test_embed"
"$ADB" shell "cd $DEV && ./test_embed"
exit 0
fi
# The suite runs on the device as the shell user; tests that want a real
# /bin/sh, a host fixture tree or a Linux-specific sandbox report their own
# failure, which is the point of running them here.
# The sandbox test builds its scratch trees under $PWD/tests, the same way
# ctest runs it from the repo root.
"$ADB" shell mkdir -p "$DEV/tests"
pass=0; fail=0; skipped=()
for t in "${TESTS[@]}"; do
name="${t##*/}"
if "$ADB" shell "cd $DEV && ./$name" >/dev/null 2>&1; then
pass=$((pass + 1)); echo "ok $name"
else
fail=$((fail + 1)); skipped+=("$name"); echo "FAIL $name"
fi
done
echo "--- $pass passed, $fail failed"
[[ "$fail" == 0 ]] || { printf 'failed: %s\n' "${skipped[*]}"; exit 1; }
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env bash
# Build the Qt Quick Android APK of app/ (the engine is linked in-process, so
# the APK opens no port). This is the front end counterpart of
# scripts/build_android.sh, which cross-builds the engine alone.
#
# scripts/build_apk_android.sh [--install] [--logs] [--clean]
#
# --install adb install -r the debug APK on the connected device
# --logs stream logcat after installing (Qt + engine tags)
# --clean wipe the build directory first
#
# Needs, all overridable through the environment:
# QT_ANDROID Qt for Android (default ~/Qt/<ver>/android_arm64_v8a)
# QT_HOST matching host Qt (default ~/Qt/<ver>/gcc_64)
# ANDROID_SDK_ROOT SDK with platforms/ + build-tools/ (see docs/android.md)
# ANDROID_NDK_HOME NDK, same one as scripts/build_third_party_android.sh
# JAVA_HOME JDK 17+ for Gradle (falls back to the javac on PATH)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ABI="${ABI:-arm64-v8a}"
API_LEVEL="${API_LEVEL:-24}"
BUILD="$ROOT/build-apk-android"
PREFIX="$ROOT/third_party/prefix-android-$ABI"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
DO_INSTALL=0
DO_LOGS=0
for a in "$@"; do
case "$a" in
--install) DO_INSTALL=1 ;;
--logs) DO_INSTALL=1; DO_LOGS=1 ;;
--clean) rm -rf "$BUILD" ;;
*) echo "usage: $0 [--install] [--logs] [--clean]" >&2; exit 1 ;;
esac
done
# --- Qt: Android target first, then the host tools of the same version ----
if [[ -z "${QT_ANDROID:-}" ]]; then
# aqt names the directory after the ABI with underscores: android_arm64_v8a
for q in "$HOME"/Qt/*/android_"${ABI//-/_}"/lib/cmake/Qt6/qt.toolchain.cmake; do
[[ -f "$q" ]] && QT_ANDROID="${q%/lib/cmake/Qt6/qt.toolchain.cmake}"
done
fi
if [[ -z "${QT_ANDROID:-}" || ! -x "$QT_ANDROID/bin/qt-cmake" ]]; then
echo "set QT_ANDROID to a Qt for Android install ('aqt install-qt all_os android ...')" >&2
exit 1
fi
QT_VER="$(basename "$(dirname "$QT_ANDROID")")"
QT_HOST="${QT_HOST:-$(dirname "$QT_ANDROID")/gcc_64}"
if [[ ! -x "$QT_HOST/bin/qmake" && ! -x "$QT_HOST/bin/androiddeployqt" ]]; then
echo "set QT_HOST to the host Qt $QT_VER (same version, needed for androiddeployqt)" >&2
exit 1
fi
NDK="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
if [[ -z "$NDK" || ! -f "$NDK/build/cmake/android.toolchain.cmake" ]]; then
echo "set ANDROID_NDK_HOME to an NDK (see scripts/build_third_party_android.sh)" >&2
exit 1
fi
if [[ -z "${ANDROID_SDK_ROOT:-}" || ! -d "$ANDROID_SDK_ROOT/platforms" ]]; then
echo "set ANDROID_SDK_ROOT to an SDK with platforms/android-<n> and build-tools/" >&2
exit 1
fi
if [[ -z "${JAVA_HOME:-}" ]]; then
JAVAC="$(command -v javac || true)"
[[ -n "$JAVAC" ]] && JAVA_HOME="$(dirname "$(dirname "$(readlink -f "$JAVAC")")")"
fi
if [[ -z "${JAVA_HOME:-}" || ! -x "$JAVA_HOME/bin/java" ]]; then
echo "set JAVA_HOME to a JDK 17+ (Gradle needs it)" >&2
exit 1
fi
if [[ ! -f "$PREFIX/lib/libcurl.a" ]]; then
echo "missing $PREFIX/lib/libcurl.a -- run:" >&2
echo " scripts/build_third_party_android.sh $ABI all" >&2
exit 1
fi
export ANDROID_SDK_ROOT ANDROID_NDK_HOME="$NDK" ANDROID_NDK_ROOT="$NDK" JAVA_HOME
export PATH="$JAVA_HOME/bin:$PATH"
# androiddeployqt shells out to the generated project's gradlew, which picks up
# init scripts from $GRADLE_USER_HOME/init.d. Install ours (release lint off)
# so every build behaves the same. See the file for why lint has to go.
GRADLE_INIT_DIR="${GRADLE_USER_HOME:-$HOME/.gradle}/init.d"
mkdir -p "$GRADLE_INIT_DIR"
if ! cmp -s "$ROOT/scripts/gradle-android-init.gradle" "$GRADLE_INIT_DIR/cst-android.gradle"; then
install -m644 "$ROOT/scripts/gradle-android-init.gradle" "$GRADLE_INIT_DIR/cst-android.gradle"
echo "installed $GRADLE_INIT_DIR/cst-android.gradle"
fi
# The release APK has to be signed to install. With CST_ANDROID_KEYSTORE the
# init script uses that keystore; otherwise it falls back to the standard
# Android debug key, created here on first use (same one Android Studio makes).
KEYSTORE="${CST_ANDROID_KEYSTORE:-$HOME/.android/debug.keystore}"
if [[ ! -f "$KEYSTORE" && -z "${CST_ANDROID_KEYSTORE:-}" ]]; then
mkdir -p "$(dirname "$KEYSTORE")"
keytool -genkeypair -keystore "$KEYSTORE" -storepass android -keypass android \
-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Android Debug,O=Android,C=US" >/dev/null
chmod 600 "$KEYSTORE"
echo "created debug keystore: $KEYSTORE"
fi
# androiddeployqt reads these (the path/alias/passwords never reach the CMake
# command line, only the QT_ANDROID_SIGN_APK flag does).
export QT_ANDROID_KEYSTORE_PATH="$KEYSTORE"
export QT_ANDROID_KEYSTORE_ALIAS="${CST_ANDROID_KEY_ALIAS:-androiddebugkey}"
export QT_ANDROID_KEYSTORE_STORE_PASS="${CST_ANDROID_KEYSTORE_PASS:-android}"
export QT_ANDROID_KEYSTORE_KEY_PASS="${CST_ANDROID_KEY_PASS:-android}"
# NixOS has no /bin/bash, and build-tools' shell wrappers (apksigner is the one
# androiddeployqt calls, d8 and friends are the rest) start with #!/bin/bash.
# Point them at the bash that exists here; the SDK is a local copy, so this
# sticks.
BT_DIR="$(ls -d "$ANDROID_SDK_ROOT"/build-tools/* 2>/dev/null | sort -V | tail -1)"
BASH_BIN="$(command -v bash || echo /bin/sh)"
if [[ -n "$BT_DIR" ]]; then
while IFS= read -r f; do
sed -i "1s|^#!/bin/bash|#!$BASH_BIN|" "$f" && echo "fixed shebang: ${f#"$ANDROID_SDK_ROOT"/}"
done < <(grep -rls '^#!/bin/bash' "$BT_DIR" 2>/dev/null)
fi
# Qt6Gui wants GLESv2/EGL: the NDK sysroot has both, but Qt's Find modules
# only search CMAKE_FIND_ROOT_PATH prefixes (<sysroot>/include), while the
# headers live in <sysroot>/usr/include -- point them there directly.
NDK_SYSROOT_INC="$NDK/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/include"
[[ -d "$NDK_SYSROOT_INC/GLES2" ]] || echo "warning: no GLES2 headers under $NDK_SYSROOT_INC" >&2
echo "Qt for Android : $QT_ANDROID ($QT_VER)"
echo "host Qt : $QT_HOST"
echo "SDK : $ANDROID_SDK_ROOT ($(ls "$ANDROID_SDK_ROOT/build-tools" | tr '\n' ' '))"
echo "NDK : $NDK"
echo "JDK : $JAVA_HOME"
echo "ABI : $ABI, minSdk $API_LEVEL"
# The engine comes from libcstcore.a, exactly as on the desktop: no TUI, no
# HTTP server (src/webui_stub.c stands in), no bundled busybox inside an APK.
"$QT_ANDROID/bin/qt-cmake" -S "$ROOT" -B "$BUILD" -G Ninja \
-DQT_HOST_PATH="$QT_HOST" \
-DANDROID_ABI="$ABI" -DANDROID_PLATFORM="android-$API_LEVEL" \
-DANDROID_SDK_ROOT="$ANDROID_SDK_ROOT" -DANDROID_NDK="$NDK" \
-DGLESv2_INCLUDE_DIR="$NDK_SYSROOT_INC" -DEGL_INCLUDE_DIR="$NDK_SYSROOT_INC" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="$PREFIX" \
-DCURL_INCLUDE_DIR="$PREFIX/include" -DCURL_LIBRARY="$PREFIX/lib/libcurl.a" \
-DCMAKE_EXE_LINKER_FLAGS="-L$PREFIX/lib" \
-DCMAKE_SHARED_LINKER_FLAGS="-L$PREFIX/lib" \
-DCMAKE_MODULE_LINKER_FLAGS="-L$PREFIX/lib" \
-DCST_BUILD_TUI=OFF -DCST_BUILD_WEBUI=OFF -DCST_BUILD_APP=ON \
-DCST_BUNDLE_BUSYBOX=OFF
cmake --build "$BUILD" --target apk -j"$JOBS"
# AGP names the file after the variant and signing state
# (android-build-release.apk vs ...-unsigned.apk), so take the newest one.
APK="$(find "$BUILD/app/android-build/build/outputs/apk" "$BUILD" -name '*.apk' \
-printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-)"
[[ -n "$APK" && -f "$APK" ]] || { echo "no APK produced" >&2; exit 1; }
echo
echo "APK: $APK ($(du -h "$APK" | cut -f1))"
[[ "$DO_INSTALL" == 1 ]] || exit 0
ADB="${ADB:-adb}"
command -v "$ADB" >/dev/null || { echo "adb not found (nix-shell -p android-tools)" >&2; exit 1; }
"$ADB" install -r "$APK"
echo "installed: $("$ADB" shell pm list packages | grep cstagent)"
[[ "$DO_LOGS" == 1 ]] || exit 0
echo "--- am start (Ctrl-C to stop; logcat follows)"
"$ADB" shell monkey -p xyz.whcself.cstagent -c android.intent.category.LAUNCHER 1 >/dev/null
sleep 2
exec "$ADB" logcat -s cst-agent:V Qt:V AndroidRuntime:E libc:V DEBUG:V
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# Static deps for Android into third_party/prefix-android-ABI: mbedTLS and
# libcurl (HTTP(S) only, mbedTLS), the same pair the Linux release uses --
# a phone has no system libcurl and bionic's CA store is /system/etc/security,
# so libcurl is built here rather than taken from the platform.
#
# scripts/build_third_party_android.sh arm64-v8a|x86_64 [all|mbedtls|curl]
#
# Needs the NDK in ANDROID_NDK_HOME (a directory with
# build/cmake/android.toolchain.cmake). On NixOS the packaged one is:
# export ANDROID_NDK_HOME=$(
# NIXPKGS_ALLOW_UNFREE=1 nix build --impure --no-link --print-out-paths \
# 'nixpkgs#androidenv.androidPkgs.ndk-bundle')/libexec/android-sdk/ndk-bundle
# Host tools: cmake ninja make.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ABI="${1:?usage: $0 arm64-v8a|x86_64 [all|mbedtls|curl]}"
TP="$ROOT/third_party"
PREFIX="$TP/prefix-android-$ABI"
SRC="$TP/src"
WORK="$TP/build-android-$ABI"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
MBEDTLS_VER=3.6.7
MBEDTLS_SHA=a7e8bcbec0e6f761b4af24f25677626b35f762f68eef79c08677a363212d11f6
CURL_VER=8.22.0
CURL_SHA=f7ef3ae8a22e521f289803fe93543eb64c329b58aa73a9e224dfd915a2a5f4f7
# Android 7.0: what bionic gives the engine (threads, exec, /system CA store).
API_LEVEL="${API_LEVEL:-24}"
# Per-app CAs land in the user store; the system bundle is the readable one.
CA_PATH=/system/etc/security/cacerts
NDK="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
if [[ -z "$NDK" || ! -f "$NDK/build/cmake/android.toolchain.cmake" ]]; then
echo "set ANDROID_NDK_HOME to an NDK (see the header of this script)" >&2
exit 1
fi
TOOLCHAIN="$NDK/build/cmake/android.toolchain.cmake"
need() { command -v "$1" >/dev/null || { echo "missing: $1" >&2; exit 1; }; }
need cmake
need ninja
mkdir -p "$PREFIX" "$SRC" "$WORK"
fetch() { # file sha url...
local file="$1" sha="$2" u
shift 2
if [[ -f "$SRC/$file" ]] && echo "$sha $SRC/$file" | sha256sum -c --quiet - 2>/dev/null; then
return 0
fi
for u in "$@"; do
echo " try $u"
if curl -fsSL --retry 2 --connect-timeout 15 --max-time 600 -o "$SRC/$file.part" "$u" &&
echo "$sha $SRC/$file.part" | sha256sum -c --quiet -; then
mv "$SRC/$file.part" "$SRC/$file"
return 0
fi
done
rm -f "$SRC/$file.part"
echo "download failed: $file" >&2
exit 1
}
unpack() { # file dir
rm -rf "${WORK:?}/$2"
tar -C "$WORK" -xf "$SRC/$1"
}
gh() { # path -> GitHub URL candidates (CN proxies first)
printf '%s\n' "https://ghfast.top/https://github.com/$1" \
"https://ghproxy.net/https://github.com/$1" "https://github.com/$1"
}
cmake_android() { # src build args...
local s="$1" b="$2"
shift 2
cmake -S "$s" -B "$b" -G Ninja \
-DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" \
-DANDROID_ABI="$ABI" -DANDROID_PLATFORM="android-$API_LEVEL" \
-DANDROID_NDK="$NDK" \
-DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$PREFIX" \
-DCMAKE_PREFIX_PATH="$PREFIX" -DCMAKE_INSTALL_LIBDIR=lib \
-DCMAKE_POSITION_INDEPENDENT_CODE=OFF \
"$@"
cmake --build "$b" -j"$JOBS"
cmake --install "$b"
}
build_mbedtls() {
local f="mbedtls-$MBEDTLS_VER.tar.bz2"
mapfile -t urls < <(gh "Mbed-TLS/mbedtls/releases/download/mbedtls-$MBEDTLS_VER/$f")
fetch "$f" "$MBEDTLS_SHA" "${urls[@]}"
unpack "$f" "mbedtls-$MBEDTLS_VER"
# 3.6.x keeps its own entropy source on Android, no /dev/urandom probing.
cmake_android "$WORK/mbedtls-$MBEDTLS_VER" "$WORK/mbedtls-$MBEDTLS_VER/build" \
-DENABLE_TESTING=OFF -DENABLE_PROGRAMS=OFF -DGEN_FILES=OFF \
-DUSE_STATIC_MBEDTLS_LIBRARY=ON -DUSE_SHARED_MBEDTLS_LIBRARY=OFF \
-DMBEDTLS_FATAL_WARNINGS=OFF
echo "mbedTLS $MBEDTLS_VER -> $PREFIX"
}
build_curl() {
local f="curl-$CURL_VER.tar.xz"
mapfile -t urls < <(gh "curl/curl/releases/download/curl-${CURL_VER//./_}/$f")
fetch "$f" "$CURL_SHA" "https://curl.se/download/$f" "${urls[@]}"
unpack "$f" "curl-$CURL_VER"
cmake_android "$WORK/curl-$CURL_VER" "$WORK/curl-$CURL_VER/build" \
-DBUILD_SHARED_LIBS=OFF -DBUILD_STATIC_LIBS=ON \
-DCURL_USE_MBEDTLS=ON -DCURL_USE_OPENSSL=OFF \
-DMBEDTLS_INCLUDE_DIR="$PREFIX/include" \
-DMBEDTLS_LIBRARY="$PREFIX/lib/libmbedtls.a" \
-DMBEDX509_LIBRARY="$PREFIX/lib/libmbedx509.a" \
-DMBEDCRYPTO_LIBRARY="$PREFIX/lib/libmbedcrypto.a" \
-DCURL_CA_BUNDLE=none -DCURL_CA_PATH="$CA_PATH" \
-DHTTP_ONLY=ON -DCURL_DISABLE_LDAP=ON -DCURL_USE_LIBPSL=OFF \
-DCURL_USE_LIBSSH2=OFF -DCURL_USE_LIBSSH=OFF -DUSE_LIBIDN2=OFF \
-DUSE_NGHTTP2=OFF -DCURL_ZLIB=OFF -DCURL_BROTLI=OFF -DCURL_ZSTD=OFF \
-DCURL_USE_GSSAPI=OFF -DENABLE_THREADED_RESOLVER=ON \
-DBUILD_CURL_EXE=OFF -DBUILD_TESTING=OFF -DBUILD_EXAMPLES=OFF \
-DBUILD_LIBCURL_DOCS=OFF -DBUILD_MISC_DOCS=OFF -DENABLE_CURL_MANUAL=OFF
echo "curl $CURL_VER -> $PREFIX"
}
case "${2:-all}" in
mbedtls) build_mbedtls ;;
curl) build_curl ;;
all)
build_mbedtls
build_curl
;;
*)
echo "usage: $0 ABI [all|mbedtls|curl]" >&2
exit 1
;;
esac
echo "DONE android-$ABI"
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env bash
# Build pinned static deps for a Linux release into third_party/prefix-linux-ARCH:
# mbedTLS, libcurl (HTTP(S) only, mbedTLS) and wide ncurses.
# scripts/build_third_party_linux.sh x86_64|x86|aarch64 [all|mbedtls|curl|ncurses]
# Uses LINUX_HOST-gcc from PATH, else fetches it via ci_linux_toolchain.sh.
# Host tools: cmake ninja make perl, and tic/infocmp with the fallback
# terminal entries (Debian: ncurses-bin ncurses-term).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ARCH="${1:?usage: $0 x86_64|x86|aarch64 [all|mbedtls|curl|ncurses]}"
TP="$ROOT/third_party"
PREFIX="$TP/prefix-linux-$ARCH"
SRC="$TP/src"
WORK="$TP/build-linux-$ARCH"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
MBEDTLS_VER=3.6.7
MBEDTLS_SHA=a7e8bcbec0e6f761b4af24f25677626b35f762f68eef79c08677a363212d11f6
CURL_VER=8.22.0
CURL_SHA=f7ef3ae8a22e521f289803fe93543eb64c329b58aa73a9e224dfd915a2a5f4f7
NCURSES_VER=6.6
NCURSES_SHA=355b4cbbed880b0381a04c46617b7656e362585d52e9cf84a67e2009b749ff11
# Compiled in, so the TUI starts even where no terminfo database is found.
NCURSES_FALLBACKS=xterm-256color,xterm,screen-256color,tmux-256color,linux,vt100
if [[ -z "${LINUX_HOST:-}" ]] || ! command -v "$LINUX_HOST-gcc" >/dev/null; then
read -r LINUX_HOST bindir < <(bash "$ROOT/scripts/ci_linux_toolchain.sh" "$ARCH")
export PATH="$bindir:$PATH"
fi
T="$LINUX_HOST-"
need() { command -v "$1" >/dev/null || { echo "missing: $1" >&2; exit 1; }; }
need "${T}gcc"
need cmake
need ninja
need make
need tic
need infocmp
mkdir -p "$PREFIX" "$SRC" "$WORK"
fetch() { # file sha url...
local file="$1" sha="$2" u
shift 2
if [[ -f "$SRC/$file" ]] && echo "$sha $SRC/$file" | sha256sum -c --quiet - 2>/dev/null; then
return 0
fi
for u in "$@"; do
echo " try $u"
if curl -fsSL --retry 2 --connect-timeout 15 --max-time 600 -o "$SRC/$file.part" "$u" &&
echo "$sha $SRC/$file.part" | sha256sum -c --quiet -; then
mv "$SRC/$file.part" "$SRC/$file"
return 0
fi
done
rm -f "$SRC/$file.part"
echo "download failed: $file" >&2
exit 1
}
unpack() { # file dir
rm -rf "${WORK:?}/$2"
tar -C "$WORK" -xf "$SRC/$1"
}
gh() { # path -> GitHub URL candidates (CN proxies first)
printf '%s\n' "https://ghfast.top/https://github.com/$1" \
"https://ghproxy.net/https://github.com/$1" "https://github.com/$1"
}
cmake_cross() { # src build args...
local s="$1" b="$2"
shift 2
cmake -S "$s" -B "$b" -G Ninja \
-DCMAKE_SYSTEM_NAME=Linux \
-DCMAKE_C_COMPILER="${T}gcc" -DCMAKE_AR="$(command -v "${T}ar")" \
-DCMAKE_RANLIB="$(command -v "${T}ranlib")" \
-DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$PREFIX" \
-DCMAKE_INSTALL_LIBDIR=lib -DCMAKE_PREFIX_PATH="$PREFIX" \
-DCMAKE_FIND_ROOT_PATH="$PREFIX" \
-DCMAKE_FIND_ROOT_PATH_MODE_PROGRAM=NEVER \
-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY \
-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=ONLY \
-DCMAKE_POSITION_INDEPENDENT_CODE=OFF \
"$@"
cmake --build "$b" -j"$JOBS"
cmake --install "$b"
}
build_mbedtls() {
local f="mbedtls-$MBEDTLS_VER.tar.bz2"
mapfile -t urls < <(gh "Mbed-TLS/mbedtls/releases/download/mbedtls-$MBEDTLS_VER/$f")
fetch "$f" "$MBEDTLS_SHA" "${urls[@]}"
unpack "$f" "mbedtls-$MBEDTLS_VER"
cmake_cross "$WORK/mbedtls-$MBEDTLS_VER" "$WORK/mbedtls-$MBEDTLS_VER/build" \
-DENABLE_TESTING=OFF -DENABLE_PROGRAMS=OFF -DGEN_FILES=OFF \
-DUSE_STATIC_MBEDTLS_LIBRARY=ON -DUSE_SHARED_MBEDTLS_LIBRARY=OFF \
-DMBEDTLS_FATAL_WARNINGS=OFF
echo "mbedTLS $MBEDTLS_VER -> $PREFIX"
}
build_curl() {
local f="curl-$CURL_VER.tar.xz"
mapfile -t urls < <(gh "curl/curl/releases/download/curl-${CURL_VER//./_}/$f")
fetch "$f" "$CURL_SHA" "https://curl.se/download/$f" "${urls[@]}"
unpack "$f" "curl-$CURL_VER"
# Cross builds skip CA detection; this is only libcurl's default, the
# program probes the usual distro paths when it is missing.
cmake_cross "$WORK/curl-$CURL_VER" "$WORK/curl-$CURL_VER/build" \
-DBUILD_SHARED_LIBS=OFF -DBUILD_STATIC_LIBS=ON \
-DCURL_USE_MBEDTLS=ON -DCURL_USE_OPENSSL=OFF \
-DMBEDTLS_INCLUDE_DIR="$PREFIX/include" \
-DMBEDTLS_LIBRARY="$PREFIX/lib/libmbedtls.a" \
-DMBEDX509_LIBRARY="$PREFIX/lib/libmbedx509.a" \
-DMBEDCRYPTO_LIBRARY="$PREFIX/lib/libmbedcrypto.a" \
-DCURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt -DCURL_CA_PATH=none \
-DHTTP_ONLY=ON -DCURL_DISABLE_LDAP=ON -DCURL_USE_LIBPSL=OFF \
-DCURL_USE_LIBSSH2=OFF -DCURL_USE_LIBSSH=OFF -DUSE_LIBIDN2=OFF \
-DUSE_NGHTTP2=OFF -DCURL_ZLIB=OFF -DCURL_BROTLI=OFF -DCURL_ZSTD=OFF \
-DCURL_USE_GSSAPI=OFF -DENABLE_THREADED_RESOLVER=ON \
-DBUILD_CURL_EXE=OFF -DBUILD_TESTING=OFF -DBUILD_EXAMPLES=OFF \
-DBUILD_LIBCURL_DOCS=OFF -DBUILD_MISC_DOCS=OFF -DENABLE_CURL_MANUAL=OFF
echo "curl $CURL_VER -> $PREFIX"
}
build_ncurses() {
local f="ncurses-$NCURSES_VER.tar.gz"
fetch "$f" "$NCURSES_SHA" \
"https://invisible-island.net/archives/ncurses/$f" \
"https://mirrors.tuna.tsinghua.edu.cn/gnu/ncurses/$f" \
"https://ftp.gnu.org/gnu/ncurses/$f"
unpack "$f" "ncurses-$NCURSES_VER"
local b="$WORK/ncurses-$NCURSES_VER/build"
mkdir -p "$b"
cd "$b"
CC="${T}gcc" AR="${T}ar" RANLIB="${T}ranlib" STRIP="${T}strip" \
../configure --host="$LINUX_HOST" --prefix="$PREFIX" \
--without-ada --without-cxx --without-cxx-binding --without-manpages \
--without-progs --without-tests --without-debug --without-shared \
--with-normal --enable-widec --disable-db-install \
--with-default-terminfo-dir=/usr/share/terminfo \
--with-terminfo-dirs=/etc/terminfo:/lib/terminfo:/usr/share/terminfo:/usr/lib/terminfo \
--with-fallbacks="$NCURSES_FALLBACKS" \
--with-tic-path="$(command -v tic)" --with-infocmp-path="$(command -v infocmp)" \
--enable-pc-files --with-pkg-config-libdir="$PREFIX/lib/pkgconfig" \
--with-build-cc="${BUILD_CC:-cc}" >/dev/null
make -j"$JOBS" libs >/dev/null
make install.libs >/dev/null
echo "ncurses $NCURSES_VER -> $PREFIX"
}
case "${2:-all}" in
mbedtls) build_mbedtls ;;
curl) build_curl ;;
ncurses) build_ncurses ;;
all)
build_mbedtls
build_curl
build_ncurses
;;
*)
echo "usage: $0 ARCH [all|mbedtls|curl|ncurses]" >&2
exit 1
;;
esac
echo "DONE $ARCH"
+4 -1
View File
@@ -106,7 +106,10 @@ build_ncurses() {
rm -rf "$src/build-mingw" rm -rf "$src/build-mingw"
mkdir -p "$src/build-mingw" mkdir -p "$src/build-mingw"
cd "$src/build-mingw" cd "$src/build-mingw"
../configure --host="${HOST:-x86_64-w64-mingw32}" --prefix="$PREFIX" \ # --host stays canonical (config.sub may not know Debian's "mingw32ucrt");
# the tools themselves come from $HOST.
CC="${T}gcc" AR="${T}ar" RANLIB="${T}ranlib" STRIP="${T}strip" WINDRES="${T}windres" \
../configure --host=x86_64-w64-mingw32 --prefix="$PREFIX" \
--without-ada --without-cxx --without-cxx-binding --without-manpages \ --without-ada --without-cxx --without-cxx-binding --without-manpages \
--without-progs --without-tests --without-debug --without-shared \ --without-progs --without-tests --without-debug --without-shared \
--enable-widec --enable-sp-funcs --enable-term-driver \ --enable-widec --enable-sp-funcs --enable-term-driver \
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Fetch a musl cross gcc for a static Linux release build.
# scripts/ci_linux_toolchain.sh x86_64|x86|aarch64
# Prints "HOST BINDIR" on stdout (e.g. "x86_64-buildroot-linux-musl
# /opt/x86-64--musl--stable-2025.08-1/bin"); progress goes to stderr.
# Bootlin's pinned stable release first, musl.cc as a fallback.
set -euo pipefail
ARCH="${1:?usage: $0 x86_64|x86|aarch64}"
BOOTLIN_VER="${CST_BOOTLIN_VER:-2025.08-1}"
case "$ARCH" in
x86_64)
bl=x86-64 bl_host=x86_64-buildroot-linux-musl mc=x86_64-linux-musl
sha=09fca3aa89540f1b01b5f4210d488cbeb00f522044c53e9989b1dd8a38076912
;;
x86)
bl=x86-i686 bl_host=i686-buildroot-linux-musl mc=i686-linux-musl
sha=3a340ebc386a057f5ddb24d8f4bbde07d2c013c99cebc02ac38565e2849bfda0
;;
aarch64)
bl=aarch64 bl_host=aarch64-buildroot-linux-musl mc=aarch64-linux-musl
sha=defba831ffa1175236f137069333e21ed46d4d19feb5080a90cf248b6fc2cb08
;;
*)
echo "unknown arch: $ARCH" >&2
exit 1
;;
esac
[[ "$BOOTLIN_VER" == 2025.08-1 ]] || sha="" # other releases: no pinned hash
if [[ -n "${CST_TOOLCHAIN_ROOT:-}" ]]; then
ROOT_DIR="$CST_TOOLCHAIN_ROOT"
elif [[ -w /opt || "$(id -u)" -eq 0 ]]; then
ROOT_DIR=/opt
else
ROOT_DIR="$HOME/.local/toolchains"
fi
mkdir -p "$ROOT_DIR"
fetch() { # out url...
local out="$1" u
shift
for u in "$@"; do
echo " try $u" >&2
if curl -fsSL --retry 2 --connect-timeout 15 --max-time 900 -o "$out" "$u"; then
return 0
fi
done
return 1
}
emit() { # host bindir
"$2/$1-gcc" -dumpmachine >&2
[[ -n "${GITHUB_PATH:-}" ]] && echo "$2" >>"$GITHUB_PATH"
echo "$1 $2"
}
name="$bl--musl--stable-$BOOTLIN_VER"
dir="$ROOT_DIR/$name"
if [[ -x "$dir/bin/$bl_host-gcc" ]]; then
emit "$bl_host" "$dir/bin"
exit 0
fi
echo "==> toolchain $name" >&2
tmp="$ROOT_DIR/$name.tar.xz"
if fetch "$tmp" "https://toolchains.bootlin.com/downloads/releases/toolchains/$bl/tarballs/$name.tar.xz" &&
{ [[ -z "$sha" ]] || echo "$sha $tmp" | sha256sum -c --quiet - >&2; }; then
tar -C "$ROOT_DIR" -xJf "$tmp"
rm -f "$tmp"
# Bootlin toolchains are relocatable but must be told once where they are.
[[ -x "$dir/relocate-sdk.sh" ]] && "$dir/relocate-sdk.sh" >&2
emit "$bl_host" "$dir/bin"
exit 0
fi
rm -f "$tmp"
echo "==> bootlin unavailable, falling back to musl.cc" >&2
mdir="$ROOT_DIR/$mc-cross"
if [[ ! -x "$mdir/bin/$mc-gcc" ]]; then
tgz="$ROOT_DIR/$mc-cross.tgz"
fetch "$tgz" "https://musl.cc/$mc-cross.tgz" "https://more.musl.cc/x86_64-linux-musl/$mc-cross.tgz" ||
{ echo "no musl toolchain for $ARCH" >&2; exit 1; }
tar -C "$ROOT_DIR" -xzf "$tgz"
rm -f "$tgz"
fi
emit "$mc" "$mdir/bin"
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# CI helper: build the pinned TinyCC used for the verification job.
# Distro tcc packages are too old (no <stdatomic.h>), so this builds the same
# mob snapshot nixpkgs ships. Installs to $CST_TCC_PREFIX (default ~/.local/tcc)
# and, under Actions, puts its bin/ on $GITHUB_PATH.
set -euo pipefail
COMMIT="${CST_TCC_COMMIT:-f6385c05308f715bdd2c06336801193a21d69b50}"
PREFIX="${CST_TCC_PREFIX:-$HOME/.local/tcc}"
WORK="${TMPDIR:-/tmp}/tcc-build"
if [[ -x "$PREFIX/bin/tcc" ]]; then
echo "==> tcc already at $PREFIX"
else
rm -rf "$WORK"
mkdir -p "$WORK"
TGZ="$WORK/tinycc.tar.gz"
# repo.or.cz is upstream but often unreachable from CN runners; the GitHub
# mirror carries the same commits.
urls=(
"https://ghfast.top/https://github.com/TinyCC/tinycc/archive/${COMMIT}.tar.gz"
"https://ghproxy.net/https://github.com/TinyCC/tinycc/archive/${COMMIT}.tar.gz"
"https://github.com/TinyCC/tinycc/archive/${COMMIT}.tar.gz"
"https://repo.or.cz/tinycc.git/snapshot/${COMMIT}.tar.gz"
)
ok=0
for u in "${urls[@]}"; do
echo " try $u"
if curl -fsSL --retry 2 --connect-timeout 15 --max-time 300 -o "$TGZ" "$u" &&
tar -tzf "$TGZ" 2>/dev/null | sed -n 1p | grep -q "${COMMIT:0:7}"; then
ok=1
break
fi
done
[[ $ok -eq 1 ]] || { echo "failed to download tinycc $COMMIT" >&2; exit 1; }
tar -C "$WORK" -xzf "$TGZ"
src=$(find "$WORK" -mindepth 1 -maxdepth 1 -type d -name 'tinycc*' | head -1)
cd "$src"
./configure --prefix="$PREFIX" --cc="${BUILD_CC:-cc}"
make -j"$(nproc)"
make install
fi
"$PREFIX/bin/tcc" -v
if [[ -n "${GITHUB_PATH:-}" ]]; then
echo "$PREFIX/bin" >>"$GITHUB_PATH"
fi
+12 -50
View File
@@ -1,64 +1,26 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# CI helper: MinGW-w64 UCRT toolchain via llvm-mingw (no Nix / no /nix). # CI helper: Windows x86_64 release with Debian's mingw-w64 UCRT gcc.
# Downloads a pinned release, puts it on PATH, then runs bootstrap_windows.sh. # apt-get install gcc-mingw-w64-ucrt64 binutils-mingw-w64-ucrt64 nsis (trixie+)
# Checks the toolchain, then runs bootstrap_windows.sh, which builds the
# third-party libs, cst-agent.exe, the zip and the NSIS installer.
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT" cd "$ROOT"
VER="${CST_LLVM_MINGW_VER:-20241030}" export MINGW_HOST="${MINGW_HOST:-x86_64-w64-mingw32ucrt}"
NAME="llvm-mingw-${VER}-ucrt-ubuntu-20.04-x86_64"
TGZ="/tmp/${NAME}.tar.xz"
# Prefer /opt when writable (root CI); else unpack under $HOME.
if [[ -w /opt || "$(id -u)" -eq 0 ]]; then
DIR="/opt/${NAME}"
PARENT=/opt
else
DIR="${HOME}/${NAME}"
PARENT="$HOME"
fi
if [[ ! -x "$DIR/bin/x86_64-w64-mingw32-gcc" ]]; then
echo "==> download $NAME"
# Prefer CN-reachable GitHub proxies; raw github.com is often unreachable.
urls=(
"https://ghfast.top/https://github.com/mstorsjo/llvm-mingw/releases/download/${VER}/${NAME}.tar.xz"
"https://ghproxy.net/https://github.com/mstorsjo/llvm-mingw/releases/download/${VER}/${NAME}.tar.xz"
"https://gitclone.com/github.com/mstorsjo/llvm-mingw/releases/download/${VER}/${NAME}.tar.xz"
"https://github.com/mstorsjo/llvm-mingw/releases/download/${VER}/${NAME}.tar.xz"
)
ok=0
for u in "${urls[@]}"; do
echo " try $u"
if curl -fL --retry 2 --connect-timeout 15 --max-time 600 \
--retry-delay 2 -o "$TGZ" "$u"; then
# Reject tiny error pages that some proxies return with HTTP 200.
sz=$(wc -c <"$TGZ")
if [[ "$sz" -gt 10000000 ]]; then
ok=1
break
fi
echo " too small ($sz bytes), next mirror"
fi
done
[[ $ok -eq 1 ]] || { echo "failed to download llvm-mingw" >&2; exit 1; }
rm -rf "$DIR"
mkdir -p "$PARENT"
tar -C "$PARENT" -xf "$TGZ"
fi
export PATH="$DIR/bin:$PATH"
export MINGW_HOST=x86_64-w64-mingw32
export BUILD_CC="${BUILD_CC:-cc}" export BUILD_CC="${BUILD_CC:-cc}"
export CST_REQUIRE_NSIS=1
need() { command -v "$1" >/dev/null || { echo "missing $1" >&2; exit 1; }; } need() { command -v "$1" >/dev/null || { echo "missing $1" >&2; exit 1; }; }
need x86_64-w64-mingw32-gcc need "$MINGW_HOST-gcc"
need x86_64-w64-mingw32-windres need "$MINGW_HOST-windres"
need cmake; need ninja; need perl; need zip; need git; need make need cmake; need ninja; need perl; need zip; need git; need make; need makensis
if ! printf '#include <stdio.h>\n' | x86_64-w64-mingw32-gcc -dM -E - | grep -qw _UCRT; then if ! printf '#include <stdio.h>\n' | "$MINGW_HOST-gcc" -dM -E - | grep -qw _UCRT; then
echo "toolchain is not UCRT" >&2 echo "$MINGW_HOST-gcc is not UCRT" >&2
exit 1 exit 1
fi fi
"$MINGW_HOST-gcc" --version | head -1
# Do not set a global insteadOf: a single broken proxy (e.g. gitclone 502) # Do not set a global insteadOf: a single broken proxy (e.g. gitclone 502)
# would take down every third_party clone. build_third_party_mingw.sh tries # would take down every third_party clone. build_third_party_mingw.sh tries
+25
View File
@@ -0,0 +1,25 @@
// Gradle init script for the Android APK build, installed as
// ~/.gradle/init.d/cst-android.gradle by scripts/build_apk_android.sh.
//
// Qt's generated gradle project only sets "lintOptions { abortOnError false }",
// so the release build still runs lintVitalAnalyzeRelease -- and that task
// resolves its own tool jars (manifest-merger, lint-typedef-remover, ...)
// straight from dl.google.com, bypassing every repository the project (or an
// init script) configures. On a slow or filtered route that fails the build
// after all the real work is done, and nothing here wants lint output anyway.
gradle.afterProject { project ->
def android = project.extensions.findByName('android')
if (android != null) {
// AGP keeps both spellings around: lintOptions (deprecated) and lint.
try { android.lintOptions.checkReleaseBuilds = false } catch (Exception ignored) { }
try { android.lint.checkReleaseBuilds = false } catch (Exception ignored) { }
// NOTE: no signing config here on purpose. androiddeployqt copies the
// *unsigned* gradle output and signs it itself (--sign); a signed
// gradle APK makes it fail with "Could not copy unsigned APK". The
// keystore is wired up from app/CMakeLists.txt through
// QT_ANDROID_SIGN_APK + the QT_ANDROID_KEYSTORE_* environment.
}
// Belt and braces: switch the tasks themselves off.
project.tasks.matching { it.name.startsWith('lintVital') }.configureEach { it.enabled = false }
}
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Static musl build of cst-agent for one architecture, packed as
# dist/cst-agent-VERSION-linux-ARCH.tar.gz (+ .sha256).
# scripts/package_linux.sh x86_64|x86|aarch64
# CST_LINUX_CHECK=1 also runs `make check` (only where the binaries can run).
# Builds in the source tree, so the tree is cleaned first.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ARCH="${1:?usage: $0 x86_64|x86|aarch64}"
PREFIX="$ROOT/third_party/prefix-linux-$ARCH"
cd "$ROOT"
read -r LINUX_HOST bindir < <(bash scripts/ci_linux_toolchain.sh "$ARCH")
export PATH="$bindir:$PATH" LINUX_HOST
T="$LINUX_HOST-"
if [[ ! -f "$PREFIX/lib/libcurl.a" || ! -f "$PREFIX/lib/libncursesw.a" ]]; then
bash scripts/build_third_party_linux.sh "$ARCH"
fi
# Only the static prefix: no host .pc file may leak into the link.
export PKG_CONFIG_LIBDIR="$PREFIX/lib/pkgconfig"
unset PKG_CONFIG_PATH CC CFLAGS LDFLAGS
mk=(make CC="${T}gcc" AR="${T}ar" STATIC=1
CFLAGS="-std=c11 -Wall -Wextra -O2 -g -Werror")
make clean >/dev/null
make -C vendor/sashidown clean >/dev/null 2>&1 || true
"${mk[@]}" -j"$(nproc)" cst-agent
if "${T}readelf" -l cst-agent | grep -q 'Requesting program interpreter'; then
echo "cst-agent is dynamically linked" >&2
exit 1
fi
if [[ "${CST_LINUX_CHECK:-0}" == 1 ]]; then
"${mk[@]}" check
fi
ver="$(sed -n 's/^#define CST_VERSION_\(MAJOR\|MINOR\|PATCH\) \([0-9]*\)$/\2/p' include/cst_version.h | paste -sd.)"
name="cst-agent-$ver-linux-$ARCH"
rel="dist/$name"
rm -rf "$rel"
mkdir -p "$rel"
cp -f cst-agent "$rel/cst-agent"
"${T}strip" "$rel/cst-agent"
cp -f README.md "$rel/README.md"
echo "$ver" >"$rel/VERSION"
tgz="dist/$name.tar.gz"
tar -C dist -czf "$tgz" "$name"
(cd dist && sha256sum "$name.tar.gz" >"$name.tar.gz.sha256")
ls -la "$rel"
echo "$ROOT/$tgz"
+15
View File
@@ -36,3 +36,18 @@ rm -f "$zip"
(cd "$ROOT/dist" && sha256sum "$(basename "$zip")" >"$(basename "$zip").sha256") (cd "$ROOT/dist" && sha256sum "$(basename "$zip")" >"$(basename "$zip").sha256")
ls -la "$REL" ls -la "$REL"
echo "$zip" echo "$zip"
# NSIS installer from the same folder (CST_REQUIRE_NSIS=1: fail without makensis).
setup="$ROOT/dist/cst-agent-$ver-windows-x86_64-setup.exe"
if command -v makensis >/dev/null; then
rm -f "$setup"
makensis -V2 -WX -INPUTCHARSET UTF8 -DVERSION="$ver" -DSRCDIR="$REL" \
-DOUTFILE="$setup" "$ROOT/scripts/windows/cst-agent.nsi"
(cd "$ROOT/dist" && sha256sum "$(basename "$setup")" >"$(basename "$setup").sha256")
echo "$setup"
elif [[ "${CST_REQUIRE_NSIS:-0}" == 1 ]]; then
echo "makensis not found" >&2
exit 1
else
echo "makensis not found; skipping the installer" >&2
fi
@@ -1,10 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Publish dist/cst-agent-VERSION-windows-x86_64.zip (+ .sha256) to the Gitea # Upload release files to the Gitea release vVERSION, creating the release
# release vVERSION, creating the release (and its tag at HEAD) when missing. # (and its tag at HEAD) when missing.
# GITEA_TOKEN=... scripts/release_windows.sh [--build] # GITEA_TOKEN=... scripts/release.sh FILE...
# --build runs scripts/bootstrap_windows.sh first. The token is only read from # Each FILE's FILE.sha256 is uploaded too when it exists; assets of the same
# the environment and handed to curl on stdin: it never appears in argv, on # name are replaced. Several CI jobs may call this at once: the first creates
# disk or in the output. Assets of the same name are replaced. # the release, the others find it. The token is only read from the
# environment and handed to curl on stdin: it never appears in argv, on disk
# or in the output.
# #
# CI: set GITEA_TOKEN (or GITHUB_TOKEN), CST_GITEA, CST_REPO. The remote-branch # CI: set GITEA_TOKEN (or GITHUB_TOKEN), CST_GITEA, CST_REPO. The remote-branch
# check is skipped under GITEA_ACTIONS / GITHUB_ACTIONS / CI. # check is skipped under GITEA_ACTIONS / GITHUB_ACTIONS / CI.
@@ -19,16 +21,20 @@ TOKEN="${GITEA_TOKEN:-${GITHUB_TOKEN:-}}"
echo "set GITEA_TOKEN (a Gitea token with write:repository)" >&2 echo "set GITEA_TOKEN (a Gitea token with write:repository)" >&2
exit 1 exit 1
} }
[[ $# -gt 0 ]] || { echo "usage: $0 FILE..." >&2; exit 1; }
command -v python3 >/dev/null || { echo "missing python3" >&2; exit 1; } command -v python3 >/dev/null || { echo "missing python3" >&2; exit 1; }
files=()
for f in "$@"; do
[[ -f "$f" ]] || { echo "missing $f" >&2; exit 1; }
f="$(cd "$(dirname "$f")" && pwd)/$(basename "$f")"
files+=("$f")
[[ "$f" != *.sha256 && -f "$f.sha256" ]] && files+=("$f.sha256")
done
cd "$ROOT" cd "$ROOT"
[[ "${1:-}" == --build ]] && scripts/bootstrap_windows.sh "$ROOT"
ver="$(sed -n 's/^#define CST_VERSION_\(MAJOR\|MINOR\|PATCH\) \([0-9]*\)$/\2/p' include/cst_version.h | paste -sd.)" ver="$(sed -n 's/^#define CST_VERSION_\(MAJOR\|MINOR\|PATCH\) \([0-9]*\)$/\2/p' include/cst_version.h | paste -sd.)"
tag="v$ver" tag="v$ver"
zip="dist/cst-agent-$ver-windows-x86_64.zip"
for f in "$zip" "$zip.sha256"; do
[[ -f "$f" ]] || { echo "missing $f (scripts/package_windows.sh, or --build)" >&2; exit 1; }
done
# If this job was triggered by a version tag, it must match cst_version.h. # If this job was triggered by a version tag, it must match cst_version.h.
if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then
@@ -51,6 +57,12 @@ json() { # CODE [ARGS...]: d = the JSON on stdin, ARGS in sys.argv[1:]
python3 -c "import json,sys; d=json.load(sys.stdin); $code" "$@" python3 -c "import json,sys; d=json.load(sys.stdin); $code" "$@"
} }
BODY="Linux (static, musl, any distro): cst-agent-$ver-linux-{x86_64,x86,aarch64}.tar.gz -- extract and run ./cst-agent, or copy it onto PATH.
Windows x86_64: cst-agent-$ver-windows-x86_64-setup.exe installs per user (no admin rights) and adds PATH; cst-agent-$ver-windows-x86_64.zip is the portable build (install_windows.ps1 inside).
Every file has a .sha256 next to it."
if rel="$(api GET "/releases/tags/$tag" 2>/dev/null)"; then if rel="$(api GET "/releases/tags/$tag" 2>/dev/null)"; then
echo "==> release $tag exists" echo "==> release $tag exists"
else else
@@ -62,12 +74,22 @@ else
fi fi
fi fi
echo "==> creating release $tag at $head" echo "==> creating release $tag at $head"
body="$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "target_commitish": sys.argv[2], "name": "CST-Agent " + sys.argv[1], "body": "Windows x86_64: unzip, or install with install_windows.ps1 from the zip.\\n\\nArtifacts: `cst-agent-VERSION-windows-x86_64.zip` + `.sha256`."}))' "$tag" "$head")" body="$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "target_commitish": sys.argv[2], "name": "CST-Agent " + sys.argv[1], "body": sys.argv[3]}))' "$tag" "$head" "$BODY")"
rel="$(api POST /releases -H 'Content-Type: application/json' -d "$body")" if ! rel="$(api POST /releases -H 'Content-Type: application/json' -d "$body" 2>/dev/null)"; then
# Another job created it first (409).
rel=""
for _ in 1 2 3 4 5 6; do
sleep 5
rel="$(api GET "/releases/tags/$tag" 2>/dev/null)" && break
rel=""
done
[[ -n "$rel" ]] || { echo "could not create or find release $tag" >&2; exit 1; }
echo "==> release $tag created by another job"
fi
fi fi
id="$(json 'print(d["id"])' <<<"$rel")" id="$(json 'print(d["id"])' <<<"$rel")"
for f in "$zip" "$zip.sha256"; do for f in "${files[@]}"; do
name="$(basename "$f")" name="$(basename "$f")"
old="$(json "print(' '.join(str(a['id']) for a in d.get('assets') or [] if a['name'] == sys.argv[1]))" "$name" <<<"$rel")" old="$(json "print(' '.join(str(a['id']) for a in d.get('assets') or [] if a['name'] == sys.argv[1]))" "$name" <<<"$rel")"
for aid in $old; do for aid in $old; do
+151
View File
@@ -0,0 +1,151 @@
; CST-Agent per-user installer (no admin rights).
; makensis -INPUTCHARSET UTF8 -DVERSION=0.8.5 -DSRCDIR=dist/windows-x86_64 -DOUTFILE=setup.exe cst-agent.nsi
; Installs the same files as the zip into %LOCALAPPDATA%\Programs\cst-agent,
; adds that folder to the user PATH and registers an uninstaller under HKCU.
Unicode true
SetCompressor /SOLID lzma
RequestExecutionLevel user
!ifndef VERSION
!error "pass -DVERSION=x.y.z"
!endif
!ifndef SRCDIR
!error "pass -DSRCDIR=<dist/windows-x86_64>"
!endif
!ifndef OUTFILE
!define OUTFILE "cst-agent-${VERSION}-windows-x86_64-setup.exe"
!endif
!define APP "CST-Agent"
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\cst-agent"
!include "MUI2.nsh"
!include "LogicLib.nsh"
!include "x64.nsh"
Name "${APP} ${VERSION}"
OutFile "${OUTFILE}"
InstallDir "$LOCALAPPDATA\Programs\cst-agent"
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation"
BrandingText "${APP} ${VERSION}"
VIProductVersion "${VERSION}.0"
VIAddVersionKey /LANG=1033 "ProductName" "${APP}"
VIAddVersionKey /LANG=1033 "ProductVersion" "${VERSION}"
VIAddVersionKey /LANG=1033 "FileVersion" "${VERSION}"
VIAddVersionKey /LANG=1033 "FileDescription" "${APP} installer"
VIAddVersionKey /LANG=1033 "LegalCopyright" "WHC"
!define MUI_ABORTWARNING
!define MUI_FINISHPAGE_RUN
!define MUI_FINISHPAGE_RUN_TEXT "$(RunWebUI)"
!define MUI_FINISHPAGE_RUN_FUNCTION LaunchWebUI
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English"
!insertmacro MUI_LANGUAGE "SimpChinese"
LangString NeedX64 ${LANG_ENGLISH} "${APP} needs 64-bit Windows."
LangString NeedX64 ${LANG_SIMPCHINESE} "${APP} 需要 64 位 Windows。"
LangString NeedBuild ${LANG_ENGLISH} "${APP} needs Windows 10 1903 (build 18362) or newer; this is build $0."
LangString NeedBuild ${LANG_SIMPCHINESE} "${APP} 需要 Windows 10 1903(内部版本 18362)或更新版本,当前为 $0。"
LangString RunWebUI ${LANG_ENGLISH} "Open the ${APP} WebUI"
LangString RunWebUI ${LANG_SIMPCHINESE} "打开 ${APP} WebUI"
LangString PathFail ${LANG_ENGLISH} "Could not add $INSTDIR to the user PATH; add it by hand."
LangString PathFail ${LANG_SIMPCHINESE} "未能把 $INSTDIR 加入用户 PATH,请手动添加。"
; PowerShell edits the user PATH: NSIS strings are capped at 1024 chars, and
; .NET broadcasts the change so new terminals see it. The folder is passed in
; the environment, so quotes in it cannot break the command.
!macro EditUserPath ACTION
System::Call 'Kernel32::SetEnvironmentVariable(t "CST_INSTDIR", t "$INSTDIR")'
nsExec::ExecToLog `powershell -NoProfile -ExecutionPolicy Bypass -Command "$$d = $$env:CST_INSTDIR.TrimEnd('\'); $$p = [Environment]::GetEnvironmentVariable('Path', 'User'); $$parts = @([string]$$p -split ';' | Where-Object { $$_ -and $$_.TrimEnd('\') -ine $$d }); if ('${ACTION}' -eq 'add') { $$parts += $$d }; [Environment]::SetEnvironmentVariable('Path', ($$parts -join ';'), 'User')"`
Pop $0
!macroend
; A running exe cannot be overwritten but can be renamed out of the way.
!macro MoveAside NAME
Delete "$INSTDIR\${NAME}.old"
ClearErrors
Delete "$INSTDIR\${NAME}"
${If} ${Errors}
Rename "$INSTDIR\${NAME}" "$INSTDIR\${NAME}.old"
${EndIf}
!macroend
Function .onInit
${IfNot} ${RunningX64}
MessageBox MB_ICONSTOP "$(NeedX64)"
Abort
${EndIf}
SetRegView 64
ReadRegStr $0 HKLM "SOFTWARE\Microsoft\Windows NT\CurrentVersion" "CurrentBuildNumber"
${If} $0 < 18362
MessageBox MB_ICONSTOP "$(NeedBuild)"
Abort
${EndIf}
FunctionEnd
Function LaunchWebUI
SetOutPath "$PROFILE"
Exec '"$INSTDIR\cst-agent.exe" --web --web-open'
FunctionEnd
Section "Install"
SetShellVarContext current
SetOutPath "$INSTDIR"
!insertmacro MoveAside "cst-agent.exe"
!insertmacro MoveAside "busybox.exe"
File "${SRCDIR}/cst-agent.exe"
File "${SRCDIR}/busybox.exe"
File "${SRCDIR}/LICENSE.busybox"
File "${SRCDIR}/README.md"
File "${SRCDIR}/cst-agent-update.cmd"
File "${SRCDIR}/install_windows.ps1"
File "${SRCDIR}/VERSION"
WriteUninstaller "$INSTDIR\uninstall.exe"
!insertmacro EditUserPath "add"
${If} $0 != 0
DetailPrint "$(PathFail)"
${EndIf}
; The WebUI's workspace is the shortcut's start folder.
SetOutPath "$PROFILE"
CreateShortCut "$SMPROGRAMS\${APP} WebUI.lnk" "$INSTDIR\cst-agent.exe" "--web --web-open" "$INSTDIR\cst-agent.exe" 0
SetOutPath "$INSTDIR"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${APP}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${VERSION}"
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "WHC"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\cst-agent.exe"
WriteRegStr HKCU "${UNINST_KEY}" "InstallLocation" "$INSTDIR"
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" '"$INSTDIR\uninstall.exe"'
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" '"$INSTDIR\uninstall.exe" /S'
WriteRegStr HKCU "${UNINST_KEY}" "URLInfoAbout" "https://git.whcself.xyz/WHC/CST-agent"
WriteRegDWORD HKCU "${UNINST_KEY}" "NoModify" 1
WriteRegDWORD HKCU "${UNINST_KEY}" "NoRepair" 1
SectionEnd
Section "Uninstall"
SetShellVarContext current
!insertmacro EditUserPath "remove"
Delete "$SMPROGRAMS\${APP} WebUI.lnk"
Delete "$INSTDIR\cst-agent.exe"
Delete "$INSTDIR\cst-agent.exe.old"
Delete "$INSTDIR\busybox.exe"
Delete "$INSTDIR\busybox.exe.old"
Delete "$INSTDIR\LICENSE.busybox"
Delete "$INSTDIR\README.md"
Delete "$INSTDIR\cst-agent-update.cmd"
Delete "$INSTDIR\install_windows.ps1"
Delete "$INSTDIR\VERSION"
Delete "$INSTDIR\uninstall.exe"
RMDir "$INSTDIR"
DeleteRegKey HKCU "${UNINST_KEY}"
SectionEnd
+1
View File
@@ -15,6 +15,7 @@ win.mkShell {
pkg-config pkg-config
zip zip
curl curl
nsis
]; ];
buildInputs = [ win.windows.pthreads ]; buildInputs = [ win.windows.pthreads ];
# mkShell exports CC/AR for the target; the build scripts call the prefixed tools directly. # mkShell exports CC/AR for the target; the build scripts call the prefixed tools directly.
+147 -16
View File
@@ -177,8 +177,62 @@ static int tool_on(const cst_agent_config_t *cfg, const char *name) {
return cst_tool_active(cfg, cst_tool_find(name)); return cst_tool_active(cfg, cst_tool_find(name));
} }
/* /mode general (cst_mode_name covers "" and unknown values). */
static int general_mode(const cst_agent_config_t *cfg) {
return cfg && strcmp(cst_mode_name(cfg->mode), "general") == 0;
}
/* Workflow for /mode general: gather, then do the work, then check it. */
static void put_workflow_general(cst_buf_t *b, const cst_agent_config_t *cfg) {
cst_buf_puts(b, "\nWorkflow:\n1) Gather what you need");
const char *explore[] = {"list_dir", "glob", "grep"};
int n = 0;
for (int i = 0; i < 3; i++) {
if (tool_on(cfg, explore[i]))
cst_buf_printf(b, "%s%s", n++ ? ", " : " (", explore[i]);
}
if (n)
cst_buf_puts(b, ")");
if (tool_on(cfg, "read_file"))
cst_buf_puts(b, "; read big files in slices with read_file offset/limit, "
"and use the documents the user attached");
cst_buf_puts(b, ".\n2) Do the work");
n = 0;
if (tool_on(cfg, "write_file")) {
cst_buf_puts(b, ": write_file for new files");
n++;
}
if (tool_on(cfg, "edit_file"))
cst_buf_printf(b, "%sedit_file for small exact replacements",
n++ ? ", " : ": ");
if (tool_on(cfg, "apply_patch"))
cst_buf_printf(b, "%sapply_patch for multi-hunk diffs to existing files",
n++ ? ", " : ": ");
if (tool_on(cfg, "run_shell"))
cst_buf_printf(b, "%srun_shell for commands, computations, data "
"processing and other CLIs",
n++ ? ", " : ": ");
cst_buf_puts(b, ".\n");
if (tool_on(cfg, "run_shell"))
cst_buf_puts(b, "3) Check the result before saying it works (re-read it, "
"run it, or verify the numbers); fix and re-run until it "
"holds.\n");
if (tool_on(cfg, "todo_write"))
cst_buf_puts(b, "- For work with several steps keep a todo list with "
"todo_write and update it as you go.\n");
if (tool_on(cfg, "ask_user"))
cst_buf_puts(b, "- Use ask_user only when a decision is truly the user's; "
"otherwise choose sensibly and say so.\n");
cst_buf_puts(b, "Finish with a short plain-text summary of the result.\n"
"Never invent other tool names.\n");
}
/* Workflow section for the default tool profile; names only active tools. */ /* Workflow section for the default tool profile; names only active tools. */
static void put_workflow(cst_buf_t *b, const cst_agent_config_t *cfg) { static void put_workflow(cst_buf_t *b, const cst_agent_config_t *cfg) {
if (general_mode(cfg)) {
put_workflow_general(b, cfg);
return;
}
cst_buf_puts(b, "\nWorkflow:\n1) Inspect only what you need"); cst_buf_puts(b, "\nWorkflow:\n1) Inspect only what you need");
const char *explore[] = {"list_dir", "glob", "grep"}; const char *explore[] = {"list_dir", "glob", "grep"};
int n = 0; int n = 0;
@@ -235,6 +289,25 @@ static const char DEF_INTRO[] =
"could not verify, say so.\n" "could not verify, say so.\n"
"- Keep going until the task is done or truly blocked.\n\n"; "- Keep going until the task is done or truly blocked.\n\n";
/* Same role, framed for /mode general: the workspace may hold documents,
* data or anything else, not just code. */
static const char DEF_INTRO_GENERAL[] =
"You are CST-Agent, a general-purpose assistant working in the user's "
"local workspace. All tool paths are relative to the workspace root and "
"shell commands run there; the material you work on may be code, "
"documents, data or anything else.\n\n"
"Principles:\n"
"- Reply in the user's language. Be concise: lead with the result, no "
"filler.\n"
"- Understand before answering: read the files, documents and sources the "
"task refers to instead of guessing; never invent content, numbers or "
"tool results.\n"
"- Do what was asked, at the scope asked: leave unrelated files, data and "
"material alone.\n"
"- Check your work when you can (re-read it, run it, cross-check the "
"source); if you could not verify something, say so.\n"
"- Keep going until the task is done or truly blocked.\n\n";
static void put_tool_use(cst_buf_t *b, const cst_agent_config_t *cfg) { static void put_tool_use(cst_buf_t *b, const cst_agent_config_t *cfg) {
cst_buf_puts(b, "\nTool use:\n"); cst_buf_puts(b, "\nTool use:\n");
if (tool_on(cfg, "run_shell") && if (tool_on(cfg, "run_shell") &&
@@ -248,15 +321,26 @@ static void put_tool_use(cst_buf_t *b, const cst_agent_config_t *cfg) {
cst_buf_puts(b, "- Read a file before editing it and again after a failed " cst_buf_puts(b, "- Read a file before editing it and again after a failed "
"edit. The `N|` prefixes in read_file output are not part " "edit. The `N|` prefixes in read_file output are not part "
"of the file.\n"); "of the file.\n");
if (general_mode(cfg) && tool_on(cfg, "web_fetch"))
cst_buf_puts(b, "- Use web_fetch for pages the user points you to; say so "
"when a page could not be fetched instead of guessing its "
"content.\n");
cst_buf_puts(b, "- When a call fails, read the error and change approach " cst_buf_puts(b, "- When a call fails, read the error and change approach "
"instead of repeating the same call.\n"); "instead of repeating the same call.\n");
} }
static void put_safety(cst_buf_t *b, const cst_agent_config_t *cfg) { static void put_safety(cst_buf_t *b, const cst_agent_config_t *cfg) {
cst_buf_puts(b, "\nSafety:\n" if (general_mode(cfg))
"- Do not commit, push or rewrite git history unless asked. " cst_buf_puts(b, "\nSafety:\n"
"Do not delete or overwrite files you did not create unless " "- Do not delete or overwrite files or data you did not "
"the task requires it. Never print secrets or API keys.\n"); "create unless the task requires it, and do not send the "
"user's content anywhere the task did not ask for. Never "
"print secrets or API keys.\n");
else
cst_buf_puts(b, "\nSafety:\n"
"- Do not commit, push or rewrite git history unless asked. "
"Do not delete or overwrite files you did not create unless "
"the task requires it. Never print secrets or API keys.\n");
if (tool_on(cfg, "run_shell")) if (tool_on(cfg, "run_shell"))
cst_buf_puts(b, "- run_shell may run in a sandbox (writes only inside the " cst_buf_puts(b, "- run_shell may run in a sandbox (writes only inside the "
"workspace and temp dir; the network works). If a command is " "workspace and temp dir; the network works). If a command is "
@@ -365,7 +449,9 @@ char *cst_agent_build_prompt(const cst_agent_config_t *cfg, cst_protocol_t p,
const char *project_notes) { const char *project_notes) {
int minimal = !cfg || cfg->tool_profile == CST_TOOLS_MINIMAL; int minimal = !cfg || cfg->tool_profile == CST_TOOLS_MINIMAL;
cst_buf_t b = {0}; cst_buf_t b = {0};
cst_buf_puts(&b, minimal ? PROMPT_BASE : DEF_INTRO); cst_buf_puts(&b, minimal ? PROMPT_BASE
: general_mode(cfg) ? DEF_INTRO_GENERAL
: DEF_INTRO);
if (cst_protocol_is_native(p)) if (cst_protocol_is_native(p))
cst_buf_puts(&b, minimal ? SEC_NATIVE cst_buf_puts(&b, minimal ? SEC_NATIVE
: "Call tools through the native " : "Call tools through the native "
@@ -1095,6 +1181,8 @@ static void exec_end(turn_t *t, pcall_t *pc, exec_t *x) {
if (t->hooks && t->hooks->on_tool_result) if (t->hooks && t->hooks->on_tool_result)
t->hooks->on_tool_result(c, pc->output, pc->output_len, pc->exit_code, t->hooks->on_tool_result(c, pc->output, pc->output_len, pc->exit_code,
t->hooks->userdata); t->hooks->userdata);
if (t->hooks && t->hooks->on_tool_images && pc->nattach > 0)
t->hooks->on_tool_images(c, pc->attach, pc->nattach, t->hooks->userdata);
if (pc->status != PC_DENIED) { if (pc->status != PC_DENIED) {
char d[96]; char d[96];
snprintf(d, sizeof(d), "%sexit=%d bytes=%zu", snprintf(d, sizeof(d), "%sexit=%d bytes=%zu",
@@ -1764,6 +1852,9 @@ static void fill_request(cst_agent_t *a, cst_llm_request_t *req,
req->session_path = cst_session_path(a->session); req->session_path = cst_session_path(a->session);
req->cancel_flag = &a->cancel; req->cancel_flag = &a->cancel;
req->tool_out_cap = a->cfg.tool_out_cap; req->tool_out_cap = a->cfg.tool_out_cap;
req->temperature = a->cfg.temperature;
req->top_p = a->cfg.top_p;
req->max_tokens = a->cfg.max_tokens;
if (cst_protocol_is_native(proto)) { if (cst_protocol_is_native(proto)) {
req->enable_tools = 1; req->enable_tools = 1;
req->tool_cfg = &a->cfg; req->tool_cfg = &a->cfg;
@@ -1956,16 +2047,8 @@ void cst_agent_session_id(const cst_agent_t *a, char *out, size_t out_sz) {
out[0] = 0; out[0] = 0;
if (!a) if (!a)
return; return;
const char *p = a->cfg.session_path; if (cst_agent_store_id_of_path(a->cfg.session_path, out, out_sz) != 0)
const char *sl = strrchr(p, '/'); out[0] = 0;
const char *base = sl ? sl + 1 : p;
size_t n = strlen(base);
if (n >= out_sz)
n = out_sz - 1;
memcpy(out, base, n);
out[n] = 0;
if (n > 6 && strcmp(out + n - 6, ".jsonl") == 0)
out[n - 6] = 0;
} }
const char *cst_agent_session_path(const cst_agent_t *a) { const char *cst_agent_session_path(const cst_agent_t *a) {
@@ -2084,6 +2167,20 @@ int cst_agent_attach_bytes(cst_agent_t *a, const char *name, const char *data,
err, err_sz); err, err_sz);
} }
/* The stored name of a pending attachment: the window serves the file from the
* session's attachment directory while the message is still being written. */
const char *cst_agent_pending_rel(const cst_agent_t *a, int i) {
if (!a || i < 0 || i >= a->npending)
return NULL;
return a->pending[i].rel;
}
int cst_agent_pending_is_image(const cst_agent_t *a, int i) {
if (!a || i < 0 || i >= a->npending)
return 0;
return cst_attach_is_image(&a->pending[i]);
}
const char *cst_agent_todo(const cst_agent_t *a) { return a ? a->todo : NULL; } const char *cst_agent_todo(const cst_agent_t *a) { return a ? a->todo : NULL; }
const char *cst_agent_notes(const cst_agent_t *a) { const char *cst_agent_notes(const cst_agent_t *a) {
@@ -2241,11 +2338,12 @@ int cst_agent_undo_available(const cst_agent_t *a) {
* later changes reach the model as persisted notes appended to the history, * later changes reach the model as persisted notes appended to the history,
* which keeps the cached prefix intact. */ * which keeps the cached prefix intact. */
#define PROMPT_VERSION 2 #define PROMPT_VERSION 3
typedef struct { typedef struct {
int plan; int plan;
char approval[96]; char approval[96];
char mode[16];
char branch[128]; char branch[128];
char day[16]; char day[16];
char notes[24]; /* hash of AGENTS.md, "" = none */ char notes[24]; /* hash of AGENTS.md, "" = none */
@@ -2255,6 +2353,7 @@ static void state_now(const cst_agent_t *a, pstate_t *s) {
memset(s, 0, sizeof(*s)); memset(s, 0, sizeof(*s));
s->plan = a->cfg.plan_mode; s->plan = a->cfg.plan_mode;
snprintf(s->approval, sizeof(s->approval), "%s", approval_desc(&a->cfg)); snprintf(s->approval, sizeof(s->approval), "%s", approval_desc(&a->cfg));
snprintf(s->mode, sizeof(s->mode), "%s", cst_mode_name(a->cfg.mode));
git_branch(a->cfg.workspace_root, s->branch, sizeof(s->branch)); git_branch(a->cfg.workspace_root, s->branch, sizeof(s->branch));
today(s->day, sizeof(s->day)); today(s->day, sizeof(s->day));
if (a->notes && a->notes[0]) if (a->notes && a->notes[0])
@@ -2267,6 +2366,7 @@ static cJSON *state_json(const pstate_t *s) {
cJSON *o = cJSON_CreateObject(); cJSON *o = cJSON_CreateObject();
cJSON_AddNumberToObject(o, "plan", s->plan); cJSON_AddNumberToObject(o, "plan", s->plan);
cJSON_AddStringToObject(o, "approval", s->approval); cJSON_AddStringToObject(o, "approval", s->approval);
cJSON_AddStringToObject(o, "mode", s->mode);
cJSON_AddStringToObject(o, "branch", s->branch); cJSON_AddStringToObject(o, "branch", s->branch);
cJSON_AddStringToObject(o, "day", s->day); cJSON_AddStringToObject(o, "day", s->day);
cJSON_AddStringToObject(o, "notes", s->notes); cJSON_AddStringToObject(o, "notes", s->notes);
@@ -2282,6 +2382,7 @@ static void state_parse(const cJSON *o, pstate_t *s) {
if (cJSON_IsString(v)) \ if (cJSON_IsString(v)) \
snprintf(s->field, sizeof(s->field), "%s", v->valuestring); snprintf(s->field, sizeof(s->field), "%s", v->valuestring);
STATE_STR("approval", approval) STATE_STR("approval", approval)
STATE_STR("mode", mode)
STATE_STR("branch", branch) STATE_STR("branch", branch)
STATE_STR("day", day) STATE_STR("day", day)
STATE_STR("notes", notes) STATE_STR("notes", notes)
@@ -2293,6 +2394,7 @@ static char *system_meta(const cst_agent_t *a, const pstate_t *s) {
cJSON_AddStringToObject(o, "protocol", cst_protocol_name(a->cfg.protocol)); cJSON_AddStringToObject(o, "protocol", cst_protocol_name(a->cfg.protocol));
cJSON_AddNumberToObject(o, "pv", PROMPT_VERSION); cJSON_AddNumberToObject(o, "pv", PROMPT_VERSION);
cJSON_AddNumberToObject(o, "tools", a->cfg.tool_profile); cJSON_AddNumberToObject(o, "tools", a->cfg.tool_profile);
cJSON_AddStringToObject(o, "mode", cst_mode_name(a->cfg.mode));
cJSON_AddItemToObject(o, "state", state_json(s)); cJSON_AddItemToObject(o, "state", state_json(s));
char *out = cJSON_PrintUnformatted(o); char *out = cJSON_PrintUnformatted(o);
cJSON_Delete(o); cJSON_Delete(o);
@@ -2312,10 +2414,13 @@ static int system_current(const cst_message_t *m, const cst_agent_config_t *c) {
const cJSON *pv = cJSON_GetObjectItemCaseSensitive(meta, "pv"); const cJSON *pv = cJSON_GetObjectItemCaseSensitive(meta, "pv");
const cJSON *pr = cJSON_GetObjectItemCaseSensitive(meta, "protocol"); const cJSON *pr = cJSON_GetObjectItemCaseSensitive(meta, "protocol");
const cJSON *tp = cJSON_GetObjectItemCaseSensitive(meta, "tools"); const cJSON *tp = cJSON_GetObjectItemCaseSensitive(meta, "tools");
const cJSON *md = cJSON_GetObjectItemCaseSensitive(meta, "mode");
int ok = cJSON_IsNumber(pv) && pv->valueint == PROMPT_VERSION && int ok = cJSON_IsNumber(pv) && pv->valueint == PROMPT_VERSION &&
cJSON_IsString(pr) && cJSON_IsString(pr) &&
strcmp(pr->valuestring, cst_protocol_name(c->protocol)) == 0 && strcmp(pr->valuestring, cst_protocol_name(c->protocol)) == 0 &&
cJSON_IsNumber(tp) && tp->valueint == c->tool_profile; cJSON_IsNumber(tp) && tp->valueint == c->tool_profile;
ok = ok && cJSON_IsString(md) &&
strcmp(md->valuestring, cst_mode_name(c->mode)) == 0;
cJSON_Delete(meta); cJSON_Delete(meta);
return ok; return ok;
} }
@@ -2393,6 +2498,14 @@ static void sync_notes(cst_agent_t *a) {
"is available again.\n"); "is available again.\n");
if (strcmp(now.approval, was.approval) != 0) if (strcmp(now.approval, was.approval) != 0)
cst_buf_printf(&b, "[cst] Approval mode is now %s.\n", now.approval); cst_buf_printf(&b, "[cst] Approval mode is now %s.\n", now.approval);
if (strcmp(now.mode, was.mode) != 0)
cst_buf_printf(&b,
"[cst] Working mode is now %s%s. Earlier instructions about "
"how to frame the work no longer apply.\n",
now.mode,
strcmp(now.mode, "general") == 0
? " (general: documents, data, research)"
: " (coding: repo, build, tests)");
if (strcmp(now.branch, was.branch) != 0) if (strcmp(now.branch, was.branch) != 0)
cst_buf_printf(&b, "[cst] The git branch is now %s.\n", cst_buf_printf(&b, "[cst] The git branch is now %s.\n",
now.branch[0] ? now.branch : "(none)"); now.branch[0] ? now.branch : "(none)");
@@ -2436,6 +2549,24 @@ cst_agent_t *cst_agent_create(const cst_agent_config_t *cfg) {
a->cfg.max_steps = CST_DEFAULT_MAX_STEPS; a->cfg.max_steps = CST_DEFAULT_MAX_STEPS;
if (a->cfg.tool_out_cap == 0) if (a->cfg.tool_out_cap == 0)
a->cfg.tool_out_cap = CST_DEFAULT_TOOL_OUT_CAP; a->cfg.tool_out_cap = CST_DEFAULT_TOOL_OUT_CAP;
/* The generation parameters the user saved for this provider (Settings ->
* Advanced in the app, "params" in settings.json). They are read here so
* that the CLI and the TUI send what the GUI was set to; a caller that
* named one explicitly wins. */
{
cst_settings_t st;
if (cst_settings_load(&st) == 0) {
const cst_gen_params_t *g = cst_settings_params(&st, a->cfg.provider);
if (g) {
if (a->cfg.temperature <= 0)
a->cfg.temperature = g->temperature;
if (a->cfg.top_p <= 0)
a->cfg.top_p = g->top_p;
if (a->cfg.max_tokens <= 0)
a->cfg.max_tokens = g->max_tokens;
}
}
}
a->session = cst_session_open(cfg->session_path[0] ? cfg->session_path a->session = cst_session_open(cfg->session_path[0] ? cfg->session_path
: "session.jsonl"); : "session.jsonl");
if (!a->session) { if (!a->session) {
+401 -57
View File
@@ -23,6 +23,29 @@
#define OS_F_OK F_OK #define OS_F_OK F_OK
#endif #endif
/* --- the store's shape ----------------------------------------------------
* A session owns a directory: everything the session keeps is inside it, so
* "delete this conversation" is one rmdir and nothing has to be found by
* guesswork (a phone's app data directory has no user to tidy it by hand).
*
* <workspace>/.agent/sessions/
* <uuid>/session.jsonl the transcript
* <uuid>/title the window title, one line
* <uuid>/attachments/ what the messages carry: images, delivered
* files (<id>.png, art-<id>.md, ...)
* <uuid>/sub-1.jsonl a sub-agent's own transcript
* CURRENT the id the next run opens
*
* Sessions written before this layout are flat, with one shared attachment
* directory, and keep working: every reader here takes either shape and
* cst_agent_store_migrate() (--migrate-store) moves them in.
*
* <uuid>.jsonl
* <uuid>.title
* attachments/<uuid>/... (shared by every session)
* <uuid>/sub-1.jsonl (already per session)
*/
int cst_agent_store_dir(const char *workspace, char *out, size_t out_sz) { int cst_agent_store_dir(const char *workspace, char *out, size_t out_sz) {
if (!workspace || !out || out_sz < 16) if (!workspace || !out || out_sz < 16)
return -1; return -1;
@@ -53,24 +76,140 @@ int cst_agent_store_ensure(const char *workspace) {
return 0; return 0;
} }
static void make_id(char *out, size_t n) { /* An id is a directory name: anything that could leave the store, and the two
time_t now = time(NULL); * names the store itself uses, is not one. */
struct tm tm; static int id_ok(const char *id) {
os_localtime(&now, &tm); if (!id || !id[0] || id[0] == '.')
static unsigned seq; return 0;
unsigned r = (unsigned)(os_getpid() ^ (unsigned)now) + 0x9e37u * seq++; for (const char *p = id; *p; p++) {
snprintf(out, n, "%04d%02d%02d-%02d%02d%02d-%04x", tm.tm_year + 1900, char c = *p;
tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec, if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
r & 0xffff); (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.'))
return 0;
}
if (strstr(id, ".."))
return 0;
return strcmp(id, "CURRENT") != 0 && strcmp(id, "attachments") != 0;
} }
/* 16 random bytes, whatever the platform offers; the clock and the pid are
* the fallback (a collision only costs a reopen, not data). */
static void random_bytes(unsigned char *out, size_t n) {
FILE *f = fopen("/dev/urandom", "rb");
if (f) {
size_t got = fread(out, 1, n, f);
fclose(f);
if (got == n)
return;
}
static unsigned seq;
unsigned long long x = (unsigned long long)time(NULL) ^
((unsigned long long)os_getpid() << 20) ^
((unsigned long long)++seq << 8);
for (size_t i = 0; i < n; i++) {
x = x * 6364136223846793005ULL + 1442695040888963407ULL;
out[i] = (unsigned char)(x >> 33);
}
}
/* A uuid v4: unique across machines and time, which a timestamp is not (two
* phones, one synced workspace, and the ids have to stay apart). */
int cst_agent_store_new_id(char *id_out, size_t id_sz) { int cst_agent_store_new_id(char *id_out, size_t id_sz) {
if (!id_out || id_sz < 24) if (!id_out || id_sz < 37)
return -1; return -1;
make_id(id_out, id_sz); unsigned char b[16];
random_bytes(b, sizeof(b));
b[6] = (unsigned char)((b[6] & 0x0f) | 0x40); /* version 4 */
b[8] = (unsigned char)((b[8] & 0x3f) | 0x80); /* variant 1 */
snprintf(id_out, id_sz,
"%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-"
"%02x%02x%02x%02x%02x%02x",
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10],
b[11], b[12], b[13], b[14], b[15]);
return 0; return 0;
} }
/* <sessions>/<id>, the session's own directory (not made here). */
static int dir_for_id(const char *workspace, const char *id, char *out,
size_t out_sz) {
if (!workspace || !id_ok(id))
return -1;
char dir[OS_PATH_MAX];
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
return -1;
return os_path_join(out, out_sz, dir, id);
}
/* <sessions>/<id>.jsonl -- the shape before per-session directories. */
static int flat_for_id(const char *workspace, const char *id, char *out,
size_t out_sz) {
if (!workspace || !id_ok(id))
return -1;
char dir[OS_PATH_MAX], leaf[192];
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
return -1;
snprintf(leaf, sizeof(leaf), "%s.jsonl", id);
return os_path_join(out, out_sz, dir, leaf);
}
/* The transcript of an id in the per-session layout. */
static int nested_for_id(const char *workspace, const char *id, char *out,
size_t out_sz) {
char d[OS_PATH_MAX];
if (dir_for_id(workspace, id, d, sizeof(d)) != 0)
return -1;
return os_path_join(out, out_sz, d, "session.jsonl");
}
int cst_agent_store_id_of_path(const char *session_path, char *out,
size_t out_sz) {
if (!session_path || !session_path[0] || !out || out_sz < 2)
return -1;
const char *base = session_path;
for (const char *p = session_path; *p; p++)
if (*p == '/' || *p == '\\')
base = p + 1;
size_t n = strlen(base);
if (n > 6 && strcmp(base + n - 6, ".jsonl") == 0) {
size_t stem = n - 6;
if (stem == 7 && strncmp(base, "session", 7) == 0) {
/* <id>/session.jsonl: the id is the directory the file sits in. */
if (base == session_path)
return -1; /* a bare "session.jsonl" has no session to name */
const char *end = base - 1; /* the separator before the leaf */
const char *start = end;
while (start > session_path && start[-1] != '/' && start[-1] != '\\')
start--;
size_t dn = (size_t)(end - start);
if (!dn || dn >= out_sz)
return -1;
memcpy(out, start, dn);
out[dn] = 0;
return 0;
}
n = stem;
}
if (!n || n >= out_sz)
return -1;
memcpy(out, base, n);
out[n] = 0;
return 0;
}
/* Which shape this id has. 1 = its own directory, 0 = flat, -1 = neither
* (and not a usable id). */
static int shape_of_id(const char *workspace, const char *id) {
char p[OS_PATH_MAX];
os_stat_t st;
if (nested_for_id(workspace, id, p, sizeof(p)) == 0 &&
os_lstat(p, &st) == 0 && !st.is_dir)
return 1;
if (flat_for_id(workspace, id, p, sizeof(p)) == 0 && os_lstat(p, &st) == 0 &&
!st.is_dir)
return 0;
return -1;
}
int cst_agent_store_path_for_id(const char *workspace, const char *id, int cst_agent_store_path_for_id(const char *workspace, const char *id,
char *out, size_t out_sz) { char *out, size_t out_sz) {
if (!workspace || !id || !out) if (!workspace || !id || !out)
@@ -80,12 +219,19 @@ int cst_agent_store_path_for_id(const char *workspace, const char *id,
snprintf(out, out_sz, "%s", id); snprintf(out, out_sz, "%s", id);
return 0; return 0;
} }
char dir[OS_PATH_MAX]; int shape = shape_of_id(workspace, id);
char leaf[160]; if (shape == 0)
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0) return flat_for_id(workspace, id, out, out_sz);
if (nested_for_id(workspace, id, out, out_sz) != 0)
return -1; return -1;
snprintf(leaf, sizeof(leaf), "%s.jsonl", id); if (shape < 0) {
return os_path_join(out, out_sz, dir, leaf); /* A new session: its directory is made here, so whoever opens the
* transcript next has a place to put it (and its attachments). */
char d[OS_PATH_MAX];
if (dir_for_id(workspace, id, d, sizeof(d)) == 0)
os_mkdir_p(d, 0700);
}
return 0;
} }
int cst_agent_store_set_current(const char *workspace, const char *id) { int cst_agent_store_set_current(const char *workspace, const char *id) {
@@ -164,24 +310,42 @@ int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids) {
return 0; return 0;
typedef struct { typedef struct {
char id[128]; char id[128];
time_t mtime; double mtime;
} ent_t; } ent_t;
ent_t *ents = calloc((size_t)max_ids * 4, sizeof(ent_t)); ent_t *ents = calloc((size_t)max_ids * 4, sizeof(ent_t));
int n = 0, cap = max_ids * 4; int n = 0, cap = max_ids * 4;
const os_dirent_t *de; const os_dirent_t *de;
while ((de = os_dir_next(d)) != NULL) { while ((de = os_dir_next(d)) != NULL) {
if (n >= cap)
break;
if (de->is_dir) {
/* a per-session directory: only one that holds a transcript counts */
if (!id_ok(de->name))
continue;
char p[OS_PATH_MAX];
if (nested_for_id(workspace, de->name, p, sizeof(p)) != 0)
continue;
os_stat_t st;
if (os_lstat(p, &st) != 0 || st.is_dir)
continue;
snprintf(ents[n].id, sizeof(ents[n].id), "%s", de->name);
ents[n].mtime = st.mtime;
n++;
continue;
}
size_t len = strlen(de->name); size_t len = strlen(de->name);
if (len < 7 || strcmp(de->name + len - 6, ".jsonl") != 0) if (len < 7 || strcmp(de->name + len - 6, ".jsonl") != 0)
continue; continue;
if (n >= cap) char id[128];
break; snprintf(id, sizeof(id), "%.*s", (int)(len - 6), de->name);
snprintf(ents[n].id, sizeof(ents[n].id), "%.*s", (int)(len - 6), if (!id_ok(id))
de->name); continue;
char full[OS_PATH_MAX]; char full[OS_PATH_MAX];
if (os_path_join(full, sizeof(full), dir, de->name) != 0) if (os_path_join(full, sizeof(full), dir, de->name) != 0)
continue; continue;
struct stat st; os_stat_t st;
ents[n].mtime = (stat(full, &st) == 0) ? st.st_mtime : 0; snprintf(ents[n].id, sizeof(ents[n].id), "%s", id);
ents[n].mtime = os_lstat(full, &st) == 0 ? st.mtime : 0;
n++; n++;
} }
os_dir_close(d); os_dir_close(d);
@@ -201,20 +365,28 @@ int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids) {
return out_n; return out_n;
} }
static int side_path(const char *workspace, const char *id, const char *ext, /* The title of a session: inside its directory in the new layout, a <id>.title
char *out, size_t out_sz) { * beside the transcript in the old one. */
char dir[OS_PATH_MAX], leaf[192]; static int title_for_id(const char *workspace, const char *id, char *out,
if (!id || !id[0] || strchr(id, '/') || strchr(id, '\\') || size_t out_sz) {
cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0) if (shape_of_id(workspace, id) == 0) {
char dir[OS_PATH_MAX];
char leaf[192];
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
return -1;
snprintf(leaf, sizeof(leaf), "%s.title", id);
return os_path_join(out, out_sz, dir, leaf);
}
char d[OS_PATH_MAX];
if (dir_for_id(workspace, id, d, sizeof(d)) != 0)
return -1; return -1;
snprintf(leaf, sizeof(leaf), "%s%s", id, ext); return os_path_join(out, out_sz, d, "title");
return os_path_join(out, out_sz, dir, leaf);
} }
int cst_agent_store_title_set(const char *workspace, const char *id, int cst_agent_store_title_set(const char *workspace, const char *id,
const char *title) { const char *title) {
char p[OS_PATH_MAX]; char p[OS_PATH_MAX];
if (side_path(workspace, id, ".title", p, sizeof(p)) != 0) if (title_for_id(workspace, id, p, sizeof(p)) != 0)
return -1; return -1;
if (!title || !title[0]) if (!title || !title[0])
return remove(p) == 0 || errno == ENOENT ? 0 : -1; return remove(p) == 0 || errno == ENOENT ? 0 : -1;
@@ -247,14 +419,18 @@ int cst_agent_store_info(const char *workspace, const char *id,
memset(info, 0, sizeof(*info)); memset(info, 0, sizeof(*info));
snprintf(info->id, sizeof(info->id), "%s", id ? id : ""); snprintf(info->id, sizeof(info->id), "%s", id ? id : "");
char p[OS_PATH_MAX]; char p[OS_PATH_MAX];
if (side_path(workspace, id, ".jsonl", p, sizeof(p)) != 0) if (shape_of_id(workspace, id) == 0) {
if (flat_for_id(workspace, id, p, sizeof(p)) != 0)
return -1;
} else if (nested_for_id(workspace, id, p, sizeof(p)) != 0) {
return -1; return -1;
struct stat st; }
if (stat(p, &st) != 0) os_stat_t st;
if (os_lstat(p, &st) != 0)
return -1; return -1;
info->mtime = (long long)st.st_mtime; info->mtime = (long long)st.mtime;
char tp[OS_PATH_MAX]; char tp[OS_PATH_MAX];
if (side_path(workspace, id, ".title", tp, sizeof(tp)) == 0) { if (title_for_id(workspace, id, tp, sizeof(tp)) == 0) {
FILE *tf = fopen(tp, "rb"); FILE *tf = fopen(tp, "rb");
if (tf) { if (tf) {
if (fgets(info->title, sizeof(info->title), tf)) { if (fgets(info->title, sizeof(info->title), tf)) {
@@ -304,36 +480,204 @@ int cst_agent_store_info(const char *workspace, const char *id,
return 0; return 0;
} }
int cst_agent_store_delete(const char *workspace, const char *id) { /* Remove a tree (a session's directory: its transcript, its attachments and
char p[OS_PATH_MAX]; * its sub-agent files). Links are removed, never followed. */
if (side_path(workspace, id, ".jsonl", p, sizeof(p)) != 0) static int rm_tree(const char *path) {
os_stat_t st;
if (os_lstat(path, &st) != 0)
return -1; return -1;
if (remove(p) != 0) if (!st.is_dir)
return -1; return remove(path) == 0 ? 0 : -1;
if (side_path(workspace, id, ".title", p, sizeof(p)) == 0) os_dir_t *d = os_dir_open(path);
remove(p); if (d) {
/* sub-agent transcripts live in <id>/ */ const os_dirent_t *de;
if (side_path(workspace, id, "", p, sizeof(p)) == 0) { while ((de = os_dir_next(d)) != NULL) {
os_dir_t *d = os_dir_open(p); char f[OS_PATH_MAX];
if (d) { if (os_path_join(f, sizeof(f), path, de->name) == 0)
const os_dirent_t *de; rm_tree(f);
while ((de = os_dir_next(d)) != NULL) {
char f[OS_PATH_MAX];
if (strncmp(de->name, "sub-", 4) == 0 &&
os_path_join(f, sizeof(f), p, de->name) == 0)
remove(f);
}
os_dir_close(d);
rmdir(p);
} }
os_dir_close(d);
}
return rmdir(path) == 0 ? 0 : -1;
}
int cst_agent_store_delete(const char *workspace, const char *id) {
if (!id_ok(id))
return -1;
char p[OS_PATH_MAX];
int found = 0;
/* both shapes, whichever this session has (or had) */
if (flat_for_id(workspace, id, p, sizeof(p)) == 0 && remove(p) == 0)
found = 1;
if (nested_for_id(workspace, id, p, sizeof(p)) == 0 && remove(p) == 0)
found = 1;
if (!found)
return -1;
if (title_for_id(workspace, id, p, sizeof(p)) == 0)
remove(p);
/* the session's own directory: attachments, sub-agent transcripts */
if (dir_for_id(workspace, id, p, sizeof(p)) == 0)
rm_tree(p);
/* and the shared attachment directory of the old layout */
char dir[OS_PATH_MAX];
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) == 0 &&
os_path_join(p, sizeof(p), dir, "attachments") == 0) {
char one[OS_PATH_MAX];
if (os_path_join(one, sizeof(one), p, id) == 0)
rm_tree(one);
} }
char cur[128]; char cur[128];
if (cst_agent_store_get_current(workspace, cur, sizeof(cur)) == 0 && if (cst_agent_store_get_current(workspace, cur, sizeof(cur)) == 0 &&
strcmp(cur, id) == 0) { strcmp(cur, id) == 0) {
char dir[OS_PATH_MAX];
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) == 0 && if (cst_agent_store_dir(workspace, dir, sizeof(dir)) == 0 &&
os_path_join(p, sizeof(p), dir, "CURRENT") == 0) os_path_join(p, sizeof(p), dir, "CURRENT") == 0)
remove(p); remove(p);
} }
return 0; return 0;
} }
/* Move a directory: rename if the OS likes it, otherwise copy file by file
* (a synced workspace can be odd about renames). */
static int move_file(const char *from, const char *to) {
if (os_rename_replace(from, to) == 0)
return 0;
FILE *in = fopen(from, "rb");
if (!in)
return -1;
FILE *out = fopen(to, "wb");
if (!out) {
fclose(in);
return -1;
}
char buf[16384];
size_t n;
int rc = 0;
while ((n = fread(buf, 1, sizeof(buf), in)) > 0) {
if (fwrite(buf, 1, n, out) != n) {
rc = -1;
break;
}
}
fclose(in);
if (fclose(out) != 0)
rc = -1;
if (rc == 0)
remove(from);
return rc;
}
static int move_tree(const char *from, const char *to) {
os_stat_t st;
if (os_lstat(from, &st) != 0)
return -1;
if (!st.is_dir)
return move_file(from, to);
if (os_mkdir_p(to, 0700) != 0)
return -1;
os_dir_t *d = os_dir_open(from);
if (!d)
return -1;
int rc = 0;
const os_dirent_t *de;
while ((de = os_dir_next(d)) != NULL) {
char f[OS_PATH_MAX], t[OS_PATH_MAX];
if (os_path_join(f, sizeof(f), from, de->name) != 0 ||
os_path_join(t, sizeof(t), to, de->name) != 0 || move_tree(f, t) != 0)
rc = -1;
}
os_dir_close(d);
if (rc == 0)
rmdir(from);
return rc;
}
int cst_agent_store_migrate(const char *workspace, int *moved_out,
int *failed_out, char *err, size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
if (moved_out)
*moved_out = 0;
if (failed_out)
*failed_out = 0;
if (!workspace || !workspace[0]) {
snprintf(err, err_sz, "no workspace");
return -1;
}
if (cst_agent_store_ensure(workspace) != 0) {
snprintf(err, err_sz, "cannot use the store under %s", workspace);
return -1;
}
char dir[OS_PATH_MAX];
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0) {
snprintf(err, err_sz, "no sessions directory");
return -1;
}
os_dir_t *d = os_dir_open(dir);
if (!d) {
snprintf(err, err_sz, "cannot read %s", dir);
return -1;
}
int moved = 0, failed = 0;
const os_dirent_t *de;
while ((de = os_dir_next(d)) != NULL) {
if (de->is_dir)
continue;
size_t len = strlen(de->name);
if (len < 7 || strcmp(de->name + len - 6, ".jsonl") != 0)
continue;
char id[128];
snprintf(id, sizeof(id), "%.*s", (int)(len - 6), de->name);
if (!id_ok(id))
continue;
char nested_dir[OS_PATH_MAX], nested[OS_PATH_MAX], flat[OS_PATH_MAX];
if (dir_for_id(workspace, id, nested_dir, sizeof(nested_dir)) != 0 ||
nested_for_id(workspace, id, nested, sizeof(nested)) != 0 ||
flat_for_id(workspace, id, flat, sizeof(flat)) != 0) {
failed++;
continue;
}
/* already moved (a second run, or a session that never was flat) */
os_stat_t st;
if (os_lstat(nested, &st) == 0)
continue;
if (os_mkdir_p(nested_dir, 0700) != 0 || move_file(flat, nested) != 0) {
failed++;
continue;
}
/* the title and the attachments that belong to this session */
char sdir[OS_PATH_MAX];
if (cst_agent_store_dir(workspace, sdir, sizeof(sdir)) == 0) {
char t_from[OS_PATH_MAX], t_to[OS_PATH_MAX];
char leaf[192];
snprintf(leaf, sizeof(leaf), "%s.title", id);
if (os_path_join(t_from, sizeof(t_from), sdir, leaf) == 0 &&
os_path_join(t_to, sizeof(t_to), nested_dir, "title") == 0)
move_file(t_from, t_to); /* no title is not a failure */
}
char att_dir[OS_PATH_MAX], att_one[OS_PATH_MAX];
char a_from[OS_PATH_MAX], a_to[OS_PATH_MAX];
if (cst_agent_store_dir(workspace, att_dir, sizeof(att_dir)) == 0 &&
os_path_join(att_one, sizeof(att_one), att_dir, "attachments") == 0 &&
os_path_join(a_from, sizeof(a_from), att_one, id) == 0 &&
os_path_join(a_to, sizeof(a_to), nested_dir, "attachments") == 0) {
os_stat_t ast;
if (os_lstat(a_from, &ast) == 0 && ast.is_dir)
move_tree(a_from, a_to);
}
moved++;
}
os_dir_close(d);
/* the shared directory itself is empty now (and has no other purpose) */
char att[OS_PATH_MAX];
if (os_path_join(att, sizeof(att), dir, "attachments") == 0)
rmdir(att); /* fails while another session still uses it: fine */
if (moved_out)
*moved_out = moved;
if (failed_out)
*failed_out = failed;
return 0;
}
+288 -34
View File
@@ -4,6 +4,7 @@
#include "cJSON.h" #include "cJSON.h"
#include "cst_attach.h" #include "cst_attach.h"
#include "cst_pdf.h"
#include "cst_util.h" #include "cst_util.h"
#include "os_compat.h" #include "os_compat.h"
@@ -102,6 +103,9 @@ static int le16(const unsigned char *p) {
} }
static const char *mime_of_ext(const char *mime) { static const char *mime_of_ext(const char *mime) {
if (strcmp(mime, "application/pdf") == 0 ||
strcmp(mime, "text/plain") == 0)
return "txt"; /* a document is stored as the text it was reduced to */
if (strcmp(mime, "image/png") == 0) if (strcmp(mime, "image/png") == 0)
return "png"; return "png";
if (strcmp(mime, "image/jpeg") == 0) if (strcmp(mime, "image/jpeg") == 0)
@@ -256,8 +260,13 @@ int cst_attach_dimensions(const void *data, size_t n, int *w, int *h) {
/* --- storage ------------------------------------------------------------- */ /* --- storage ------------------------------------------------------------- */
/* <session dir>/attachments/<session stem> — beside the JSONL so moving the /* Where a session's payloads live, beside its JSONL so moving the workspace
* workspace keeps the two together. */ * keeps the two together:
* <uuid>/session.jsonl -> <uuid>/attachments/ (the store's shape
* since v0.9.5)
* <id>.jsonl -> <dir>/attachments/<id>/ (older sessions; the
* migration moves them)
* The directory is made here: every store() writes into it next. */
int cst_attach_dir(const char *session_path, char *out, size_t out_sz) { int cst_attach_dir(const char *session_path, char *out, size_t out_sz) {
if (!session_path || !session_path[0]) if (!session_path || !session_path[0])
return -1; return -1;
@@ -285,8 +294,13 @@ int cst_attach_dir(const char *session_path, char *out, size_t out_sz) {
char *dot = strstr(leaf, ".jsonl"); char *dot = strstr(leaf, ".jsonl");
if (dot) if (dot)
*dot = 0; *dot = 0;
/* A session that owns its directory keeps its payloads directly inside it;
* only the flat layout needs the shared directory and its stem. */
char rel[320]; char rel[320];
snprintf(rel, sizeof(rel), "attachments/%s", leaf[0] ? leaf : "session"); if (strcmp(leaf, "session") == 0)
snprintf(rel, sizeof(rel), "attachments");
else
snprintf(rel, sizeof(rel), "attachments/%s", leaf[0] ? leaf : "session");
char full[OS_PATH_MAX]; char full[OS_PATH_MAX];
if (os_path_join(full, sizeof(full), dir, rel) != 0) if (os_path_join(full, sizeof(full), dir, rel) != 0)
return -1; return -1;
@@ -340,35 +354,91 @@ static char *read_whole(const char *path, size_t *out_len, size_t max) {
} }
/* FNV over the bytes, the name and the clock: short, stable, unique enough /* FNV over the bytes, the name and the clock: short, stable, unique enough
* inside one session. */ * inside one session. The prefix says what the name is for: "img" for what a
static void make_id(const char *name, const char *data, size_t n, char *out, * model is shown, "art" for what a person is handed. */
size_t out_sz) { static void make_id(const char *prefix, const char *name, const char *data,
size_t n, char *out, size_t out_sz) {
uint64_t h = cst_fnv1a64(CST_FNV_INIT, data, n > 4096 ? 4096 : n); uint64_t h = cst_fnv1a64(CST_FNV_INIT, data, n > 4096 ? 4096 : n);
h = cst_fnv1a64(h, name, strlen(name)); h = cst_fnv1a64(h, name, strlen(name));
h = cst_fnv1a64(h, &n, sizeof(n)); h = cst_fnv1a64(h, &n, sizeof(n));
double now = os_now_ms(); double now = os_now_ms();
h = cst_fnv1a64(h, &now, sizeof(now)); h = cst_fnv1a64(h, &now, sizeof(now));
h = cst_fnv1a64(h, &(int){os_getpid()}, sizeof(int)); h = cst_fnv1a64(h, &(int){os_getpid()}, sizeof(int));
snprintf(out, out_sz, "img-%08x", (unsigned)(h & 0xFFFFFFFFu)); snprintf(out, out_sz, "%s-%08x", prefix && prefix[0] ? prefix : "img",
(unsigned)(h & 0xFFFFFFFFu));
} }
/* n bytes, NUL-terminated: a text payload is handed around as a C string. */
static char *memdup_z(const char *data, size_t n) {
char *copy = malloc(n + 1);
if (!copy)
return NULL;
memcpy(copy, data, n);
copy[n] = 0;
return copy;
}
/* What a picked file is, plus the payload to store for it: an image stays as
* it is, a PDF is reduced to its text layer (the mime still says PDF, the
* stored file holds text), anything else must be UTF-8 text. The caller frees
* the result; *out_len is its length. */
static char *prepare(const char *data, size_t n, char *mime, size_t mime_sz,
int *pages, size_t *out_len, char *err, size_t err_sz) {
*pages = 0;
*out_len = n;
if (cst_attach_sniff(data, n, mime, mime_sz) == 0) {
if (n > CST_ATTACH_MAX_BYTES) {
snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB",
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
return NULL;
}
char *copy = memdup_z(data, n);
if (!copy)
snprintf(err, err_sz, "out of memory");
return copy;
}
if (n >= 4 && memcmp(data, "%PDF", 4) == 0) {
int pg = 0;
char perr[256];
char *text = cst_pdf_text(data, n, &pg, perr, sizeof(perr));
if (!text) {
snprintf(err, err_sz, "cannot read the PDF: %.200s", perr);
return NULL;
}
snprintf(mime, mime_sz, "application/pdf");
*pages = pg;
*out_len = strlen(text);
return text;
}
if (n > CST_ATTACH_TEXT_MAX) {
snprintf(err, err_sz, "text is %.1f MB; the limit is %d MB",
(double)n / (1024 * 1024), (int)(CST_ATTACH_TEXT_MAX >> 20));
return NULL;
}
if (memchr(data, 0, n) || !cst_utf8_valid(data, n)) {
snprintf(err, err_sz,
"not an image (PNG, JPEG, GIF, WebP or BMP), a PDF or UTF-8 "
"text; the content decides, not the extension");
return NULL;
}
snprintf(mime, mime_sz, "text/plain");
char *copy = memdup_z(data, n);
if (!copy)
snprintf(err, err_sz, "out of memory");
return copy;
}
/* Write one payload whose kind is already known. id_prefix names it ("img" /
* "art"), for_user marks it as the person's rather than the model's, and
* ext_override is the stored file's extension when the caller knows better
* than the mime does. */
static cst_attachment_t *store(const char *session_path, const char *name, static cst_attachment_t *store(const char *session_path, const char *name,
const char *data, size_t n, const char *origin, const char *data, size_t n, const char *mime,
int pages, const char *origin, int for_user,
const char *id_prefix, const char *ext_override,
char *err, size_t err_sz) { char *err, size_t err_sz) {
if (!name || !name[0]) if (!name || !name[0])
name = "image"; name = "image";
char mime[32];
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0) {
snprintf(err, err_sz,
"not a supported image (PNG, JPEG, GIF, WebP or BMP; "
"checked by content, not by extension)");
return NULL;
}
if (n > CST_ATTACH_MAX_BYTES) {
snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB",
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
return NULL;
}
char dir[OS_PATH_MAX]; char dir[OS_PATH_MAX];
if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0) { if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0) {
snprintf(err, err_sz, "cannot create the attachment directory"); snprintf(err, err_sz, "cannot create the attachment directory");
@@ -379,14 +449,18 @@ static cst_attachment_t *store(const char *session_path, const char *name,
snprintf(err, err_sz, "out of memory"); snprintf(err, err_sz, "out of memory");
return NULL; return NULL;
} }
make_id(name, data, n, a->id, sizeof(a->id)); make_id(id_prefix, name, data, n, a->id, sizeof(a->id));
snprintf(a->rel, sizeof(a->rel), "%s.%s", a->id, mime_of_ext(mime)); snprintf(a->rel, sizeof(a->rel), "%s.%s", a->id,
ext_override && ext_override[0] ? ext_override : mime_of_ext(mime));
snprintf(a->name, sizeof(a->name), "%s", name && name[0] ? name : "image"); snprintf(a->name, sizeof(a->name), "%s", name && name[0] ? name : "image");
snprintf(a->mime, sizeof(a->mime), "%s", mime); snprintf(a->mime, sizeof(a->mime), "%s", mime);
snprintf(a->origin, sizeof(a->origin), "%s", origin ? origin : "user"); snprintf(a->origin, sizeof(a->origin), "%s", origin ? origin : "user");
snprintf(a->detail, sizeof(a->detail), "auto"); snprintf(a->detail, sizeof(a->detail), "auto");
a->for_user = for_user ? 1 : 0;
a->bytes = (long long)n; a->bytes = (long long)n;
cst_attach_dimensions(data, n, &a->width, &a->height); a->pages = pages;
if (cst_attach_is_image(a))
cst_attach_dimensions(data, n, &a->width, &a->height);
char full[OS_PATH_MAX]; char full[OS_PATH_MAX];
if (cst_attach_full_path(session_path, a, full, sizeof(full)) != 0) { if (cst_attach_full_path(session_path, a, full, sizeof(full)) != 0) {
@@ -419,14 +493,27 @@ cst_attachment_t *cst_attach_import_bytes(const char *session_path,
} }
err[0] = 0; err[0] = 0;
if (!session_path || !session_path[0]) { if (!session_path || !session_path[0]) {
snprintf(err, err_sz, "no session to store the image in"); snprintf(err, err_sz, "no session to store the file in");
return NULL; return NULL;
} }
if (!data || !n) { if (!data || !n) {
snprintf(err, err_sz, "image is empty"); snprintf(err, err_sz, "the file is empty");
return NULL; return NULL;
} }
return store(session_path, name, data, n, "user", err, err_sz); char mime[32];
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0) {
snprintf(err, err_sz,
"not a supported image (PNG, JPEG, GIF, WebP or BMP; "
"checked by content, not by extension)");
return NULL;
}
if (n > CST_ATTACH_MAX_BYTES) {
snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB",
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
return NULL;
}
return store(session_path, name, data, n, mime, 0, "user", 0, "img", NULL,
err, err_sz);
} }
cst_attachment_t *cst_attach_import_file(const char *session_path, cst_attachment_t *cst_attach_import_file(const char *session_path,
@@ -449,11 +536,155 @@ cst_attachment_t *cst_attach_import_file(const char *session_path,
for (const char *p = src_path; *p; p++) for (const char *p = src_path; *p; p++)
if (*p == '/' || *p == '\\') if (*p == '/' || *p == '\\')
leaf = p + 1; leaf = p + 1;
cst_attachment_t *a = store(session_path, leaf, data, n, "user", err, err_sz); char mime[32];
int pages = 0;
size_t pn = 0;
char *payload =
prepare(data, n, mime, sizeof(mime), &pages, &pn, err, err_sz);
free(data);
if (!payload)
return NULL;
cst_attachment_t *a =
store(session_path, leaf, payload, pn, mime, pages, "user", 0, "img",
NULL, err, err_sz);
free(payload);
return a;
}
/* The mime of a delivered file: its extension decides (the content is not
* sniffed -- a .zip is not a picture), and anything unknown is a plain blob
* the OS still opens by name. */
static const char *mime_from_name(const char *name) {
static const struct {
const char *ext, *mime;
} T[] = {
{"md", "text/markdown"}, {"markdown", "text/markdown"},
{"txt", "text/plain"}, {"log", "text/plain"},
{"json", "application/json"}, {"csv", "text/csv"},
{"html", "text/html"}, {"htm", "text/html"},
{"xml", "application/xml"}, {"yaml", "application/yaml"},
{"yml", "application/yaml"}, {"toml", "application/toml"},
{"pdf", "application/pdf"}, {"zip", "application/zip"},
{"gz", "application/gzip"}, {"tgz", "application/gzip"},
{"tar", "application/x-tar"}, {"sh", "text/x-shellscript"},
{"py", "text/x-python"}, {"c", "text/x-c"},
{"h", "text/x-c"}, {"cpp", "text/x-c++"},
{"java", "text/x-java"}, {"kt", "text/x-kotlin"},
{"js", "text/javascript"}, {"ts", "text/typescript"},
{"go", "text/x-go"}, {"rs", "text/x-rust"},
{"rb", "text/x-ruby"}, {"qml", "text/x-qml"},
{"css", "text/css"}, {"sql", "text/x-sql"},
{"diff", "text/x-diff"}, {"patch", "text/x-diff"},
{"png", "image/png"}, {"jpg", "image/jpeg"},
{"jpeg", "image/jpeg"}, {"gif", "image/gif"},
{"webp", "image/webp"}, {"bmp", "image/bmp"},
{"svg", "image/svg+xml"}, {"ico", "image/x-icon"},
{"mp3", "audio/mpeg"}, {"m4a", "audio/mp4"},
{"ogg", "audio/ogg"}, {"wav", "audio/wav"},
{"mp4", "video/mp4"}, {"webm", "video/webm"},
{"apk", "application/vnd.android.package-archive"},
{"docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
{"xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
{"pptx",
"application/vnd.openxmlformats-officedocument.presentationml."
"presentation"},
};
const char *dot = name ? strrchr(name, '.') : NULL;
if (!dot || !dot[1])
return "application/octet-stream";
const char *e = dot + 1;
size_t el = strlen(e);
for (size_t i = 0; i < sizeof(T) / sizeof(T[0]); i++) {
if (strlen(T[i].ext) != el)
continue;
size_t k = 0;
for (; k < el; k++) {
char c = e[k];
if (c >= 'A' && c <= 'Z')
c = (char)(c - 'A' + 'a');
if (c != T[i].ext[k])
break;
}
if (k == el)
return T[i].mime;
}
return "application/octet-stream";
}
/* The stored file's extension: the name's own when it is plain ([a-z0-9], at
* most 8 characters -- a stored name has to stay portable), else whatever the
* mime maps to. */
static void ext_for_name(const char *name, const char *mime, char *out,
size_t out_sz) {
size_t k = 0;
const char *dot = name ? strrchr(name, '.') : NULL;
if (dot && dot[1] && out_sz > 1) {
for (const char *p = dot + 1; *p && k < 8 && k + 1 < out_sz; p++) {
char c = *p;
if (c >= 'A' && c <= 'Z')
c = (char)(c - 'A' + 'a');
if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')))
break;
out[k++] = c;
}
}
out[k] = 0;
if (!k)
snprintf(out, out_sz, "%s", mime_of_ext(mime));
}
cst_attachment_t *cst_attach_import_artifact(const char *session_path,
const char *src_path,
const char *title, char *err,
size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
if (!session_path || !session_path[0]) {
snprintf(err, err_sz, "no session to store the file in");
return NULL;
}
if (!src_path || !src_path[0]) {
snprintf(err, err_sz, "no file to deliver");
return NULL;
}
size_t n = 0;
char *data = read_whole(src_path, &n, CST_ARTIFACT_MAX_BYTES);
if (!data) {
snprintf(err, err_sz, "cannot read %.300s (or it is larger than %d MB)",
src_path, (int)(CST_ARTIFACT_MAX_BYTES >> 20));
return NULL;
}
const char *leaf = src_path;
for (const char *p = src_path; *p; p++)
if (*p == '/' || *p == '\\')
leaf = p + 1;
const char *name = (title && title[0]) ? title : leaf;
char mime[64], ext[16];
snprintf(mime, sizeof(mime), "%s", mime_from_name(name));
ext_for_name(name, mime, ext, sizeof(ext));
cst_attachment_t *a = store(session_path, name, data, n, mime, 0, "tool", 1,
"art", ext, err, err_sz);
free(data); free(data);
return a; return a;
} }
/* Images are opaque and travel as image parts; everything else is text the
* store holds and the request inlines. */
int cst_attach_is_image(const cst_attachment_t *a) {
return a && strncmp(a->mime, "image/", 6) == 0;
}
int cst_attach_is_text(const cst_attachment_t *a) {
/* A delivered file is the person's: it is never inlined into a request. */
return a && !a->for_user && !cst_attach_is_image(a);
}
char *cst_attach_read(const char *session_path, const cst_attachment_t *a, char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
size_t *out_len, char *err, size_t err_sz) { size_t *out_len, char *err, size_t err_sz) {
char dummy[8]; char dummy[8];
@@ -470,11 +701,12 @@ char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
char *data = read_whole(full, out_len, CST_ATTACH_MAX_BYTES); char *data = read_whole(full, out_len, CST_ATTACH_MAX_BYTES);
if (!data) { if (!data) {
snprintf(err, err_sz, snprintf(err, err_sz,
"%.200s is missing; the image was deleted from the session " "%.200s is missing; it was deleted from the session store",
"store",
a->name); a->name);
return NULL; return NULL;
} }
if (!cst_attach_is_image(a)) /* a document is text, nothing to verify */
return data;
char mime[32]; char mime[32];
if (cst_attach_sniff(data, *out_len, mime, sizeof(mime)) != 0) { if (cst_attach_sniff(data, *out_len, mime, sizeof(mime)) != 0) {
snprintf(err, err_sz, "%.200s in the session store is no longer an image", snprintf(err, err_sz, "%.200s in the session store is no longer an image",
@@ -486,6 +718,8 @@ char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
} }
char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a) { char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a) {
if (!cst_attach_is_image(a)) /* a document travels as text, not as a URL */
return NULL;
char err[256]; char err[256];
size_t n = 0; size_t n = 0;
char *data = cst_attach_read(session_path, a, &n, err, sizeof(err)); char *data = cst_attach_read(session_path, a, &n, err, sizeof(err));
@@ -509,17 +743,29 @@ void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz) {
snprintf(out, out_sz, "(none)"); snprintf(out, out_sz, "(none)");
return; return;
} }
const char *kind = strncmp(a->mime, "image/", 6) == 0 ? a->mime + 6 : a->mime; const char *kind = a->mime;
char dim[32] = ""; char dim[32] = "";
if (a->width > 0 && a->height > 0) if (cst_attach_is_image(a)) {
snprintf(dim, sizeof(dim), "%dx%d ", a->width, a->height); if (strncmp(a->mime, "image/", 6) == 0)
kind = a->mime + 6;
if (a->width > 0 && a->height > 0)
snprintf(dim, sizeof(dim), "%dx%d ", a->width, a->height);
} else if (strcmp(a->mime, "application/pdf") == 0) {
kind = "pdf";
if (a->pages > 0)
snprintf(dim, sizeof(dim), "%d page%s ", a->pages,
a->pages == 1 ? "" : "s");
} else if (strcmp(a->mime, "text/plain") == 0 || !a->mime[0]) {
kind = "text";
}
out[0] = 0; out[0] = 0;
if (a->bytes >= 1024 * 1024) if (a->bytes >= 1024 * 1024)
snprintf(out, out_sz, "%s%s %.1f MB", dim, kind, snprintf(out, out_sz, "%s%s %.1f MB", dim, kind,
(double)a->bytes / (1024 * 1024)); (double)a->bytes / (1024 * 1024));
else if (a->bytes >= 1024)
snprintf(out, out_sz, "%s%s %.1f KB", dim, kind, (double)a->bytes / 1024);
else else
snprintf(out, out_sz, "%s%s %.0f KB", dim, kind, snprintf(out, out_sz, "%s%s %lld B", dim, kind, a->bytes);
(double)a->bytes / 1024);
} }
/* --- lifetime ------------------------------------------------------------ */ /* --- lifetime ------------------------------------------------------------ */
@@ -580,10 +826,14 @@ char *cst_attachments_to_json(const cst_attachment_t *list, int n) {
cJSON_AddNumberToObject(o, "width", a->width); cJSON_AddNumberToObject(o, "width", a->width);
cJSON_AddNumberToObject(o, "height", a->height); cJSON_AddNumberToObject(o, "height", a->height);
} }
if (a->pages > 0)
cJSON_AddNumberToObject(o, "pages", a->pages);
if (a->detail[0] && strcmp(a->detail, "auto") != 0) if (a->detail[0] && strcmp(a->detail, "auto") != 0)
cJSON_AddStringToObject(o, "detail", a->detail); cJSON_AddStringToObject(o, "detail", a->detail);
if (a->origin[0]) if (a->origin[0])
cJSON_AddStringToObject(o, "origin", a->origin); cJSON_AddStringToObject(o, "origin", a->origin);
if (a->for_user)
cJSON_AddNumberToObject(o, "for_user", 1);
cJSON_AddItemToArray(arr, o); cJSON_AddItemToArray(arr, o);
} }
char *s = cJSON_PrintUnformatted(arr); char *s = cJSON_PrintUnformatted(arr);
@@ -628,12 +878,16 @@ cst_attachment_t *cst_attachments_from_json(const void *array_json, int *n_out)
a->width = cJSON_IsNumber(v) ? v->valueint : 0; a->width = cJSON_IsNumber(v) ? v->valueint : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "height"); v = cJSON_GetObjectItemCaseSensitive(it, "height");
a->height = cJSON_IsNumber(v) ? v->valueint : 0; a->height = cJSON_IsNumber(v) ? v->valueint : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "pages");
a->pages = cJSON_IsNumber(v) ? v->valueint : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "detail"); v = cJSON_GetObjectItemCaseSensitive(it, "detail");
snprintf(a->detail, sizeof(a->detail), "%s", snprintf(a->detail, sizeof(a->detail), "%s",
cJSON_IsString(v) ? v->valuestring : "auto"); cJSON_IsString(v) ? v->valuestring : "auto");
v = cJSON_GetObjectItemCaseSensitive(it, "origin"); v = cJSON_GetObjectItemCaseSensitive(it, "origin");
snprintf(a->origin, sizeof(a->origin), "%s", snprintf(a->origin, sizeof(a->origin), "%s",
cJSON_IsString(v) ? v->valuestring : ""); cJSON_IsString(v) ? v->valuestring : "");
v = cJSON_GetObjectItemCaseSensitive(it, "for_user");
a->for_user = cJSON_IsNumber(v) ? v->valueint : 0;
k++; k++;
} }
if (k == 0) { if (k == 0) {
+214
View File
@@ -18,6 +18,51 @@ static void set_err(char *err, size_t err_sz, const char *msg) {
snprintf(err, err_sz, "%s", msg); snprintf(err, err_sz, "%s", msg);
} }
#ifndef _WIN32
/* PATH lookup without shelling out: cheap enough for a capability probe. */
static int on_path(const char *name) {
const char *path = getenv("PATH");
if (!path)
return 0;
for (const char *p = path;;) {
const char *colon = strchr(p, ':');
size_t len = colon ? (size_t)(colon - p) : strlen(p);
char cand[4096];
if (len + strlen(name) + 2 < sizeof(cand)) {
if (len == 0)
snprintf(cand, sizeof(cand), "./%s", name);
else
snprintf(cand, sizeof(cand), "%.*s/%s", (int)len, p, name);
if (access(cand, X_OK) == 0)
return 1;
}
if (!colon)
break;
p = colon + 1;
}
return 0;
}
#endif
int cst_clipboard_available(char *why, size_t why_sz) {
if (why && why_sz)
why[0] = 0;
#ifdef _WIN32
return 0; /* the Win32 clipboard API is always there */
#elif defined(__APPLE__)
return 0; /* pbpaste / pbcopy ship with the OS */
#else
static const char *const helpers[] = {"wl-paste", "wl-copy", "xclip", NULL};
for (int i = 0; helpers[i]; i++)
if (on_path(helpers[i]))
return 0;
if (why && why_sz)
snprintf(why, why_sz,
"no clipboard helper on PATH (install wl-clipboard or xclip)");
return -1;
#endif
}
static void put_le32(unsigned char *p, unsigned long v) { static void put_le32(unsigned char *p, unsigned long v) {
p[0] = (unsigned char)(v & 0xff); p[0] = (unsigned char)(v & 0xff);
p[1] = (unsigned char)((v >> 8) & 0xff); p[1] = (unsigned char)((v >> 8) & 0xff);
@@ -390,6 +435,175 @@ int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz) {
#endif #endif
/* --- text clipboard ------------------------------------------------------ */
#ifdef _WIN32
int cst_clipboard_text(char **data, size_t *n, char *err, size_t err_sz) {
*data = NULL;
*n = 0;
if (!IsClipboardFormatAvailable(CF_UNICODETEXT) || !OpenClipboard(NULL)) {
set_err(err, err_sz, "no text on the clipboard");
return -1;
}
HANDLE h = GetClipboardData(CF_UNICODETEXT);
const wchar_t *w = h ? GlobalLock(h) : NULL;
if (!w) {
if (h)
GlobalUnlock(h);
CloseClipboard();
set_err(err, err_sz, "no text on the clipboard");
return -1;
}
char *utf8 = os_wide_to_utf8(w);
size_t len = utf8 ? strlen(utf8) : 0;
GlobalUnlock(h);
CloseClipboard();
if (!utf8 || !len) {
free(utf8);
set_err(err, err_sz, "no text on the clipboard");
return -1;
}
if (len > CST_CLIPBOARD_TEXT_MAX) {
free(utf8);
set_err(err, err_sz, "clipboard text is too large");
return -1;
}
*data = utf8;
*n = len;
return 0;
}
int cst_clipboard_set_text(const char *text, size_t n, char *err,
size_t err_sz) {
if (!text || os_clipboard_set(text, n) != 0) {
set_err(err, err_sz, "could not put text on the clipboard");
return -1;
}
return 0;
}
#else
/* stdout of a clipboard reader, capped and NUL-terminated. */
static int clip_read(const char *cmd, const char *tool, char **data, size_t *n,
char *err, size_t err_sz) {
os_run_result_t r;
memset(&r, 0, sizeof(r));
if (os_run_shell_c(NULL, cmd, CST_CLIPBOARD_TEXT_MAX + 1, &r) != 0) {
os_run_result_free(&r);
set_err(err, err_sz, "could not read the clipboard");
return -1;
}
if (r.exit_code != 0 || r.out_len == 0) {
int missing = r.exit_code == 127;
os_run_result_free(&r);
if (missing) {
char msg[96];
snprintf(msg, sizeof(msg), "reading the clipboard needs %s", tool);
set_err(err, err_sz, msg);
} else {
set_err(err, err_sz, "no text on the clipboard");
}
return -1;
}
if (r.out_len > CST_CLIPBOARD_TEXT_MAX) {
os_run_result_free(&r);
set_err(err, err_sz, "clipboard text is too large");
return -1;
}
char *copy = malloc(r.out_len + 1);
if (!copy) {
os_run_result_free(&r);
set_err(err, err_sz, "out of memory");
return -1;
}
memcpy(copy, r.out, r.out_len);
copy[r.out_len] = 0;
os_run_result_free(&r);
*data = copy;
*n = r.out_len;
return 0;
}
int cst_clipboard_text(char **data, size_t *n, char *err, size_t err_sz) {
*data = NULL;
*n = 0;
#ifdef __APPLE__
return clip_read("pbpaste 2>/dev/null", "pbpaste", data, n, err, err_sz);
#else
const char *wl = getenv("WAYLAND_DISPLAY");
if (wl && *wl)
return clip_read("wl-paste --type text/plain --no-newline 2>/dev/null",
"wl-clipboard (wl-paste)", data, n, err, err_sz);
const char *x = getenv("DISPLAY");
if (x && *x)
return clip_read("xclip -selection clipboard -o 2>/dev/null", "xclip",
data, n, err, err_sz);
set_err(err, err_sz, "no clipboard here (no Wayland or X11 display)");
return -1;
#endif
}
int cst_clipboard_set_text(const char *text, size_t n, char *err,
size_t err_sz) {
if (!text)
return -1;
if (n > CST_CLIPBOARD_TEXT_MAX) {
set_err(err, err_sz, "text is too large for the clipboard");
return -1;
}
const char *fmt = NULL, *tool = NULL;
#ifdef __APPLE__
fmt = "pbcopy < '%s'";
tool = "pbcopy";
#else
if (getenv("WAYLAND_DISPLAY")) {
fmt = "wl-copy < '%s'";
tool = "wl-clipboard (wl-copy)";
} else if (getenv("DISPLAY")) {
fmt = "xclip -selection clipboard -i < '%s'";
tool = "xclip";
}
#endif
if (!fmt) {
set_err(err, err_sz, "no clipboard here (no Wayland or X11 display)");
return -1;
}
char tmp[OS_PATH_MAX];
int fd = os_mkstemp(tmp, sizeof(tmp), "cst-clip");
if (fd < 0) {
set_err(err, err_sz, "could not create a temporary file");
return -1;
}
FILE *fp = fdopen(fd, "wb");
int ok = fp && fwrite(text, 1, n, fp) == n && fclose(fp) == 0;
if (!ok) {
if (fp)
fclose(fp);
remove(tmp);
set_err(err, err_sz, "could not write the text");
return -1;
}
char cmd[OS_PATH_MAX + 64];
snprintf(cmd, sizeof(cmd), fmt, tmp);
os_run_result_t r;
memset(&r, 0, sizeof(r));
int rc = os_run_shell_c(NULL, cmd, 4096, &r);
int code = r.exit_code;
os_run_result_free(&r);
remove(tmp);
if (rc != 0 || code != 0) {
char msg[96];
snprintf(msg, sizeof(msg), "writing the clipboard needs %s", tool);
set_err(err, err_sz, msg);
return -1;
}
return 0;
}
#endif
static int hexval(int c) { static int hexval(int c) {
if (c >= '0' && c <= '9') if (c >= '0' && c <= '9')
return c - '0'; return c - '0';
+176 -52
View File
@@ -22,43 +22,75 @@
#define ERRBODY_MAX 4096 #define ERRBODY_MAX 4096
#define LLM_MAX_RETRIES 3 #define LLM_MAX_RETRIES 3
/* Point libcurl at a CA bundle on Windows. static int ca_file_ok(const char *path) {
if (!path || !path[0])
return 0;
FILE *f = fopen(path, "rb");
if (!f)
return 0;
fclose(f);
return 1;
}
/* Point libcurl at a CA bundle.
* *
* An OpenSSL-based libcurl keeps the path it was built with -- MSYS2's is * libcurl keeps the bundle path it was built with. On Windows an OpenSSL
* "/mingw64/etc/ssl/certs/ca-bundle.crt", which resolves to nothing once the * build carries MSYS2's "/mingw64/etc/ssl/certs/ca-bundle.crt"; the static
* binary runs outside an MSYS2 shell, and that build ignores CURL_CA_BUNDLE. * Linux release is built on one distro and runs on all of them, whose bundles
* Every HTTPS request then dies with CURLE_SSL_CACERT_BADFILE (77). * live in different places. A path that does not exist fails every HTTPS
* request with CURLE_SSL_CACERT_BADFILE (77).
* *
* Order: $CST_CA_BUNDLE, then ca-bundle.crt beside the executable. If neither * Order: $CST_CA_BUNDLE; on Windows ca-bundle.crt beside the executable (if
* exists nothing is set, so a libcurl linked against Schannel (the Windows * absent nothing is set, so Schannel uses the certificate store); elsewhere
* certificate store) behaves exactly as before. */ * $SSL_CERT_FILE, libcurl's own default if that file exists, then the usual
static void llm_apply_ca_bundle(CURL *curl) { * distro locations. Decided once per process. */
#if defined(_WIN32) void cst_curl_apply_ca(void *curl) {
static char ca[OS_PATH_MAX]; static char ca[OS_PATH_MAX];
static int checked; static atomic_int state; /* 0 unchecked, 1 checking, 2 done */
if (!checked) { int expect = 0;
checked = 1; if (atomic_compare_exchange_strong(&state, &expect, 1)) {
const char *env = getenv("CST_CA_BUNDLE"); const char *env = getenv("CST_CA_BUNDLE");
if (env && env[0]) { if (ca_file_ok(env)) {
snprintf(ca, sizeof(ca), "%s", env); snprintf(ca, sizeof(ca), "%s", env);
} else { } else {
char dir[OS_PATH_MAX]; #if defined(_WIN32)
if (os_exe_dir(dir, sizeof(dir)) == 0) char dir[OS_PATH_MAX], p[OS_PATH_MAX];
os_path_join(ca, sizeof(ca), dir, "ca-bundle.crt"); if (os_exe_dir(dir, sizeof(dir)) == 0 &&
} os_path_join(p, sizeof(p), dir, "ca-bundle.crt") == 0 &&
if (ca[0]) { ca_file_ok(p))
FILE *f = fopen(ca, "rb"); snprintf(ca, sizeof(ca), "%s", p);
if (f) #else
fclose(f); static const char *const known[] = {
"/etc/ssl/certs/ca-certificates.crt", /* Debian, Ubuntu, Arch, Alpine */
"/etc/pki/tls/certs/ca-bundle.crt", /* Fedora, RHEL */
"/etc/ssl/cert.pem", /* macOS, BSD, Alpine */
"/etc/ssl/ca-bundle.pem", /* openSUSE */
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
};
const char *pick = NULL;
const char *sslenv = getenv("SSL_CERT_FILE");
const curl_version_info_data *vi = curl_version_info(CURLVERSION_NOW);
if (ca_file_ok(sslenv))
pick = sslenv;
else if (vi && ca_file_ok(vi->cainfo))
pick = NULL; /* libcurl's built-in default already works */
else else
ca[0] = '\0'; for (size_t i = 0; i < sizeof(known) / sizeof(known[0]); i++)
if (ca_file_ok(known[i])) {
pick = known[i];
break;
}
if (pick)
snprintf(ca, sizeof(ca), "%s", pick);
#endif
} }
atomic_store(&state, 2);
} else {
while (atomic_load(&state) != 2)
;
} }
if (ca[0]) if (ca[0])
curl_easy_setopt(curl, CURLOPT_CAINFO, ca); curl_easy_setopt((CURL *)curl, CURLOPT_CAINFO, ca);
#else
(void)curl;
#endif
} }
#include "cst_attach.h" #include "cst_attach.h"
@@ -223,37 +255,99 @@ static cJSON *image_part(const cst_llm_request_t *req,
return p; return p;
} }
/* Append every attachment of m as image parts (or a note saying why not). */ /* One stored document as a text part: its text, framed so the model knows
static void parts_add_images(const cst_llm_request_t *req, cJSON *parts, * where it came from. Documents are plain text on the wire, so a text-only
const cst_message_t *m, size_t *budget) { * 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. Returns how many parts were added: a
* delivered file (for_user) adds none. */
static int 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 added = 0;
int n = m->nattach; int n = m->nattach;
if (n > CST_ATTACH_MAX_COUNT) { if (n > CST_ATTACH_MAX_COUNT) {
char note[160]; char note[160];
snprintf(note, sizeof(note), "[%d of %d images sent]", CST_ATTACH_MAX_COUNT, snprintf(note, sizeof(note),
n); "[only the first %d of %d attachments are sent]",
CST_ATTACH_MAX_COUNT, n);
parts_add_text(parts, cJSON_CreateString(note)); parts_add_text(parts, cJSON_CreateString(note));
added++;
n = CST_ATTACH_MAX_COUNT; n = CST_ATTACH_MAX_COUNT;
} }
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
const cst_attachment_t *a = &m->attach[i]; const cst_attachment_t *a = &m->attach[i];
/* A delivered file belongs to the person, not to the model: no part, and
* not even a "not sent" note -- it was never meant to be in the request. */
if (a->for_user)
continue;
int is_img = cst_attach_is_image(a);
char why[256]; 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) { if (p) {
cJSON_AddItemToArray(parts, p); cJSON_AddItemToArray(parts, p);
added++;
continue; continue;
} }
char note[512]; char note[512];
snprintf(note, sizeof(note), "[image \"%.120s\" not sent: %.200s]", snprintf(note, sizeof(note), "[%s \"%.120s\" not sent: %.200s]",
a->name, why); is_img ? "image" : "document", a->name, why);
parts_add_text(parts, cJSON_CreateString(note)); parts_add_text(parts, cJSON_CreateString(note));
added++;
} }
return added;
} }
/* Images a tool result produced are deferred: an assistant tool_call must be /* 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 * answered by tool messages only, so they ride in one extra user message
* after the run of tool messages ends. *pending collects it. */ * 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, static void defer_attachments(const cst_llm_request_t *req,
cJSON **pending, size_t *budget) { const cst_message_t *m, cJSON **pending,
size_t *budget, size_t *doc_budget,
const char *img_off) {
cJSON *p = *pending; cJSON *p = *pending;
if (!p) { if (!p) {
p = cJSON_CreateObject(); p = cJSON_CreateObject();
@@ -261,15 +355,24 @@ static void defer_images(const cst_llm_request_t *req, const cst_message_t *m,
cJSON_AddArrayToObject(p, "content"); cJSON_AddArrayToObject(p, "content");
*pending = p; *pending = p;
} }
parts_add_images(req, cJSON_GetObjectItemCaseSensitive(p, "content"), m, cJSON *content = cJSON_GetObjectItemCaseSensitive(p, "content");
budget); parts_add_attachments(req, content, m, budget, doc_budget, img_off);
if (cJSON_GetArraySize(content) == 0) {
/* Nothing to carry (a tool result that only delivered a file): an empty
* user message is noise, and some providers refuse it. */
cJSON_Delete(p);
*pending = NULL;
}
} }
/* 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) { static char *image_notes(const cst_message_t *m, const char *why) {
cst_buf_t b = {0}; cst_buf_t b = {0};
cst_buf_puts(&b, m->content ? m->content : ""); cst_buf_puts(&b, m->content ? m->content : "");
for (int i = 0; i < m->nattach; i++) { for (int i = 0; i < m->nattach; i++) {
if (!cst_attach_is_image(&m->attach[i]))
continue;
char d[128]; char d[128];
cst_attach_describe(&m->attach[i], d, sizeof(d)); cst_attach_describe(&m->attach[i], d, sizeof(d));
cst_buf_printf(&b, "%s[image \"%.120s\" (%s) not sent: %s]", cst_buf_printf(&b, "%s[image \"%.120s\" (%s) not sent: %s]",
@@ -357,11 +460,21 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cJSON *so = cJSON_AddObjectToObject(root, "stream_options"); cJSON *so = cJSON_AddObjectToObject(root, "stream_options");
cJSON_AddTrueToObject(so, "include_usage"); cJSON_AddTrueToObject(so, "include_usage");
} }
/* The generation parameters the user set (Settings -> Advanced). A field
* that was not set is left out: an absent field is the provider's own
* default, which "temperature": 0 would not be. */
if (req->temperature > 0)
cJSON_AddNumberToObject(root, "temperature", req->temperature);
if (req->top_p > 0)
cJSON_AddNumberToObject(root, "top_p", req->top_p);
if (req->max_tokens > 0)
cJSON_AddNumberToObject(root, "max_tokens", req->max_tokens);
cJSON *msgs = cJSON_AddArrayToObject(root, "messages"); cJSON *msgs = cJSON_AddArrayToObject(root, "messages");
size_t tcap = wire_tool_cap(req); size_t tcap = wire_tool_cap(req);
cJSON *prev_user = NULL; /* merge consecutive user messages */ cJSON *prev_user = NULL; /* merge consecutive user messages */
cJSON *pending = NULL; /* images from tool results, waiting for a break */ cJSON *pending = NULL; /* images from tool results, waiting for a break */
size_t img_budget = CST_ATTACH_MAX_TOTAL; 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 *noimg = getenv("CST_NO_IMAGES");
const char *img_off = const char *img_off =
noimg && noimg[0] && noimg[0] != '0' noimg && noimg[0] && noimg[0] != '0'
@@ -375,15 +488,26 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cst_role_t role = m->role; cst_role_t role = m->role;
if (role == CST_ROLE_TOOL && !m->tool_call_id[0]) if (role == CST_ROLE_TOOL && !m->tool_call_id[0])
role = CST_ROLE_USER; 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; char *noted = NULL;
if (has_img && img_off) { 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; 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 /* 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. */ * images only leave the queue when the run of tool results is over. */
@@ -399,7 +523,7 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cJSON *pc = prev_user cJSON *pc = prev_user
? cJSON_GetObjectItemCaseSensitive(prev_user, "content") ? cJSON_GetObjectItemCaseSensitive(prev_user, "content")
: NULL; : NULL;
if (!has_img && !cJSON_IsArray(pc)) { if (!att_parts && !cJSON_IsArray(pc)) {
cJSON *c = clean_string(m->content, 0); cJSON *c = clean_string(m->content, 0);
if (prev_user) { if (prev_user) {
cst_buf_t b = {0}; cst_buf_t b = {0};
@@ -431,8 +555,8 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
} }
cJSON *parts = content_parts(o); cJSON *parts = content_parts(o);
parts_add_text(parts, clean_string(m->content, 0)); parts_add_text(parts, clean_string(m->content, 0));
if (has_img) if (att_parts)
parts_add_images(req, parts, m, &img_budget); parts_add_attachments(req, parts, m, &img_budget, &doc_budget, img_off);
free(noted); free(noted);
continue; continue;
} }
@@ -466,8 +590,8 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cJSON_AddTrueToObject(o, "prefix"); cJSON_AddTrueToObject(o, "prefix");
} }
cJSON_AddItemToArray(msgs, o); cJSON_AddItemToArray(msgs, o);
if (role == CST_ROLE_TOOL && has_img) if (role == CST_ROLE_TOOL && n_att)
defer_images(req, m, &pending, &img_budget); defer_attachments(req, m, &pending, &img_budget, &doc_budget, img_off);
free(noted); free(noted);
} }
if (pending) if (pending)
@@ -988,7 +1112,7 @@ int cst_llm_chat_stream(const cst_llm_request_t *req, char *err,
ret = -1; ret = -1;
break; break;
} }
llm_apply_ca_bundle(curl); cst_curl_apply_ca(curl);
sse_reset(&s); sse_reset(&s);
s.curl = curl; s.curl = curl;
if (rec_dir && rec_dir[0]) { if (rec_dir && rec_dir[0]) {
@@ -1233,7 +1357,7 @@ int cst_llm_list_models_meta(const char *api_base, const char *api_key,
snprintf(err, err_sz, "curl init failed"); snprintf(err, err_sz, "curl init failed");
return -1; return -1;
} }
llm_apply_ca_bundle(curl); cst_curl_apply_ca(curl);
char url[1024]; char url[1024];
size_t bl = strlen(api_base); size_t bl = strlen(api_base);
while (bl && api_base[bl - 1] == '/') while (bl && api_base[bl - 1] == '/')
+57 -9
View File
@@ -26,11 +26,15 @@ static void usage(const char *argv0) {
" (last used)\n" " (last used)\n"
" -m, --model NAME model (last used for the provider)\n" " -m, --model NAME model (last used for the provider)\n"
" -e, --effort LEVEL low | medium | high | max (last used)\n" " -e, --effort LEVEL low | medium | high | max (last used)\n"
" -M, --mode NAME coding (default, code framed) | general\n"
" (documents, data, research; last used)\n"
" -w, --workspace DIR project directory (default: current)\n" " -w, --workspace DIR project directory (default: current)\n"
" -c, --continue reopen the last session (default)\n" " -c, --continue reopen the last session (default)\n"
" -N, --new start a new session\n" " -N, --new start a new session\n"
" -R, --resume ID open a session by id or unique prefix\n" " -R, --resume ID open a session by id or unique prefix\n"
" -L, --list-sessions list sessions and exit\n" " -L, --list-sessions list sessions and exit\n"
" --migrate-store move sessions written by an older version\n"
" into one directory per session, and exit\n"
" --list-models [P] what is known about each provider's models\n" " --list-models [P] what is known about each provider's models\n"
" (built-in, <config>/models.json, GET /models)\n" " (built-in, <config>/models.json, GET /models)\n"
" -A, --approval MODE ask | auto (default: edits run, shell in\n" " -A, --approval MODE ask | auto (default: edits run, shell in\n"
@@ -59,13 +63,13 @@ static void usage(const char *argv0) {
"API keys: the TUI asks for a missing key and saves it per provider\n" "API keys: the TUI asks for a missing key and saves it per provider\n"
" in the config dir (credentials, mode 0600); /key changes it.\n" " in the config dir (credentials, mode 0600); /key changes it.\n"
" Fallbacks: <provider env> (DEEPSEEK_API_KEY, ...), CST_API_KEY.\n" " Fallbacks: <provider env> (DEEPSEEK_API_KEY, ...), CST_API_KEY.\n"
"Settings: provider, models and effort are remembered in\n" "Settings: provider, models, effort and mode are remembered in\n"
" settings.json next to the credentials.\n" " settings.json next to the credentials.\n"
"Env: CST_PROVIDER CST_MODEL CST_EFFORT CST_PROTOCOL CST_TOOLS\n" "Env: CST_PROVIDER CST_MODEL CST_EFFORT CST_MODE CST_PROTOCOL\n"
" CST_CONFIRM CST_MAX_STEPS CST_TOOL_OUT_CAP CST_CTX_WINDOW\n" " CST_TOOLS CST_CONFIRM CST_MAX_STEPS CST_TOOL_OUT_CAP\n"
" CST_CTX_TOKENS CST_AUTO_COMPACT=0 CST_SHELL_TIMEOUT\n" " CST_CTX_WINDOW CST_CTX_TOKENS CST_AUTO_COMPACT=0\n"
" CST_METRICS=0 CST_RECORD_SSE CST_REPLAY_SSE CST_MODELS\n" " CST_SHELL_TIMEOUT CST_METRICS=0 CST_RECORD_SSE CST_REPLAY_SSE\n"
" CST_APPROVAL=ask|auto|yolo CST_AUTO_APPROVE=1 (=yolo)\n" " CST_MODELS CST_APPROVAL=ask|auto|yolo CST_AUTO_APPROVE=1 (=yolo)\n"
" CST_SANDBOX=off CST_SHELL CST_NO_PROJECT_NOTES=1 NO_COLOR\n" " CST_SANDBOX=off CST_SHELL CST_NO_PROJECT_NOTES=1 NO_COLOR\n"
"TUI: type /help inside for commands and keys\n", "TUI: type /help inside for commands and keys\n",
argv0); argv0);
@@ -173,6 +177,25 @@ static int list_sessions(const char *ws) {
return 0; return 0;
} }
/* Move a store written by an older version into one directory per session
* (--migrate-store). Idempotent: what is already a directory is left alone. */
static int migrate_store(const char *ws) {
int moved = 0, failed = 0;
char err[256] = "";
if (cst_agent_store_migrate(ws, &moved, &failed, err, sizeof(err)) != 0) {
fprintf(stderr, "cannot migrate %s/.agent/sessions: %s\n", ws,
err[0] ? err : "unknown error");
return 1;
}
printf("migrated %d session(s) into %s/.agent/sessions/<id>/\n", moved, ws);
if (failed)
printf("%d session(s) could not be moved; their files are untouched\n",
failed);
if (moved == 0)
printf("nothing to move (already one directory per session)\n");
return failed ? 1 : 0;
}
/* Exact id or unique prefix → id. */ /* Exact id or unique prefix → id. */
static int resolve_session_id(const char *ws, const char *want, char *out, static int resolve_session_id(const char *ws, const char *want, char *out,
size_t out_sz) { size_t out_sz) {
@@ -336,7 +359,7 @@ int main(int argc, char **argv) {
int force_ask_key = 0; int force_ask_key = 0;
int session_explicit = 0; int session_explicit = 0;
const char *prompt = NULL; const char *prompt = NULL;
int new_session = 0, do_list = 0; int new_session = 0, do_list = 0, do_migrate = 0;
const char *resume_id = NULL; const char *resume_id = NULL;
/* WebUI (--web): a second front end on the same engine. */ /* WebUI (--web): a second front end on the same engine. */
int web_mode = 0; int web_mode = 0;
@@ -370,10 +393,12 @@ int main(int argc, char **argv) {
{"continue", no_argument, 0, 'c'}, {"continue", no_argument, 0, 'c'},
{"resume", required_argument, 0, 'R'}, {"resume", required_argument, 0, 'R'},
{"list-sessions", no_argument, 0, 'L'}, {"list-sessions", no_argument, 0, 'L'},
{"migrate-store", no_argument, 0, 0x107},
{"yolo", no_argument, 0, 'y'}, {"yolo", no_argument, 0, 'y'},
{"approval", required_argument, 0, 'A'}, {"approval", required_argument, 0, 'A'},
{"confirm", required_argument, 0, 'C'}, {"confirm", required_argument, 0, 'C'},
{"effort", required_argument, 0, 'e'}, {"effort", required_argument, 0, 'e'},
{"mode", required_argument, 0, 'M'},
{"help", no_argument, 0, 'h'}, {"help", no_argument, 0, 'h'},
{"version", no_argument, 0, 'V'}, {"version", no_argument, 0, 'V'},
{"list-models", optional_argument, 0, 0x100}, {"list-models", optional_argument, 0, 0x100},
@@ -385,8 +410,8 @@ int main(int argc, char **argv) {
{"web-open", no_argument, 0, 0x106}, {"web-open", no_argument, 0, 0x106},
{0, 0, 0, 0}}; {0, 0, 0, 0}};
int c; int c;
while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:", opts, NULL)) != while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:M:",
-1) { opts, NULL)) != -1) {
switch (c) { switch (c) {
case 'w': case 'w':
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", optarg); snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", optarg);
@@ -478,6 +503,14 @@ int main(int argc, char **argv) {
} }
snprintf(cfg.effort, sizeof(cfg.effort), "%s", optarg); snprintf(cfg.effort, sizeof(cfg.effort), "%s", optarg);
break; break;
case 'M':
if (!cst_mode_valid(optarg)) {
fprintf(stderr, "bad --mode %s (coding | general)\n", optarg);
curl_global_cleanup();
return 1;
}
snprintf(cfg.mode, sizeof(cfg.mode), "%s", optarg);
break;
case 'h': case 'h':
usage(argv[0]); usage(argv[0]);
curl_global_cleanup(); curl_global_cleanup();
@@ -517,6 +550,9 @@ int main(int argc, char **argv) {
web.open_browser = 1; web.open_browser = 1;
web_mode = 1; web_mode = 1;
break; break;
case 0x107:
do_migrate = 1;
break;
case 'V': case 'V':
printf("cst-agent %s\n", CST_VERSION); printf("cst-agent %s\n", CST_VERSION);
curl_global_cleanup(); curl_global_cleanup();
@@ -528,6 +564,12 @@ int main(int argc, char **argv) {
} }
} }
if (do_migrate) {
int rc = migrate_store(cfg.workspace_root);
curl_global_cleanup();
return rc;
}
if (do_list) { if (do_list) {
int rc = list_sessions(cfg.workspace_root); int rc = list_sessions(cfg.workspace_root);
curl_global_cleanup(); curl_global_cleanup();
@@ -557,6 +599,12 @@ int main(int argc, char **argv) {
snprintf(cfg.effort, sizeof(cfg.effort), "%s", snprintf(cfg.effort, sizeof(cfg.effort), "%s",
cst_effort_valid(e) ? e : CST_DEFAULT_EFFORT); cst_effort_valid(e) ? e : CST_DEFAULT_EFFORT);
} }
if (!cfg.mode[0]) {
const char *m = getenv("CST_MODE");
if (!cst_mode_valid(m))
m = saved.mode;
snprintf(cfg.mode, sizeof(cfg.mode), "%s", cst_mode_name(m));
}
if (!cfg.api_base[0]) { if (!cfg.api_base[0]) {
const char *env_base = getenv("CST_API_BASE"); const char *env_base = getenv("CST_API_BASE");
if (!env_base || !env_base[0]) if (!env_base || !env_base[0])
+5 -11
View File
@@ -1,6 +1,7 @@
#include "cst_metrics.h" #include "cst_metrics.h"
#include "cJSON.h" #include "cJSON.h"
#include "cst_agent.h"
#include "os_compat.h" #include "os_compat.h"
#include <stdio.h> #include <stdio.h>
@@ -31,18 +32,11 @@ cst_metrics_t *cst_metrics_open(const char *workspace, const char *session_path)
os_mkdir(agent, 0755); os_mkdir(agent, 0755);
os_path_join(dir, sizeof(dir), agent, "metrics"); os_path_join(dir, sizeof(dir), agent, "metrics");
os_mkdir(dir, 0755); os_mkdir(dir, 0755);
const char *base = session_path ? session_path : "session.jsonl"; /* One file per session: the id of the transcript's path (<id>.jsonl, or
const char *s1 = strrchr(base, '/'); * <id>/session.jsonl in the store's own layout). */
const char *s2 = strrchr(base, '\\');
if (s2 && (!s1 || s2 > s1))
s1 = s2;
if (s1)
base = s1 + 1;
char leaf[256]; char leaf[256];
snprintf(leaf, sizeof(leaf), "%s", base); if (cst_agent_store_id_of_path(session_path, leaf, sizeof(leaf)) != 0)
size_t n = strlen(leaf); snprintf(leaf, sizeof(leaf), "session");
if (n > 6 && strcmp(leaf + n - 6, ".jsonl") == 0)
leaf[n - 6] = 0;
char file[300]; char file[300];
snprintf(file, sizeof(file), "%s.jsonl", leaf); snprintf(file, sizeof(file), "%s.jsonl", leaf);
os_path_join(m->path, sizeof(m->path), dir, file); os_path_join(m->path, sizeof(m->path), dir, file);
+158
View File
@@ -1,9 +1,11 @@
#include "cst_models.h" #include "cst_models.h"
#include "cst_util.h"
#include "cJSON.h" #include "cJSON.h"
#include "os_compat.h" #include "os_compat.h"
#include <ctype.h> #include <ctype.h>
#include <errno.h>
#include <pthread.h> #include <pthread.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -326,6 +328,162 @@ int cst_models_user_path(char *out, size_t out_sz) {
return w > 0 && (size_t)w < out_sz ? 0 : -1; return w > 0 && (size_t)w < out_sz ? 0 : -1;
} }
/* --- the user file: providers the presets do not know -------------------- */
static int valid_provider_name(const char *name) {
if (!name || !name[0] || strlen(name) >= 32)
return 0;
for (const char *q = name; *q; q++)
if (!isalnum((unsigned char)*q) && *q != '.' && *q != '_' && *q != '-')
return 0;
return 1;
}
/* The user file as a JSON object: an empty one when there is no file, NULL
* when there is one that is not an object (never overwrite somebody else's
* file, their edit is not worth losing). */
static cJSON *read_user_doc(char *path, size_t path_sz) {
if (cst_models_user_path(path, path_sz) != 0)
return NULL;
char *text = read_file(path);
if (!text)
return cJSON_CreateObject();
cJSON *doc = cJSON_Parse(text + cst_bom_len(text, strlen(text)));
free(text);
if (!cJSON_IsObject(doc)) {
cJSON_Delete(doc);
return NULL;
}
return doc;
}
static int write_user_doc(cJSON *doc, const char *path, char *err,
size_t err_sz) {
char *text = cJSON_Print(doc);
if (!text) {
snprintf(err, err_sz, "out of memory");
return -1;
}
char tmp[1200];
if (snprintf(tmp, sizeof(tmp), "%s.tmp", path) >= (int)sizeof(tmp)) {
cJSON_free(text);
snprintf(err, err_sz, "path too long");
return -1;
}
int rc = -1;
FILE *f = fopen(tmp, "wb");
if (!f) {
snprintf(err, err_sz, "%s: cannot write (%s)", tmp, strerror(errno));
} else {
const int ok = fputs(text, f) >= 0 && fputc('\n', f) != EOF;
const int closed = fclose(f);
if (ok && closed == 0 &&
#ifdef _WIN32
(remove(path), 1) &&
#endif
rename(tmp, path) == 0)
rc = 0;
else
snprintf(err, err_sz, "%s: cannot replace (%s)", path, strerror(errno));
if (rc != 0)
remove(tmp);
}
cJSON_free(text);
return rc;
}
int cst_models_user_set_provider(const char *name, const char *api_base,
const char *key_env, const char *default_model,
const char *const *models, int nmodels,
char *err, size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
if (!valid_provider_name(name)) {
snprintf(err, err_sz,
"a provider name is 1-31 of letters, digits, '.', '_' or '-'");
return -1;
}
if (!api_base || !api_base[0]) {
snprintf(err, err_sz, "a base URL is needed");
return -1;
}
char path[1100];
pthread_mutex_lock(&g_mu);
cJSON *doc = read_user_doc(path, sizeof(path));
if (!doc) {
snprintf(err, err_sz, "%s: not a JSON object, left alone", path);
pthread_mutex_unlock(&g_mu);
return -1;
}
cJSON *providers = cJSON_GetObjectItemCaseSensitive(doc, "providers");
if (!cJSON_IsObject(providers))
providers = cJSON_AddObjectToObject(doc, "providers");
cJSON *entry = cJSON_CreateObject();
cJSON_AddStringToObject(entry, "api_base", api_base);
if (key_env && key_env[0])
cJSON_AddStringToObject(entry, "key_env", key_env);
if (default_model && default_model[0])
cJSON_AddStringToObject(entry, "default_model", default_model);
if (models && nmodels > 0) {
cJSON *arr = cJSON_AddArrayToObject(entry, "models");
for (int i = 0; i < nmodels; i++)
if (models[i] && models[i][0])
cJSON_AddItemToArray(arr, cJSON_CreateString(models[i]));
}
cJSON_DeleteItemFromObjectCaseSensitive(providers, name);
cJSON_AddItemToObject(providers, name, entry);
const int rc = write_user_doc(doc, path, err, err_sz);
cJSON_Delete(doc);
pthread_mutex_unlock(&g_mu);
if (rc == 0)
cst_models_reload(); /* the provider is there from now on */
return rc;
}
int cst_models_user_drop_provider(const char *name, char *err, size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
if (!valid_provider_name(name)) {
snprintf(err, err_sz, "not a provider name");
return -1;
}
char path[1100];
if (cst_models_user_path(path, sizeof(path)) != 0) {
snprintf(err, err_sz, "no config directory");
return -1;
}
FILE *probe = fopen(path, "rb");
if (!probe)
return 0; /* no user file: nothing of ours to drop */
fclose(probe);
pthread_mutex_lock(&g_mu);
cJSON *doc = read_user_doc(path, sizeof(path));
if (!doc) {
snprintf(err, err_sz, "%s: not a JSON object, left alone", path);
pthread_mutex_unlock(&g_mu);
return -1;
}
cJSON *providers = cJSON_GetObjectItemCaseSensitive(doc, "providers");
const int found = cJSON_IsObject(providers) &&
cJSON_GetObjectItemCaseSensitive(providers, name) != NULL;
if (found)
cJSON_DeleteItemFromObjectCaseSensitive(providers, name);
const int rc = found ? write_user_doc(doc, path, err, err_sz) : 0;
cJSON_Delete(doc);
pthread_mutex_unlock(&g_mu);
if (rc == 0 && found)
cst_models_reload();
return rc == 0 ? found : -1;
}
static void load_default_locked(void) { static void load_default_locked(void) {
reset_locked(); reset_locked();
char e[128]; char e[128];
+4 -5
View File
@@ -37,11 +37,6 @@
#define NET_SEND_TIMEOUT_MS 20000 #define NET_SEND_TIMEOUT_MS 20000
static void set_err(char *err, size_t err_sz, const char *what) {
if (err && err_sz)
snprintf(err, err_sz, "%s", what);
}
#ifdef _WIN32 #ifdef _WIN32
static void set_err_win(char *err, size_t err_sz, const char *what) { static void set_err_win(char *err, size_t err_sz, const char *what) {
if (err && err_sz) if (err && err_sz)
@@ -59,6 +54,10 @@ static int net_last_conn_reset(void) {
return e == WSAECONNRESET || e == WSAECONNABORTED || e == WSAENOTSOCK; return e == WSAECONNRESET || e == WSAECONNABORTED || e == WSAENOTSOCK;
} }
#else #else
static void set_err(char *err, size_t err_sz, const char *what) {
if (err && err_sz)
snprintf(err, err_sz, "%s", what);
}
#define SET_ERR(e, n, w) set_err(e, n, w) #define SET_ERR(e, n, w) set_err(e, n, w)
#define NET_FD(s) ((int)(s)) #define NET_FD(s) ((int)(s))
static void net_close_raw(os_socket_t s) { close(s); } static void net_close_raw(os_socket_t s) { close(s); }
+71 -12
View File
@@ -13,6 +13,7 @@
#include "os_internal.h" #include "os_internal.h"
#include <errno.h> #include <errno.h>
#include <stdint.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
@@ -503,19 +504,74 @@ int os_resolve_shell(char *out, size_t out_sz) {
#endif #endif
} }
/* Per thread: parallel tool runs and sub-agents each watch their own flag. */ /* Per thread: parallel tool runs and sub-agents each watch their own flag.
static _Thread_local volatile int *g_run_cancel; * Thread keys rather than _Thread_local, which tcc does not implement; each
static _Thread_local int g_run_timeout_s; * value fits in the key's pointer slot, so nothing is allocated. */
#ifdef _WIN32
static INIT_ONCE g_run_once = INIT_ONCE_STATIC_INIT;
static DWORD g_key_cancel = TLS_OUT_OF_INDEXES, g_key_timeout = TLS_OUT_OF_INDEXES;
static BOOL CALLBACK run_keys_init(PINIT_ONCE once, PVOID param, PVOID *ctx) {
(void)once;
(void)param;
(void)ctx;
g_key_cancel = TlsAlloc();
g_key_timeout = TlsAlloc();
return TRUE;
}
static void *run_key_get(DWORD *key) {
InitOnceExecuteOnce(&g_run_once, run_keys_init, NULL, NULL);
if (*key == TLS_OUT_OF_INDEXES)
return NULL;
DWORD err = GetLastError(); /* TlsGetValue clears it */
void *v = TlsGetValue(*key);
SetLastError(err);
return v;
}
static void run_key_set(DWORD *key, void *v) {
InitOnceExecuteOnce(&g_run_once, run_keys_init, NULL, NULL);
if (*key != TLS_OUT_OF_INDEXES)
TlsSetValue(*key, v);
}
#else
static pthread_once_t g_run_once = PTHREAD_ONCE_INIT;
static pthread_key_t g_key_cancel, g_key_timeout;
static int g_run_keys_ok;
static void run_keys_init(void) {
g_run_keys_ok = pthread_key_create(&g_key_cancel, NULL) == 0 &&
pthread_key_create(&g_key_timeout, NULL) == 0;
}
static void *run_key_get(pthread_key_t *key) {
pthread_once(&g_run_once, run_keys_init);
return g_run_keys_ok ? pthread_getspecific(*key) : NULL;
}
static void run_key_set(pthread_key_t *key, void *v) {
pthread_once(&g_run_once, run_keys_init);
if (g_run_keys_ok)
pthread_setspecific(*key, v);
}
#endif
void os_set_cancel_flag(volatile int *flag) { g_run_cancel = flag; } static volatile int *run_cancel(void) {
return (volatile int *)run_key_get(&g_key_cancel);
}
static int run_timeout(void) {
return (int)(intptr_t)run_key_get(&g_key_timeout);
}
void os_set_run_timeout(int seconds) { g_run_timeout_s = seconds; } void os_set_cancel_flag(volatile int *flag) {
run_key_set(&g_key_cancel, (void *)(uintptr_t)flag);
}
int os_get_run_timeout(void) { return g_run_timeout_s; } void os_set_run_timeout(int seconds) {
run_key_set(&g_key_timeout, (void *)(intptr_t)seconds);
}
int os_get_run_timeout(void) { return run_timeout(); }
int os_run_timeout_effective(void) { int os_run_timeout_effective(void) {
if (g_run_timeout_s > 0) int t = run_timeout();
return g_run_timeout_s; if (t > 0)
return t;
const char *e = getenv("CST_SHELL_TIMEOUT"); const char *e = getenv("CST_SHELL_TIMEOUT");
if (e && atoi(e) > 0) if (e && atoi(e) > 0)
return atoi(e); return atoi(e);
@@ -693,8 +749,8 @@ int os_run_capture_ex(const char *cwd, const char *const argv[],
} }
/* Checked on every pass, so a chatty command still stops promptly. */ /* Checked on every pass, so a chatty command still stops promptly. */
if (!reaped && !killed && if (!reaped && !killed &&
((g_run_cancel && *g_run_cancel) || (deadline > 0 && now > deadline))) { (os_cancel_requested() || (deadline > 0 && now > deadline))) {
killed = (g_run_cancel && *g_run_cancel) ? 2 : 1; killed = os_cancel_requested() ? 2 : 1;
killed_at = now; killed_at = now;
kill(-pid, SIGTERM); kill(-pid, SIGTERM);
} }
@@ -900,9 +956,12 @@ void os_run_parallel(os_task_fn fn, void *const args[], int n) {
free(tasks); free(tasks);
} }
int os_cancel_requested(void) { return g_run_cancel && *g_run_cancel; } int os_cancel_requested(void) {
volatile int *c = run_cancel();
return c && *c;
}
volatile int *os_get_cancel_flag(void) { return g_run_cancel; } volatile int *os_get_cancel_flag(void) { return run_cancel(); }
#ifndef _WIN32 #ifndef _WIN32
int os_proc_spawn(const char *cwd, const char *const argv[], int os_proc_spawn(const char *cwd, const char *const argv[],

Some files were not shown because too many files have changed in this diff Show More