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
+99
View File
@@ -0,0 +1,99 @@
import type { HarnessContentPart, HarnessMessage } from "@/src/harness/types";
import type { BotMessageWithAttachments } from "@/src/types";
type Params = {
messages: Pick<BotMessageWithAttachments, "id" | "author" | "text" | "attachments">[];
/**
* Loaded files by message id (`loadAttachmentParts`). Files of other
* messages are only named.
*/
parts?: Map<number, HarnessContentPart[]>;
};
type Turn = { role: "user" | "assistant"; parts: HarnessContentPart[] };
/**
* A bot's conversation (oldest first) as model turns: the user's messages
* (with their files) as user turns, the bot's as its own. Back-to-back
* messages from the same side are joined, since some providers reject
* repeated roles. Leading bot messages are dropped so the conversation
* starts with the user. A turn that is only text stays a string.
*/
export default function botMessagesToHarness({ messages, parts }: Params): HarnessMessage[] {
const turns: Turn[] = [];
for (const message of messages) {
const role = message.author == "user" ? "user" : "assistant";
const content = messageParts({ message, role, parts });
if (!content.length) continue;
if (!turns.length && role == "assistant") continue;
const last = turns[turns.length - 1];
if (last?.role == role) {
last.parts.push(...content);
} else {
turns.push({ role, parts: content });
}
}
return turns.map(({ role, parts }) => {
const merged = mergeText({ parts });
if (role == "assistant" || merged.every((part) => part.type == "text")) {
return {
role,
content: merged.map((part) => (part.type == "text" ? part.text : "")).join("\n\n"),
};
}
return { role, content: merged };
});
}
function messageParts({
message,
role,
parts,
}: {
message: Params["messages"][number];
role: Turn["role"];
parts?: Params["parts"];
}): HarnessContentPart[] {
const text = message.text?.trim();
const content: HarnessContentPart[] = text ? [{ type: "text", text }] : [];
if (role == "assistant" || !message.attachments?.length) return content;
const loaded = parts?.get(Number(message.id));
if (loaded?.length) return [...content, ...loaded];
return [
...content,
{
type: "text",
text: `[Attached earlier: ${message.attachments.map((file) => `"${file.name}"`).join(", ")}]`,
},
];
}
/**
* Joins neighbouring text parts with a blank line.
*/
function mergeText({ parts }: { parts: HarnessContentPart[] }) {
const merged: HarnessContentPart[] = [];
for (const part of parts) {
const last = merged[merged.length - 1];
if (part.type == "text" && last?.type == "text") {
merged[merged.length - 1] = { type: "text", text: `${last.text}\n\n${part.text}` };
} else {
merged.push(part);
}
}
return merged;
}