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.
- package/dist/client/assets/index-BDPV6BTa.js +2009 -0
- package/dist/client/assets/{index-DzxOqQtK.css → index-BLAQpfJ9.css} +2 -2
- package/dist/client/index.html +2 -2
- package/package.json +2 -5
- package/src/client/components/AssistantMarkdown.tsx +74 -83
- package/src/client/components/ChatView.tsx +2 -2
- package/src/server/scheduler/outcome.ts +20 -5
- package/src/server/scheduler/runner.ts +12 -1
- package/dist/client/assets/index-C50rEoAa.js +0 -2112
- package/src/client/bare-v3.d.ts +0 -15
- package/src/client/components/AssistantMarkdown.css +0 -18
|
@@ -1,94 +1,85 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import '
|
|
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 './
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 '
|
|
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.
|
|
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
|
-
/**
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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","
|
|
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(
|
|
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}).` };
|