shraga 0.1.147 → 0.1.149

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,94 +1,85 @@
1
- import { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
2
- import { Marked } from 'marked';
3
- import DOMPurify from 'dompurify';
4
- import hljs from 'highlight.js/lib/common';
5
- import { MarkdownStream, codeBlock } from '@livx.cc/bare-v3/elements/markdown-stream';
6
- import '@livx.cc/bare-v3/css';
1
+ import { useRef, useState } from 'react';
2
+ import ReactMarkdown from 'react-markdown';
3
+ import remarkGfm from 'remark-gfm';
4
+ import rehypeHighlight from 'rehype-highlight';
5
+ import type { Root, Element } from 'hast';
6
+ import type { Plugin } from 'unified';
7
+ import { Copy, Check } from 'lucide-react';
7
8
  import 'highlight.js/styles/github.css';
8
- import './AssistantMarkdown.css';
9
- import { NEEDS_AUTH, authedBlobUrl } from './AuthedImage';
10
- import { useWorkspace } from '@/lib/workspaceContext';
11
- import { useDarkMode } from '@/hooks/useDarkMode';
12
- import { logger } from '@/lib/debug';
9
+ import { AuthedImage, NEEDS_AUTH } from './AuthedImage';
13
10
 
14
- const log = logger.forComponent('AssistantMarkdown');
11
+ // Assistant-reply markdown renderer — a replaceable seam: ChatView imports it as
12
+ // `@/components/AssistantMarkdown`, so a distribution overrides it by shipping a file at that path
13
+ // (the EE build's EE-first `@/` alias). Keep the props contract stable.
15
14
 
16
- // Assistant text renders through bare-v3's MarkdownStream: finished blocks freeze, only the tail
17
- // re-renders, and half-written syntax never flashes raw. One instance per text block, so text
18
- // segments split by tool calls each get their own stream.
19
-
20
- const RTL_BLOCK_TAGS = new Set(['P', 'LI', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'TD', 'TH']);
21
- // bare-v3 component roots — `not-prose` keeps Tailwind Typography off them (it would override their styles).
22
- const B3_BLOCKS = ['b3-table-wrap', 'b3-alert', 'b3-md-code', 'b3-md-stats', 'b3-md-bars', 'b3-md-tasks'];
23
- const IMG_CLASS = 'max-h-[80vh] max-w-full rounded-xl border object-contain cursor-pointer hover:opacity-80 transition-opacity';
24
-
25
- const purify = DOMPurify(window);
26
- purify.addHook('afterSanitizeAttributes', (node) => {
27
- const el = node as Element;
28
- if (RTL_BLOCK_TAGS.has(el.tagName)) el.setAttribute('dir', 'auto');
29
- if (B3_BLOCKS.some((c) => el.classList?.contains(c))) el.classList.add('not-prose');
30
- if (el.tagName === 'A' && el.hasAttribute('href')) { el.setAttribute('target', '_blank'); el.setAttribute('rel', 'noopener noreferrer'); }
31
- if (el.tagName === 'IMG') {
32
- el.setAttribute('class', IMG_CLASS);
33
- const src = el.getAttribute('src') ?? '';
34
- // `/uploads/*` 401s without a bearer header — park the src; resolveImages() swaps in a blob URL.
35
- if (NEEDS_AUTH.test(src)) { el.setAttribute('data-authed-src', src); el.removeAttribute('src'); }
36
- }
37
- });
38
- const sanitize = (html: string) => purify.sanitize(html);
39
-
40
- const code = (text: string, lang: string) => {
41
- const base = codeBlock(text, lang);
42
- if (!lang || !hljs.getLanguage(lang)) return base;
43
- const html = hljs.highlight(text, { language: lang, ignoreIllegals: true }).value;
44
- return base.replace(/<code>[\s\S]*<\/code>/, () => `<code class="hljs language-${lang}">${html}</code>`);
15
+ const RTL_BLOCK_TAGS = new Set(['p', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'td', 'th']);
16
+ const rehypeBidi: Plugin<[], Root> = () => (tree) => {
17
+ const visit = (node: Root | Element) => {
18
+ for (const child of (node.children ?? [])) {
19
+ if (child.type === 'element') {
20
+ if (RTL_BLOCK_TAGS.has(child.tagName)) {
21
+ child.properties ??= {};
22
+ child.properties.dir = 'auto';
23
+ }
24
+ visit(child);
25
+ }
26
+ }
27
+ };
28
+ visit(tree);
45
29
  };
46
30
 
47
- export function AssistantMarkdown({ text, streaming = false, onImageClick }: { text: string; streaming?: boolean; onImageClick?: (src: string) => void }) {
48
- const clean = text.replace(/\[Image #\d+\]\s*/g, '').trim();
49
- const ref = useRef<HTMLDivElement>(null);
50
- const view = useMemo(() => new MarkdownStream({ Marked, sanitize, code, images: (src) => NEEDS_AUTH.test(src) }), []);
51
- const shown = useRef<{ text: string; live: boolean } | null>(null);
52
- const blobs = useRef(new Map<string, Promise<string>>());
53
- const { getToken } = useWorkspace();
54
- const { dark } = useDarkMode();
31
+ export interface AssistantMarkdownProps {
32
+ text: string;
33
+ /** This block is still streaming (a renderer may use it; this one re-renders whole). */
34
+ streaming?: boolean;
35
+ onImageClick?: (src: string) => void;
36
+ }
55
37
 
56
- useLayoutEffect(() => {
57
- const el = ref.current;
58
- if (!el) return;
59
- const s = shown.current;
60
- const extends_ = !!s?.live && clean.startsWith(s.text);
61
- if (streaming) {
62
- if (!extends_) { el.replaceChildren(); view.attach(el); view.push(clean); }
63
- else if (clean.length > s!.text.length) view.push(clean.slice(s!.text.length));
64
- } else if (extends_) {
65
- view.push(clean.slice(s!.text.length));
66
- view.end();
67
- } else if (!s || s.text !== clean) {
68
- view.attach(el); // binds the Copy-button delegate; toHtml renders the settled message in one pass
69
- el.innerHTML = view.toHtml(clean);
70
- }
71
- shown.current = { text: clean, live: streaming };
72
- for (const img of el.querySelectorAll<HTMLImageElement>('img[data-authed-src]:not([src])')) {
73
- const src = img.dataset.authedSrc!;
74
- if (!blobs.current.has(src)) blobs.current.set(src, authedBlobUrl(src, getToken));
75
- blobs.current.get(src)!.then((url) => { img.src = url; }, (err) => log.error('image load failed:', src, err));
76
- }
77
- }, [clean, streaming, view, getToken]);
38
+ export function AssistantMarkdown({ text, onImageClick }: AssistantMarkdownProps) {
39
+ const clean = text.replace(/\[Image #\d+\]\s*/g, '').trim();
40
+ return (
41
+ <ReactMarkdown
42
+ remarkPlugins={[remarkGfm]}
43
+ rehypePlugins={[rehypeHighlight, rehypeBidi]}
44
+ className="prose prose-sm max-w-none dark:prose-invert prose-pre:bg-muted prose-pre:border prose-code:before:content-none prose-code:after:content-none break-words min-w-0"
45
+ components={{
46
+ pre: ({ children, ...props }) => <CodeBlock {...props}>{children}</CodeBlock>,
47
+ a: ({ href, children }) => <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>,
48
+ // Only same-origin /uploads images load; anything else in model output (exfil via a
49
+ // prompt-injected image URL) shows its alt text instead of auto-fetching.
50
+ img: ({ src, alt }) => src && NEEDS_AUTH.test(src)
51
+ ? <AuthedImage src={src} alt={alt ?? ''} className="max-h-[80vh] max-w-full rounded-xl border object-contain cursor-pointer hover:opacity-80 transition-opacity" onClick={(s) => onImageClick?.(s)} />
52
+ : <span>{alt}</span>,
53
+ }}
54
+ >{clean}</ReactMarkdown>
55
+ );
56
+ }
78
57
 
79
- useEffect(() => () => {
80
- view.detach();
81
- shown.current = null;
82
- for (const p of blobs.current.values()) p.then(URL.revokeObjectURL, () => {});
83
- blobs.current.clear();
84
- }, [view]);
58
+ function CodeBlock({ children, ...props }: any) {
59
+ const ref = useRef<HTMLPreElement>(null);
60
+ return (
61
+ <div className="relative group">
62
+ <pre ref={ref} {...props} className="rounded-lg border bg-muted p-4 overflow-x-auto text-xs text-foreground">
63
+ {children}
64
+ </pre>
65
+ <CopyButton getText={() => ref.current?.textContent || ''} />
66
+ </div>
67
+ );
68
+ }
85
69
 
70
+ function CopyButton({ getText }: { getText: () => string }) {
71
+ const [copied, setCopied] = useState(false);
86
72
  return (
87
- <div
88
- ref={ref}
89
- data-b3-theme={dark ? 'dark' : 'light'}
90
- onClick={(e) => { const t = e.target as HTMLElement; if (t instanceof HTMLImageElement && t.src) onImageClick?.(t.src); }}
91
- className="b3-root assistant-md bg-transparent prose prose-sm max-w-none dark:prose-invert prose-code:before:content-none prose-code:after:content-none break-words min-w-0"
92
- />
73
+ <button
74
+ onClick={() => {
75
+ navigator.clipboard.writeText(getText());
76
+ setCopied(true);
77
+ setTimeout(() => setCopied(false), 2000);
78
+ }}
79
+ className="absolute top-2 right-2 p-1.5 rounded-md bg-background/80 border opacity-0 group-hover:opacity-100 transition-opacity"
80
+ title="Copy"
81
+ >
82
+ {copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
83
+ </button>
93
84
  );
94
85
  }
@@ -10,12 +10,12 @@ import { AuthedImage, AuthedFileLink } from './AuthedImage';
10
10
  import { useSlots } from '@/lib/slots';
11
11
  import { Dialog, DialogContent } from '@/components/ui/dialog';
12
12
  import { ZoomableImage } from './ZoomableImage';
13
- import { AssistantMarkdown } from './AssistantMarkdown';
13
+ import { AssistantMarkdown } from '@/components/AssistantMarkdown';
14
14
 
15
15
  // The appwrap iOS WKWebView wedges its GPU compositor when rendering react-markdown's DOM for a chat with
16
16
  // history — JS keeps running but paint + hit-testing freeze for ~1-2min (screen stuck/touch-dead) before
17
17
  // WebKit lazily restores. The SAME content in mobile Safari and on desktop is fine, so this is specific to
18
- // the native WKWebView shell, not the web app. (Observed with react-markdown; MarkdownStream is unverified there.) Until the native shell issue is fixed, render assistant
18
+ // the native WKWebView shell, not the web app. Until the native shell issue is fixed, render assistant
19
19
  // messages as PLAIN TEXT inside the native app only; full markdown stays on Safari/desktop/web.
20
20
  const IS_APPWRAP_NATIVE = typeof window !== 'undefined' && !!(window as { webkit?: { messageHandlers?: { appwrap?: unknown } } }).webkit?.messageHandlers?.appwrap;
21
21
 
@@ -33,6 +33,8 @@ export interface DeclaredOutcome {
33
33
  error?: string;
34
34
  /** For `pending`: when the run gives up and is recorded as failed. Epoch ms or ISO-8601. */
35
35
  deadline?: number | string;
36
+ /** For `pending`: minutes from the moment of declaration. Preferred over `deadline` — no clock math. */
37
+ deadlineInMin?: number;
36
38
  }
37
39
 
38
40
  const dir = (): string => { const d = dataPath('scheduler', 'outcomes'); mkdirSync(d, { recursive: true }); return d; };
@@ -72,21 +74,34 @@ export function writeOutcome(sessionId: string, o: DeclaredOutcome): void {
72
74
  writeFileSync(outcomeFile(sessionId), JSON.stringify(o));
73
75
  }
74
76
 
75
- /** Absolute epoch ms a `pending` run expires at, clamped to MAX_PENDING_MS. */
76
- export function pendingDeadline(o: DeclaredOutcome, now: number): number {
77
+ /**
78
+ * Absolute epoch ms a `pending` declaration expires at, resolved ONCE at the moment it was first seen.
79
+ *
80
+ * `deadlineInMin` wins over `deadline`. An absolute `deadline` that is missing, unparseable, or
81
+ * already in the past at declaration time is treated as a mistake in the declaration (a run that
82
+ * just said "still working" cannot have already run out of time) and falls back to the default
83
+ * window instead of failing the run on the spot. `pastDeadline` reports that fallback so the
84
+ * caller can surface it. Always clamped to MAX_PENDING_MS from declaration.
85
+ */
86
+ export function pendingDeadline(o: DeclaredOutcome, declaredAt: number): { deadline: number; pastDeadline: boolean } {
87
+ const cap = declaredAt + MAX_PENDING_MS;
88
+ if (typeof o.deadlineInMin === 'number' && Number.isFinite(o.deadlineInMin) && o.deadlineInMin > 0)
89
+ return { deadline: Math.min(declaredAt + o.deadlineInMin * 60_000, cap), pastDeadline: false };
77
90
  const raw = typeof o.deadline === 'string' ? Date.parse(o.deadline) : o.deadline;
78
- const asked = Number.isFinite(raw) ? (raw as number) : now + DEFAULT_PENDING_MS;
79
- return Math.min(Math.max(asked, now), now + MAX_PENDING_MS);
91
+ if (!Number.isFinite(raw)) return { deadline: declaredAt + DEFAULT_PENDING_MS, pastDeadline: false };
92
+ if ((raw as number) <= declaredAt) return { deadline: declaredAt + DEFAULT_PENDING_MS, pastDeadline: true };
93
+ return { deadline: Math.min(raw as number, cap), pastDeadline: false };
80
94
  }
81
95
 
82
96
  /** The contract, appended to a scheduled prompt run so a run can report itself truthfully. */
83
97
  export function outcomePrompt(sessionId: string): string {
84
98
  return `# Reporting this run's outcome
99
+ Current time: ${new Date().toISOString()} (UTC).
85
100
  This is a scheduled run. Unless you say otherwise, it is recorded as SUCCESSFUL the moment your turn returns — which is a lie whenever the work failed, was skipped, or is still in flight. Correct that by writing this file:
86
101
  \`${outcomeFile(sessionId)}\`
87
102
  - \`{"status":"ok"}\` — the run delivered what it was for.
88
103
  - \`{"status":"error","error":"<what went wrong>"}\` — it did not. This is what raises the failure alert; write it for a leg that never ran, a worker that died, or work you could not finish.
89
- - \`{"status":"pending","deadline":"<ISO-8601>"}\` — work you started outlives this turn (e.g. a background job). The run stays open and NOT successful until you write a terminal status from a later turn; if the deadline passes with no terminal status, the run is recorded as failed automatically.
104
+ - \`{"status":"pending","deadlineInMin":<minutes>}\` — work you started outlives this turn (e.g. a background job). Give the deadline as minutes from now (pad it generously); an absolute \`"deadline":"<ISO-8601>"\` is also accepted, but a deadline already in the past is ignored and replaced with ${DEFAULT_PENDING_MS / 60_000} minutes. The run stays open and NOT successful until you write a terminal status from a later turn; if the deadline passes with no terminal status, the run is recorded as failed automatically.
90
105
  Write it atomically — write a temp file next to it and \`mv\` it into place — so a reader can never catch it half-written.
91
106
  Declare \`pending\` BEFORE you end a turn that leaves work running, and re-declare \`ok\`/\`error\` from the turn that sees it finish. Never declare \`ok\` for a run that did not deliver.`;
92
107
  }
@@ -147,9 +147,20 @@ async function resolveDeclaredOutcome(
147
147
  return next && next > Date.now() ? next - 60_000 : Infinity;
148
148
  };
149
149
  const absoluteStop = Date.now() + MAX_PENDING_MS;
150
+ // Each distinct declaration is resolved once, when first seen: a re-declared `pending` extends
151
+ // the wait, but re-reading the same one must not keep moving its deadline.
152
+ let seen = '';
153
+ let declaredDeadline = 0;
150
154
  while (declared?.status === 'pending' && !ac.signal.aborted) {
155
+ const key = JSON.stringify(declared);
156
+ if (key !== seen) {
157
+ seen = key;
158
+ const r = pendingDeadline(declared, Date.now());
159
+ declaredDeadline = r.deadline;
160
+ if (r.pastDeadline) console.warn(`[scheduler] ${sessionId} declared pending with a deadline already in the past (${String(declared.deadline)}); using the default window instead`);
161
+ }
151
162
  const stop = windowStop();
152
- const deadline = Math.min(pendingDeadline(declared, Date.now()), absoluteStop, stop);
163
+ const deadline = Math.min(declaredDeadline, absoluteStop, stop);
153
164
  if (Date.now() >= deadline) {
154
165
  const why = deadline === stop ? 'its next scheduled window arrived first' : `deadline ${new Date(deadline).toISOString()}`;
155
166
  return { status: 'error', error: `Run declared itself still in flight and never reported a terminal outcome (${why}).` };