@guuey/chat 0.4.0 → 0.6.0
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/hitl.d.ts +58 -0
- package/dist/hitl.d.ts.map +1 -0
- package/dist/hitl.js +81 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/native/components.d.ts +110 -0
- package/dist/native/components.d.ts.map +1 -0
- package/dist/native/components.js +341 -0
- package/dist/native/markdown.d.ts +31 -0
- package/dist/native/markdown.d.ts.map +1 -0
- package/dist/native/markdown.js +69 -0
- package/dist/native/theme-native.d.ts +27 -0
- package/dist/native/theme-native.d.ts.map +1 -0
- package/dist/native/theme-native.js +28 -0
- package/dist/native/transcript.d.ts +48 -0
- package/dist/native/transcript.d.ts.map +1 -0
- package/dist/native/transcript.js +86 -0
- package/dist/native.d.ts +26 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +27 -0
- package/dist/plan.d.ts +15 -0
- package/dist/plan.d.ts.map +1 -1
- package/dist/plan.js +211 -13
- package/dist/policy.d.ts +4 -0
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +2 -0
- package/dist/react/components.d.ts +42 -5
- package/dist/react/components.d.ts.map +1 -1
- package/dist/react/components.js +51 -1
- package/dist/react/guuey-chat.d.ts +62 -4
- package/dist/react/guuey-chat.d.ts.map +1 -1
- package/dist/react/guuey-chat.js +73 -8
- package/dist/react/transcript.d.ts +1 -1
- package/dist/react/transcript.d.ts.map +1 -1
- package/dist/react/transcript.js +2 -2
- package/dist/react/use-transcript.d.ts +21 -2
- package/dist/react/use-transcript.d.ts.map +1 -1
- package/dist/react/use-transcript.js +57 -3
- package/dist/react.d.ts +2 -2
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +1 -1
- package/dist/strings.d.ts +13 -0
- package/dist/strings.d.ts.map +1 -1
- package/dist/strings.js +8 -0
- package/dist/types.d.ts +146 -7
- package/dist/types.d.ts.map +1 -1
- package/package.json +18 -7
- package/src/corpus/README.md +13 -1
- package/src/corpus/__snapshots__/corpus.test.ts.snap +304 -0
- package/src/corpus/drive.ts +3 -3
- package/src/corpus/fixtures.ts +220 -1
- package/src/hitl.ts +103 -0
- package/src/index.ts +15 -0
- package/src/native/components.tsx +812 -0
- package/src/native/markdown.tsx +205 -0
- package/src/native/theme-native.ts +48 -0
- package/src/native/transcript.tsx +189 -0
- package/src/native.tsx +63 -0
- package/src/plan.ts +233 -20
- package/src/policy.ts +4 -0
- package/src/react/components.tsx +171 -8
- package/src/react/guuey-chat.tsx +143 -9
- package/src/react/transcript.tsx +4 -3
- package/src/react/use-transcript.ts +83 -5
- package/src/react.tsx +3 -1
- package/src/strings.ts +25 -0
- package/src/types.ts +152 -6
- package/styles.css +21 -0
package/src/plan.ts
CHANGED
|
@@ -9,11 +9,14 @@
|
|
|
9
9
|
* Source-ownership rules (spec §9 Change-1 — one unified plan owns both):
|
|
10
10
|
*
|
|
11
11
|
* - USER rows always come from the flat `inputs.messages`.
|
|
12
|
-
* - ASSISTANT content comes from the fold (`inputs.result`)
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* - ASSISTANT content comes from the fold (`inputs.result`) for the
|
|
13
|
+
* TRAILING turns the fold covers; earlier flat assistant entries (a
|
|
14
|
+
* rehydrated/persisted prefix the session's Reducer never saw) render as
|
|
15
|
+
* text slots in front (the turn-scoped refinement — see
|
|
16
|
+
* `TranscriptInputs.result`). Without a fold, flat entries plus the
|
|
17
|
+
* in-flight `assistantText` own everything. Both paths normalize to the
|
|
18
|
+
* same block walk, so a silver stream carrying only text plans
|
|
19
|
+
* byte-identically to a bypass stream (fixture 5).
|
|
17
20
|
* - Interleaving is conversational alternation (user[i] then assistant[i]) —
|
|
18
21
|
* the transcript invariant of a request/reply chat. Finer-grained
|
|
19
22
|
* interleaving (true seq-ordering of history cards inside turns) needs
|
|
@@ -31,11 +34,13 @@
|
|
|
31
34
|
import type { AgBlock, AgReduceResult, JsonValue } from "@silverprotocol/core";
|
|
32
35
|
import { snapshotViewMount, toolResultViewMount, uiLocator, type ViewMount } from "@guuey/mcp-apps-host";
|
|
33
36
|
import type { TranscriptPolicy } from "./policy.js";
|
|
37
|
+
import { grantModeDisplay } from "./hitl.js";
|
|
34
38
|
import type {
|
|
35
39
|
CitationsItem,
|
|
36
40
|
DataResultItem,
|
|
37
41
|
DisplayItem,
|
|
38
42
|
ItemKey,
|
|
43
|
+
NoticeItem,
|
|
39
44
|
StatusLineItem,
|
|
40
45
|
ToolGroupItem,
|
|
41
46
|
ToolItem,
|
|
@@ -44,6 +49,7 @@ import type {
|
|
|
44
49
|
TranscriptPlan,
|
|
45
50
|
UnknownItem,
|
|
46
51
|
ViewMountItem,
|
|
52
|
+
ViewRefItem,
|
|
47
53
|
} from "./types.js";
|
|
48
54
|
|
|
49
55
|
/** R5's giant threshold: above this byte count the state is `giant`. */
|
|
@@ -91,27 +97,77 @@ interface AssistantSource {
|
|
|
91
97
|
stopped: boolean;
|
|
92
98
|
}
|
|
93
99
|
|
|
94
|
-
|
|
100
|
+
/** A fold-borne `role:"notice"` row, anchored to the source it followed. */
|
|
101
|
+
interface FoldNotice {
|
|
102
|
+
text: string;
|
|
103
|
+
source: NoticeItem["source"];
|
|
104
|
+
/** Index into the returned sources of the message this notice followed; -1 = before any. */
|
|
105
|
+
afterSourceIndex: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** R16 notice text: the message's text blocks, joined (non-text blocks carry no notice prose). */
|
|
109
|
+
function noticeText(content: readonly AgBlock[]): string {
|
|
110
|
+
return content
|
|
111
|
+
.filter((b): b is Extract<AgBlock, { type: "text" }> => b.type === "text")
|
|
112
|
+
.map((b) => b.text)
|
|
113
|
+
.join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function foldAssistantSources(
|
|
117
|
+
result: AgReduceResult,
|
|
118
|
+
inFlight: boolean,
|
|
119
|
+
aborted: boolean,
|
|
120
|
+
): { sources: AssistantSource[]; notices: FoldNotice[] } {
|
|
95
121
|
// Real pod folds carry `tool-result` blocks in separate `role: "tool"`
|
|
96
122
|
// messages between assistant turns (the production ggui-render capture is
|
|
97
123
|
// the receipt — guuey#135 3b widget convergence found this): an
|
|
98
124
|
// assistant-only filter orphans every call and drops every mount. A tool
|
|
99
125
|
// message's blocks belong to the PRECEDING assistant slot's walk, exactly
|
|
100
126
|
// the whole-fold message order the retired first-party renderers used.
|
|
127
|
+
//
|
|
128
|
+
// LIVE detection is turn-identity-based where the fold carries it: while
|
|
129
|
+
// a turn is in flight, an assistant message whose `turnId` maps to a turn
|
|
130
|
+
// record WITHOUT an `outcome` belongs to the open turn — settled earlier
|
|
131
|
+
// turns stay settled even mid-session (what makes the tail alignment
|
|
132
|
+
// below count them correctly). Nothing is live once the status is back to
|
|
133
|
+
// `ready`, whatever the records say (a fold that missed `turn.done` must
|
|
134
|
+
// not pin a settled turn open forever). Folds without turn identity (a
|
|
135
|
+
// hand-driven fold that never saw `turn.*` lifecycle) fall back to the
|
|
136
|
+
// last-source-is-live-iff-in-flight heuristic — the pre-refinement
|
|
137
|
+
// behaviour, kept so identity-less folds plan exactly as before.
|
|
138
|
+
const openTurns = new Set(
|
|
139
|
+
result.turns.filter((t) => t.outcome === undefined).map((t) => t.turnId),
|
|
140
|
+
);
|
|
141
|
+
const hasTurnIdentity = result.messages.some(
|
|
142
|
+
(m) => m.role === "assistant" && m.turnId !== undefined,
|
|
143
|
+
);
|
|
101
144
|
const sources: AssistantSource[] = [];
|
|
145
|
+
const notices: FoldNotice[] = [];
|
|
102
146
|
for (const m of result.messages) {
|
|
103
147
|
if (m.role === "assistant") {
|
|
104
|
-
sources.push({
|
|
148
|
+
sources.push({
|
|
149
|
+
blocks: [...m.content],
|
|
150
|
+
live: inFlight && hasTurnIdentity && m.turnId !== undefined && openTurns.has(m.turnId),
|
|
151
|
+
stopped: false,
|
|
152
|
+
});
|
|
105
153
|
} else if (m.role === "tool" && sources.length > 0) {
|
|
106
154
|
sources[sources.length - 1]!.blocks.push(...m.content);
|
|
155
|
+
} else if (m.role === "notice") {
|
|
156
|
+
// R16 (spec draft.2): a session annotation — a PEER row, never folded
|
|
157
|
+
// into assistant content; it anchors after the source it followed.
|
|
158
|
+
notices.push({
|
|
159
|
+
text: noticeText(m.content),
|
|
160
|
+
source: m.noticeSource ?? null,
|
|
161
|
+
afterSourceIndex: sources.length - 1,
|
|
162
|
+
});
|
|
107
163
|
}
|
|
108
164
|
}
|
|
109
165
|
const last = sources[sources.length - 1];
|
|
110
166
|
if (last) {
|
|
111
|
-
last.live = inFlight;
|
|
167
|
+
if (!hasTurnIdentity) last.live = inFlight;
|
|
112
168
|
last.stopped = aborted;
|
|
113
169
|
}
|
|
114
|
-
return sources;
|
|
170
|
+
return { sources, notices };
|
|
115
171
|
}
|
|
116
172
|
|
|
117
173
|
function flatAssistantSources(inputs: TranscriptInputs, inFlight: boolean): AssistantSource[] {
|
|
@@ -621,12 +677,73 @@ export function planTranscript(
|
|
|
621
677
|
|
|
622
678
|
const inFlight = inputs.status !== "ready";
|
|
623
679
|
const users = inputs.messages.filter((m) => m.role === "user");
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
680
|
+
// R16 — notice rows are session annotations anchored between turns: a
|
|
681
|
+
// flat notice renders after the conversation slot of the user turn it
|
|
682
|
+
// followed (usersSeen-1; -1 = before the conversation). They are PEER
|
|
683
|
+
// rows — excluded from both the user pairing and the assistant seam
|
|
684
|
+
// count, so the fold alignment below never sees them.
|
|
685
|
+
const notices: Array<{ key: string; text: string; source: NoticeItem["source"]; afterSlot: number }> = [];
|
|
686
|
+
if (policy.notice.show) {
|
|
687
|
+
let usersSeen = 0;
|
|
688
|
+
inputs.messages.forEach((m, i) => {
|
|
689
|
+
if (m.role === "user") usersSeen += 1;
|
|
690
|
+
else if (m.role === "notice") {
|
|
691
|
+
notices.push({ key: `n${i}`, text: m.text, source: m.noticeSource ?? null, afterSlot: usersSeen - 1 });
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
// Turn-scoped fold composition (guuey#135 kit refinement): the fold owns
|
|
696
|
+
// the TRAILING assistant turns it actually covers — its settled sources
|
|
697
|
+
// replace that many trailing flat assistant entries; any EARLIER flat
|
|
698
|
+
// entries (a rehydrated/persisted prefix the session's Reducer never saw)
|
|
699
|
+
// keep rendering as flat text slots in front. When the fold spans the
|
|
700
|
+
// whole conversation (a pure-live session — the corpus's world), the kept
|
|
701
|
+
// prefix is empty and the plan is byte-identical to the old wholesale
|
|
702
|
+
// replace. Alignment is by COUNT (trailing flat entries ↔ settled fold
|
|
703
|
+
// sources): flat rows carry no turn identity, so a persisted CARD-ONLY
|
|
704
|
+
// turn inside the fold's span (an empty-text assistant row the assembler
|
|
705
|
+
// dropped) can offset the seam by one — a documented approximation, same
|
|
706
|
+
// class as the header's read-plane-seq note, resolved for the visible
|
|
707
|
+
// cards by the actionScope dedupe below.
|
|
708
|
+
let assistants: AssistantSource[];
|
|
709
|
+
if (inputs.result) {
|
|
710
|
+
const fold = foldAssistantSources(inputs.result, inFlight, inputs.aborted === true);
|
|
711
|
+
const foldSources = fold.sources;
|
|
712
|
+
const settledFoldCount = foldSources.filter((s) => !s.live).length;
|
|
713
|
+
const flatSettled = inputs.messages.filter((m) => m.role === "assistant");
|
|
714
|
+
const keep = Math.max(0, flatSettled.length - settledFoldCount);
|
|
715
|
+
assistants = [
|
|
716
|
+
...flatSettled.slice(0, keep).map(
|
|
717
|
+
(m): AssistantSource => ({
|
|
718
|
+
blocks: [{ type: "text", text: m.text }],
|
|
719
|
+
live: false,
|
|
720
|
+
stopped: false,
|
|
721
|
+
}),
|
|
722
|
+
),
|
|
723
|
+
...foldSources,
|
|
724
|
+
];
|
|
725
|
+
if (policy.notice.show) {
|
|
726
|
+
// Fold-borne notices anchor to the fold source they followed, which
|
|
727
|
+
// sits at global slot `keep + afterSourceIndex` after the seam.
|
|
728
|
+
fold.notices.forEach((n, i) => {
|
|
729
|
+
notices.push({ key: `nf${i}`, text: n.text, source: n.source, afterSlot: keep + n.afterSourceIndex });
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
} else {
|
|
733
|
+
assistants = flatAssistantSources(inputs, inFlight);
|
|
734
|
+
}
|
|
627
735
|
|
|
628
736
|
const slots = Math.max(users.length, assistants.length);
|
|
629
737
|
const conversation: DisplayItem[] = [];
|
|
738
|
+
const noticeItem = (n: (typeof notices)[number]): NoticeItem => ({
|
|
739
|
+
kind: "notice",
|
|
740
|
+
key: n.key,
|
|
741
|
+
expanded: true,
|
|
742
|
+
text: n.text,
|
|
743
|
+
source: n.source,
|
|
744
|
+
sourceLabel: policy.debugDetail && n.source !== null ? n.source : null,
|
|
745
|
+
});
|
|
746
|
+
for (const n of notices) if (n.afterSlot < 0) conversation.push(noticeItem(n));
|
|
630
747
|
for (let slot = 0; slot < slots; slot++) {
|
|
631
748
|
const user = users[slot];
|
|
632
749
|
if (user) {
|
|
@@ -648,16 +765,35 @@ export function planTranscript(
|
|
|
648
765
|
if (assistant) {
|
|
649
766
|
conversation.push(...planAssistantSource(assistant, slot, inputs, policy, overrides));
|
|
650
767
|
}
|
|
768
|
+
for (const n of notices) if (n.afterSlot === slot) conversation.push(noticeItem(n));
|
|
651
769
|
}
|
|
652
770
|
items.push(...groupTools(conversation, policy, overrides));
|
|
653
771
|
|
|
654
772
|
// R13 — persisted cards, seq order. (Position: after the settled
|
|
655
773
|
// conversation; true in-turn interleave needs read-plane seqs the flat
|
|
656
774
|
// surface lacks — the 3b assemblers own that refinement.)
|
|
775
|
+
//
|
|
776
|
+
// Dedupe against LIVE mounts by actionScope (the persisted `ui://`
|
|
777
|
+
// identity both sides carry): a session whose fold still holds a turn the
|
|
778
|
+
// read plane has ALSO persisted — the #192 adopted turn, a thread view's
|
|
779
|
+
// post-handoff refetch — would otherwise render the same card twice. The
|
|
780
|
+
// fold's view row owns it (richer phase state); the history snapshot
|
|
781
|
+
// renders only when no live mount claims its identity.
|
|
782
|
+
const liveViewScopes = new Set<string>();
|
|
783
|
+
for (const item of items) {
|
|
784
|
+
if (item.kind === "view" && item.actionScope !== null) liveViewScopes.add(item.actionScope);
|
|
785
|
+
}
|
|
657
786
|
const cards = [...(inputs.historyCards ?? [])].sort((a, b) => a.seq - b.seq);
|
|
658
787
|
for (const card of cards) {
|
|
659
788
|
const key = `card.${card.seq}`;
|
|
660
789
|
const mount = snapshotViewMount(card.cardSnapshot);
|
|
790
|
+
const scope =
|
|
791
|
+
mount === undefined
|
|
792
|
+
? null
|
|
793
|
+
: mount.channel === "locator"
|
|
794
|
+
? mount.resourceUri
|
|
795
|
+
: mount.resource.uri;
|
|
796
|
+
if (scope !== null && liveViewScopes.has(scope)) continue; // live mount owns it
|
|
661
797
|
const view: ViewMountItem = {
|
|
662
798
|
kind: "view",
|
|
663
799
|
key,
|
|
@@ -668,20 +804,42 @@ export function planTranscript(
|
|
|
668
804
|
label: null,
|
|
669
805
|
attribution: null,
|
|
670
806
|
toolTitle: null,
|
|
671
|
-
actionScope:
|
|
672
|
-
mount === undefined
|
|
673
|
-
? null
|
|
674
|
-
: mount.channel === "locator"
|
|
675
|
-
? mount.resourceUri
|
|
676
|
-
: mount.resource.uri,
|
|
807
|
+
actionScope: scope,
|
|
677
808
|
};
|
|
678
809
|
view.label = viewLabel(view, policy);
|
|
679
810
|
items.push(view);
|
|
680
811
|
}
|
|
681
812
|
|
|
682
|
-
// R10 — prompts, in input order
|
|
813
|
+
// R10 — prompts, in input order (narrow on `kind`: the guuey-wire
|
|
814
|
+
// profile arm vs the AgJSON hitl arm, spec draft.2).
|
|
683
815
|
for (const prompt of inputs.prompts) {
|
|
684
816
|
const key = `p.${prompt.id}`;
|
|
817
|
+
if (prompt.kind === "hitl") {
|
|
818
|
+
const chosen =
|
|
819
|
+
prompt.grantModeId !== undefined
|
|
820
|
+
? (prompt.ask.grantModes ?? []).find((m) => m.id === prompt.grantModeId)
|
|
821
|
+
: undefined;
|
|
822
|
+
items.push({
|
|
823
|
+
kind: "prompt",
|
|
824
|
+
key,
|
|
825
|
+
promptId: prompt.id,
|
|
826
|
+
// `cancelled` collapses to a dismissed record but stays expandable —
|
|
827
|
+
// guuey's re-askable ruling (silverprotocol#16); pending opens.
|
|
828
|
+
expanded: resolveExpanded(key, prompt.state === "pending", overrides),
|
|
829
|
+
promptKind: "hitl",
|
|
830
|
+
ask: prompt.ask,
|
|
831
|
+
message: prompt.ask.message ?? null,
|
|
832
|
+
askKind: prompt.ask.kind,
|
|
833
|
+
grantModes: prompt.ask.grantModes ?? [],
|
|
834
|
+
state: prompt.state,
|
|
835
|
+
chosenModeId: prompt.grantModeId ?? null,
|
|
836
|
+
chosenModeLabel: chosen !== undefined ? grantModeDisplay(chosen) : null,
|
|
837
|
+
raw: policy.prompt.rawPayload
|
|
838
|
+
? { askId: prompt.ask.askId, kind: prompt.ask.kind, state: prompt.state, grantModeId: prompt.grantModeId ?? null }
|
|
839
|
+
: null,
|
|
840
|
+
});
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
685
843
|
items.push({
|
|
686
844
|
kind: "prompt",
|
|
687
845
|
key,
|
|
@@ -731,10 +889,65 @@ export function planTranscript(
|
|
|
731
889
|
});
|
|
732
890
|
}
|
|
733
891
|
|
|
892
|
+
// guuey#204 "promote and reference": the ONE mount a host-owned stage
|
|
893
|
+
// shows (`inputs.promotedViewKey`) renders as a compact reference chip
|
|
894
|
+
// instead of a second live app instance. A key matching nothing (stale
|
|
895
|
+
// host state) or an expired mount promotes nothing — the honest no-op;
|
|
896
|
+
// the next render's key corrects it. The chip keeps the mount's key, so
|
|
897
|
+
// overrides and phase reports survive a swap back to the full mount.
|
|
898
|
+
const promoted = inputs.promotedViewKey;
|
|
899
|
+
const finalItems =
|
|
900
|
+
promoted === undefined
|
|
901
|
+
? items
|
|
902
|
+
: items.map((item): DisplayItem => {
|
|
903
|
+
if (item.kind !== "view" || item.key !== promoted) return item;
|
|
904
|
+
if (item.phase === "expired" || item.mount === null) return item;
|
|
905
|
+
const title = item.toolTitle ?? policy.strings.viewRefFallbackTitle;
|
|
906
|
+
const ref: ViewRefItem = {
|
|
907
|
+
kind: "viewRef",
|
|
908
|
+
key: item.key,
|
|
909
|
+
expanded: false,
|
|
910
|
+
title,
|
|
911
|
+
label: policy.strings.viewPromoted(title),
|
|
912
|
+
};
|
|
913
|
+
return ref;
|
|
914
|
+
});
|
|
915
|
+
|
|
734
916
|
return {
|
|
735
|
-
items,
|
|
917
|
+
items: finalItems,
|
|
736
918
|
status: deriveStatus(inputs, policy),
|
|
737
919
|
recovery:
|
|
738
920
|
inputs.adopted === true && policy.debugDetail ? policy.strings.recoveredFromHistory : null,
|
|
739
921
|
};
|
|
740
922
|
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* The plan key of the NEWEST mountable view — what a "most recent card"
|
|
926
|
+
* stage (the widget's CanvasPane) will show, derived in the same family
|
|
927
|
+
* the plan's own emission uses: live fold mounts (transcript order, last
|
|
928
|
+
* wins) outrank history cards (ascending `seq`). `undefined` when nothing
|
|
929
|
+
* narrows to a mountable resource. Hosts pass the key back as
|
|
930
|
+
* `TranscriptInputs.promotedViewKey`; `channel` lets a host decline to
|
|
931
|
+
* promote a bare locator when no reader is wired (the stage would show
|
|
932
|
+
* nothing, and a chip must never point at an empty canvas).
|
|
933
|
+
*/
|
|
934
|
+
export function newestViewKey(
|
|
935
|
+
inputs: Pick<TranscriptInputs, "result" | "historyCards">,
|
|
936
|
+
): { key: string; channel: ViewMount["channel"] } | undefined {
|
|
937
|
+
let newest: { key: string; channel: ViewMount["channel"] } | undefined;
|
|
938
|
+
const cards = [...(inputs.historyCards ?? [])].sort((a, b) => a.seq - b.seq);
|
|
939
|
+
for (const card of cards) {
|
|
940
|
+
const mount = snapshotViewMount(card.cardSnapshot);
|
|
941
|
+
if (mount === undefined) continue;
|
|
942
|
+
newest = { key: `card.${card.seq}`, channel: mount.channel };
|
|
943
|
+
}
|
|
944
|
+
for (const message of inputs.result?.messages ?? []) {
|
|
945
|
+
for (const block of message.content) {
|
|
946
|
+
if (block.type !== "tool-result") continue;
|
|
947
|
+
const mount = toolResultViewMount(block);
|
|
948
|
+
if (mount === undefined) continue;
|
|
949
|
+
newest = { key: `view.${block.toolCallId}`, channel: mount.channel };
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
return newest;
|
|
953
|
+
}
|
package/src/policy.ts
CHANGED
|
@@ -69,6 +69,8 @@ export interface TranscriptPolicy {
|
|
|
69
69
|
};
|
|
70
70
|
/** R12 — thresholds are chosen-not-measured (§10-F6; 3b validates vs #188 data). */
|
|
71
71
|
status: { wakingMs: number; longStartMs: number };
|
|
72
|
+
/** R16 (spec draft.2) — notice rows; provenance facet shows under debug. */
|
|
73
|
+
notice: { show: boolean };
|
|
72
74
|
/** R14. */
|
|
73
75
|
compaction: { show: boolean };
|
|
74
76
|
/** R15. */
|
|
@@ -95,6 +97,7 @@ function withOverrides(base: TranscriptPolicy, overrides?: Partial<TranscriptPol
|
|
|
95
97
|
prompt: { ...base.prompt, ...(overrides.prompt ?? {}) },
|
|
96
98
|
error: { ...base.error, ...(overrides.error ?? {}) },
|
|
97
99
|
status: { ...base.status, ...(overrides.status ?? {}) },
|
|
100
|
+
notice: { ...base.notice, ...(overrides.notice ?? {}) },
|
|
98
101
|
compaction: { ...base.compaction, ...(overrides.compaction ?? {}) },
|
|
99
102
|
unknown: { ...base.unknown, ...(overrides.unknown ?? {}) },
|
|
100
103
|
};
|
|
@@ -115,6 +118,7 @@ function calmBase(): TranscriptPolicy {
|
|
|
115
118
|
media: { inlineImageCapRem: 20, chipOnly: false },
|
|
116
119
|
code: { capRem: 16, wrap: false },
|
|
117
120
|
citations: { style: "chips" },
|
|
121
|
+
notice: { show: true },
|
|
118
122
|
prompt: { placement: "inline", rawPayload: false },
|
|
119
123
|
error: { verbatim: false, copyByCode: {}, verbatimCodes: [] },
|
|
120
124
|
status: { wakingMs: 2500, longStartMs: 15_000 },
|
package/src/react/components.tsx
CHANGED
|
@@ -38,6 +38,7 @@ import type {
|
|
|
38
38
|
HistoryBoundaryItem,
|
|
39
39
|
ItemKey,
|
|
40
40
|
MediaItem,
|
|
41
|
+
NoticeItem,
|
|
41
42
|
PromptItem,
|
|
42
43
|
ReasoningItem,
|
|
43
44
|
StatusLineItem,
|
|
@@ -47,6 +48,7 @@ import type {
|
|
|
47
48
|
UserMessageItem,
|
|
48
49
|
TextItem,
|
|
49
50
|
ViewMountItem,
|
|
51
|
+
ViewRefItem,
|
|
50
52
|
} from "../types.js";
|
|
51
53
|
import { Markdown } from "./markdown.js";
|
|
52
54
|
|
|
@@ -57,21 +59,56 @@ export interface TranscriptItemContext {
|
|
|
57
59
|
onToggle: (key: ItemKey) => void;
|
|
58
60
|
/** R0 failed-send retry. */
|
|
59
61
|
onRetry?: (item: UserMessageItem) => void;
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
+
/**
|
|
63
|
+
* R10 prompt actions — the host owns what accept/decline DO. A hitl card
|
|
64
|
+
* with declared grant modes reports the pick as `{ grantModeId }` (spec
|
|
65
|
+
* draft.2); plain accepts stay the `"accept"` string.
|
|
66
|
+
*/
|
|
67
|
+
onPromptAction?: (
|
|
68
|
+
item: PromptItem,
|
|
69
|
+
action: "accept" | "decline" | "dismiss" | { grantModeId: string },
|
|
70
|
+
) => void;
|
|
62
71
|
/** R11 action slots (sign-in / upgrade / retry affordances). */
|
|
63
72
|
onErrorAction?: (item: ErrorItem) => void;
|
|
64
73
|
/** R6: locator mounts resolved by `useTranscript` ("expired" = failed). */
|
|
65
74
|
resolvedMounts: ReadonlyMap<ItemKey, ResolvedViewMount | "expired">;
|
|
66
75
|
/** R6: live phase reports wired back into the next plan. */
|
|
67
76
|
onViewPhase: (key: ItemKey, phase: ViewHostPhase) => void;
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
77
|
+
/**
|
|
78
|
+
* guuey#204: a promoted-view reference chip was activated — the host
|
|
79
|
+
* focuses/reveals its stage (canvas). Absent ⇒ the chip renders as a
|
|
80
|
+
* non-interactive label.
|
|
81
|
+
*/
|
|
82
|
+
onViewRef?: (item: ViewRefItem) => void;
|
|
83
|
+
/**
|
|
84
|
+
* R6 pass-through (sandbox overrides, the two-origin mount, relay hooks
|
|
85
|
+
* — policy-gated by the host). Static, or a FUNCTION of the mount being
|
|
86
|
+
* rendered: view configuration legitimately varies per mount — the
|
|
87
|
+
* relay-page URL picks a per-channel egress grant (`?channel=`), and a
|
|
88
|
+
* `tools/call` hook scopes to the item's persisted locator (guuey#158) —
|
|
89
|
+
* so the function form receives the item and its resolved mount and is
|
|
90
|
+
* called only when something actually mounts. Static keeps working
|
|
91
|
+
* unchanged.
|
|
92
|
+
*/
|
|
93
|
+
viewProps?: ViewSlotProps | ((item: ViewMountItem, mount: ResolvedViewMount) => ViewSlotProps);
|
|
73
94
|
}
|
|
74
95
|
|
|
96
|
+
/** The per-view slice of `GuueyViewProps` a transcript may configure. */
|
|
97
|
+
export type ViewSlotProps = Pick<
|
|
98
|
+
GuueyViewProps,
|
|
99
|
+
| "hostCapabilities"
|
|
100
|
+
| "hostInfo"
|
|
101
|
+
| "hostContext"
|
|
102
|
+
| "onCallTool"
|
|
103
|
+
| "onReadResource"
|
|
104
|
+
| "onSizeChanged"
|
|
105
|
+
| "negotiationTimeoutMs"
|
|
106
|
+
| "dangerouslyAddSandboxFlags"
|
|
107
|
+
| "allow"
|
|
108
|
+
| "sandboxPageUrl"
|
|
109
|
+
| "autoResize"
|
|
110
|
+
>;
|
|
111
|
+
|
|
75
112
|
interface ItemProps<T> {
|
|
76
113
|
item: T;
|
|
77
114
|
ctx: TranscriptItemContext;
|
|
@@ -318,11 +355,15 @@ export function DefaultView({ item, ctx }: ItemProps<ViewMountItem>): ReactNode
|
|
|
318
355
|
</div>
|
|
319
356
|
);
|
|
320
357
|
}
|
|
358
|
+
// The function form resolves against the ACTUAL mount (per-channel relay
|
|
359
|
+
// URLs, per-locator action scoping); it runs only when something mounts.
|
|
360
|
+
const viewProps =
|
|
361
|
+
typeof ctx.viewProps === "function" ? ctx.viewProps(item, mount) : (ctx.viewProps ?? {});
|
|
321
362
|
return (
|
|
322
363
|
<div className="guuey-chat-view">
|
|
323
364
|
<GuueyView
|
|
324
365
|
mount={mount}
|
|
325
|
-
{...
|
|
366
|
+
{...viewProps}
|
|
326
367
|
onPhaseChange={(phase) => ctx.onViewPhase(item.key, phase)}
|
|
327
368
|
/>
|
|
328
369
|
{item.attribution !== null ? (
|
|
@@ -332,6 +373,31 @@ export function DefaultView({ item, ctx }: ItemProps<ViewMountItem>): ReactNode
|
|
|
332
373
|
);
|
|
333
374
|
}
|
|
334
375
|
|
|
376
|
+
/**
|
|
377
|
+
* guuey#204: the "promote and reference" chip — stands in for the one
|
|
378
|
+
* mount the host's stage shows. A real button when the host wires
|
|
379
|
+
* `onViewRef` (keyboard-accessible for free); a plain label otherwise.
|
|
380
|
+
*/
|
|
381
|
+
export function DefaultViewRef({ item, ctx }: ItemProps<ViewRefItem>): ReactNode {
|
|
382
|
+
if (ctx.onViewRef === undefined) {
|
|
383
|
+
return (
|
|
384
|
+
<p className="guuey-chat-view-ref" role="note">
|
|
385
|
+
{item.label}
|
|
386
|
+
</p>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
const onViewRef = ctx.onViewRef;
|
|
390
|
+
return (
|
|
391
|
+
<button
|
|
392
|
+
type="button"
|
|
393
|
+
className="guuey-chat-view-ref guuey-chat-view-ref-button"
|
|
394
|
+
onClick={() => onViewRef(item)}
|
|
395
|
+
>
|
|
396
|
+
{item.label}
|
|
397
|
+
</button>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
335
401
|
// ─── R7 ────────────────────────────────────────────────────────────────────
|
|
336
402
|
|
|
337
403
|
/** Inline images allow https URLs and image/* base64 data — nothing else. */
|
|
@@ -439,6 +505,72 @@ export function DefaultPrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
|
|
|
439
505
|
wasPending.current = item.state === "pending";
|
|
440
506
|
}, [item.state]);
|
|
441
507
|
|
|
508
|
+
if (item.promptKind === "hitl") {
|
|
509
|
+
const s = ctx.strings;
|
|
510
|
+
// Settled records: resolved shows the CHOSEN MODE'S LABEL (ids are
|
|
511
|
+
// echo-only identity — asker-scoped semantics, spec §7); declined is
|
|
512
|
+
// the durable deny. `cancelled` is guuey's re-askable dismissal: it
|
|
513
|
+
// collapses to a record but stays answerable when expanded.
|
|
514
|
+
if (item.state === "resolved" || item.state === "declined") {
|
|
515
|
+
return (
|
|
516
|
+
<p className={`guuey-chat-prompt-record guuey-chat-prompt-${item.state}`}>
|
|
517
|
+
{item.state === "resolved"
|
|
518
|
+
? item.chosenModeLabel !== null
|
|
519
|
+
? s.promptAnsweredWith(item.chosenModeLabel)
|
|
520
|
+
: s.promptAccept
|
|
521
|
+
: s.promptDeclinedRecord}
|
|
522
|
+
</p>
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
const answerable = item.state === "pending" || (item.state === "cancelled" && item.expanded);
|
|
526
|
+
return (
|
|
527
|
+
<div className="guuey-chat-prompt" role="group">
|
|
528
|
+
{item.state === "cancelled" && (
|
|
529
|
+
<button
|
|
530
|
+
type="button"
|
|
531
|
+
className="guuey-chat-prompt-record guuey-chat-prompt-cancelled"
|
|
532
|
+
aria-expanded={item.expanded}
|
|
533
|
+
onClick={() => ctx.onToggle(item.key)}
|
|
534
|
+
>
|
|
535
|
+
{s.promptDismissed}
|
|
536
|
+
</button>
|
|
537
|
+
)}
|
|
538
|
+
{answerable && (
|
|
539
|
+
<>
|
|
540
|
+
{item.message !== null && <p className="guuey-chat-prompt-ask">{item.message}</p>}
|
|
541
|
+
<div className="guuey-chat-prompt-actions">
|
|
542
|
+
{item.grantModes.length === 0 ? (
|
|
543
|
+
<button
|
|
544
|
+
ref={firstAction}
|
|
545
|
+
type="button"
|
|
546
|
+
className="guuey-chat-prompt-accept"
|
|
547
|
+
onClick={() => ctx.onPromptAction?.(item, "accept")}
|
|
548
|
+
>
|
|
549
|
+
{s.promptAccept}
|
|
550
|
+
</button>
|
|
551
|
+
) : (
|
|
552
|
+
item.grantModes.map((mode, i) => (
|
|
553
|
+
<button
|
|
554
|
+
key={mode.id}
|
|
555
|
+
ref={i === 0 ? firstAction : undefined}
|
|
556
|
+
type="button"
|
|
557
|
+
className="guuey-chat-prompt-accept"
|
|
558
|
+
title={mode.description}
|
|
559
|
+
onClick={() => ctx.onPromptAction?.(item, { grantModeId: mode.id })}
|
|
560
|
+
>
|
|
561
|
+
{mode.label ?? mode.id}
|
|
562
|
+
</button>
|
|
563
|
+
))
|
|
564
|
+
)}
|
|
565
|
+
<button type="button" onClick={() => ctx.onPromptAction?.(item, "decline")}>
|
|
566
|
+
{s.promptDecline}
|
|
567
|
+
</button>
|
|
568
|
+
</div>
|
|
569
|
+
</>
|
|
570
|
+
)}
|
|
571
|
+
</div>
|
|
572
|
+
);
|
|
573
|
+
}
|
|
442
574
|
if (item.state !== "pending") {
|
|
443
575
|
return (
|
|
444
576
|
<p className={`guuey-chat-prompt-record guuey-chat-prompt-${item.state}`}>
|
|
@@ -473,6 +605,25 @@ export function DefaultPrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
|
|
|
473
605
|
);
|
|
474
606
|
}
|
|
475
607
|
|
|
608
|
+
// ─── R16 ──────────────────────────────────────────────────────────────────
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* A `role:"notice"` session annotation (spec draft.2) — a labeled,
|
|
612
|
+
* non-conversational row that must never read as agent-authored. Calm
|
|
613
|
+
* shows the quiet label; debug appends the provenance facet verbatim.
|
|
614
|
+
*/
|
|
615
|
+
export function DefaultNotice({ item, ctx }: ItemProps<NoticeItem>): ReactNode {
|
|
616
|
+
return (
|
|
617
|
+
<div className="guuey-chat-notice" role="note">
|
|
618
|
+
<span className="guuey-chat-notice-label">
|
|
619
|
+
{ctx.strings.noticeLabel}
|
|
620
|
+
{item.sourceLabel !== null ? ` · ${item.sourceLabel}` : ""}
|
|
621
|
+
</span>
|
|
622
|
+
{item.text !== "" && <span className="guuey-chat-notice-text">{item.text}</span>}
|
|
623
|
+
</div>
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
476
627
|
// ─── R11 ───────────────────────────────────────────────────────────────────
|
|
477
628
|
|
|
478
629
|
export function DefaultError({ item, ctx }: ItemProps<ErrorItem>): ReactNode {
|
|
@@ -554,10 +705,12 @@ export interface TranscriptComponents {
|
|
|
554
705
|
toolGroup: ComponentType<ItemProps<ToolGroupItem>>;
|
|
555
706
|
dataResult: ComponentType<ItemProps<DataResultItem>>;
|
|
556
707
|
view: ComponentType<ItemProps<ViewMountItem>>;
|
|
708
|
+
viewRef: ComponentType<ItemProps<ViewRefItem>>;
|
|
557
709
|
media: ComponentType<ItemProps<MediaItem>>;
|
|
558
710
|
code: ComponentType<ItemProps<CodeItem>>;
|
|
559
711
|
citations: ComponentType<ItemProps<CitationsItem>>;
|
|
560
712
|
prompt: ComponentType<ItemProps<PromptItem>>;
|
|
713
|
+
notice: ComponentType<ItemProps<NoticeItem>>;
|
|
561
714
|
error: ComponentType<ItemProps<ErrorItem>>;
|
|
562
715
|
history: ComponentType<ItemProps<HistoryBoundaryItem>>;
|
|
563
716
|
compaction: ComponentType<ItemProps<CompactionItem>>;
|
|
@@ -573,10 +726,12 @@ export const defaultTranscriptComponents: TranscriptComponents = {
|
|
|
573
726
|
toolGroup: DefaultToolGroup,
|
|
574
727
|
dataResult: DefaultDataResult,
|
|
575
728
|
view: DefaultView,
|
|
729
|
+
viewRef: DefaultViewRef,
|
|
576
730
|
media: DefaultMedia,
|
|
577
731
|
code: DefaultCode,
|
|
578
732
|
citations: DefaultCitations,
|
|
579
733
|
prompt: DefaultPrompt,
|
|
734
|
+
notice: DefaultNotice,
|
|
580
735
|
error: DefaultError,
|
|
581
736
|
history: DefaultHistoryBoundary,
|
|
582
737
|
compaction: DefaultCompaction,
|
|
@@ -619,6 +774,10 @@ export function renderItem(
|
|
|
619
774
|
const C = components.view;
|
|
620
775
|
return <C key={item.key} item={item} ctx={ctx} />;
|
|
621
776
|
}
|
|
777
|
+
case "viewRef": {
|
|
778
|
+
const C = components.viewRef;
|
|
779
|
+
return <C key={item.key} item={item} ctx={ctx} />;
|
|
780
|
+
}
|
|
622
781
|
case "media": {
|
|
623
782
|
const C = components.media;
|
|
624
783
|
return <C key={item.key} item={item} ctx={ctx} />;
|
|
@@ -635,6 +794,10 @@ export function renderItem(
|
|
|
635
794
|
const C = components.prompt;
|
|
636
795
|
return <C key={item.key} item={item} ctx={ctx} />;
|
|
637
796
|
}
|
|
797
|
+
case "notice": {
|
|
798
|
+
const C = components.notice;
|
|
799
|
+
return <C key={item.key} item={item} ctx={ctx} />;
|
|
800
|
+
}
|
|
638
801
|
case "error": {
|
|
639
802
|
const C = components.error;
|
|
640
803
|
return <C key={item.key} item={item} ctx={ctx} />;
|