Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1406.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Copying a conversation in the details drawer now copies each reply's own words, keeping the line breaks the reply was written with: the `untrusted-message` fence and a delegation trace whose first hop is one of the Bot's own names are dropped, while a line like `Settings → Models 页面` is kept as written ([#1406](https://github.com/ThinkFlowLab/FlyBot/issues/1406)).
79 changes: 67 additions & 12 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2382,6 +2382,72 @@ function stripTrace(text) {
if (!line2 || /[.!?。!?::,,]/.test(line2.replace(/[^\S\n]*\n+$/, "").replace(/→/g, ""))) return text;
return text.slice(line2.length).replace(/^\s+/, "");
}
var COPY_FENCE = /untrusted-(?:message|file)\b/i;
var escapeName = (name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
var sortedNames = (names) => (Array.isArray(names) ? names : []).map((name) => String(name ?? "").trim()).filter(Boolean).sort((a, b) => b.length - a.length);
var isTraceLine = (line2, known) => {
if (known.length === 0 || /[.!?。!?::,,]/.test(line2.replace(/→/g, ""))) return false;
const hop = `(?:${known.map(escapeName).join("|")})`;
return new RegExp(`^[ \\t]*${hop}(?:[ \\t]*→[ \\t]*${hop})+[ \\t]*$`).test(line2);
};
function stripFlatTrace(text, names = []) {
const body = String(text ?? "");
const known = sortedNames(names);
if (known.length === 0) return body;
const hop = `(?:${known.map(escapeName).join("|")})`;
const trace = new RegExp(`^[ \\t]*${hop}(?:[ \\t]*→[ \\t]*${hop})*[ \\t]*(?=\\S)`);
const found = trace.exec(body);
return found ? body.slice(found[0].length) : body;
}
function copyBodyOf(text, names = []) {
let body = String(text ?? "");
for (let previous; previous !== body; ) {
previous = body;
body = stripHeader(body);
}
const lines = [];
const known = sortedNames(names);
let first = true;
for (const raw of body.split("\n")) {
if (COPY_FENCE.test(raw)) {
const words = visibleText(raw);
if (words !== "") {
lines.push(first ? stripFlatTrace(words, known) : words);
first = false;
}
continue;
}
const line2 = raw.trim();
if (line2 === "") {
lines.push("");
continue;
}
if (isTraceLine(line2, known)) continue;
lines.push(first ? stripFlatTrace(line2, known) : line2);
first = false;
}
return lines.join("\n").trim();
}
function copyNames(roster) {
const names = [];
const records = [...Object.values(roster?.byId ?? {}), ...Array.isArray(roster?.bots) ? roster.bots : []];
for (const bot of records) {
const name = typeof bot?.name === "string" ? bot.name.trim() : "";
if (name && !names.includes(name)) names.push(name);
}
return names.sort((a, b) => b.length - a.length);
}
function transcriptOf(list, id, name, hiddenTurns = [], names = []) {
const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline;
if (!Array.isArray(outline)) return "";
const lines = [];
for (const entry of outline) {
if (hiddenTurns.includes(entry.turn)) continue;
if (entry.prompt && !HEADER.test(entry.prompt)) lines.push(`**${t("You")}:** ${entry.prompt.trim()}`);
if (entry.response) lines.push(`**${name}:** ${copyBodyOf(entry.response, names)}`);
}
return lines.join("\n\n");
}
function latestMemoryChange(news, ids) {
const at = (id) => news?.[id]?.seen ? news[id].at ?? 0 : 0;
return ids.reduce((best, id) => at(id) > at(best) ? id : best, ids[0] ?? null);
Expand Down Expand Up @@ -17168,17 +17234,6 @@ function migrateStorage(storage = globalThis.localStorage) {

// src/client/details-drawer.js
var import_react41 = require("react");
function transcriptOf(list, id, name, hiddenTurns = []) {
const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline;
if (!Array.isArray(outline)) return "";
const lines = [];
for (const entry of outline) {
if (hiddenTurns.includes(entry.turn)) continue;
if (entry.prompt && !HEADER.test(entry.prompt)) lines.push(`**${t("You")}:** ${entry.prompt.trim()}`);
if (entry.response) lines.push(`**${name}:** ${stripHeader(entry.response).trim()}`);
}
return lines.join("\n\n");
}
var TAB_LABELS = { details: "Details", browser: "Browser" };
var hostOf = (url) => {
try {
Expand Down Expand Up @@ -17468,7 +17523,7 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a
const bot = owner;
const running = useSessionStatus((map) => map.get(part)?.running === true);
const doing = useActivity((map) => map[bot?.id ?? sessionId]);
const transcript = useSessions((list) => transcriptOf(list, part, bot?.name ?? room2?.name ?? t("Bot"), roster.exchangeTurns[part]));
const transcript = useSessions((list) => transcriptOf(list, part, bot?.name ?? room2?.name ?? t("Bot"), roster.exchangeTurns[part], copyNames(roster)));
const usage = useSessions((list) => usageOf(list, [part, ...bot?.parts ?? room2?.parts ?? []]));
const run = (promise) => Promise.resolve(promise).then(() => {
setError("");
Expand Down
16 changes: 2 additions & 14 deletions core/src/client/details-drawer.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createElement as h, Fragment, useEffect, useRef, useState } from 'react'
import { isMainOf } from './sources.js'
import { HEADER, currentPart, stripHeader, usageOf } from './text.js'
import { copyNames, currentPart, transcriptOf, usageOf } from './text.js'
import { PlusIcon, ShareIcon, CloseIcon, ChevronLeftIcon, GearIcon } from './icons.js'
import { botState, BotMark, BotAvatar, RoomAvatar, lookBotForRemote } from './mark.js'
import { GlobeIcon, Favicon } from './browser-pane.js'
Expand All @@ -18,18 +18,6 @@ import { remoteBotTitle, roomTitle, copiedToClipboard } from '../shared/remote-l
import { FlyBotRoomOverview, FlyBotDetails } from './flybot-slots.js'
import { call } from './api.js'

function transcriptOf(list, id, name, hiddenTurns = []) {
const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline
if (!Array.isArray(outline)) return ''
const lines = []
for (const entry of outline) {
if (hiddenTurns.includes(entry.turn)) continue
if (entry.prompt && !HEADER.test(entry.prompt)) lines.push(`**${t('You')}:** ${entry.prompt.trim()}`)
if (entry.response) lines.push(`**${name}:** ${stripHeader(entry.response).trim()}`)
}
return lines.join('\n\n')
}

const TAB_LABELS = { details: 'Details', browser: 'Browser' }

const hostOf = (url) => { try { return new URL(url).hostname.replace(/^www\./, '') } catch { return '' } }
Expand Down Expand Up @@ -251,7 +239,7 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro
const bot = owner
const running = useSessionStatus(map => map.get(part)?.running === true)
const doing = useActivity(map => map[bot?.id ?? sessionId])
const transcript = useSessions(list => transcriptOf(list, part, bot?.name ?? room?.name ?? t('Bot'), roster.exchangeTurns[part]))
const transcript = useSessions(list => transcriptOf(list, part, bot?.name ?? room?.name ?? t('Bot'), roster.exchangeTurns[part], copyNames(roster)))
const usage = useSessions(list => usageOf(list, [part, ...(bot?.parts ?? room?.parts ?? [])]))
const run = promise => Promise.resolve(promise)
.then(() => { setError(''); return true })
Expand Down
90 changes: 90 additions & 0 deletions core/src/client/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,96 @@ export function stripTrace(text) {
return text.slice(line.length).replace(/^\s+/, '')
}

// The copy export reads a stored turn the way the drawer's rows read one (#130, #1347): a
// delegation trace or an untrusted fence is routing or format, never something a person copies.
// Copy is read line by line, so the paragraph breaks a person typed stay paragraph breaks (#1406):
// only a line that is nothing but a trace goes, and only the first line loses a leading trace.
const COPY_FENCE = /untrusted-(?:message|file)\b/i
const escapeName = name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const sortedNames = names => (Array.isArray(names) ? names : [])
.map(name => String(name ?? '').trim())
.filter(Boolean)
.sort((a, b) => b.length - a.length)
// A whole line that is only a trace: every hop is a known Bot name, so `Chief → SelfB1` is routing
// and goes, while `Chief → Product Hi there` stays a line whose leading `Chief → Product` is then
// read as the trace it is. Without names no hop is known, so no whole line is dropped.
const isTraceLine = (line, known) => {
if (known.length === 0 || /[.!?。!?::,,]/.test(line.replace(/→/g, ''))) return false
const hop = `(?:${known.map(escapeName).join('|')})`
return new RegExp(`^[ \\t]*${hop}(?:[ \\t]*→[ \\t]*${hop})+[ \\t]*$`).test(line)
}
// The flat trace: `Chief → Product` and the words on the same line. Only a first hop that is a
// Bot's name — whole, exactly, longest name first so `Dev Lead` never matches as `Dev` — is
// routing. `Settings → Models 页面` is a person naming a setting, so it stays as written.
export function stripFlatTrace(text, names = []) {
const body = String(text ?? '')
const known = sortedNames(names)
if (known.length === 0) return body
const hop = `(?:${known.map(escapeName).join('|')})`
const trace = new RegExp(`^[ \\t]*${hop}(?:[ \\t]*→[ \\t]*${hop})*[ \\t]*(?=\\S)`)
const found = trace.exec(body)
return found ? body.slice(found[0].length) : body
}
// `names` are the Bots a trace can start from: the roster's, longest first. Without them no first
// hop can be proven to be a name, so nothing is stripped — a missing roster must never eat prose.
export function copyBodyOf(text, names = []) {
let body = String(text ?? '')
for (let previous; previous !== body;) {
previous = body
body = stripHeader(body)
}
const lines = []
const known = sortedNames(names)
let first = true
for (const raw of body.split('\n')) {
// Only the fence marker itself goes through the fence reader (a marker is a wrap, not words);
// what it empties was never a paragraph, so it is dropped rather than kept as a blank line.
if (COPY_FENCE.test(raw)) {
const words = visibleText(raw)
if (words !== '') {
lines.push(first ? stripFlatTrace(words, known) : words)
first = false
}
continue
}
const line = raw.trim()
if (line === '') {
lines.push('')
continue
}
if (isTraceLine(line, known)) continue
// The first line of the reply is the one a delegation trace can stand on.
lines.push(first ? stripFlatTrace(line, known) : line)
first = false
}
return lines.join('\n').trim()
}

// The names a flat trace may start from: every Bot the drawer already knows, longest first, so a
// name that contains a shorter one is one alternative and never two ("Dev Lead" before "Dev").
export function copyNames(roster) {
const names = []
const records = [...Object.values(roster?.byId ?? {}), ...(Array.isArray(roster?.bots) ? roster.bots : [])]
for (const bot of records) {
const name = typeof bot?.name === 'string' ? bot.name.trim() : ''
if (name && !names.includes(name)) names.push(name)
}
return names.sort((a, b) => b.length - a.length)
}
// The conversation copy export: one `**Name:**` line per turn, the owner's own lines kept as they
// were, and every stored marker read away. `list` is the session list the drawer already holds.
export function transcriptOf(list, id, name, hiddenTurns = [], names = []) {
const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline
if (!Array.isArray(outline)) return ''
const lines = []
for (const entry of outline) {
if (hiddenTurns.includes(entry.turn)) continue
if (entry.prompt && !HEADER.test(entry.prompt)) lines.push(`**${t('You')}:** ${entry.prompt.trim()}`)
if (entry.response) lines.push(`**${name}:** ${copyBodyOf(entry.response, names)}`)
}
return lines.join('\n\n')
}

// Of the Bots in a conversation, the one whose memory changed last, by the host's
// clock: changes reaching one window in the same poll share a `seen` time.
export function latestMemoryChange(news, ids) {
Expand Down
102 changes: 102 additions & 0 deletions core/test/details-copy.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// The details drawer's copy export is a read surface: a person copies the words they see, not the
// stored markers. Two shapes reach the clipboard today — a delegation trace ("Chief → SelfB1") and
// an untrusted fence ("<untrusted-message from=\"User\">…") — and both are routing/format, never
// content. A line with no marker keeps its bytes exactly, line breaks included.
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { test } from 'node:test'
import { fenceUntrusted } from '../src/host/delivery.js'

const read = name => readFileSync(new URL(`../src/client/${name}`, import.meta.url), 'utf8')
// The names a trace can start from: the drawer passes the roster's, longest first.
const BOT_NAMES = ['Chief', 'Product', 'Dev Lead', 'SelfB1', '助手']
// The stored shape the host writes: the delivery header, then the fenced body.
const stored = (header, from, body) => `${header}\n${fenceUntrusted(from, body)}`
const outlineOf = (...entries) => ({ projectionsBySession: { s1: { values: { turnOutline: entries } } } })
// Loaded lazily so a red base reports the missing reader per test, not as one import crash.
const copyReader = () => import('../src/client/text.js').then(module => module.transcriptOf)

test('the conversation copy shows the words of a stored line, never its trace or fence', async () => {
const transcriptOf = await copyReader()
assert.equal(typeof transcriptOf, 'function', 'the copy reader is exported from the text helpers')
const body = 'B kick test 4 > hello'
const withTrace = `Chief → SelfB1\n\n${stored('[Message from User]', 'User', body)}`
const groupLine = stored('[Group chat "Build" · new message]', 'Chief', 'another stored line')
const text = transcriptOf(outlineOf(
{ turn: 1, prompt: 'typed by the owner', response: withTrace },
{ turn: 2, response: groupLine },
{ turn: 3, response: 'plain words\nsecond line' },
), 's1', 'Chief', [], BOT_NAMES)
assert.doesNotMatch(text, /<untrusted/, 'the fence marker never reaches the clipboard')
assert.doesNotMatch(text, /from="/, 'the fence attribute never reaches the clipboard')
assert.doesNotMatch(text, /^Chief → /m, 'the delegation trace never reaches the clipboard')
assert.match(text, /\*\*You:\*\* typed by the owner/, "the owner's own line keeps its label")
assert.equal(text.includes(body), true, 'the stored words are kept')
assert.equal(text.includes('another stored line'), true, 'a group line keeps its words')
// A stored line with no marker is copied as it was written: the words in order, its line break kept.
assert.match(text, /\*\*Chief:\*\* plain words\nsecond line$/)
})

test('the details drawer copies through the shared transcript reader', () => {
const drawer = read('details-drawer.js')
// On today's main this line is the leak: the stored response goes to the clipboard with only its
// delivery header removed, so the trace and the fence travel with it.
assert.doesNotMatch(drawer, /stripHeader\(entry\.response\)/, 'a stored reply is not copied raw')
assert.match(drawer, /import \{[^}]*\btranscriptOf\b[^}]*\} from '\.\/text\.js'/, 'the drawer uses the shared reader')
assert.doesNotMatch(drawer, /function transcriptOf\(/, 'the drawer keeps no second copy of the reader')
const text = read('text.js')
assert.match(text, /export function transcriptOf\(/, 'the reader is testable and exported')
assert.match(text, /visibleText\(/, 'the copy goes through the same fence-free text a row reads')
})

// A stored outline line is flat: the trace and the words share one line, so a reader that only
// looks for a trace line ending in a newline copies `Chief → Product Hi there` verbatim. The copy
// path flattens the line and removes a leading trace only when the first hop is a Bot's name —
// exactly, whole — because `Settings → Models 页面` is prose a person may be quoting.
const copyBody = () => import('../src/client/text.js').then(module => module.copyBodyOf)

test('the copy drops a flat trace whose first hop is a known Bot name', async () => {
const copyBodyOf = await copyBody()
assert.equal(typeof copyBodyOf, 'function', 'copyBodyOf is exported')
assert.equal(copyBodyOf('Chief → Product Hi there', BOT_NAMES), 'Hi there')
})

test('the copy drops a flat trace that sits behind a delivery header', async () => {
const copyBodyOf = await copyBody()
assert.equal(copyBodyOf('[Reply from Chief] Chief → Product Hi there', BOT_NAMES), 'Hi there')
})

test('the copy drops a flat trace with more than one hop', async () => {
const copyBodyOf = await copyBody()
assert.equal(copyBodyOf('Chief → Dev Lead → Product 收到,开始', BOT_NAMES), '收到,开始')
})

test('the copy keeps a line whose first hop is not a Bot name, and keeps every line without names', async () => {
const copyBodyOf = await copyBody()
// A model list is prose, not routing: stripping it to `页面` would delete what the person said.
assert.equal(copyBodyOf('Settings → Models 页面', BOT_NAMES), 'Settings → Models 页面')
// A soft line break is kept, not folded into one line: only a recognized trace is removed.
assert.equal(copyBodyOf('Settings → Models\n页面', BOT_NAMES), 'Settings → Models\n页面')
// No names means no evidence that the first hop is a Bot, so nothing is stripped at all.
assert.equal(copyBodyOf('Chief → Product Hi there'), 'Chief → Product Hi there')
assert.equal(copyBodyOf('Chief → Product Hi there', []), 'Chief → Product Hi there')
})

// A copy is the words on their own lines. Folding a reply's paragraphs into one line is a format
// regression, not a fix: the reader the drawer replaces kept the newlines (`stripHeader(...).trim()`),
// and D2's exchange reader keeps them too.
test('the copy keeps the paragraph breaks a stored reply was written with', async () => {
const copyBodyOf = await copyBody()
const body = 'para one\n\npara two\n\npara three'
assert.equal(copyBodyOf(body, BOT_NAMES), body, 'every paragraph stays its own paragraph')
assert.equal(copyBodyOf(body, BOT_NAMES).split('\n').length, 5, 'four line breaks, one blank line between paragraphs')
})

test('the copy drops a flat trace on the first line and keeps the paragraphs under it', async () => {
const copyBodyOf = await copyBody()
const text = copyBodyOf('Chief → Product Hi there\n\nsecond para', BOT_NAMES)
assert.equal(text, 'Hi there\n\nsecond para', 'only the first line loses the trace; the break stays')
// The same line read flat: the words are the ones a single-line read would show.
assert.equal(text.split('\n').map(line => line.trim()).filter(Boolean).join(' '), 'Hi there second para')
assert.equal(text.split('\n').length, 3, 'the paragraph break is not folded away')
})
Loading
Loading