@extrachill/chat 0.5.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/CHANGELOG.md +10 -0
- package/README.md +1 -1
- package/css/chat.css +184 -1
- package/dist/Chat.d.ts +27 -1
- package/dist/Chat.d.ts.map +1 -1
- package/dist/Chat.js +4 -2
- package/dist/client-context.d.ts +31 -0
- package/dist/client-context.d.ts.map +1 -0
- package/dist/client-context.js +100 -0
- package/dist/components/ChatMessages.d.ts +18 -1
- package/dist/components/ChatMessages.d.ts.map +1 -1
- package/dist/components/ChatMessages.js +5 -1
- package/dist/components/CopyTranscriptButton.d.ts +12 -0
- package/dist/components/CopyTranscriptButton.d.ts.map +1 -0
- package/dist/components/CopyTranscriptButton.js +26 -0
- package/dist/components/DiffCard.d.ts +40 -0
- package/dist/components/DiffCard.d.ts.map +1 -0
- package/dist/components/DiffCard.js +162 -0
- package/dist/components/ErrorBoundary.d.ts +1 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/diff.d.ts +27 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +109 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/transcript.d.ts +4 -0
- package/dist/transcript.d.ts.map +1 -0
- package/dist/transcript.js +32 -0
- package/package.json +1 -1
- package/src/Chat.tsx +58 -9
- package/src/client-context.ts +160 -0
- package/src/components/ChatMessages.tsx +26 -0
- package/src/components/CopyTranscriptButton.tsx +58 -0
- package/src/components/DiffCard.tsx +252 -0
- package/src/components/index.ts +1 -0
- package/src/diff.ts +159 -0
- package/src/index.ts +39 -1
- package/src/transcript.ts +41 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface ClientContextProvider {
|
|
4
|
+
id: string;
|
|
5
|
+
priority?: number;
|
|
6
|
+
getContext: () => Record< string, unknown > | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ClientContextProviderSnapshot {
|
|
10
|
+
id: string;
|
|
11
|
+
priority: number;
|
|
12
|
+
context: Record< string, unknown >;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ClientContextSnapshot {
|
|
16
|
+
activeContext: Record< string, unknown > | null;
|
|
17
|
+
providers: ClientContextProviderSnapshot[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ClientContextRegistry {
|
|
21
|
+
registerProvider: ( provider: ClientContextProvider ) => () => void;
|
|
22
|
+
unregisterProvider: ( id: string ) => void;
|
|
23
|
+
subscribe: ( listener: () => void ) => () => void;
|
|
24
|
+
notify: () => void;
|
|
25
|
+
getSnapshot: () => ClientContextSnapshot;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
declare global {
|
|
29
|
+
interface Window {
|
|
30
|
+
ecClientContextRegistry?: ClientContextRegistry;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getOrCreateClientContextRegistry(): ClientContextRegistry {
|
|
35
|
+
if ( typeof window === 'undefined' ) {
|
|
36
|
+
return {
|
|
37
|
+
registerProvider: () => () => undefined,
|
|
38
|
+
unregisterProvider: () => undefined,
|
|
39
|
+
subscribe: () => () => undefined,
|
|
40
|
+
notify: () => undefined,
|
|
41
|
+
getSnapshot: () => ( {
|
|
42
|
+
activeContext: null,
|
|
43
|
+
providers: [],
|
|
44
|
+
} ),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if ( window.ecClientContextRegistry ) {
|
|
49
|
+
return window.ecClientContextRegistry;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const providers = new Map< string, ClientContextProvider >();
|
|
53
|
+
const listeners = new Set< () => void >();
|
|
54
|
+
|
|
55
|
+
const registry: ClientContextRegistry = {
|
|
56
|
+
registerProvider( provider ) {
|
|
57
|
+
providers.set( provider.id, provider );
|
|
58
|
+
registry.notify();
|
|
59
|
+
|
|
60
|
+
return () => {
|
|
61
|
+
registry.unregisterProvider( provider.id );
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
unregisterProvider( id ) {
|
|
66
|
+
if ( providers.delete( id ) ) {
|
|
67
|
+
registry.notify();
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
subscribe( listener ) {
|
|
72
|
+
listeners.add( listener );
|
|
73
|
+
|
|
74
|
+
return () => {
|
|
75
|
+
listeners.delete( listener );
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
notify() {
|
|
80
|
+
listeners.forEach( ( listener ) => listener() );
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
getSnapshot() {
|
|
84
|
+
const providerSnapshots = Array.from( providers.values() )
|
|
85
|
+
.map( ( provider ) => {
|
|
86
|
+
const context = provider.getContext();
|
|
87
|
+
if ( ! context ) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
id: provider.id,
|
|
93
|
+
priority: provider.priority ?? 0,
|
|
94
|
+
context,
|
|
95
|
+
};
|
|
96
|
+
} )
|
|
97
|
+
.filter(
|
|
98
|
+
(
|
|
99
|
+
provider
|
|
100
|
+
): provider is ClientContextProviderSnapshot => Boolean( provider )
|
|
101
|
+
)
|
|
102
|
+
.sort( ( a, b ) => b.priority - a.priority );
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
activeContext: providerSnapshots[ 0 ]?.context ?? null,
|
|
106
|
+
providers: providerSnapshots,
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
window.ecClientContextRegistry = registry;
|
|
112
|
+
|
|
113
|
+
return registry;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function registerClientContextProvider( provider: ClientContextProvider ): () => void {
|
|
117
|
+
return getOrCreateClientContextRegistry().registerProvider( provider );
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function getClientContextMetadata(): Record< string, unknown > {
|
|
121
|
+
if ( typeof window === 'undefined' ) {
|
|
122
|
+
return {};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const snapshot = getOrCreateClientContextRegistry().getSnapshot();
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
client_context: {
|
|
129
|
+
site: window.location.hostname,
|
|
130
|
+
url: window.location.href,
|
|
131
|
+
path: window.location.pathname,
|
|
132
|
+
page_title: document.title,
|
|
133
|
+
active_context: snapshot.activeContext,
|
|
134
|
+
available_contexts: snapshot.providers.map( ( provider ) => ( {
|
|
135
|
+
id: provider.id,
|
|
136
|
+
priority: provider.priority,
|
|
137
|
+
context: provider.context,
|
|
138
|
+
} ) ),
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function useClientContextMetadata(): Record< string, unknown > {
|
|
144
|
+
const registry = useMemo( () => getOrCreateClientContextRegistry(), [] );
|
|
145
|
+
const [ metadata, setMetadata ] = useState< Record< string, unknown > >( () =>
|
|
146
|
+
getClientContextMetadata()
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
useEffect( () => {
|
|
150
|
+
const sync = (): void => {
|
|
151
|
+
setMetadata( getClientContextMetadata() );
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
sync();
|
|
155
|
+
|
|
156
|
+
return registry.subscribe( sync );
|
|
157
|
+
}, [ registry ] );
|
|
158
|
+
|
|
159
|
+
return metadata;
|
|
160
|
+
}
|
|
@@ -14,6 +14,22 @@ export interface ChatMessagesProps {
|
|
|
14
14
|
showTools?: boolean;
|
|
15
15
|
/** Custom tool name display map. Maps tool function names to friendly labels. */
|
|
16
16
|
toolNames?: Record<string, string>;
|
|
17
|
+
/**
|
|
18
|
+
* Custom renderers for specific tool names.
|
|
19
|
+
*
|
|
20
|
+
* Map tool function names to React render functions. When a tool group
|
|
21
|
+
* matches a registered name, the custom renderer is used instead of
|
|
22
|
+
* the default `ToolMessage` JSON display.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```tsx
|
|
26
|
+
* toolRenderers={{
|
|
27
|
+
* edit_post_blocks: (group) => <DiffCard diff={parseDiff(group)} />,
|
|
28
|
+
* replace_post_blocks: (group) => <DiffCard diff={parseDiff(group)} />,
|
|
29
|
+
* }}
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
toolRenderers?: Record<string, (group: ToolGroup) => ReactNode>;
|
|
17
33
|
/** Whether to auto-scroll to bottom on new messages. Defaults to true. */
|
|
18
34
|
autoScroll?: boolean;
|
|
19
35
|
/** Placeholder content shown when there are no messages. */
|
|
@@ -34,6 +50,7 @@ export function ChatMessages({
|
|
|
34
50
|
renderContent,
|
|
35
51
|
showTools = false,
|
|
36
52
|
toolNames,
|
|
53
|
+
toolRenderers,
|
|
37
54
|
autoScroll = true,
|
|
38
55
|
emptyState,
|
|
39
56
|
className,
|
|
@@ -81,6 +98,15 @@ export function ChatMessages({
|
|
|
81
98
|
}
|
|
82
99
|
|
|
83
100
|
if (item.type === 'tool-group' && showTools) {
|
|
101
|
+
const customRenderer = toolRenderers?.[item.group.toolName];
|
|
102
|
+
if (customRenderer) {
|
|
103
|
+
return (
|
|
104
|
+
<div key={item.group.callMessage.id}>
|
|
105
|
+
{customRenderer(item.group)}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
84
110
|
return (
|
|
85
111
|
<ToolMessage
|
|
86
112
|
key={item.group.callMessage.id}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import type { ChatMessage } from '../types/index.ts';
|
|
3
|
+
import { copyChatAsMarkdown } from '../transcript.ts';
|
|
4
|
+
|
|
5
|
+
export interface CopyTranscriptButtonProps {
|
|
6
|
+
messages: ChatMessage[];
|
|
7
|
+
label?: string;
|
|
8
|
+
copiedLabel?: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
copyTimeoutMs?: number;
|
|
11
|
+
onCopy?: ( markdown: string ) => void;
|
|
12
|
+
onError?: ( error: Error ) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function CopyTranscriptButton( {
|
|
16
|
+
messages,
|
|
17
|
+
label = 'Copy',
|
|
18
|
+
copiedLabel = 'Copied!',
|
|
19
|
+
className,
|
|
20
|
+
copyTimeoutMs = 2000,
|
|
21
|
+
onCopy,
|
|
22
|
+
onError,
|
|
23
|
+
}: CopyTranscriptButtonProps ) {
|
|
24
|
+
const [ isCopied, setIsCopied ] = useState( false );
|
|
25
|
+
|
|
26
|
+
useEffect( () => {
|
|
27
|
+
if ( ! isCopied ) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const timer = window.setTimeout( () => setIsCopied( false ), copyTimeoutMs );
|
|
32
|
+
return () => window.clearTimeout( timer );
|
|
33
|
+
}, [ isCopied, copyTimeoutMs ] );
|
|
34
|
+
|
|
35
|
+
const handleClick = useCallback( async () => {
|
|
36
|
+
try {
|
|
37
|
+
const markdown = await copyChatAsMarkdown( messages );
|
|
38
|
+
setIsCopied( true );
|
|
39
|
+
onCopy?.( markdown );
|
|
40
|
+
} catch ( error ) {
|
|
41
|
+
onError?.( error as Error );
|
|
42
|
+
}
|
|
43
|
+
}, [ messages, onCopy, onError ] );
|
|
44
|
+
|
|
45
|
+
const baseClass = 'ec-chat-copy';
|
|
46
|
+
const classes = [ baseClass, className ].filter( Boolean ).join( ' ' );
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
className={ classes }
|
|
52
|
+
onClick={ handleClick }
|
|
53
|
+
disabled={ messages.length === 0 }
|
|
54
|
+
>
|
|
55
|
+
{ isCopied ? copiedLabel : label }
|
|
56
|
+
</button>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react';
|
|
2
|
+
import type { CanonicalDiffData } from '../diff.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Diff data returned by a content-editing tool in preview mode.
|
|
6
|
+
*/
|
|
7
|
+
export type DiffData = CanonicalDiffData;
|
|
8
|
+
|
|
9
|
+
export interface DiffCardProps {
|
|
10
|
+
/** The diff data to visualize. */
|
|
11
|
+
diff: DiffData;
|
|
12
|
+
/** Called when the user accepts the change. */
|
|
13
|
+
onAccept?: (diffId: string) => void;
|
|
14
|
+
/** Called when the user rejects the change. */
|
|
15
|
+
onReject?: (diffId: string) => void;
|
|
16
|
+
/** Whether the accept/reject action is in progress. */
|
|
17
|
+
loading?: boolean;
|
|
18
|
+
/** Additional CSS class name. */
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type DiffCardStatus = 'pending' | 'accepted' | 'rejected';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Portable diff visualization card.
|
|
26
|
+
*
|
|
27
|
+
* Renders a before/after comparison with word-level `<ins>` / `<del>` tags
|
|
28
|
+
* and Accept / Reject buttons. Pure React — no Gutenberg or WordPress
|
|
29
|
+
* dependencies. Works anywhere `@extrachill/chat` is consumed.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```tsx
|
|
33
|
+
* <DiffCard
|
|
34
|
+
* diff={{
|
|
35
|
+
* diffId: 'abc123',
|
|
36
|
+
* diffType: 'edit',
|
|
37
|
+
* originalContent: 'Hello world',
|
|
38
|
+
* replacementContent: 'Hello universe',
|
|
39
|
+
* }}
|
|
40
|
+
* onAccept={(id) => apiFetch({ path: `/resolve/${id}`, method: 'POST', data: { action: 'accept' } })}
|
|
41
|
+
* onReject={(id) => apiFetch({ path: `/resolve/${id}`, method: 'POST', data: { action: 'reject' } })}
|
|
42
|
+
* />
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function DiffCard({
|
|
46
|
+
diff,
|
|
47
|
+
onAccept,
|
|
48
|
+
onReject,
|
|
49
|
+
loading = false,
|
|
50
|
+
className,
|
|
51
|
+
}: DiffCardProps) {
|
|
52
|
+
const [status, setStatus] = useState<DiffCardStatus>(diff.status ?? 'pending');
|
|
53
|
+
|
|
54
|
+
const baseClass = 'ec-chat-diff';
|
|
55
|
+
const classes = [
|
|
56
|
+
baseClass,
|
|
57
|
+
status !== 'pending' ? `${baseClass}--${status}` : '',
|
|
58
|
+
className,
|
|
59
|
+
].filter(Boolean).join(' ');
|
|
60
|
+
|
|
61
|
+
const handleAccept = () => {
|
|
62
|
+
setStatus('accepted');
|
|
63
|
+
onAccept?.(diff.diffId);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const handleReject = () => {
|
|
67
|
+
setStatus('rejected');
|
|
68
|
+
onReject?.(diff.diffId);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const diffHtml = renderDiff(diff);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className={classes}>
|
|
75
|
+
<div className={`${baseClass}__header`}>
|
|
76
|
+
<span className={`${baseClass}__icon`}>
|
|
77
|
+
{status === 'accepted' ? '✓' : status === 'rejected' ? '✗' : '⟳'}
|
|
78
|
+
</span>
|
|
79
|
+
<span className={`${baseClass}__label`}>
|
|
80
|
+
{diff.summary ?? formatDiffLabel(diff.diffType)}
|
|
81
|
+
</span>
|
|
82
|
+
{status !== 'pending' && (
|
|
83
|
+
<span className={`${baseClass}__status`}>
|
|
84
|
+
{status === 'accepted' ? 'Applied' : 'Rejected'}
|
|
85
|
+
</span>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{diff.insertionPoint && (
|
|
90
|
+
<div className={`${baseClass}__meta`}>
|
|
91
|
+
{diff.insertionPoint}
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
<div
|
|
96
|
+
className={`${baseClass}__content`}
|
|
97
|
+
dangerouslySetInnerHTML={{ __html: diffHtml }}
|
|
98
|
+
/>
|
|
99
|
+
|
|
100
|
+
{status === 'pending' && (
|
|
101
|
+
<div className={`${baseClass}__actions`}>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
className={`${baseClass}__accept`}
|
|
105
|
+
onClick={handleAccept}
|
|
106
|
+
disabled={loading}
|
|
107
|
+
>
|
|
108
|
+
Accept
|
|
109
|
+
</button>
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
className={`${baseClass}__reject`}
|
|
113
|
+
onClick={handleReject}
|
|
114
|
+
disabled={loading}
|
|
115
|
+
>
|
|
116
|
+
Reject
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Render diff HTML based on the diff type.
|
|
126
|
+
*
|
|
127
|
+
* For 'edit' diffs: word-level comparison between original and replacement.
|
|
128
|
+
* For 'replace' diffs: word-level comparison between original and replacement.
|
|
129
|
+
* For 'insert' diffs: everything is new (all `<ins>`).
|
|
130
|
+
*/
|
|
131
|
+
function renderDiff(diff: DiffData): string {
|
|
132
|
+
const { diffType, originalContent, replacementContent } = diff;
|
|
133
|
+
|
|
134
|
+
if (diffType === 'insert') {
|
|
135
|
+
return `<ins class="ec-chat-diff__added">${escapeHtml(replacementContent)}</ins>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Both 'edit' and 'replace' get word-level diff
|
|
139
|
+
return createWordLevelDiff(originalContent, replacementContent);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Create a word-level diff between two strings.
|
|
144
|
+
*
|
|
145
|
+
* Splits both strings into words, runs a longest-common-subsequence (LCS)
|
|
146
|
+
* alignment, and wraps removed words in `<del>` and added words in `<ins>`.
|
|
147
|
+
* Consecutive unchanged words are emitted as-is.
|
|
148
|
+
*/
|
|
149
|
+
function createWordLevelDiff(oldText: string, newText: string): string {
|
|
150
|
+
if (oldText === newText) {
|
|
151
|
+
return escapeHtml(newText);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const oldWords = tokenize(oldText);
|
|
155
|
+
const newWords = tokenize(newText);
|
|
156
|
+
|
|
157
|
+
// LCS table
|
|
158
|
+
const m = oldWords.length;
|
|
159
|
+
const n = newWords.length;
|
|
160
|
+
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0));
|
|
161
|
+
|
|
162
|
+
for (let i = 1; i <= m; i++) {
|
|
163
|
+
for (let j = 1; j <= n; j++) {
|
|
164
|
+
if (oldWords[i - 1] === newWords[j - 1]) {
|
|
165
|
+
dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
166
|
+
} else {
|
|
167
|
+
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Backtrack to produce diff operations
|
|
173
|
+
const ops: Array<{ type: 'keep' | 'del' | 'ins'; text: string }> = [];
|
|
174
|
+
let i = m;
|
|
175
|
+
let j = n;
|
|
176
|
+
|
|
177
|
+
while (i > 0 || j > 0) {
|
|
178
|
+
if (i > 0 && j > 0 && oldWords[i - 1] === newWords[j - 1]) {
|
|
179
|
+
ops.unshift({ type: 'keep', text: oldWords[i - 1] });
|
|
180
|
+
i--;
|
|
181
|
+
j--;
|
|
182
|
+
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
183
|
+
ops.unshift({ type: 'ins', text: newWords[j - 1] });
|
|
184
|
+
j--;
|
|
185
|
+
} else {
|
|
186
|
+
ops.unshift({ type: 'del', text: oldWords[i - 1] });
|
|
187
|
+
i--;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Merge consecutive same-type ops into spans
|
|
192
|
+
let html = '';
|
|
193
|
+
let currentType: 'keep' | 'del' | 'ins' | null = null;
|
|
194
|
+
let buffer: string[] = [];
|
|
195
|
+
|
|
196
|
+
const flush = () => {
|
|
197
|
+
if (buffer.length === 0) return;
|
|
198
|
+
const text = buffer.join(' ');
|
|
199
|
+
if (currentType === 'del') {
|
|
200
|
+
html += `<del class="ec-chat-diff__removed">${escapeHtml(text)}</del>`;
|
|
201
|
+
} else if (currentType === 'ins') {
|
|
202
|
+
html += `<ins class="ec-chat-diff__added">${escapeHtml(text)}</ins>`;
|
|
203
|
+
} else {
|
|
204
|
+
html += escapeHtml(text);
|
|
205
|
+
}
|
|
206
|
+
buffer = [];
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
for (const op of ops) {
|
|
210
|
+
if (op.type !== currentType) {
|
|
211
|
+
flush();
|
|
212
|
+
currentType = op.type;
|
|
213
|
+
}
|
|
214
|
+
buffer.push(op.text);
|
|
215
|
+
}
|
|
216
|
+
flush();
|
|
217
|
+
|
|
218
|
+
return html;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Tokenize text into words, preserving whitespace as separate tokens
|
|
223
|
+
* so the diff output is readable.
|
|
224
|
+
*/
|
|
225
|
+
function tokenize(text: string): string[] {
|
|
226
|
+
return text.split(/(\s+)/).filter(Boolean);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Escape HTML special characters.
|
|
231
|
+
*/
|
|
232
|
+
function escapeHtml(text: string): string {
|
|
233
|
+
return text
|
|
234
|
+
.replace(/&/g, '&')
|
|
235
|
+
.replace(/</g, '<')
|
|
236
|
+
.replace(/>/g, '>')
|
|
237
|
+
.replace(/"/g, '"');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Human-readable label for a diff type.
|
|
242
|
+
*/
|
|
243
|
+
function formatDiffLabel(diffType: DiffData['diffType']): string {
|
|
244
|
+
switch (diffType) {
|
|
245
|
+
case 'edit':
|
|
246
|
+
return 'Content edit';
|
|
247
|
+
case 'replace':
|
|
248
|
+
return 'Content replacement';
|
|
249
|
+
case 'insert':
|
|
250
|
+
return 'New content';
|
|
251
|
+
}
|
|
252
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export { ChatMessage, type ChatMessageProps } from './ChatMessage.tsx';
|
|
|
2
2
|
export { ChatMessages, type ChatMessagesProps } from './ChatMessages.tsx';
|
|
3
3
|
export { ChatInput, type ChatInputProps } from './ChatInput.tsx';
|
|
4
4
|
export { ToolMessage, type ToolMessageProps, type ToolGroup } from './ToolMessage.tsx';
|
|
5
|
+
export { DiffCard, type DiffCardProps, type DiffData } from './DiffCard.tsx';
|
|
5
6
|
export { TypingIndicator, type TypingIndicatorProps } from './TypingIndicator.tsx';
|
|
6
7
|
export { SessionSwitcher, type SessionSwitcherProps } from './SessionSwitcher.tsx';
|
|
7
8
|
export { ErrorBoundary, type ErrorBoundaryProps } from './ErrorBoundary.tsx';
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { ToolGroup } from './components/ToolMessage.tsx';
|
|
2
|
+
|
|
3
|
+
export type CanonicalDiffType = 'edit' | 'replace' | 'insert';
|
|
4
|
+
export type CanonicalDiffStatus = 'pending' | 'accepted' | 'rejected';
|
|
5
|
+
|
|
6
|
+
export interface CanonicalDiffItem {
|
|
7
|
+
blockIndex?: number;
|
|
8
|
+
originalContent?: string;
|
|
9
|
+
replacementContent?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CanonicalDiffEditorData {
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CanonicalDiffData {
|
|
17
|
+
diffId: string;
|
|
18
|
+
diffType: CanonicalDiffType;
|
|
19
|
+
originalContent: string;
|
|
20
|
+
replacementContent: string;
|
|
21
|
+
status?: CanonicalDiffStatus;
|
|
22
|
+
summary?: string;
|
|
23
|
+
items?: CanonicalDiffItem[];
|
|
24
|
+
position?: string;
|
|
25
|
+
insertionPoint?: string;
|
|
26
|
+
editor?: CanonicalDiffEditorData;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type UnknownRecord = Record< string, unknown >;
|
|
30
|
+
|
|
31
|
+
function isRecord( value: unknown ): value is UnknownRecord {
|
|
32
|
+
return !! value && typeof value === 'object' && ! Array.isArray( value );
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeItem( item: unknown ): CanonicalDiffItem | null {
|
|
36
|
+
if ( ! isRecord( item ) ) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const blockIndex = typeof item.blockIndex === 'number'
|
|
41
|
+
? item.blockIndex
|
|
42
|
+
: typeof item.block_index === 'number'
|
|
43
|
+
? item.block_index
|
|
44
|
+
: undefined;
|
|
45
|
+
|
|
46
|
+
const originalContent = typeof item.originalContent === 'string'
|
|
47
|
+
? item.originalContent
|
|
48
|
+
: typeof item.original_content === 'string'
|
|
49
|
+
? item.original_content
|
|
50
|
+
: undefined;
|
|
51
|
+
|
|
52
|
+
const replacementContent = typeof item.replacementContent === 'string'
|
|
53
|
+
? item.replacementContent
|
|
54
|
+
: typeof item.replacement_content === 'string'
|
|
55
|
+
? item.replacement_content
|
|
56
|
+
: undefined;
|
|
57
|
+
|
|
58
|
+
if ( blockIndex === undefined && originalContent === undefined && replacementContent === undefined ) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
blockIndex,
|
|
64
|
+
originalContent,
|
|
65
|
+
replacementContent,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseCanonicalDiff( value: unknown ): CanonicalDiffData | null {
|
|
70
|
+
if ( ! isRecord( value ) ) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const container = isRecord( value.data ) ? value.data : value;
|
|
75
|
+
const rawDiff = isRecord( container.diff ) ? container.diff : container;
|
|
76
|
+
|
|
77
|
+
const diffId = typeof rawDiff.diffId === 'string'
|
|
78
|
+
? rawDiff.diffId
|
|
79
|
+
: typeof rawDiff.diff_id === 'string'
|
|
80
|
+
? rawDiff.diff_id
|
|
81
|
+
: typeof container.diff_id === 'string'
|
|
82
|
+
? container.diff_id
|
|
83
|
+
: '';
|
|
84
|
+
|
|
85
|
+
const diffType = rawDiff.diffType === 'replace' || rawDiff.diffType === 'insert'
|
|
86
|
+
? rawDiff.diffType
|
|
87
|
+
: rawDiff.diffType === 'edit'
|
|
88
|
+
? 'edit'
|
|
89
|
+
: rawDiff.diff_type === 'replace' || rawDiff.diff_type === 'insert'
|
|
90
|
+
? rawDiff.diff_type
|
|
91
|
+
: 'edit';
|
|
92
|
+
|
|
93
|
+
const originalContent = typeof rawDiff.originalContent === 'string'
|
|
94
|
+
? rawDiff.originalContent
|
|
95
|
+
: typeof rawDiff.original_content === 'string'
|
|
96
|
+
? rawDiff.original_content
|
|
97
|
+
: '';
|
|
98
|
+
|
|
99
|
+
const replacementContent = typeof rawDiff.replacementContent === 'string'
|
|
100
|
+
? rawDiff.replacementContent
|
|
101
|
+
: typeof rawDiff.replacement_content === 'string'
|
|
102
|
+
? rawDiff.replacement_content
|
|
103
|
+
: '';
|
|
104
|
+
|
|
105
|
+
if ( ! diffId && ! originalContent && ! replacementContent ) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const itemsSource = Array.isArray( rawDiff.items )
|
|
110
|
+
? rawDiff.items
|
|
111
|
+
: Array.isArray( rawDiff.edits )
|
|
112
|
+
? rawDiff.edits
|
|
113
|
+
: Array.isArray( rawDiff.replacements )
|
|
114
|
+
? rawDiff.replacements
|
|
115
|
+
: undefined;
|
|
116
|
+
|
|
117
|
+
const items = itemsSource
|
|
118
|
+
?.map( normalizeItem )
|
|
119
|
+
.filter( ( item ): item is CanonicalDiffItem => item !== null );
|
|
120
|
+
|
|
121
|
+
const summary = typeof rawDiff.summary === 'string'
|
|
122
|
+
? rawDiff.summary
|
|
123
|
+
: typeof container.message === 'string'
|
|
124
|
+
? container.message
|
|
125
|
+
: undefined;
|
|
126
|
+
|
|
127
|
+
const status = rawDiff.status === 'accepted' || rawDiff.status === 'rejected' || rawDiff.status === 'pending'
|
|
128
|
+
? rawDiff.status
|
|
129
|
+
: undefined;
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
diffId,
|
|
133
|
+
diffType,
|
|
134
|
+
originalContent,
|
|
135
|
+
replacementContent,
|
|
136
|
+
status,
|
|
137
|
+
summary,
|
|
138
|
+
items: items && items.length > 0 ? items : undefined,
|
|
139
|
+
position: typeof rawDiff.position === 'string' ? rawDiff.position : undefined,
|
|
140
|
+
insertionPoint: typeof rawDiff.insertionPoint === 'string' ? rawDiff.insertionPoint : undefined,
|
|
141
|
+
editor: isRecord( rawDiff.editor ) ? rawDiff.editor : undefined,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function parseCanonicalDiffFromJson( json: string ): CanonicalDiffData | null {
|
|
146
|
+
try {
|
|
147
|
+
return parseCanonicalDiff( JSON.parse( json ) );
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function parseCanonicalDiffFromToolGroup( group: ToolGroup ): CanonicalDiffData | null {
|
|
154
|
+
if ( ! group.resultMessage ) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return parseCanonicalDiffFromJson( group.resultMessage.content );
|
|
159
|
+
}
|