mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 22:59:00 -10:00
Time-skip in chat: working view with Stop, comparison sidebar only when there is an update, A versus B that adapts to the kind of file
- while a real run works, an animated A/B card shows what the assistant is doing (host activity), elapsed time and Stop (TimeSkipWorking); the sidebar no longer opens at the start and opens only when the finished run changed files; a run that changed nothing says so in the chat and has no tab - the finished A versus B (TimeSkipCompare) keeps one window size whatever was made: text and Markdown rendered with changed words marked; HTML as two sandboxed previews (no permissions, no scripts, no outside requests) with a View code toggle; pictures side by side, click to enlarge; code and other files as a folded line diff; several files through a compact file switcher - host: changed pictures are listed as pictures and served from the run's own folders by GET /api/timeskip/file (images only, inside the run, size-capped) - strings in 8 languages; tests for the file kinds, word and line diffs, the HTML policy, the activity words and the picture route
This commit is contained in:
1 parent
7c4092dabe
commit
088f8685a7
20 files changed
+744
-36
No files matched your search
@@ -968,6 +968,14 @@ async function api(req, res, path, method, url) {
|
||||
if (!found) return error(res, 404, 'No such Time-skip run.');
|
||||
return json(res, 200, found);
|
||||
}
|
||||
/* One picture of a run, original or updated, for the Compare view. Only pictures inside the run's own folders; served as an image and nothing else. */
|
||||
if (path === '/api/timeskip/file' && method === 'GET') {
|
||||
const found = timeskip.image(url.searchParams.get('id'), url.searchParams.get('path'), url.searchParams.get('version'));
|
||||
if (!found) return error(res, 404, 'No such picture in this Time-skip run.');
|
||||
res.writeHead(200, { 'content-type': found.type, 'cache-control': 'private, max-age=300', 'x-content-type-options': 'nosniff', 'content-security-policy': "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:" });
|
||||
createReadStream(found.path).pipe(res);
|
||||
return;
|
||||
}
|
||||
if (path === '/api/timeskip/stop' && method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
return json(res, 200, { stopped: timeskip.cancel(String(body.id ?? '')) });
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { timeSkipDir } from './platform-paths.mjs';
|
||||
|
||||
/** Where runs are recorded. Mirrors the CLI's own default resolution. */
|
||||
@@ -160,15 +160,36 @@ export function measureFolder(root, { maxFiles = 3000, maxBytes = 300 * 1024 * 1
|
||||
* What a run changed, file by file: the saved original against the revision, for the Compare view.
|
||||
* Reads the folders the job itself kept; it decides nothing.
|
||||
*/
|
||||
export function diffRun(record) {
|
||||
/** Pictures the Compare view shows side by side (read as bytes through /api/timeskip/file, never as text). */
|
||||
export const IMAGE_TYPES = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp' };
|
||||
const MAX_IMAGE_BYTES = 12 * 1024 * 1024;
|
||||
export const imageTypeOf = path => IMAGE_TYPES[String(path).split('.').pop().toLowerCase()] ?? null;
|
||||
|
||||
/** The two folders a run compares: the saved original and the revision (or the last attempt when the checks kept nothing). */
|
||||
export function runRoots(record) {
|
||||
const baseline = (record.checkpoints ?? []).find(c => c.label === 'baseline');
|
||||
const selected = (record.checkpoints ?? []).find(c => c.id === record.selected_checkpoint);
|
||||
const attempts = record.attempts ?? [];
|
||||
const last = attempts[attempts.length - 1] ?? null;
|
||||
// A kept checkpoint is the revision; when nothing was kept (the checks could not decide) the person looks at the last attempt.
|
||||
const useAttempt = !selected || selected.id === baseline?.id;
|
||||
const updatedRoot = useAttempt ? last?.path : selected?.path;
|
||||
const baseRoot = baseline?.path;
|
||||
return { baseRoot: baseline?.path ?? null, updatedRoot: (useAttempt ? last?.path : selected?.path) ?? null, useAttempt, last };
|
||||
}
|
||||
|
||||
/** The absolute path of one picture of a run, for one side, or null: not a picture, not inside the run's folder, missing or too big. */
|
||||
export function runImagePath(record, relPath, version) {
|
||||
if (!imageTypeOf(relPath)) return null;
|
||||
const { baseRoot, updatedRoot } = runRoots(record);
|
||||
const root = version === 'original' ? baseRoot : version === 'updated' ? updatedRoot : null;
|
||||
if (!root) return null;
|
||||
const full = resolve(root, ...String(relPath).split('/'));
|
||||
const inside = relative(resolve(root), full);
|
||||
if (!inside || inside.startsWith('..') || isAbsolute(inside)) return null;
|
||||
try { const st = statSync(full); return st.isFile() && st.size <= MAX_IMAGE_BYTES ? full : null; } catch { return null; }
|
||||
}
|
||||
|
||||
export function diffRun(record) {
|
||||
// A kept checkpoint is the revision; when nothing was kept (the checks could not decide) the person looks at the last attempt.
|
||||
const { baseRoot, updatedRoot, useAttempt, last } = runRoots(record);
|
||||
if (!baseRoot || !updatedRoot) return { files: [], applyAttempt: null, more: 0 };
|
||||
const paths = [...new Set([...walkFiles(baseRoot), ...walkFiles(updatedRoot)])].sort();
|
||||
const files = [];
|
||||
@@ -178,6 +199,15 @@ export function diffRun(record) {
|
||||
const status = a.missing ? 'added' : b.missing ? 'removed' : null;
|
||||
const same = !status && a.text !== undefined && a.text === b.text;
|
||||
if (same) continue;
|
||||
if (imageTypeOf(path)) {
|
||||
// A picture is compared as bytes and shown from the file itself, never read as text.
|
||||
let differs = true;
|
||||
try { differs = a.missing || b.missing || !readFileSync(join(baseRoot, path)).equals(readFileSync(join(updatedRoot, path))); } catch { /* treat as changed */ }
|
||||
if (!differs) continue;
|
||||
if (files.length >= MAX_LISTED) { more += 1; continue; }
|
||||
files.push({ path, status: status ?? 'changed', image: true });
|
||||
continue;
|
||||
}
|
||||
if (!status && !a.text && !b.text && !a.big && !b.big) {
|
||||
// Both binary: compare sizes only.
|
||||
try { if (statSync(join(baseRoot, path)).size === statSync(join(updatedRoot, path)).size && readFileSync(join(baseRoot, path)).equals(readFileSync(join(updatedRoot, path)))) continue; } catch { /* treat as changed */ }
|
||||
@@ -290,6 +320,14 @@ export function createTimeSkip({ repoRoot, python, pythonArgs = [], timeoutMs =
|
||||
return run ? diffRun(run) : null;
|
||||
},
|
||||
|
||||
/** One picture of a run (original or updated), for the Compare view: { path, type } or null. */
|
||||
image: (id, relPath, version) => {
|
||||
if (!/^[0-9a-f]{8,64}$/.test(String(id ?? ''))) return null;
|
||||
const run = readRun(stateDir(env), id);
|
||||
const full = run ? runImagePath(run, relPath, version) : null;
|
||||
return full ? { path: full, type: imageTypeOf(relPath) } : null;
|
||||
},
|
||||
|
||||
/** Stop a run this host is driving. The saved version stays; the run reads as interrupted. */
|
||||
cancel: id => {
|
||||
const child = live.get(id);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { tr } from '../lib/i18n';
|
||||
import type { TimeSkipFile } from '../lib/time-skip';
|
||||
import { diffStats, fileKind, htmlPreviewDocument, lineDiff, segmentLines, timeSkipImageUrl, wordDiff, type Segment } from '../lib/time-skip-compare';
|
||||
import { Btn, Dialog, Icon } from './ui';
|
||||
|
||||
const baseName = (path: string) => path.split('/').pop() || path;
|
||||
const statusText = (status: TimeSkipFile['status']) => tr(status === 'added' ? 'new' : status === 'removed' ? 'removed' : 'changed');
|
||||
|
||||
/** Lines of marked words laid out as the document they are: headings, list items, code fences and paragraphs (Markdown), or plain lines. */
|
||||
function MarkedDoc({ segments, markdown }: { segments: Segment[]; markdown: boolean }) {
|
||||
const lines = segmentLines(segments);
|
||||
let fenced = false;
|
||||
return <div className={markdown ? 'tsc-doc tsc-doc--md' : 'tsc-doc'}>
|
||||
{lines.map((line, i) => {
|
||||
const plain = line.map(s => s.text).join('');
|
||||
const marks = (parts: Segment[]) => parts.map((s, k) => s.changed ? <mark key={k}>{s.text}</mark> : <span key={k}>{s.text}</span>);
|
||||
if (!markdown) return <p key={i}>{plain ? marks(line) : <br/>}</p>;
|
||||
if (/^\s*```/.test(plain)) { fenced = !fenced; return <p key={i} className="tsc-fence">{plain ? marks(line) : null}</p>; }
|
||||
if (fenced) return <p key={i} className="tsc-code">{plain ? marks(line) : <br/>}</p>;
|
||||
const heading = /^(#{1,6})\s+/.exec(plain);
|
||||
if (heading) return <p key={i} className={`tsc-h tsc-h${Math.min(3, heading[1].length)}`}>{marks(stripLead(line, heading[0].length))}</p>;
|
||||
const item = /^(\s*)([-*+]|\d+[.)])\s+/.exec(plain);
|
||||
if (item) return <p key={i} className="tsc-li">{marks(stripLead(line, item[0].length))}</p>;
|
||||
return <p key={i}>{plain ? marks(line) : <br/>}</p>;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
|
||||
/** Drop the first `count` characters (the Markdown marker) from the front of a line of segments. */
|
||||
function stripLead(line: Segment[], count: number): Segment[] {
|
||||
const out: Segment[] = []; let left = count;
|
||||
for (const seg of line) {
|
||||
if (left >= seg.text.length) { left -= seg.text.length; continue; }
|
||||
out.push({ text: seg.text.slice(left), changed: seg.changed }); left = 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function DiffView({ original, updated }: { original: string; updated: string }) {
|
||||
const rows = useMemo(() => lineDiff(original, updated), [original, updated]);
|
||||
const stats = diffStats(rows);
|
||||
return <div className="tsc-diff" role="table" aria-label={tr("Changes")}>
|
||||
<div className="tsc-diff__stats"><span className="tsc-add">+{stats.added}</span><span className="tsc-del">−{stats.removed}</span></div>
|
||||
{rows.map((r, i) => r.t === 'gap'
|
||||
? <div className="tsc-diff__gap" role="row" key={i}>{tr('{0} unchanged lines', [r.n])}</div>
|
||||
: <div className={`tsc-diff__row tsc-diff__row--${r.t}`} role="row" key={i}><span className="tsc-diff__no" aria-hidden="true">{r.a ?? ''}</span><span className="tsc-diff__no" aria-hidden="true">{r.b ?? ''}</span><span className="tsc-diff__sign" aria-hidden="true">{r.t === 'add' ? '+' : r.t === 'del' ? '−' : ''}</span><code>{r.text || ' '}</code></div>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Picture({ src, label, missing, onOpen }: { src: string; label: string; missing?: string; onOpen: () => void }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => { setFailed(false); }, [src]);
|
||||
if (missing || failed) return <div className="tsc-picture tsc-picture--empty"><span>{missing ?? tr("This picture could not be loaded.")}</span></div>;
|
||||
return <button type="button" className="tsc-picture" onClick={onOpen} aria-label={tr("Enlarge the {0} picture", [label])}><img src={src} alt={label} onError={() => setFailed(true)}/></button>;
|
||||
}
|
||||
|
||||
function FileView({ jobId, file }: { jobId: string; file: TimeSkipFile }) {
|
||||
const kind = fileKind(file.path, file);
|
||||
const [code, setCode] = useState(false);
|
||||
const [big, setBig] = useState<'original' | 'updated' | null>(null);
|
||||
useEffect(() => { setCode(false); setBig(null); }, [file.path]);
|
||||
const original = file.original ?? '', updated = file.updated ?? '';
|
||||
const words = useMemo(() => (kind === 'markdown' || kind === 'text') ? wordDiff(original, updated) : null, [kind, original, updated]);
|
||||
const heads = <div className="tsc-heads"><span><b>A</b> · {tr("Original")}</span><span><b>B</b> · {tr("Updated")}</span></div>;
|
||||
if (kind === 'other') return <p className="tsc-note">{tr("This file is too big, or not text, so it is listed but not shown.")}</p>;
|
||||
if (kind === 'image') {
|
||||
const a = timeSkipImageUrl(jobId, file.path, 'original'), b = timeSkipImageUrl(jobId, file.path, 'updated');
|
||||
return <>{heads}<div className="tsc-pair tsc-pair--pictures">
|
||||
<Picture src={a} label={tr("Original")} missing={file.status === 'added' ? tr("Not there before.") : undefined} onOpen={() => setBig('original')}/>
|
||||
<Picture src={b} label={tr("Updated")} missing={file.status === 'removed' ? tr("Removed.") : undefined} onOpen={() => setBig('updated')}/>
|
||||
</div>
|
||||
<Dialog open={!!big} title={`${baseName(file.path)} · ${big === 'original' ? tr('Original') : tr('Updated')}`} onClose={() => setBig(null)} sheet>
|
||||
{big ? <img className="tsc-enlarged" src={big === 'original' ? a : b} alt={baseName(file.path)}/> : null}
|
||||
</Dialog></>;
|
||||
}
|
||||
if (kind === 'html') return <>
|
||||
<div className="tsc-tools"><Btn size="sm" variant="ghost" icon={code ? 'image' : 'file'} aria-pressed={code} onClick={() => setCode(v => !v)}>{tr(code ? 'View preview' : 'View code')}</Btn></div>
|
||||
{code ? <DiffView original={original} updated={updated}/> : <>{heads}<div className="tsc-pair tsc-pair--html">
|
||||
{/* No permissions at all: no scripts, no forms, no navigation. The page also carries a policy that blocks every outside request. */}
|
||||
<iframe title={tr("Original page")} sandbox="" srcDoc={htmlPreviewDocument(original)} loading="lazy"/>
|
||||
<iframe title={tr("Updated page")} sandbox="" srcDoc={htmlPreviewDocument(updated)} loading="lazy"/>
|
||||
</div></>}
|
||||
</>;
|
||||
if (words) return <>{heads}<div className="tsc-pair tsc-pair--text">
|
||||
<MarkedDoc segments={words.original} markdown={kind === 'markdown'}/>
|
||||
<MarkedDoc segments={words.updated} markdown={kind === 'markdown'}/>
|
||||
</div></>;
|
||||
return <DiffView original={original} updated={updated}/>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The finished A versus B, in one window of a fixed, modest size whatever was made: text and Markdown rendered with the changed words marked,
|
||||
* a web page as two sandboxed previews (with a code toggle), pictures side by side to enlarge, anything else as a diff, and a compact switcher
|
||||
* when several files changed. It scrolls inside itself; it never grows to fill the screen.
|
||||
*/
|
||||
export function TimeSkipCompare({ jobId, files, more = 0 }: { jobId: string; files: TimeSkipFile[]; more?: number }) {
|
||||
const [at, setAt] = useState(0);
|
||||
useEffect(() => { setAt(0); }, [jobId]);
|
||||
const file = files[Math.min(at, files.length - 1)];
|
||||
if (!file) return null;
|
||||
return <section className="tsc" aria-label={tr("Original and updated, side by side")}>
|
||||
{files.length > 1 ? <div className="tsc-files" role="tablist" aria-label={tr("Changed files")}>
|
||||
{files.map((f, i) => <button key={f.path} type="button" role="tab" aria-selected={i === at} tabIndex={i === at ? 0 : -1} title={f.path} onClick={() => setAt(i)}
|
||||
onKeyDown={e => { if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { e.preventDefault(); setAt((i + (e.key === 'ArrowRight' ? 1 : files.length - 1)) % files.length); } }}>
|
||||
<Icon name={fileKind(f.path, f) === 'image' ? 'image' : 'file'} size={13}/><span>{baseName(f.path)}</span><small>{statusText(f.status)}</small></button>)}
|
||||
{more > 0 ? <span className="tsc-more">{tr('+{0} more', [more])}</span> : null}
|
||||
</div> : <div className="tsc-one"><Icon name={fileKind(file.path, file) === 'image' ? 'image' : 'file'} size={14}/><strong title={file.path}>{baseName(file.path)}</strong><small>{statusText(file.status)}</small></div>}
|
||||
<div className="tsc-stage"><FileView jobId={jobId} file={file}/></div>
|
||||
</section>;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { PreviewArtifact } from '../lib/artifacts';
|
||||
import { plainTimeSkipError, replayTimeSkip, setTimeSkipResult, stopTimeSkip } from '../lib/store';
|
||||
import { realTimeSkipDocuments, timeSkipCheckOutcomes, timeSkipDocumentMarkdown, timeSkipDocuments, timeSkipExample, timeSkipResultState, type TimeSkipDocument, type TimeSkipRun, type TimeSkipVersion } from '../lib/time-skip';
|
||||
import { FloatingMenu } from './FloatingMenu';
|
||||
import { TimeSkipCompare } from './TimeSkipCompare';
|
||||
import { Btn, Dialog, Icon } from './ui';
|
||||
function VersionTabs({ version, onChange, done }: {
|
||||
version: TimeSkipVersion;
|
||||
@@ -99,6 +100,8 @@ export function TimeSkipPanel({ run, sessionId, inline, expanded, onCompare, onC
|
||||
const documents = run.job ? realTimeSkipDocuments(run.job) : timeSkipDocuments(run.prompt);
|
||||
const checks = timeSkipExample(run.prompt);
|
||||
const sideBySide = expanded && wide;
|
||||
// A finished real run that changed files: the A versus B view, one window whatever was made. Everything else keeps the older layout.
|
||||
const compareReal = real && frame.done && (run.job?.files?.length ?? 0) > 0;
|
||||
useEffect(() => {
|
||||
if (!panel.current)
|
||||
return;
|
||||
@@ -139,6 +142,7 @@ export function TimeSkipPanel({ run, sessionId, inline, expanded, onCompare, onC
|
||||
<div className="ts-content">
|
||||
<div className="ts-heading"><h2 aria-live="polite"><span className="ts-status-icon">{tr(!frame.done ? <span className="ts-activity"/> : <Icon name={kept === 'original' ? 'file' : 'shield'} size={21}/>)}</span><span>{tr(status)}</span></h2></div>
|
||||
<p className="ts-caption">{tr(frame.done ? kept === 'original' ? 'The updated result is still available.' : 'The original is always available.' : frame.pass === 2 ? 'Trying one more revision. Your progress is saved.' : 'Improving the saved draft.')}</p>
|
||||
{tr(compareReal ? <TimeSkipCompare jobId={run.job!.id} files={run.job!.files!} more={run.job!.moreFiles ?? 0}/> : <>
|
||||
{tr(expanded ? <div className="ts-compare-heading"><h3>{tr("Compare results")}</h3><Btn size="sm" variant="ghost" icon="contract" onClick={() => onCompare(false)}>{tr(inline ? 'Back to chat' : 'Done comparing')}</Btn></div> : null)}
|
||||
<div className="ts-switch-row" data-comparing={expanded || undefined}>
|
||||
<VersionTabs version={version} onChange={which => { setVersion(which); setImageCompare(false); }} done={frame.done}/>
|
||||
@@ -155,7 +159,8 @@ export function TimeSkipPanel({ run, sessionId, inline, expanded, onCompare, onC
|
||||
{tr(result(which))}
|
||||
</section>))}
|
||||
</div> : <div id="ts-result" role="tabpanel" aria-labelledby={`ts-result-tab-${version}`} className="ts-selected" key={version}>{tr(result(version))}</div>)}
|
||||
{tr(ready && (!real || documents.changes.length > 0) ? <section className="ts-changes"><h3><Icon name="sliders" size={14}/>{tr("What changed")}<span>{tr(documents.changes.length)}{tr(" updates")}</span></h3><ul>{tr(documents.changes.map((change, i) => <li key={change}><Icon name={i === documents.changes.length - 1 ? 'shield' : 'pencil'} size={14}/><span>{tr(change)}</span><Icon name="check" size={11}/></li>))}</ul></section> : null)}
|
||||
</>)}
|
||||
{tr(!compareReal && ready && (!real || documents.changes.length > 0) ? <section className="ts-changes"><h3><Icon name="sliders" size={14}/>{tr("What changed")}<span>{tr(documents.changes.length)}{tr(" updates")}</span></h3><ul>{tr(documents.changes.map((change, i) => <li key={change}><Icon name={i === documents.changes.length - 1 ? 'shield' : 'pencil'} size={14}/><span>{tr(change)}</span><Icon name="check" size={11}/></li>))}</ul></section> : null)}
|
||||
{tr(!real && kept === 'original' && frame.done ? <div className="ts-restored" role="status"><span>{tr("Original restored in this example.")}</span><button onClick={() => restore('updated')}>{tr("Undo")}</button></div> : null)}
|
||||
{real && frame.done && (run.job?.files?.length ?? 0) > 0 ? <section className="ts-keep" aria-label={tr('Keep the update')}>
|
||||
<p>{tr(run.retainedVersion === 'updated' ? 'The update is in your files. You can put the original back.' : 'Your files are unchanged. Keep the update to put it in your folder; you can put the original back later.')}</p>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { tr } from '../lib/i18n';
|
||||
import type { TimeSkipRun } from '../lib/time-skip';
|
||||
import { activityWords, phaseFraction } from '../lib/time-skip-activity';
|
||||
import { stopTimeSkip } from '../lib/store';
|
||||
import { Btn } from './ui';
|
||||
|
||||
const clock = (ms: number) => { const t = Math.max(0, Math.round(ms / 1000)); return t < 60 ? `${t}s` : `${Math.floor(t / 60)}m ${String(t % 60).padStart(2, '0')}s`; };
|
||||
|
||||
/**
|
||||
* While a Time-skip run works, in the chat: the saved version (A) and the one being written (B) with a stream of light between them, what
|
||||
* the assistant is doing right now, how long, and Stop. It is shown from the first moment, so a long run never looks like nothing happening.
|
||||
*/
|
||||
export function TimeSkipWorking({ run, sessionId }: { run: TimeSkipRun; sessionId: string }) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [stopping, setStopping] = useState(false);
|
||||
useEffect(() => { const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t); }, []);
|
||||
const fraction = phaseFraction(run.job?.phase);
|
||||
return <section className="tsw" aria-label={tr("Time-skip is working")}>
|
||||
<div className="tsw__stage" aria-hidden="true">
|
||||
<div className="tsw__ver"><span>A · {tr("Original")}</span><i style={{ width: '92%' }}/><i style={{ width: '100%' }}/><i style={{ width: '78%' }}/><i style={{ width: '96%' }}/></div>
|
||||
<div className="tsw__flow"><i/><i/><i/></div>
|
||||
<div className="tsw__ver tsw__ver--b"><span>B · {tr("Revising")}</span><i style={{ width: '70%' }}/><i style={{ width: '88%' }}/><i style={{ width: '55%' }}/></div>
|
||||
</div>
|
||||
<div className="tsw__bar">
|
||||
<div className="tsw__step" role="status"><strong>{activityWords(run.job)}</strong><span className="small muted">{clock(now - run.startedAt)}</span>
|
||||
<div className="tsw__prog" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(fraction * 100)}><i style={{ width: `${Math.round(fraction * 100)}%` }}/></div></div>
|
||||
<Btn size="sm" icon="stop" disabled={stopping || !run.job?.id} onClick={() => { setStopping(true); void stopTimeSkip(sessionId, run.id).finally(() => setStopping(false)); }}>{tr("Stop")}</Btn>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -1181,7 +1181,8 @@ async function startTimeSkipReal(prompt: string) {
|
||||
patchSession(sessionId, s => ({ ...s,
|
||||
defaultTitle: false,
|
||||
title: s.messages.length ? s.title : prompt.length > 48 ? prompt.slice(0, 47).trimEnd() + '…' : prompt, updatedAt: at,
|
||||
timeSkips: [...(s.timeSkips ?? []), run], previewOpen: timeSkipTabId(run.id),
|
||||
// The comparison sidebar opens only when there is an update to compare (see followTimeSkip), not while it works.
|
||||
timeSkips: [...(s.timeSkips ?? []), run],
|
||||
messages: [...s.messages,
|
||||
{ id: uid('m'), role: 'user', at, content: '/time-skip ' + prompt, timeSkipId: run.id },
|
||||
{ id: uid('m'), role: 'assistant', at, timeSkipId: run.id, content: TS_WORKING() },
|
||||
@@ -1220,6 +1221,8 @@ export async function followTimeSkip(sessionId: string, runId: string) {
|
||||
} catch { /* the list is a convenience; the job's record stands */ }
|
||||
const finished = job;
|
||||
patchRun(sessionId, runId, r => ({ ...r, completedAt: Date.now(), job: { ...r.job!, ...finished, ...extra } }), timeSkipEnding(finished, files));
|
||||
// A run that changed files opens the comparison; one that changed nothing, failed or was stopped has no sidebar tab.
|
||||
if (files.length > 0 && !['failed', 'cancelled', 'interrupted'].includes(finished.phase)) patchSession(sessionId, s => ({ ...s, previewOpen: timeSkipTabId(runId) }));
|
||||
return;
|
||||
}
|
||||
if (job) { const live = job; patchRun(sessionId, runId, r => ({ ...r, job: { ...r.job!, ...live } })); }
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { tr } from './i18n';
|
||||
import type { TimeSkipJob } from './time-skip';
|
||||
|
||||
/** What the working view says it is doing: the host's own words (activity.code / text / args.step, notes-split-agent3.md), else from the phase. */
|
||||
export function activityWords(job: Pick<TimeSkipJob, 'phase' | 'activity'> | undefined): string {
|
||||
const a = job?.activity;
|
||||
if (a?.code === 'trying-version') {
|
||||
const step = typeof a.args?.step === 'string' && a.args.step ? String(a.args.step) : '';
|
||||
return step || tr('Trying an improved version…');
|
||||
}
|
||||
switch (a?.code) {
|
||||
case 'reading-files': return tr('Reading your files…');
|
||||
case 'checking-result': return tr('Checking the result…');
|
||||
case 'comparing': return tr('Comparing the two versions…');
|
||||
default: break;
|
||||
}
|
||||
switch (job?.phase) {
|
||||
case 'generating': return tr('Trying an improved version…');
|
||||
case 'checking': case 'decision': return tr('Checking the result…');
|
||||
default: return tr('Reading your files…');
|
||||
}
|
||||
}
|
||||
|
||||
/** A rough fraction for the progress line from the phase (the host reports phases, not percentages). */
|
||||
export function phaseFraction(phase: string | undefined): number {
|
||||
switch (phase) {
|
||||
case 'queued': return 0.06;
|
||||
case 'checkpointing': return 0.18;
|
||||
case 'generating': return 0.6;
|
||||
case 'checking': return 0.85;
|
||||
case 'decision': return 0.95;
|
||||
default: return 0.06;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* The pure parts of the Time-skip A versus B view: what kind of file it is (the view adapts to it), which words changed, which lines
|
||||
* changed, and how an HTML page is made safe to show. The same window serves every kind, so none of this decides a size.
|
||||
*/
|
||||
export type FileKind = 'markdown' | 'text' | 'html' | 'image' | 'code' | 'other';
|
||||
|
||||
const IMAGE = /\.(png|jpe?g|gif|webp|svg|bmp)$/i;
|
||||
const MARKDOWN = /\.(md|markdown|mdx)$/i;
|
||||
const TEXT = /\.(txt|text|log|rst|csv|tsv)$/i;
|
||||
const HTML = /\.(html?|xhtml)$/i;
|
||||
|
||||
export function fileKind(path: string, file?: { image?: boolean; unreadable?: boolean }): FileKind {
|
||||
if (file?.image || IMAGE.test(path)) return 'image';
|
||||
if (file?.unreadable) return 'other';
|
||||
if (MARKDOWN.test(path)) return 'markdown';
|
||||
if (HTML.test(path)) return 'html';
|
||||
if (TEXT.test(path)) return 'text';
|
||||
return 'code';
|
||||
}
|
||||
|
||||
export interface Segment { text: string; changed: boolean }
|
||||
|
||||
/** Cap on the work a diff may do (a product of the two lengths); past it the whole text counts as changed rather than freezing the page. */
|
||||
const WORK_LIMIT = 6_000_000;
|
||||
|
||||
function lcsPairs(a: string[], b: string[]): [number, number][] | null {
|
||||
if (a.length * b.length > WORK_LIMIT) return null;
|
||||
const w = b.length + 1;
|
||||
const table = new Uint32Array((a.length + 1) * w);
|
||||
for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) {
|
||||
table[i * w + j] = a[i] === b[j] ? table[(i + 1) * w + j + 1] + 1 : Math.max(table[(i + 1) * w + j], table[i * w + j + 1]);
|
||||
}
|
||||
const pairs: [number, number][] = [];
|
||||
let i = 0, j = 0;
|
||||
while (i < a.length && j < b.length) {
|
||||
if (a[i] === b[j]) { pairs.push([i, j]); i++; j++; }
|
||||
else if (table[(i + 1) * w + j] >= table[i * w + j + 1]) i++;
|
||||
else j++;
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
const tokens = (text: string) => text.split(/(\s+)/).filter(t => t !== '');
|
||||
|
||||
function joinSegments(parts: string[], keep: Set<number>): Segment[] {
|
||||
const flags = parts.map((text, i) => /\S/.test(text) && !keep.has(i));
|
||||
// Whitespace between two changed words belongs to the mark, so a changed phrase reads as one.
|
||||
parts.forEach((text, i) => { if (!/\S/.test(text) && i > 0 && i < parts.length - 1 && flags[i - 1] && flags[i + 1]) flags[i] = true; });
|
||||
const out: Segment[] = [];
|
||||
parts.forEach((text, i) => {
|
||||
const last = out.at(-1);
|
||||
if (last && last.changed === flags[i]) last.text += text; else out.push({ text, changed: flags[i] });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The words of both versions with the changed ones marked. Unchanged text is identical on both sides. */
|
||||
export function wordDiff(original: string, updated: string): { original: Segment[]; updated: Segment[] } {
|
||||
const a = tokens(original), b = tokens(updated);
|
||||
const pairs = lcsPairs(a, b);
|
||||
if (!pairs) return { original: [{ text: original, changed: original !== updated }], updated: [{ text: updated, changed: original !== updated }] };
|
||||
const keepA = new Set(pairs.map(p => p[0])), keepB = new Set(pairs.map(p => p[1]));
|
||||
return { original: joinSegments(a, keepA), updated: joinSegments(b, keepB) };
|
||||
}
|
||||
|
||||
/** Split marked segments into lines, so a page can lay out headings, lists and paragraphs and still mark words inside them. */
|
||||
export function segmentLines(segments: Segment[]): Segment[][] {
|
||||
const lines: Segment[][] = [[]];
|
||||
for (const seg of segments) {
|
||||
const parts = seg.text.split(/\r?\n/);
|
||||
parts.forEach((part, i) => {
|
||||
if (i > 0) lines.push([]);
|
||||
if (part) lines[lines.length - 1].push({ text: part, changed: seg.changed });
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export type DiffRow =
|
||||
| { t: 'same' | 'add' | 'del'; text: string; a?: number; b?: number }
|
||||
| { t: 'gap'; n: number };
|
||||
|
||||
/** A line diff for code and other text, with unchanged stretches folded to one "n unchanged lines" row so it stays short. */
|
||||
export function lineDiff(original: string, updated: string, context = 2): DiffRow[] {
|
||||
const a = original === '' ? [] : original.split(/\r?\n/), b = updated === '' ? [] : updated.split(/\r?\n/);
|
||||
const pairs = lcsPairs(a, b);
|
||||
const rows: DiffRow[] = [];
|
||||
if (!pairs) {
|
||||
a.forEach((text, i) => rows.push({ t: 'del', text, a: i + 1 }));
|
||||
b.forEach((text, j) => rows.push({ t: 'add', text, b: j + 1 }));
|
||||
return rows;
|
||||
}
|
||||
let i = 0, j = 0;
|
||||
const flush = (ei: number, ej: number) => {
|
||||
while (i < ei) { rows.push({ t: 'del', text: a[i], a: i + 1 }); i++; }
|
||||
while (j < ej) { rows.push({ t: 'add', text: b[j], b: j + 1 }); j++; }
|
||||
};
|
||||
for (const [pi, pj] of pairs) { flush(pi, pj); rows.push({ t: 'same', text: a[pi], a: pi + 1, b: pj + 1 }); i = pi + 1; j = pj + 1; }
|
||||
flush(a.length, b.length);
|
||||
// fold unchanged stretches longer than twice the context
|
||||
const out: DiffRow[] = [];
|
||||
let k = 0;
|
||||
while (k < rows.length) {
|
||||
if (rows[k].t !== 'same') { out.push(rows[k]); k++; continue; }
|
||||
let end = k;
|
||||
while (end < rows.length && rows[end].t === 'same') end++;
|
||||
const run = rows.slice(k, end);
|
||||
const head = k === 0 ? 0 : context, tail = end === rows.length ? 0 : context;
|
||||
if (run.length > head + tail + 1) {
|
||||
out.push(...run.slice(0, head), { t: 'gap', n: run.length - head - tail }, ...run.slice(run.length - tail));
|
||||
} else out.push(...run);
|
||||
k = end;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const diffStats = (rows: DiffRow[]) => ({ added: rows.filter(r => r.t === 'add').length, removed: rows.filter(r => r.t === 'del').length });
|
||||
|
||||
/**
|
||||
* A page for the preview frame. The frame itself is sandboxed with no permissions (no scripts, no forms, no navigation); this adds a content
|
||||
* policy so the page also cannot fetch anything from the internet: it shows what was written, nothing more.
|
||||
*/
|
||||
export function htmlPreviewDocument(html: string): string {
|
||||
const policy = '<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; img-src data:; style-src \'unsafe-inline\'; font-src data:; media-src data:">';
|
||||
const base = '<base target="_blank">';
|
||||
const head = /<head[^>]*>/i.exec(html);
|
||||
if (head) return html.slice(0, head.index + head[0].length) + policy + base + html.slice(head.index + head[0].length);
|
||||
return `<!doctype html><html><head><meta charset="utf-8">${policy}${base}</head><body>${html}</body></html>`;
|
||||
}
|
||||
|
||||
/** The address of one side of a changed picture on the host. */
|
||||
export const timeSkipImageUrl = (jobId: string, path: string, version: 'original' | 'updated') =>
|
||||
`/api/timeskip/file?id=${encodeURIComponent(jobId)}&path=${encodeURIComponent(path)}&version=${version}`;
|
||||
@@ -2961,7 +2961,27 @@
|
||||
"s2963": "Ziel speichern",
|
||||
"s2964": "Woran soll der Assistent weiterarbeiten?",
|
||||
"s2965": "Dieses Ziel entfernen?",
|
||||
"s2966": "Ziel behalten",
|
||||
"s2967": "Ziel entfernen",
|
||||
"s2968": "Der Assistent arbeitet nicht mehr daran. Ihr Chat und Ihre Dateien bleiben unverändert."
|
||||
"s2966": "neu",
|
||||
"s2967": "entfernt",
|
||||
"s2968": "geändert",
|
||||
"s2969": "Diese Datei ist zu groß oder kein Text, daher wird sie nur aufgelistet und nicht angezeigt.",
|
||||
"s2970": "Vorher nicht vorhanden.",
|
||||
"s2971": "Entfernt.",
|
||||
"s2972": "Vorschau anzeigen",
|
||||
"s2973": "Code anzeigen",
|
||||
"s2974": "Originalseite",
|
||||
"s2975": "Aktualisierte Seite",
|
||||
"s2976": "{0} unveränderte Zeilen",
|
||||
"s2977": "Änderungen",
|
||||
"s2978": "Bild „{0}“ vergrößern",
|
||||
"s2979": "Dieses Bild konnte nicht geladen werden.",
|
||||
"s2980": "Original und Aktualisierung nebeneinander",
|
||||
"s2981": "Geänderte Dateien",
|
||||
"s2982": "+{0} weitere",
|
||||
"s2983": "Time-skip arbeitet",
|
||||
"s2984": "Wird überarbeitet",
|
||||
"s2985": "Lese Ihre Dateien …",
|
||||
"s2986": "Probiere eine verbesserte Version aus …",
|
||||
"s2987": "Prüfe das Ergebnis …",
|
||||
"s2988": "Vergleiche die beiden Versionen …"
|
||||
}
|
||||
@@ -2961,7 +2961,27 @@
|
||||
"s2963": "Save goal",
|
||||
"s2964": "What should the assistant keep working towards?",
|
||||
"s2965": "Remove this goal?",
|
||||
"s2966": "Keep goal",
|
||||
"s2967": "Remove goal",
|
||||
"s2968": "The assistant stops working towards it. Your chat and files stay as they are."
|
||||
"s2966": "new",
|
||||
"s2967": "removed",
|
||||
"s2968": "changed",
|
||||
"s2969": "This file is too big, or not text, so it is listed but not shown.",
|
||||
"s2970": "Not there before.",
|
||||
"s2971": "Removed.",
|
||||
"s2972": "View preview",
|
||||
"s2973": "View code",
|
||||
"s2974": "Original page",
|
||||
"s2975": "Updated page",
|
||||
"s2976": "{0} unchanged lines",
|
||||
"s2977": "Changes",
|
||||
"s2978": "Enlarge the {0} picture",
|
||||
"s2979": "This picture could not be loaded.",
|
||||
"s2980": "Original and updated, side by side",
|
||||
"s2981": "Changed files",
|
||||
"s2982": "+{0} more",
|
||||
"s2983": "Time-skip is working",
|
||||
"s2984": "Revising",
|
||||
"s2985": "Reading your files…",
|
||||
"s2986": "Trying an improved version…",
|
||||
"s2987": "Checking the result…",
|
||||
"s2988": "Comparing the two versions…"
|
||||
}
|
||||
@@ -2961,7 +2961,27 @@
|
||||
"s2963": "Guardar objetivo",
|
||||
"s2964": "¿Hacia qué debe seguir trabajando el asistente?",
|
||||
"s2965": "¿Quitar este objetivo?",
|
||||
"s2966": "Mantener objetivo",
|
||||
"s2967": "Quitar objetivo",
|
||||
"s2968": "El asistente deja de trabajar en ello. Tu chat y tus archivos no cambian."
|
||||
"s2966": "nuevo",
|
||||
"s2967": "eliminado",
|
||||
"s2968": "cambiado",
|
||||
"s2969": "Este archivo es demasiado grande o no es texto, así que solo se lista y no se muestra.",
|
||||
"s2970": "No existía antes.",
|
||||
"s2971": "Eliminado.",
|
||||
"s2972": "Ver vista previa",
|
||||
"s2973": "Ver código",
|
||||
"s2974": "Página original",
|
||||
"s2975": "Página actualizada",
|
||||
"s2976": "{0} líneas sin cambios",
|
||||
"s2977": "Cambios",
|
||||
"s2978": "Ampliar la imagen {0}",
|
||||
"s2979": "No se pudo cargar esta imagen.",
|
||||
"s2980": "Original y actualizado, lado a lado",
|
||||
"s2981": "Archivos cambiados",
|
||||
"s2982": "+{0} más",
|
||||
"s2983": "Time-skip está trabajando",
|
||||
"s2984": "Revisando",
|
||||
"s2985": "Leyendo tus archivos…",
|
||||
"s2986": "Probando una versión mejorada…",
|
||||
"s2987": "Comprobando el resultado…",
|
||||
"s2988": "Comparando las dos versiones…"
|
||||
}
|
||||
@@ -2961,7 +2961,27 @@
|
||||
"s2963": "目標を保存",
|
||||
"s2964": "アシスタントが取り組み続ける目標は何ですか?",
|
||||
"s2965": "この目標を削除しますか?",
|
||||
"s2966": "目標を残す",
|
||||
"s2967": "目標を削除",
|
||||
"s2968": "アシスタントはこの目標への作業を止めます。チャットとファイルはそのままです。"
|
||||
"s2966": "新規",
|
||||
"s2967": "削除",
|
||||
"s2968": "変更",
|
||||
"s2969": "このファイルは大きすぎるかテキストではないため、一覧には出しますが表示はしません。",
|
||||
"s2970": "以前はありませんでした。",
|
||||
"s2971": "削除されました。",
|
||||
"s2972": "プレビューを見る",
|
||||
"s2973": "コードを見る",
|
||||
"s2974": "元のページ",
|
||||
"s2975": "更新後のページ",
|
||||
"s2976": "変更のない {0} 行",
|
||||
"s2977": "変更点",
|
||||
"s2978": "{0}の画像を拡大",
|
||||
"s2979": "この画像を読み込めませんでした。",
|
||||
"s2980": "元と更新後を並べて表示",
|
||||
"s2981": "変更されたファイル",
|
||||
"s2982": "ほか {0} 件",
|
||||
"s2983": "Time-skip が作業中です",
|
||||
"s2984": "改訂中",
|
||||
"s2985": "ファイルを読み込んでいます…",
|
||||
"s2986": "改良版を試しています…",
|
||||
"s2987": "結果を確認しています…",
|
||||
"s2988": "2つのバージョンを比べています…"
|
||||
}
|
||||
@@ -2961,7 +2961,27 @@
|
||||
"s2963": "목표 저장",
|
||||
"s2964": "어시스턴트가 계속 달성해야 할 목표는 무엇인가요?",
|
||||
"s2965": "이 목표를 제거할까요?",
|
||||
"s2966": "목표 유지",
|
||||
"s2967": "목표 제거",
|
||||
"s2968": "어시스턴트가 더 이상 이 목표를 진행하지 않습니다. 채팅과 파일은 그대로 유지됩니다."
|
||||
"s2966": "새 파일",
|
||||
"s2967": "삭제됨",
|
||||
"s2968": "변경됨",
|
||||
"s2969": "이 파일은 너무 크거나 텍스트가 아니어서 목록에만 표시되고 내용은 보여 주지 않습니다.",
|
||||
"s2970": "이전에는 없었습니다.",
|
||||
"s2971": "삭제되었습니다.",
|
||||
"s2972": "미리보기 보기",
|
||||
"s2973": "코드 보기",
|
||||
"s2974": "원본 페이지",
|
||||
"s2975": "업데이트된 페이지",
|
||||
"s2976": "변경되지 않은 {0}줄",
|
||||
"s2977": "변경 사항",
|
||||
"s2978": "{0} 그림 확대",
|
||||
"s2979": "이 그림을 불러올 수 없습니다.",
|
||||
"s2980": "원본과 업데이트 나란히 보기",
|
||||
"s2981": "변경된 파일",
|
||||
"s2982": "+{0}개 더",
|
||||
"s2983": "Time-skip 작업 중",
|
||||
"s2984": "수정 중",
|
||||
"s2985": "파일을 읽는 중…",
|
||||
"s2986": "개선된 버전을 시도하는 중…",
|
||||
"s2987": "결과를 확인하는 중…",
|
||||
"s2988": "두 버전을 비교하는 중…"
|
||||
}
|
||||
@@ -2961,7 +2961,27 @@
|
||||
"s2963": "Salvar meta",
|
||||
"s2964": "No que o assistente deve continuar trabalhando?",
|
||||
"s2965": "Remover esta meta?",
|
||||
"s2966": "Manter meta",
|
||||
"s2967": "Remover meta",
|
||||
"s2968": "O assistente para de trabalhar nela. Seu chat e seus arquivos continuam como estão."
|
||||
"s2966": "novo",
|
||||
"s2967": "removido",
|
||||
"s2968": "alterado",
|
||||
"s2969": "Este arquivo é grande demais ou não é texto, então só aparece na lista e não é exibido.",
|
||||
"s2970": "Não existia antes.",
|
||||
"s2971": "Removido.",
|
||||
"s2972": "Ver pré-visualização",
|
||||
"s2973": "Ver código",
|
||||
"s2974": "Página original",
|
||||
"s2975": "Página atualizada",
|
||||
"s2976": "{0} linhas sem alterações",
|
||||
"s2977": "Alterações",
|
||||
"s2978": "Ampliar a imagem {0}",
|
||||
"s2979": "Não foi possível carregar esta imagem.",
|
||||
"s2980": "Original e atualizado, lado a lado",
|
||||
"s2981": "Arquivos alterados",
|
||||
"s2982": "+{0} mais",
|
||||
"s2983": "O Time-skip está trabalhando",
|
||||
"s2984": "Revisando",
|
||||
"s2985": "Lendo seus arquivos…",
|
||||
"s2986": "Testando uma versão melhorada…",
|
||||
"s2987": "Verificando o resultado…",
|
||||
"s2988": "Comparando as duas versões…"
|
||||
}
|
||||
@@ -2961,7 +2961,27 @@
|
||||
"s2963": "Сохранить цель",
|
||||
"s2964": "К чему ассистент должен продолжать идти?",
|
||||
"s2965": "Удалить эту цель?",
|
||||
"s2966": "Оставить цель",
|
||||
"s2967": "Удалить цель",
|
||||
"s2968": "Ассистент перестанет работать над ней. Чат и файлы останутся как есть."
|
||||
"s2966": "новый",
|
||||
"s2967": "удалён",
|
||||
"s2968": "изменён",
|
||||
"s2969": "Этот файл слишком большой или не текстовый, поэтому он только указан в списке и не показывается.",
|
||||
"s2970": "Раньше не было.",
|
||||
"s2971": "Удалено.",
|
||||
"s2972": "Показать превью",
|
||||
"s2973": "Показать код",
|
||||
"s2974": "Исходная страница",
|
||||
"s2975": "Обновлённая страница",
|
||||
"s2976": "{0} неизменённых строк",
|
||||
"s2977": "Изменения",
|
||||
"s2978": "Увеличить изображение «{0}»",
|
||||
"s2979": "Не удалось загрузить это изображение.",
|
||||
"s2980": "Оригинал и обновление рядом",
|
||||
"s2981": "Изменённые файлы",
|
||||
"s2982": "ещё {0}",
|
||||
"s2983": "Time-skip работает",
|
||||
"s2984": "Правка",
|
||||
"s2985": "Читаю ваши файлы…",
|
||||
"s2986": "Пробую улучшенную версию…",
|
||||
"s2987": "Проверяю результат…",
|
||||
"s2988": "Сравниваю две версии…"
|
||||
}
|
||||
@@ -2961,7 +2961,27 @@
|
||||
"s2963": "保存目标",
|
||||
"s2964": "助手应该持续朝什么目标努力?",
|
||||
"s2965": "移除这个目标?",
|
||||
"s2966": "保留目标",
|
||||
"s2967": "移除目标",
|
||||
"s2968": "助手将停止朝它努力。你的聊天和文件保持不变。"
|
||||
"s2966": "新建",
|
||||
"s2967": "已删除",
|
||||
"s2968": "已更改",
|
||||
"s2969": "这个文件太大或不是文本,所以只列出而不显示。",
|
||||
"s2970": "之前没有。",
|
||||
"s2971": "已删除。",
|
||||
"s2972": "查看预览",
|
||||
"s2973": "查看代码",
|
||||
"s2974": "原始页面",
|
||||
"s2975": "更新后的页面",
|
||||
"s2976": "{0} 行未更改",
|
||||
"s2977": "更改",
|
||||
"s2978": "放大“{0}”图片",
|
||||
"s2979": "无法加载这张图片。",
|
||||
"s2980": "原始与更新后的并排对比",
|
||||
"s2981": "已更改的文件",
|
||||
"s2982": "另外 {0} 个",
|
||||
"s2983": "Time-skip 正在工作",
|
||||
"s2984": "修改中",
|
||||
"s2985": "正在读取你的文件…",
|
||||
"s2986": "正在尝试改进版本…",
|
||||
"s2987": "正在检查结果…",
|
||||
"s2988": "正在比较两个版本…"
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { tr } from '../lib/i18n';
|
||||
import { UsageDashboard } from '../components/UsageDashboard';
|
||||
import { FloatingMenu } from '../components/FloatingMenu';
|
||||
import { TimeSkipPanel } from '../components/TimeSkipPanel';
|
||||
import { timeSkipPrompt, timeSkipTabId } from '../lib/time-skip';
|
||||
import { timeSkipPrompt, timeSkipTabId, type TimeSkipRun } from '../lib/time-skip';
|
||||
import { TimeSkipWorking } from '../components/TimeSkipWorking';
|
||||
import { ChatToolbar } from '../components/ChatToolbar';
|
||||
import { ChatTelemetry } from '../components/TelemetryPopover';
|
||||
import { ArtifactCard } from '../components/ArtifactCard';
|
||||
@@ -379,7 +380,7 @@ export function ChatScreen() {
|
||||
<Icon name="chevron" size={14} style={{ transform: 'rotate(90deg)' }}/>{tr("Latest")}</button>) : null)}
|
||||
<div className="chat__inner">
|
||||
{tr(session.messages.length === 0 ? (session.statsAvailable === false ? <p className="small muted">{tr("Editing from an earlier message. Update the prompt below.")}</p> :
|
||||
<UsageDashboard loaded={!!loaded}/>) : (session.messages.map((m, i) => (<Message key={m.id} message={m} isLast={i === session.messages.length - 1} onEditPrompt={editPrompt} onPreview={openPreview} onOpenTimeSkip={openTimeSkip} timeSkipDone={!!timeSkipRuns.find(r => r.id === m.timeSkipId)?.completedAt} timeSkipReal={!!timeSkipRuns.find(r => r.id === m.timeSkipId)?.job} onOpenLink={openLink}/>))))}
|
||||
<UsageDashboard loaded={!!loaded}/>) : (session.messages.map((m, i) => (<Message key={m.id} message={m} isLast={i === session.messages.length - 1} onEditPrompt={editPrompt} onPreview={openPreview} onOpenTimeSkip={openTimeSkip} timeSkipDone={!!timeSkipRuns.find(r => r.id === m.timeSkipId)?.completedAt} timeSkipReal={!!timeSkipRuns.find(r => r.id === m.timeSkipId)?.job} timeSkipRun={timeSkipRuns.find(r => r.id === m.timeSkipId)} onOpenLink={openLink}/>))))}
|
||||
</div>
|
||||
</div>
|
||||
<Composer draft={draft} setDraft={updateDraft} loaded={!!loaded} streaming={streaming} onSubmit={submit} onKey={onComposerKey} session={session} onPreview={openPreview} textareaRef={textarea}/>
|
||||
@@ -531,7 +532,7 @@ function Composer({ draft, setDraft, loaded, streaming, onSubmit, onKey, session
|
||||
</div>) : null)}
|
||||
</div>);
|
||||
}
|
||||
function Message({ message: m, isLast, onEditPrompt, onPreview, onOpenLink, onOpenTimeSkip, timeSkipDone, timeSkipReal, }: {
|
||||
function Message({ message: m, isLast, onEditPrompt, onPreview, onOpenLink, onOpenTimeSkip, timeSkipDone, timeSkipReal, timeSkipRun, }: {
|
||||
message: ChatMessage;
|
||||
isLast: boolean;
|
||||
onEditPrompt: (text: string) => void;
|
||||
@@ -540,6 +541,7 @@ function Message({ message: m, isLast, onEditPrompt, onPreview, onOpenLink, onOp
|
||||
onOpenTimeSkip: (id: string) => void;
|
||||
timeSkipDone: boolean;
|
||||
timeSkipReal?: boolean;
|
||||
timeSkipRun?: TimeSkipRun;
|
||||
}) {
|
||||
const streaming = useStore(s => s.streaming);
|
||||
const activeSession = useStore(s => s.activeSession);
|
||||
@@ -568,7 +570,7 @@ function Message({ message: m, isLast, onEditPrompt, onPreview, onOpenLink, onOp
|
||||
|
||||
{tr((m.tools ?? []).map((t) => (<ToolCard key={t.id} messageId={m.id} tool={t}/>)))}
|
||||
|
||||
{tr(m.content || m.error || !m.streaming || !liveStatus ? <div className="st-bubble">
|
||||
{tr(!(m.timeSkipId && timeSkipReal && !timeSkipDone) && (m.content || m.error || !m.streaming || !liveStatus) ? <div className="st-bubble">
|
||||
{tr(m.error ? (<ErrorNotice message={m.error} next={tr("Try sending the message again.")}/>) : null)}
|
||||
{tr(m.content ? (<Markdown text={m.timeSkipId && !timeSkipReal ? timeSkipDone ? tr('The result is ready. Switch between Original and Updated, or compare them side by side. This example uses sample outputs and simulated checks.') : tr('Revising the saved draft. Follow the result in the sidebar. This example uses sample outputs and simulated checks.') : m.content} onOpenLink={onOpenLink} onPreviewHtml={content => onPreview({ id: m.id + '-html', kind: 'website', title: 'HTML preview', content })}/>) : !m.error && m.streaming && !liveStatus ? (<span className="typing" aria-label={tr("Generating")}>
|
||||
<i />
|
||||
@@ -579,7 +581,7 @@ function Message({ message: m, isLast, onEditPrompt, onPreview, onOpenLink, onOp
|
||||
</div> : null)}
|
||||
{tr(m.streaming && isLast && !m.timeSkipId ? <StatusLine sessionId={activeSession} startedAt={Date.parse(m.at) || Date.now()} tokens={m.tokens}/> : null)}
|
||||
|
||||
{tr(m.timeSkipId ? <button className="timeskip-chat-card" onClick={() => onOpenTimeSkip(m.timeSkipId!)}><Icon name="branch" size={20}/><span><strong>{tr("Time-skip")}</strong><span>{tr(timeSkipDone ? 'Result ready · compare versions' : 'Revising · follow progress')}</span></span><Icon name="chevron" size={16} className="timeskip-card-arrow"/></button> : null)}
|
||||
{tr(m.timeSkipId && timeSkipReal && !timeSkipDone && timeSkipRun ? <TimeSkipWorking run={timeSkipRun} sessionId={activeSession ?? ''}/> : m.timeSkipId && (!timeSkipReal || (timeSkipRun?.job?.files?.length ?? 0) > 0) ? <button className="timeskip-chat-card" onClick={() => onOpenTimeSkip(m.timeSkipId!)}><Icon name="branch" size={20}/><span><strong>{tr("Time-skip")}</strong><span>{tr(timeSkipDone ? 'Result ready · compare versions' : 'Revising · follow progress')}</span></span><Icon name="chevron" size={16} className="timeskip-card-arrow"/></button> : null)}
|
||||
{tr(m.media?.length ? <ChatMedia items={m.media} onPreview={onPreview}/> : null)}
|
||||
{tr(m.artifacts?.length ? <div className="artifact-cards">{tr(m.artifacts.map(a => <ArtifactCard key={a.id} artifact={a} onPreview={onPreview}/>))}</div> : null)}
|
||||
|
||||
|
||||
@@ -7169,3 +7169,64 @@ textarea {
|
||||
.goalbar__text > span { color: var(--st-ink-muted); }
|
||||
.goalbar[data-phase="complete"] > svg { color: var(--st-accent-text); }
|
||||
.goalbar .st-btn--icon { width: 28px; height: 28px; }
|
||||
|
||||
/* ---- Time-skip: working in the chat ---- */
|
||||
.tsw { border: 1px solid var(--st-line); border-radius: var(--st-r-lg); background: var(--st-surface); box-shadow: var(--st-sh-card); overflow: hidden; margin: 6px 0; }
|
||||
.tsw__stage { padding: 24px 22px 16px; display: grid; grid-template-columns: 1fr 96px 1fr; align-items: center; gap: 8px; background: radial-gradient(120% 140% at 50% 0%, var(--st-accent-tint), transparent 70%); }
|
||||
.tsw__ver { background: var(--st-surface); border: 1px solid var(--st-line); border-radius: var(--st-r-md); padding: 12px 14px; display: flex; flex-direction: column; gap: 7px; box-shadow: var(--st-sh-card); }
|
||||
.tsw__ver span { font-size: var(--st-fs-xs); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--st-ink-muted); }
|
||||
.tsw__ver i { display: block; height: 6px; border-radius: 5px; background: var(--st-line); }
|
||||
.tsw__ver--b { border-color: var(--st-accent); }
|
||||
.tsw__ver--b i { background: linear-gradient(90deg, var(--st-accent), var(--st-accent-tint)); background-size: 200% 100%; animation: tsw-shimmer 1.6s linear infinite; }
|
||||
.tsw__flow { position: relative; height: 40px; }
|
||||
.tsw__flow::before { content: ""; position: absolute; left: 0; right: 0; top: 50%; height: 2px; background: var(--st-line); }
|
||||
.tsw__flow i { position: absolute; top: 50%; width: 8px; height: 8px; margin-top: -4px; border-radius: 50%; background: var(--st-accent); animation: tsw-flow 1.8s linear infinite; }
|
||||
.tsw__flow i:nth-child(2) { animation-delay: .6s; } .tsw__flow i:nth-child(3) { animation-delay: 1.2s; }
|
||||
.tsw__bar { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--st-line-soft); }
|
||||
.tsw__step { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.tsw__step strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tsw__prog { height: 6px; border-radius: 6px; background: var(--st-line); overflow: hidden; margin-top: 6px; }
|
||||
.tsw__prog i { display: block; height: 100%; background: var(--st-accent); border-radius: 6px; transition: width 800ms var(--st-ease); }
|
||||
@keyframes tsw-flow { from { left: 0; opacity: 0; } 15% { opacity: 1; } 85% { opacity: 1; } to { left: calc(100% - 8px); opacity: 0; } }
|
||||
@keyframes tsw-shimmer { to { background-position: -200% 0; } }
|
||||
@media (max-width: 560px) { .tsw__stage { grid-template-columns: 1fr; } .tsw__flow { display: none; } }
|
||||
@media (prefers-reduced-motion: reduce) { .tsw__flow i, .tsw__ver--b i { animation: none; } .tsw__prog i { transition: none; } }
|
||||
|
||||
/* ---- Time-skip: A versus B, one window whatever was made ---- */
|
||||
.tsc { display: flex; flex-direction: column; gap: 8px; margin: 10px 0; }
|
||||
.tsc-files { display: flex; gap: 2px; overflow-x: auto; border-bottom: 1px solid var(--st-line-soft); scrollbar-width: thin; }
|
||||
.tsc-files button { display: inline-flex; align-items: center; gap: 6px; padding: 7px 10px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--st-ink-muted); font: inherit; font-size: var(--st-fs-sm); cursor: pointer; white-space: nowrap; }
|
||||
.tsc-files button[aria-selected="true"] { color: var(--st-ink); font-weight: 700; border-bottom-color: var(--st-accent); }
|
||||
.tsc-files small, .tsc-one small { color: var(--st-ink-muted); font-weight: 400; }
|
||||
.tsc-more { align-self: center; padding: 0 8px; font-size: var(--st-fs-xs); color: var(--st-ink-muted); }
|
||||
.tsc-one { display: flex; align-items: center; gap: 8px; font-size: var(--st-fs-sm); }
|
||||
.tsc-stage { display: flex; flex-direction: column; height: clamp(240px, 46vh, 420px); overflow: auto; border: 1px solid var(--st-line); border-radius: var(--st-r-md); background: var(--st-surface); padding: 10px 12px; scrollbar-width: thin; }
|
||||
.tsc-heads { flex: none; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; position: sticky; top: -10px; z-index: 1; margin: -10px -12px 8px; padding: 8px 12px; background: var(--st-surface); border-bottom: 1px solid var(--st-line-soft); font-size: var(--st-fs-xs); color: var(--st-ink-muted); text-transform: uppercase; letter-spacing: .06em; }
|
||||
.tsc-heads b { color: var(--st-ink); }
|
||||
.tsc-pair { flex: none; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; align-items: start; }
|
||||
.tsc-pair--text > div + div { border-left: 1px solid var(--st-line-soft); padding-left: 12px; }
|
||||
.tsc-doc p { margin: 0 0 6px; line-height: 1.5; overflow-wrap: anywhere; font-size: var(--st-fs-sm); }
|
||||
.tsc-doc mark { background: rgba(16, 185, 129, .22); color: var(--st-ink); border-radius: 3px; padding: 0; box-decoration-break: clone; }
|
||||
.tsc-pair--text > div:first-child mark { background: rgba(224, 40, 63, .16); }
|
||||
.tsc-doc--md .tsc-h { font-weight: 700; }
|
||||
.tsc-doc--md .tsc-h1 { font-size: var(--st-fs-base); } .tsc-doc--md .tsc-h2 { font-size: var(--st-fs-md); }
|
||||
.tsc-doc--md .tsc-li { position: relative; padding-left: 16px; } .tsc-doc--md .tsc-li::before { content: "•"; position: absolute; left: 4px; color: var(--st-ink-muted); }
|
||||
.tsc-doc--md .tsc-code, .tsc-doc--md .tsc-fence { font-family: var(--st-font-mono); font-size: 12px; background: var(--st-surface-2); padding: 0 6px; margin: 0; }
|
||||
.tsc-pair--html { flex: 1 1 0; min-height: 120px; align-items: stretch; }
|
||||
.tsc-pair--html iframe { width: 100%; height: 100%; min-height: 120px; border: 1px solid var(--st-line); border-radius: var(--st-r-sm); background: #fff; }
|
||||
.tsc-pair--pictures { flex: 1 1 0; min-height: 120px; align-items: stretch; }
|
||||
.tsc-picture { display: grid; place-items: center; width: 100%; height: 100%; min-height: 160px; padding: 6px; border: 1px solid var(--st-line); border-radius: var(--st-r-sm); background: var(--st-surface-2); cursor: zoom-in; overflow: hidden; }
|
||||
.tsc-picture img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.tsc-picture--empty { cursor: default; color: var(--st-ink-muted); font-size: var(--st-fs-sm); }
|
||||
.tsc-enlarged { display: block; max-width: 100%; max-height: 78vh; margin: 0 auto; }
|
||||
.tsc-tools { flex: none; display: flex; justify-content: flex-end; margin: -4px 0 4px; }
|
||||
.tsc-note { margin: 0; color: var(--st-ink-muted); font-size: var(--st-fs-sm); }
|
||||
.tsc-diff { font-family: var(--st-font-mono); font-size: 12px; line-height: 1.5; }
|
||||
.tsc-diff__stats { display: flex; gap: 10px; margin-bottom: 6px; font-family: var(--st-font); font-size: var(--st-fs-xs); font-weight: 700; }
|
||||
.tsc-add { color: var(--st-accent-text); } .tsc-del { color: var(--st-danger-text); }
|
||||
.tsc-diff__row { display: grid; grid-template-columns: 34px 34px 14px 1fr; gap: 6px; }
|
||||
.tsc-diff__row code { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; }
|
||||
.tsc-diff__no { color: var(--st-ink-muted); text-align: right; user-select: none; }
|
||||
.tsc-diff__row--add { background: var(--st-accent-tint); } .tsc-diff__row--del { background: var(--st-danger-tint); }
|
||||
.tsc-diff__gap { padding: 2px 8px; color: var(--st-ink-muted); font-family: var(--st-font); font-size: var(--st-fs-xs); background: var(--st-surface-2); margin: 2px 0; }
|
||||
@media (max-width: 560px) { .tsc-pair, .tsc-heads { grid-template-columns: 1fr; } .tsc-pair--text > div + div { border-left: 0; padding-left: 0; } }
|
||||
@@ -0,0 +1,76 @@
|
||||
/** Item 4: the A versus B view adapts to the kind of file and keeps one window size. The pure parts. */
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { build } from 'esbuild';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
async function load(file) {
|
||||
const result = await build({ entryPoints: [fileURLToPath(new URL(`../src/lib/${file}`, import.meta.url))], bundle: true, format: 'esm', platform: 'node', write: false, alias: { react: fileURLToPath(new URL('./helpers/react-stub.mjs', import.meta.url)) }, logLevel: 'silent', loader: { '.json': 'json' } });
|
||||
return import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}`);
|
||||
}
|
||||
globalThis.window ??= globalThis; globalThis.localStorage ??= { getItem: () => null, setItem: () => {} };
|
||||
globalThis.navigator ??= { language: 'en' };
|
||||
const cmp = await load('time-skip-compare.ts');
|
||||
const act = await load('time-skip-activity.ts');
|
||||
|
||||
test('each kind of file gets its own view', () => {
|
||||
const k = (path, file) => cmp.fileKind(path, file);
|
||||
assert.equal(k('notes/plan.md'), 'markdown'); assert.equal(k('a.txt'), 'text'); assert.equal(k('site/index.html'), 'html'); assert.equal(k('b.HTM'), 'html');
|
||||
assert.equal(k('img/logo.PNG'), 'image'); assert.equal(k('x.svg'), 'image'); assert.equal(k('weird', { image: true }), 'image');
|
||||
assert.equal(k('src/app.py'), 'code'); assert.equal(k('data.bin', { unreadable: true }), 'other');
|
||||
assert.equal(k('photo.jpg', { unreadable: true }), 'image', 'a picture is not "unreadable": it is shown from the file');
|
||||
});
|
||||
|
||||
test('changed words are marked, unchanged text is identical on both sides, and nothing is lost', () => {
|
||||
const a = 'Dear Hiring Manager, I am writing to express my sincere interest in the role.';
|
||||
const b = 'Hello team, I am writing because I love the role.';
|
||||
const d = cmp.wordDiff(a, b);
|
||||
assert.equal(d.original.map(s => s.text).join(''), a); assert.equal(d.updated.map(s => s.text).join(''), b);
|
||||
assert.ok(d.original.some(s => s.changed && /Dear/.test(s.text)) && d.updated.some(s => s.changed && /Hello/.test(s.text)));
|
||||
assert.ok(d.original.some(s => !s.changed && /I am writing/.test(s.text)), 'what stayed is not marked');
|
||||
assert.deepEqual(cmp.wordDiff('same text', 'same text').updated, [{ text: 'same text', changed: false }]);
|
||||
const lines = cmp.segmentLines(cmp.wordDiff('# Title\n- one\n', '# Title\n- two\n').updated);
|
||||
assert.equal(lines[0].map(s => s.text).join(''), '# Title'); assert.equal(lines[1].map(s => s.text).join(''), '- two');
|
||||
assert.ok(lines[1].some(s => s.changed));
|
||||
});
|
||||
|
||||
test('a huge pair of texts does not freeze the page: the work is capped', () => {
|
||||
const big = Array.from({ length: 4000 }, (_, i) => `word${i}`).join(' ');
|
||||
const t = Date.now();
|
||||
const d = cmp.wordDiff(big, big + ' extra');
|
||||
assert.ok(Date.now() - t < 3000);
|
||||
assert.ok(d.updated.length >= 1);
|
||||
});
|
||||
|
||||
test('code and other files get a short line diff with the unchanged stretches folded', () => {
|
||||
const a = Array.from({ length: 40 }, (_, i) => `line ${i}`).join('\n');
|
||||
const b = a.replace('line 20', 'line twenty') + '\nline 40';
|
||||
const rows = cmp.lineDiff(a, b);
|
||||
const stats = cmp.diffStats(rows);
|
||||
assert.deepEqual(stats, { added: 2, removed: 1 });
|
||||
assert.ok(rows.length < 14, `folded to ${rows.length} rows`);
|
||||
assert.ok(rows.some(r => r.t === 'gap' && r.n > 10));
|
||||
assert.deepEqual(cmp.lineDiff('', 'a\nb').map(r => r.t), ['add', 'add'], 'a new file is all additions');
|
||||
assert.deepEqual(cmp.lineDiff('a', '').map(r => r.t), ['del'], 'a removed file is all removals');
|
||||
});
|
||||
|
||||
test('an HTML page is shown with no way to reach out: a policy is added to the page itself', () => {
|
||||
const withHead = cmp.htmlPreviewDocument('<html><head><title>x</title></head><body><script>alert(1)</script><img src="https://evil.example/a.png"></body></html>');
|
||||
assert.match(withHead, /Content-Security-Policy/); assert.match(withHead, /default-src 'none'/); assert.ok(withHead.indexOf('Content-Security-Policy') < withHead.indexOf('<script>'), 'the policy comes first');
|
||||
const bare = cmp.htmlPreviewDocument('<h1>Hi</h1>');
|
||||
assert.match(bare, /<body><h1>Hi<\/h1><\/body>/); assert.match(bare, /Content-Security-Policy/);
|
||||
assert.equal(cmp.timeSkipImageUrl('abc12345', 'img/a b.png', 'updated'), '/api/timeskip/file?id=abc12345&path=img%2Fa%20b.png&version=updated');
|
||||
});
|
||||
|
||||
test('the working view says what is happening, from the host\'s words or the phase', async () => {
|
||||
assert.equal(act.activityWords({ phase: 'generating', activity: { code: 'trying-version', text: 'x', args: { step: 'Writing notes.md…' } } }), 'Writing notes.md…');
|
||||
assert.equal(act.activityWords({ phase: 'generating', activity: { code: 'trying-version', text: 'x' } }), 'Trying an improved version…');
|
||||
assert.equal(act.activityWords({ phase: 'checking', activity: { code: 'checking-result', text: 'x' } }), 'Checking the result…');
|
||||
assert.equal(act.activityWords({ phase: 'queued' }), 'Reading your files…');
|
||||
assert.equal(act.activityWords({ phase: 'checking' }), 'Checking the result…');
|
||||
assert.ok(act.phaseFraction('generating') > act.phaseFraction('checkpointing') && act.phaseFraction('checking') > act.phaseFraction('generating'));
|
||||
const panel = await readFile(new URL('../src/components/TimeSkipCompare.tsx', import.meta.url), 'utf8');
|
||||
assert.match(panel, /sandbox=""/, 'the preview frames carry no permissions');
|
||||
assert.ok(!/allow-scripts|allow-same-origin/.test(panel));
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/** Item 4: the Compare view can show pictures side by side, so the host lists them as pictures and serves them safely. */
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { diffRun, imageTypeOf, runImagePath } from '../server/timeskip.mjs';
|
||||
|
||||
function fixture() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'strata-cmp-'));
|
||||
const base = join(dir, 'base'), upd = join(dir, 'upd'), outside = join(dir, 'secret.png');
|
||||
mkdirSync(join(base, 'img'), { recursive: true }); mkdirSync(join(upd, 'img'), { recursive: true });
|
||||
writeFileSync(outside, 'not for you');
|
||||
writeFileSync(join(base, 'index.html'), '<h1>Old</h1>'); writeFileSync(join(upd, 'index.html'), '<h1>New</h1>');
|
||||
writeFileSync(join(base, 'img', 'logo.png'), Buffer.from([137, 80, 78, 71, 0, 1, 2])); writeFileSync(join(upd, 'img', 'logo.png'), Buffer.from([137, 80, 78, 71, 0, 9, 9, 9]));
|
||||
writeFileSync(join(base, 'same.png'), Buffer.from([137, 80, 78, 71, 0])); writeFileSync(join(upd, 'same.png'), Buffer.from([137, 80, 78, 71, 0]));
|
||||
writeFileSync(join(upd, 'new.svg'), '<svg xmlns="http://www.w3.org/2000/svg"/>');
|
||||
const record = { checkpoints: [{ id: 'cp1', label: 'baseline', path: base }], selected_checkpoint: null, attempts: [{ index: 1, path: upd }] };
|
||||
return { dir, record };
|
||||
}
|
||||
|
||||
test('pictures are listed as pictures (changed ones only), text files keep their text', () => {
|
||||
const { dir, record } = fixture();
|
||||
try {
|
||||
const found = diffRun(record);
|
||||
const by = Object.fromEntries(found.files.map(f => [f.path, f]));
|
||||
assert.equal(by['img/logo.png'].image, true); assert.equal(by['img/logo.png'].status, 'changed'); assert.equal(by['img/logo.png'].original, undefined);
|
||||
assert.equal(by['new.svg'].image, true); assert.equal(by['new.svg'].status, 'added');
|
||||
assert.equal(by['same.png'], undefined, 'an unchanged picture is not listed');
|
||||
assert.equal(by['index.html'].original, '<h1>Old</h1>'); assert.equal(by['index.html'].updated, '<h1>New</h1>');
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('a picture is served only from inside the run, only as a picture', () => {
|
||||
const { dir, record } = fixture();
|
||||
try {
|
||||
assert.ok(runImagePath(record, 'img/logo.png', 'original')?.endsWith('logo.png'));
|
||||
assert.ok(runImagePath(record, 'img/logo.png', 'updated'));
|
||||
assert.equal(runImagePath(record, 'img/logo.png', 'other'), null);
|
||||
assert.equal(runImagePath(record, '../secret.png', 'original'), null, 'no way out of the folder');
|
||||
assert.equal(runImagePath(record, 'img/../../secret.png', 'updated'), null);
|
||||
assert.equal(runImagePath(record, 'index.html', 'updated'), null, 'a page is not a picture');
|
||||
assert.equal(runImagePath(record, 'missing.png', 'updated'), null);
|
||||
assert.equal(imageTypeOf('a/b/C.JPG'), 'image/jpeg'); assert.equal(imageTypeOf('a.txt'), null);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
Reference in new issue
Block a user