@aion0/forge 0.10.51 → 0.10.55
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/RELEASE_NOTES.md +3 -3
- package/app/api/cache/route.ts +125 -41
- package/app/api/chat/sessions/[id]/abort/route.ts +14 -0
- package/app/api/chat/sessions/[id]/note/route.ts +16 -0
- package/app/api/files/[...path]/route.ts +94 -0
- package/app/api/scratch/[...path]/route.ts +5 -0
- package/app/chat/page.tsx +237 -36
- package/app/files/[...path]/page.tsx +22 -0
- package/app/scratch/[...path]/page.tsx +24 -0
- package/components/Dashboard.tsx +76 -24
- package/components/ScratchViewer.tsx +152 -0
- package/lib/chat/agent-loop.ts +72 -2
- package/lib/chat/link-patterns.ts +29 -6
- package/lib/chat/tool-dispatcher.ts +270 -17
- package/lib/chat/turn-control.ts +81 -0
- package/lib/chat-standalone.ts +51 -0
- package/lib/help-docs/10-troubleshooting.md +16 -0
- package/lib/help-docs/17-connectors.md +19 -0
- package/lib/help-docs/25-chat-tools.md +125 -0
- package/lib/help-docs/CLAUDE.md +2 -0
- package/lib/scratch-cleanup.ts +25 -16
- package/package.json +1 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import MarkdownContent from './MarkdownContent';
|
|
5
|
+
|
|
6
|
+
const TEXT_LIKE = new Set(['md', 'txt', 'log', 'json', 'yaml', 'yml', 'csv', 'html']);
|
|
7
|
+
const IMAGE_LIKE = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg']);
|
|
8
|
+
const EMBED_LIKE = new Set(['pdf']);
|
|
9
|
+
|
|
10
|
+
/** Cap inline text rendering. Anything larger falls back to a download
|
|
11
|
+
* prompt — react-markdown on multi-MB strings locks the tab. */
|
|
12
|
+
const MAX_INLINE_BYTES = 2 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
function extOf(p: string): string {
|
|
15
|
+
const m = p.match(/\.([^./]+)$/);
|
|
16
|
+
return m ? m[1].toLowerCase() : '';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function ScratchViewer({
|
|
20
|
+
path,
|
|
21
|
+
apiBase = '/api/scratch',
|
|
22
|
+
topLabel = 'scratch',
|
|
23
|
+
}: {
|
|
24
|
+
path: string;
|
|
25
|
+
/** API route prefix. /api/scratch resolves under <dataDir>/scratch/;
|
|
26
|
+
* /api/files resolves dataDir-wide. Default scratch for back-compat. */
|
|
27
|
+
apiBase?: string;
|
|
28
|
+
/** Tiny header tag shown next to the file path (e.g. "scratch", "file"). */
|
|
29
|
+
topLabel?: string;
|
|
30
|
+
}) {
|
|
31
|
+
const decoded = (() => {
|
|
32
|
+
try {
|
|
33
|
+
return decodeURIComponent(path);
|
|
34
|
+
} catch {
|
|
35
|
+
return path;
|
|
36
|
+
}
|
|
37
|
+
})();
|
|
38
|
+
const ext = extOf(decoded);
|
|
39
|
+
const rawUrl = `${apiBase}/${path}`;
|
|
40
|
+
const downloadUrl = `${rawUrl}?download=1`;
|
|
41
|
+
|
|
42
|
+
const [text, setText] = useState<string | null>(null);
|
|
43
|
+
const [err, setErr] = useState<string>('');
|
|
44
|
+
const [tooLarge, setTooLarge] = useState(false);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!TEXT_LIKE.has(ext)) return;
|
|
48
|
+
let cancelled = false;
|
|
49
|
+
(async () => {
|
|
50
|
+
try {
|
|
51
|
+
const r = await fetch(rawUrl);
|
|
52
|
+
if (!r.ok) {
|
|
53
|
+
if (!cancelled) setErr(`${r.status} ${r.statusText}`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const len = Number(r.headers.get('content-length') || '0');
|
|
57
|
+
if (len > MAX_INLINE_BYTES) {
|
|
58
|
+
if (!cancelled) setTooLarge(true);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const body = await r.text();
|
|
62
|
+
if (cancelled) return;
|
|
63
|
+
if (body.length > MAX_INLINE_BYTES) {
|
|
64
|
+
setTooLarge(true);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
setText(body);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
if (!cancelled) setErr((e as Error).message);
|
|
70
|
+
}
|
|
71
|
+
})();
|
|
72
|
+
return () => {
|
|
73
|
+
cancelled = true;
|
|
74
|
+
};
|
|
75
|
+
}, [rawUrl, ext]);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="min-h-screen bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
|
79
|
+
<header className="sticky top-0 z-10 flex items-center justify-between gap-2 px-4 py-2 border-b border-[var(--border)] bg-[var(--bg-secondary)]">
|
|
80
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
81
|
+
<span className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)]">{topLabel}</span>
|
|
82
|
+
<span className="text-xs font-mono truncate" title={decoded}>
|
|
83
|
+
{decoded}
|
|
84
|
+
</span>
|
|
85
|
+
</div>
|
|
86
|
+
<div className="flex items-center gap-2 flex-shrink-0">
|
|
87
|
+
<a
|
|
88
|
+
href={rawUrl}
|
|
89
|
+
className="text-xs px-2 py-1 rounded border border-[var(--border)] hover:bg-[var(--bg-tertiary)]"
|
|
90
|
+
target="_blank"
|
|
91
|
+
rel="noopener"
|
|
92
|
+
>
|
|
93
|
+
Open raw
|
|
94
|
+
</a>
|
|
95
|
+
<a
|
|
96
|
+
href={downloadUrl}
|
|
97
|
+
className="text-xs px-2 py-1 rounded bg-[var(--accent)] text-white hover:opacity-90"
|
|
98
|
+
>
|
|
99
|
+
Download
|
|
100
|
+
</a>
|
|
101
|
+
</div>
|
|
102
|
+
</header>
|
|
103
|
+
|
|
104
|
+
<main className="px-4 py-3 max-w-4xl mx-auto">
|
|
105
|
+
{err && (
|
|
106
|
+
<div className="text-xs text-red-400 border border-red-400/40 rounded p-2 bg-red-400/5">
|
|
107
|
+
Failed to load: {err}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
|
|
111
|
+
{tooLarge && (
|
|
112
|
+
<div className="text-xs text-[var(--text-secondary)] border border-[var(--border)] rounded p-3">
|
|
113
|
+
File is larger than {Math.round(MAX_INLINE_BYTES / 1024 / 1024)} MB — inline preview skipped. Use Download or Open raw.
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{!err && !tooLarge && IMAGE_LIKE.has(ext) && (
|
|
118
|
+
<img src={rawUrl} alt={decoded} className="max-w-full rounded border border-[var(--border)]" />
|
|
119
|
+
)}
|
|
120
|
+
|
|
121
|
+
{!err && !tooLarge && EMBED_LIKE.has(ext) && (
|
|
122
|
+
<embed src={rawUrl} type="application/pdf" className="w-full h-[calc(100vh-60px)]" />
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
{!err && !tooLarge && TEXT_LIKE.has(ext) && text != null && (
|
|
126
|
+
ext === 'md' ? (
|
|
127
|
+
<MarkdownContent content={text} />
|
|
128
|
+
) : ext === 'html' ? (
|
|
129
|
+
// Wrap in iframe srcdoc so any embedded scripts can't reach Forge's
|
|
130
|
+
// origin or steal session cookies. sandbox blocks everything.
|
|
131
|
+
<iframe
|
|
132
|
+
srcDoc={text}
|
|
133
|
+
sandbox=""
|
|
134
|
+
className="w-full h-[calc(100vh-60px)] bg-white rounded border border-[var(--border)]"
|
|
135
|
+
title={decoded}
|
|
136
|
+
/>
|
|
137
|
+
) : (
|
|
138
|
+
<pre className="text-[12px] font-mono text-[var(--text-primary)] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded p-3 overflow-auto whitespace-pre-wrap break-words" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace' }}>
|
|
139
|
+
{text}
|
|
140
|
+
</pre>
|
|
141
|
+
)
|
|
142
|
+
)}
|
|
143
|
+
|
|
144
|
+
{!err && !tooLarge && !TEXT_LIKE.has(ext) && !IMAGE_LIKE.has(ext) && !EMBED_LIKE.has(ext) && (
|
|
145
|
+
<div className="text-xs text-[var(--text-secondary)] border border-[var(--border)] rounded p-3">
|
|
146
|
+
No inline preview for <code className="font-mono">.{ext || '?'}</code> files. Use Download.
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
</main>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { buildMemoryContext } from './build-memory-context';
|
|
|
28
28
|
import { buildReferencePromptSection } from './reference-prompt';
|
|
29
29
|
import { buildMemoryTools } from './memory-tools';
|
|
30
30
|
import { buildStartWatchTool } from '../watch/start-watch-tool';
|
|
31
|
+
import { beginTurn, endTurn, isAborted, consumeNotes } from './turn-control';
|
|
31
32
|
import { estimateTokens } from '../memory/token-estimate';
|
|
32
33
|
import {
|
|
33
34
|
listInstalledConnectors,
|
|
@@ -419,9 +420,16 @@ function buildSystemPrompt(
|
|
|
419
420
|
}
|
|
420
421
|
}
|
|
421
422
|
if (builtinDefs.length > 0) {
|
|
423
|
+
// Builtins are always-active (no connector_open gate) — list their
|
|
424
|
+
// FULL description so per-tool rules like "save_tmp_file: output
|
|
425
|
+
// user_message verbatim" / "dispatch_task: ASK before classifying
|
|
426
|
+
// a save as a task" reach the LLM here, not just buried in the
|
|
427
|
+
// tools schema. The connector catalog above can be terse because
|
|
428
|
+
// those tools only become live after connector_open; builtins
|
|
429
|
+
// never go through that gate, so terseness here loses real guidance.
|
|
422
430
|
lines.push('', 'Builtin tools (always available):');
|
|
423
431
|
for (const t of builtinDefs) {
|
|
424
|
-
lines.push(`- ${t.name}: ${t.description
|
|
432
|
+
lines.push(`- ${t.name}: ${t.description}`);
|
|
425
433
|
}
|
|
426
434
|
}
|
|
427
435
|
|
|
@@ -754,10 +762,52 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
754
762
|
let lastStop = '';
|
|
755
763
|
let assistantBlocksAccum: ContentBlock[] = [];
|
|
756
764
|
|
|
765
|
+
// Mark this turn live so the user can abort it / inject notes mid-flight
|
|
766
|
+
// (see turn-control.ts). Cleared in the finally below.
|
|
767
|
+
beginTurn(args.sessionId);
|
|
768
|
+
|
|
757
769
|
try {
|
|
758
770
|
while (iter < MAX_ITERATIONS) {
|
|
759
771
|
iter += 1;
|
|
760
772
|
|
|
773
|
+
// ── User intervention (turn-control) ────────────────────────
|
|
774
|
+
// Abort: stop the loop cleanly at this boundary with a sentinel so
|
|
775
|
+
// the turn doesn't just vanish. (The current in-flight LLM call /
|
|
776
|
+
// tool batch from the previous iteration has already settled here.)
|
|
777
|
+
if (isAborted(args.sessionId)) {
|
|
778
|
+
const stopMsg = appendMessage({
|
|
779
|
+
session_id: args.sessionId,
|
|
780
|
+
role: 'assistant',
|
|
781
|
+
blocks: [{ type: 'text', text: '⏹ Stopped by user.' } as TextBlock],
|
|
782
|
+
});
|
|
783
|
+
cb({ type: 'message_saved', message_id: stopMsg.id, data: stopMsg });
|
|
784
|
+
lastStop = 'aborted';
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
// Supplementary notes: splice any queued user notes in as a user
|
|
788
|
+
// message so THIS iteration's LLM call sees them. Safe after a
|
|
789
|
+
// tool_result message — the adapter emits that as a `tool` message,
|
|
790
|
+
// so a following `user` message doesn't collide on role.
|
|
791
|
+
// Drain notes — each becomes its own user message so the thread
|
|
792
|
+
// shows them in arrival order (matches the optimistic messages the
|
|
793
|
+
// client already rendered when the user hit Send). The first note
|
|
794
|
+
// carries a flag for the model so it knows this is a mid-task
|
|
795
|
+
// redirect, not ambient chat; subsequent notes go raw to avoid
|
|
796
|
+
// cluttering the visible thread.
|
|
797
|
+
const notes = consumeNotes(args.sessionId);
|
|
798
|
+
if (notes.length > 0) {
|
|
799
|
+
const FLAG = '[mid-task interjection — sent WHILE you were running tools. Treat as an authoritative redirect that overrides any plan you announced earlier (count, target, scope). Adjust on the very next step.]';
|
|
800
|
+
for (let i = 0; i < notes.length; i++) {
|
|
801
|
+
const text = i === 0 ? `${FLAG}\n\n${notes[i]}` : notes[i]!;
|
|
802
|
+
const noteMsg = appendMessage({
|
|
803
|
+
session_id: args.sessionId,
|
|
804
|
+
role: 'user',
|
|
805
|
+
blocks: [{ type: 'text', text } as TextBlock],
|
|
806
|
+
});
|
|
807
|
+
cb({ type: 'message_saved', message_id: noteMsg.id, data: noteMsg });
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
761
811
|
// ── Recompute open set every iteration ──────────────────────
|
|
762
812
|
// Scan history (since last user text msg) + this turn's accumulated
|
|
763
813
|
// blocks → which connectors are open right now. Then filter tools.
|
|
@@ -885,10 +935,28 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
885
935
|
|
|
886
936
|
if (result.stopReason !== 'tool_use') break;
|
|
887
937
|
|
|
888
|
-
// Execute tool calls
|
|
938
|
+
// Execute tool calls. The LLM can emit several tool_use blocks per
|
|
939
|
+
// iteration (parallel batch). Without an in-batch abort check, a
|
|
940
|
+
// user who clicks Stop after the batch starts has to wait for ALL
|
|
941
|
+
// tools to finish before the loop top-check fires next iter — feels
|
|
942
|
+
// like Stop did nothing. So: between tools, if abort was requested,
|
|
943
|
+
// skip the remaining ones with synthetic "aborted" tool_results
|
|
944
|
+
// (the tool_use/tool_result pairing invariant must hold for the
|
|
945
|
+
// Anthropic API; an orphan tool_use rejects the next call).
|
|
889
946
|
const toolUses = result.content.filter((b): b is ToolUseBlock => b.type === 'tool_use');
|
|
890
947
|
const toolResults: ToolResultBlock[] = [];
|
|
891
948
|
for (const t of toolUses) {
|
|
949
|
+
if (isAborted(args.sessionId)) {
|
|
950
|
+
const block: ToolResultBlock = {
|
|
951
|
+
type: 'tool_result',
|
|
952
|
+
tool_use_id: t.id,
|
|
953
|
+
content: '⏹ Skipped — user requested stop.',
|
|
954
|
+
is_error: true,
|
|
955
|
+
};
|
|
956
|
+
toolResults.push(block);
|
|
957
|
+
cb({ type: 'tool_result', data: { tool_use_id: t.id, name: t.name, result: { content: block.content, is_error: true } } });
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
892
960
|
const r = await dispatchTool({ id: t.id, name: t.name, input: t.input }, { extraBuiltins: memHandlers, sessionId: args.sessionId });
|
|
893
961
|
const block: ToolResultBlock = {
|
|
894
962
|
type: 'tool_result',
|
|
@@ -960,5 +1028,7 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
960
1028
|
});
|
|
961
1029
|
cb({ type: 'error', data: { error: msg } });
|
|
962
1030
|
return { ok: false, error: msg };
|
|
1031
|
+
} finally {
|
|
1032
|
+
endTurn(args.sessionId);
|
|
963
1033
|
}
|
|
964
1034
|
}
|
|
@@ -81,17 +81,40 @@ export const LINK_PATTERNS: LinkPattern[] = [
|
|
|
81
81
|
url: 'https://nvd.nist.gov/vuln/detail/{1}',
|
|
82
82
|
label: '{1}',
|
|
83
83
|
},
|
|
84
|
-
// Forge
|
|
85
|
-
// `scratch/foo.md`
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
84
|
+
// Forge-managed files inside <dataDir>/. LLMs emit:
|
|
85
|
+
// • `scratch/foo.md` — legacy task-workspace writes (kept under
|
|
86
|
+
// <dataDir>/scratch/, served by /scratch viewer)
|
|
87
|
+
// • `tmp/foo.md` — chat's save_tmp_file output (<dataDir>/tmp/)
|
|
88
|
+
// • `flows/x.yaml` — pipeline workflow definitions
|
|
89
|
+
// • `prompts/y.yaml` — schedule prompt bodies
|
|
90
|
+
// All four resolve through the /files/<dataDir-path> viewer (rendered
|
|
91
|
+
// markdown + download button), which fetches /api/files for raw bytes.
|
|
92
|
+
// Sensitive top-level items (encrypt key, sqlite DBs, log, token
|
|
93
|
+
// caches) are blocked at the API layer, so safe to be liberal here.
|
|
89
94
|
{
|
|
90
95
|
id: 'scratch-file',
|
|
91
96
|
regex: /\bscratch\/([\w\-./]+?\.(?:md|txt|json|yaml|yml|csv|log|html|pdf|png|jpg|jpeg|gif|svg))\b/gi,
|
|
92
|
-
url: '/
|
|
97
|
+
url: '/scratch/{1}',
|
|
93
98
|
label: 'scratch/{1}',
|
|
94
99
|
},
|
|
100
|
+
{
|
|
101
|
+
id: 'tmp-file',
|
|
102
|
+
regex: /\btmp\/([\w\-./]+?\.(?:md|txt|json|yaml|yml|csv|log|html|pdf|png|jpg|jpeg|gif|svg))\b/gi,
|
|
103
|
+
url: '/files/tmp/{1}',
|
|
104
|
+
label: 'tmp/{1}',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'flows-file',
|
|
108
|
+
regex: /\bflows\/([\w\-./]+?\.(?:yaml|yml|json))\b/gi,
|
|
109
|
+
url: '/files/flows/{1}',
|
|
110
|
+
label: 'flows/{1}',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 'prompts-file',
|
|
114
|
+
regex: /\bprompts\/([\w\-./]+?\.(?:yaml|yml|md|txt))\b/gi,
|
|
115
|
+
url: '/files/prompts/{1}',
|
|
116
|
+
label: 'prompts/{1}',
|
|
117
|
+
},
|
|
95
118
|
];
|
|
96
119
|
|
|
97
120
|
export interface CompiledPattern {
|