Add the harness, envs, per-bot models and chat attachments

- Harness: providers, model catalog synced hourly, automatic free picks,
  cooldowns, tool loop with context trimming, MCP computer tools
- Bots queue runs replies and tasks through the harness; jobs wait
  (attempt refunded) while every model is resting
- Envs: Settings > Environment, scoped general/workspace/computer,
  written to computers and redacted from tool output
- Models: Settings > Models, per-harness provider select defaulting to
  OpenRouter, and a models modal on each bot's chat (the coordinator's
  edits the defaults)
- Chat: thinking row while a reply runs, file attachments (upload,
  paste, drop) that replies read as images, audio and document text
- Fix media uploads failing after a user's first file, and private
  files being served from the public media path

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 03:38:38 +01:00
co-authored by Claude Opus 5.5
parent d49f524fe8
commit 6a2665dae9
223 changed files with 12342 additions and 162 deletions
@@ -0,0 +1,64 @@
import { useState } from "react";
import { AppData } from "@/src/data/app-data";
import type { BotMessageAttachment } from "@/src/types";
import uploadBotAttachment from "../(functions)/upload-bot-attachment";
export type PendingAttachment = {
key: string;
name: string;
status: "uploading" | "ready" | "failed";
attachment?: BotMessageAttachment;
error?: string;
};
/**
* The files picked for the next message: each uploads as soon as it's
* added, so sending only waits for ones still going up.
*/
export default function useComposerAttachments() {
const [pending, setPending] = useState<PendingAttachment[]>([]);
const update = ({ key, ...fields }: Partial<PendingAttachment> & { key: string }) =>
setPending((prev) => prev.map((item) => (item.key == key ? { ...item, ...fields } : item)));
const add = ({ files }: { files: File[] }) => {
const room = AppData.BotAttachmentMaxFiles - pending.length;
const picked = files.slice(0, Math.max(room, 0));
const added = picked.map((file) => ({
file,
item: {
key: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
name: file.name || "file",
status: "uploading" as const,
},
}));
setPending((prev) => [...prev, ...added.map(({ item }) => item)]);
for (const { file, item } of added) {
uploadBotAttachment({ file })
.then((res) =>
res.success && res.singleRes
? update({ key: item.key, status: "ready", attachment: res.singleRes })
: update({ key: item.key, status: "failed", error: res.msg || "Upload failed" }),
)
.catch(() => update({ key: item.key, status: "failed", error: "Upload failed" }));
}
};
const remove = ({ key }: { key: string }) =>
setPending((prev) => prev.filter((item) => item.key != key));
return {
pending,
add,
remove,
clear: () => setPending([]),
uploading: pending.some((item) => item.status == "uploading"),
media_ids: pending.flatMap((item) =>
item.status == "ready" && item.attachment ? [item.attachment.id] : [],
),
full: pending.length >= AppData.BotAttachmentMaxFiles,
};
}