Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

599 lines
19 KiB
TypeScript

import type {
AgentActivityMessage,
AgentPromptContentBlock
} from "@tutti-os/agent-activity-core";
import { isLocalImagePath } from "../../../shared/imageGenerationTool";
import type { AgentGUIRuntime } from "../../../agentActivityRuntime";
const COPY_PAGE_SIZE = 200;
const MAX_COPY_PAGES = 1_000;
// Per-image cap (binary bytes) for embedding into the hydrated text/html
// variant. Larger images keep the lean reference instead and are surfaced to
// the user through the omittedImages count.
export const AGENT_CONVERSATION_COPY_MAX_EMBEDDED_IMAGE_BYTES = 2 * 1024 * 1024;
export type AgentGUIConversationCopyAction = "copy-markdown" | "copy-reference";
export interface AgentGUIConversationCopyLabels {
file: string;
image: string;
mentionPrefix: string;
/** Template for the collapsed-interim summary; contains `{{count}}`. */
previousMessages: string;
}
export interface AgentGUIConversationAttachment {
data: string;
mimeType: string;
name?: string;
}
/** Hydrates a local workspace image path into base64 bytes at copy time. */
export type AgentGUIConversationLocalImageReader = (input: {
path: string;
mimeType: string | null;
}) => Promise<AgentGUIConversationAttachment>;
export interface AgentGUIConversationSerializedTranscript {
/** Hydrated variant for the text/html flavor: images embed as data URIs. */
hydratedMarkdown: string;
/** Lean variant for text/plain: short image references, never base64. */
markdown: string;
/** Hydratable images left unembedded (oversized or failed reads). */
omittedImages: number;
}
export async function loadCompleteAgentConversationMessages(input: {
agentSessionId: string;
runtime: Pick<AgentGUIRuntime, "listSessionMessages">;
workspaceId: string;
}): Promise<AgentActivityMessage[]> {
const messagesById = new Map<string, AgentActivityMessage>();
let beforeVersion: number | undefined;
for (let pageIndex = 0; pageIndex < MAX_COPY_PAGES; pageIndex += 1) {
const page = await input.runtime.listSessionMessages({
agentSessionId: input.agentSessionId,
...(beforeVersion === undefined ? {} : { beforeVersion }),
cache: false,
limit: COPY_PAGE_SIZE,
order: "desc",
workspaceId: input.workspaceId
});
for (const message of page.messages) {
const key = message.messageId.trim() || `${message.version}`;
const existing = messagesById.get(key);
if (!existing || message.version >= existing.version) {
messagesById.set(key, message);
}
}
if (!page.hasMore) {
return [...messagesById.values()].sort(compareMessagesAscending);
}
const nextBeforeVersion = minimumPositiveVersion(page.messages);
if (
nextBeforeVersion === null ||
(beforeVersion !== undefined && nextBeforeVersion >= beforeVersion)
) {
throw new Error("Conversation message pagination did not advance.");
}
beforeVersion = nextBeforeVersion;
}
throw new Error("Conversation message pagination exceeded the safety limit.");
}
// Lean Codex-export-style transcript: user inputs and per-turn final agent
// replies are kept in full; interim agent narration collapses into one
// <details> block per assistant run; tool payloads are dropped except image
// outputs (session deliverables such as generated images), which are emitted
// plain; thinking, runtime system notices, and JSON fallbacks are dropped.
// One message walk accumulates two parallel variants that differ only on
// image targets: `markdown` (text/plain) keeps short references — source
// link, attachment:ID, or a bold label — and never carries base64, while
// `hydratedMarkdown` (rendered into the text/html flavor) embeds image bytes
// as data URIs from inline data, the runtime attachment store, and local
// workspace paths via the host reader, because rich-paste targets consume
// data URIs but cannot fetch local paths.
export async function serializeAgentConversationForClipboard(input: {
labels: AgentGUIConversationCopyLabels;
messages: readonly AgentActivityMessage[];
readAttachment?: (
attachmentId: string
) => Promise<AgentGUIConversationAttachment>;
readLocalImage?: AgentGUIConversationLocalImageReader;
title: string;
}): Promise<AgentGUIConversationSerializedTranscript> {
const context: SerializeContext = {
labels: input.labels,
readAttachment: input.readAttachment,
readLocalImage: input.readLocalImage,
stats: { omittedImages: 0 }
};
const kept: {
body: SerializedBody;
isToolImage?: boolean;
role: "assistant" | "user";
}[] = [];
for (const message of [...input.messages].sort(compareMessagesAscending)) {
if (isSystemNoticeMessage(message)) {
continue;
}
if (isToolMessage(message)) {
const body = await serializeImagesOnly(message.payload ?? {}, context);
if (body.plain) {
kept.push({ body, isToolImage: true, role: "assistant" });
}
continue;
}
const role = keptMessageRole(message);
if (!role) {
continue;
}
const body = await serializeMessageContent(message.payload ?? {}, context);
if (body.plain) {
kept.push({ body, role });
}
}
// Both variants assemble in lockstep from the same kept entries so the
// grouping (blockquotes, details collapse, ordering) stays identical and
// only image targets differ.
const markdownBlocks: string[] = [`# ${singleLine(input.title)}`];
const hydratedBlocks: string[] = [`# ${singleLine(input.title)}`];
const pushBlock = (plain: string, hydrated: string): void => {
markdownBlocks.push(plain);
hydratedBlocks.push(hydrated);
};
let index = 0;
while (index < kept.length) {
const entry = kept[index]!;
if (entry.role === "user") {
pushBlock(
blockquoteMarkdown(entry.body.plain),
blockquoteMarkdown(entry.body.hydrated)
);
index += 1;
continue;
}
let last = index;
while (last + 1 < kept.length && kept[last + 1]!.role === "assistant") {
last += 1;
}
const run = kept.slice(index, last + 1);
const textEntries = run.filter((item) => !item.isToolImage);
const interim = textEntries.slice(0, -1);
const finalText = textEntries.at(-1);
if (interim.length > 0) {
const summary = input.labels.previousMessages.replace(
"{{count}}",
`${interim.length}`
);
const detailsBlock = (side: keyof SerializedBody): string =>
[
`<details><summary>${summary}</summary>`,
...interim.map((item) => blockquoteMarkdown(item.body[side])),
"</details>"
].join("\n\n");
pushBlock(detailsBlock("plain"), detailsBlock("hydrated"));
}
for (const item of run) {
if (item.isToolImage || item === finalText) {
pushBlock(item.body.plain, item.body.hydrated);
}
}
index = last + 1;
}
return {
hydratedMarkdown: hydratedBlocks.join("\n\n").trim(),
markdown: markdownBlocks.join("\n\n").trim(),
omittedImages: context.stats.omittedImages
};
}
interface SerializeContext {
labels: AgentGUIConversationCopyLabels;
readAttachment?: (
attachmentId: string
) => Promise<AgentGUIConversationAttachment>;
readLocalImage?: AgentGUIConversationLocalImageReader;
stats: { omittedImages: number };
}
// The plain/hydrated variants of one serialized fragment. Only image blocks
// diverge; every other block serializes identically on both sides.
interface SerializedBody {
hydrated: string;
plain: string;
}
function sameBody(value: string): SerializedBody {
return { hydrated: value, plain: value };
}
function joinBodies(bodies: readonly SerializedBody[]): SerializedBody {
return {
hydrated: bodies
.map((body) => body.hydrated)
.filter(Boolean)
.join("\n\n")
.trim(),
plain: bodies
.map((body) => body.plain)
.filter(Boolean)
.join("\n\n")
.trim()
};
}
function keptMessageRole(
message: AgentActivityMessage
): "assistant" | "user" | null {
const role = normalizedRole(message.role);
if (role === "user") {
return "user";
}
if (role === "assistant" || role === "agent") {
return "assistant";
}
return null;
}
// Single-pass .replace over newlines (not split/map/join) keeps peak memory
// low: bodies can carry large inline data-URI images.
function blockquoteMarkdown(value: string): string {
const quoted = value.replace(/\n(.?)/g, (_match, next: string) =>
next ? `\n> ${next}` : "\n>"
);
return `> ${quoted}`;
}
async function serializeMessageContent(
payload: Record<string, unknown>,
context: SerializeContext
): Promise<SerializedBody> {
const content = payload.content;
if (Array.isArray(content)) {
const blocks = await Promise.all(
content.map((block) => serializeContentValue(block, context))
);
const serialized = joinBodies(blocks);
if (serialized.plain) {
return serialized;
}
} else if (typeof content === "string" && content.trim()) {
return sameBody(content.trim());
}
return sameBody(
firstString(
payload.displayPrompt,
payload.text,
payload.detail,
payload.title,
payload.summary
) ?? ""
);
}
async function serializeContentValue(
value: unknown,
context: SerializeContext
): Promise<SerializedBody> {
if (typeof value === "string") {
return sameBody(value.trim());
}
if (!isRecord(value)) {
return sameBody("");
}
if (value.type === "content" && value.content !== undefined) {
return serializeContentValue(value.content, context);
}
const block = value as AgentPromptContentBlock & Record<string, unknown>;
switch (block.type) {
case "text":
return sameBody(typeof block.text === "string" ? block.text.trim() : "");
case "image":
return serializeImageBlock(block, context);
case "file":
return sameBody(serializeFileBlock(block, context.labels));
case "mention":
return sameBody(serializeMentionBlock(block, context.labels));
case "skill":
return sameBody(serializeSkillBlock(block));
default: {
const nested = await Promise.all(
[value.content, value.output, value.result]
.filter((candidate) => candidate !== undefined)
.map((candidate) => serializeContentValue(candidate, context))
);
return joinBodies(nested);
}
}
}
// Images-only projection of serializeContentValue: recurses through the same
// wrappers (arrays, {type:"content"} envelopes, and content/output/result of
// unknown records) but emits image blocks alone. Used for tool-call payloads,
// whose only copy-worthy content is a generated-image deliverable.
async function serializeImagesOnly(
value: unknown,
context: SerializeContext
): Promise<SerializedBody> {
if (Array.isArray(value)) {
const nested = await Promise.all(
value.map((item) => serializeImagesOnly(item, context))
);
return joinBodies(nested);
}
if (!isRecord(value)) {
return sameBody("");
}
if (value.type === "content" && value.content !== undefined) {
return serializeImagesOnly(value.content, context);
}
const block = value as AgentPromptContentBlock & Record<string, unknown>;
switch (block.type) {
case "image":
return serializeImageBlock(block, context);
case "text":
case "file":
case "mention":
case "skill":
return sameBody("");
default: {
const nested = await Promise.all(
[value.content, value.output, value.result]
.filter((candidate) => candidate !== undefined)
.map((candidate) => serializeImagesOnly(candidate, context))
);
return joinBodies(nested);
}
}
}
async function serializeImageBlock(
block: AgentPromptContentBlock & Record<string, unknown>,
context: SerializeContext
): Promise<SerializedBody> {
const attachmentId = firstString(block.attachmentId);
const blockMimeType = firstString(block.mimeType);
const name = firstString(block.name) ?? context.labels.image;
const source = firstString(block.url, block.uri, block.path, block.hostPath);
const inlineData =
typeof block.data === "string" && block.data.trim()
? block.data.trim()
: null;
// Plain (text/plain markdown) side: synchronous short references only,
// never base64 bytes. Inline-data images and data:-URI sources (providers
// do put data URIs into url/uri fields) have no short reference, so they
// degrade to a bold label.
const isDataSource = source?.startsWith("data:") ?? false;
const plain =
source && !isDataSource
? imageLinkMarkdown(name, source)
: attachmentId
? imageLinkMarkdown(name, `attachment:${attachmentId}`)
: `**${name}**`;
// Hydrated (text/html) side: embed real bytes as data URIs — rich-paste
// targets (Word, Feishu docs, Notion) consume and re-upload data URIs but
// never fetch local paths or localhost URLs. An oversized image and a
// failed attachment/local-path read both count as omitted (either way the
// recipient did not get the bytes) and keep the lean form. A missing
// reader is not counted: hydration was never available on that surface.
// http(s), data: and other scheme-qualified sources pass through as-is.
let hydrated = plain;
if (source && isDataSource) {
const payload = source.slice(source.indexOf(",") + 1);
if (
base64BinaryByteSize(payload) <=
AGENT_CONVERSATION_COPY_MAX_EMBEDDED_IMAGE_BYTES
) {
hydrated = imageLinkMarkdown(name, source);
} else {
context.stats.omittedImages += 1;
}
} else if (source) {
const localPath = localImagePathFromSource(source);
if (localPath && context.readLocalImage) {
try {
const attachment = await context.readLocalImage({
mimeType: blockMimeType,
path: localPath
});
hydrated = embedWithinLimit(name, attachment, plain, context);
} catch {
context.stats.omittedImages += 1;
}
}
} else if (inlineData) {
const dataUri = inlineData.startsWith("data:")
? inlineData
: `data:${blockMimeType ?? "image/png"};base64,${inlineData}`;
const payload = dataUri.slice(dataUri.indexOf(",") + 1);
if (
base64BinaryByteSize(payload) <=
AGENT_CONVERSATION_COPY_MAX_EMBEDDED_IMAGE_BYTES
) {
hydrated = imageLinkMarkdown(name, dataUri);
} else {
context.stats.omittedImages += 1;
}
} else if (attachmentId && context.readAttachment) {
try {
const attachment = await context.readAttachment(attachmentId);
hydrated = embedWithinLimit(name, attachment, plain, context);
} catch {
context.stats.omittedImages += 1;
}
}
return { hydrated, plain };
}
function embedWithinLimit(
name: string,
attachment: AgentGUIConversationAttachment,
fallback: string,
context: SerializeContext
): string {
if (
base64BinaryByteSize(attachment.data) >
AGENT_CONVERSATION_COPY_MAX_EMBEDDED_IMAGE_BYTES
) {
context.stats.omittedImages += 1;
return fallback;
}
return imageLinkMarkdown(
name,
`data:${attachment.mimeType};base64,${attachment.data}`
);
}
function imageLinkMarkdown(name: string, target: string): string {
return `![${escapeMarkdownLabel(name)}](<${escapeMarkdownTarget(target)}>)`;
}
// Binary size of a base64 payload without decoding it: 3 bytes per 4 chars,
// minus padding.
function base64BinaryByteSize(value: string): number {
const trimmed = value.trim();
const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0;
return Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding);
}
// file:// URLs address the same local files as bare paths, so they hydrate
// through the same reader after URL decoding (a Windows drive pathname keeps
// no leading slash).
function localImagePathFromSource(source: string): string | null {
const trimmed = source.trim();
if (isLocalImagePath(trimmed)) {
return trimmed;
}
if (!/^file:\/\//i.test(trimmed)) {
return null;
}
try {
const pathname = decodeURIComponent(new URL(trimmed).pathname);
return /^\/[a-zA-Z]:[\\/]/.test(pathname) ? pathname.slice(1) : pathname;
} catch {
return null;
}
}
function serializeFileBlock(
block: AgentPromptContentBlock & Record<string, unknown>,
labels: AgentGUIConversationCopyLabels
): string {
const source = firstString(block.uri, block.path, block.hostPath, block.url);
const name = firstString(block.name) ?? source ?? labels.file;
return source
? `[${escapeMarkdownLabel(name)}](<${escapeMarkdownTarget(source)}>)`
: `**${labels.file}:** ${escapeMarkdownLabel(name)}`;
}
function serializeMentionBlock(
block: AgentPromptContentBlock & Record<string, unknown>,
labels: AgentGUIConversationCopyLabels
): string {
const text = firstString(block.text, block.name);
if (text) {
return text.startsWith(labels.mentionPrefix)
? text
: `${labels.mentionPrefix}${text}`;
}
return firstString(block.uri, block.path) ?? "";
}
function serializeSkillBlock(
block: AgentPromptContentBlock & Record<string, unknown>
): string {
const name = firstString(block.name, block.text);
return name ? (name.startsWith("/") ? name : `/${name}`) : "";
}
// Runtime notices (context-budget warnings, provider advisories) ride as
// assistant-role messages; they are operational chrome, never transcript.
function isSystemNoticeMessage(message: AgentActivityMessage): boolean {
return firstString(message.payload.kind) === "agent_system_notice";
}
function isToolMessage(message: AgentActivityMessage): boolean {
const kind = message.kind.trim().toLowerCase();
return (
kind.includes("tool") ||
kind.includes("call") ||
firstString(message.payload.callType, message.payload.toolName) !== null
);
}
function compareMessagesAscending(
left: AgentActivityMessage,
right: AgentActivityMessage
): number {
const leftSequence = left.sequence ?? 0;
const rightSequence = right.sequence ?? 0;
if (leftSequence > 0 && rightSequence > 0 && leftSequence !== rightSequence) {
return leftSequence - rightSequence;
}
return (
left.occurredAtUnixMs - right.occurredAtUnixMs ||
left.version - right.version ||
left.messageId.localeCompare(right.messageId)
);
}
function minimumPositiveVersion(
messages: readonly AgentActivityMessage[]
): number | null {
let minimum = Number.POSITIVE_INFINITY;
for (const message of messages) {
if (Number.isFinite(message.version) && message.version > 0) {
minimum = Math.min(minimum, message.version);
}
}
return Number.isFinite(minimum) ? minimum : null;
}
function normalizedRole(role: string): string {
return role.trim().toLowerCase();
}
function firstString(...values: unknown[]): string | null {
for (const value of values) {
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return null;
}
function singleLine(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
function escapeMarkdownLabel(value: string): string {
return value
.replaceAll("\\", "\\\\")
.replaceAll("[", "\\[")
.replaceAll("]", "\\]");
}
function escapeMarkdownTarget(value: string): string {
return value.replace(/>/g, "%3E");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}