@jcy2387/dsh-conversation-ui 0.1.0-rc.12
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/LICENSE +21 -0
- package/README.en.md +123 -0
- package/README.md +123 -0
- package/conversation-ui-off.yml +3 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +3672 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +302 -0
- package/package.json +182 -0
- package/src/boot-config.ts +28 -0
- package/src/client/AnimatedDisclosure.tsx +89 -0
- package/src/client/ConversationCard.module.css +253 -0
- package/src/client/ConversationCard.tsx +122 -0
- package/src/client/DeliverablesCard.module.css +187 -0
- package/src/client/DeliverablesCard.tsx +142 -0
- package/src/client/FollowHost.tsx +25 -0
- package/src/client/ToolActivityGroup.tsx +341 -0
- package/src/client/TurnPreludeUserNodeView.tsx +258 -0
- package/src/client/TurnProcessMember.tsx +92 -0
- package/src/client/TypewriterAssistantNodeView.module.css +1260 -0
- package/src/client/TypewriterAssistantNodeView.tsx +390 -0
- package/src/client/TypewriterToolNodeView.tsx +916 -0
- package/src/client/conversation-ui-card-controller.ts +162 -0
- package/src/client/conversation-ui-settings-api.ts +65 -0
- package/src/client/deliverables.ts +263 -0
- package/src/client/index.ts +242 -0
- package/src/client/locales.ts +74 -0
- package/src/client/teleprompterGlide.ts +590 -0
- package/src/client/turnElapsed.ts +27 -0
- package/src/client/turnProcessFold.ts +224 -0
- package/src/client/useConversationContent.ts +471 -0
- package/src/client/useFpsGuard.ts +84 -0
- package/src/config.ts +44 -0
- package/src/css-modules.d.ts +9 -0
- package/src/index.ts +2 -0
- package/src/package-meta.ts +39 -0
- package/src/plugin.ts +155 -0
- package/src/profile-installation.ts +121 -0
- package/src/settings-api.ts +33 -0
- package/src/settings.ts +30 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/** Staged form state for the plugin-owned conversation-ui settings RPC. */
|
|
2
|
+
|
|
3
|
+
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
4
|
+
import { DEFAULT_CONVERSATION_SETTINGS } from '../settings.ts'
|
|
5
|
+
import type { ConversationInstallationKind, ConversationSettingsView } from '../settings-api.ts'
|
|
6
|
+
import type { ConversationSettingsApi } from './conversation-ui-settings-api.ts'
|
|
7
|
+
|
|
8
|
+
/** What the conversation-ui card renders. It remains visible while its Host RPC loads. */
|
|
9
|
+
export interface ConversationCardState {
|
|
10
|
+
status: 'loading' | 'ready' | 'unavailable'
|
|
11
|
+
writable: boolean
|
|
12
|
+
dirty: boolean
|
|
13
|
+
saving: boolean
|
|
14
|
+
failed: boolean
|
|
15
|
+
thinkAutoExpand: boolean
|
|
16
|
+
version: string | undefined
|
|
17
|
+
installation: ConversationInstallationKind
|
|
18
|
+
canUpgrade: boolean
|
|
19
|
+
upgrading: boolean
|
|
20
|
+
upgradeFailed: boolean
|
|
21
|
+
restartRequired: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The registration-side face injected into the settings slot renderer. */
|
|
25
|
+
export interface ConversationCardFace {
|
|
26
|
+
hooks: {
|
|
27
|
+
conversationUiCard: SnapshotStore<ConversationCardState>
|
|
28
|
+
}
|
|
29
|
+
edit: (value: boolean) => void
|
|
30
|
+
save: () => void
|
|
31
|
+
discard: () => void
|
|
32
|
+
reload: () => void
|
|
33
|
+
upgrade: () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Bridges the plugin's protected Host interface onto a staged settings form. */
|
|
37
|
+
export class ConversationCardController {
|
|
38
|
+
private readonly store = createSnapshotStore<ConversationCardState>(this.projection())
|
|
39
|
+
private loaded: ConversationSettingsView | undefined
|
|
40
|
+
private staged: boolean | undefined
|
|
41
|
+
private saving = false
|
|
42
|
+
private failed = false
|
|
43
|
+
private upgrading = false
|
|
44
|
+
private upgradeFailed = false
|
|
45
|
+
private restartRequired = false
|
|
46
|
+
private loadGeneration = 0
|
|
47
|
+
private loadStatus: 'loading' | 'ready' | 'unavailable' = 'loading'
|
|
48
|
+
|
|
49
|
+
constructor(private readonly api: ConversationSettingsApi) {}
|
|
50
|
+
|
|
51
|
+
/** Begin the background read after the card has been registered. */
|
|
52
|
+
start(): void {
|
|
53
|
+
void this.load()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Ignore a late response after the surrounding optional services unload. */
|
|
57
|
+
stop(): void {
|
|
58
|
+
this.loadGeneration += 1
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Current card snapshot, also consumed by the streaming preference cell. */
|
|
62
|
+
getSnapshot(): ConversationCardState {
|
|
63
|
+
return this.store.getSnapshot()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Subscribe to state changes. */
|
|
67
|
+
subscribe(listener: () => void): () => void {
|
|
68
|
+
return this.store.subscribe(listener)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Build the face consumed by the settings slot renderer. */
|
|
72
|
+
inject(): ConversationCardFace {
|
|
73
|
+
return {
|
|
74
|
+
hooks: { conversationUiCard: this.store },
|
|
75
|
+
edit: (value) => {
|
|
76
|
+
this.staged = value
|
|
77
|
+
this.failed = false
|
|
78
|
+
this.publish()
|
|
79
|
+
},
|
|
80
|
+
save: () => { void this.save() },
|
|
81
|
+
discard: () => {
|
|
82
|
+
if (this.staged === undefined && !this.failed) return
|
|
83
|
+
this.staged = undefined
|
|
84
|
+
this.failed = false
|
|
85
|
+
this.publish()
|
|
86
|
+
},
|
|
87
|
+
reload: () => { void this.load() },
|
|
88
|
+
upgrade: () => { void this.upgrade() },
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private projection(): ConversationCardState {
|
|
93
|
+
return {
|
|
94
|
+
status: this.loadStatus,
|
|
95
|
+
writable: this.loaded?.writable ?? false,
|
|
96
|
+
dirty: this.staged !== undefined,
|
|
97
|
+
saving: this.saving,
|
|
98
|
+
failed: this.failed,
|
|
99
|
+
thinkAutoExpand: this.staged ?? this.loaded?.thinkAutoExpand ?? DEFAULT_CONVERSATION_SETTINGS.thinkAutoExpand,
|
|
100
|
+
version: this.loaded?.version,
|
|
101
|
+
installation: this.loaded?.installation ?? 'unmanaged',
|
|
102
|
+
canUpgrade: this.loaded?.canUpgrade ?? false,
|
|
103
|
+
upgrading: this.upgrading,
|
|
104
|
+
upgradeFailed: this.upgradeFailed,
|
|
105
|
+
restartRequired: this.restartRequired,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private async load(): Promise<void> {
|
|
110
|
+
const generation = ++this.loadGeneration
|
|
111
|
+
this.loadStatus = 'loading'
|
|
112
|
+
this.loaded = undefined
|
|
113
|
+
this.publish()
|
|
114
|
+
try {
|
|
115
|
+
const view = await this.api.read()
|
|
116
|
+
if (generation !== this.loadGeneration) return
|
|
117
|
+
this.loaded = view
|
|
118
|
+
this.loadStatus = 'ready'
|
|
119
|
+
} catch {
|
|
120
|
+
if (generation !== this.loadGeneration) return
|
|
121
|
+
this.loaded = undefined
|
|
122
|
+
this.loadStatus = 'unavailable'
|
|
123
|
+
}
|
|
124
|
+
this.publish()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async save(): Promise<void> {
|
|
128
|
+
if (this.staged === undefined || this.saving || this.loaded?.writable !== true) return
|
|
129
|
+
const value = this.staged
|
|
130
|
+
this.saving = true
|
|
131
|
+
this.failed = false
|
|
132
|
+
this.publish()
|
|
133
|
+
try {
|
|
134
|
+
this.loaded = await this.api.write(value)
|
|
135
|
+
this.staged = undefined
|
|
136
|
+
} catch {
|
|
137
|
+
this.failed = true
|
|
138
|
+
}
|
|
139
|
+
this.saving = false
|
|
140
|
+
this.publish()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async upgrade(): Promise<void> {
|
|
144
|
+
if (this.loaded?.canUpgrade !== true || this.upgrading) return
|
|
145
|
+
this.upgrading = true
|
|
146
|
+
this.upgradeFailed = false
|
|
147
|
+
this.restartRequired = false
|
|
148
|
+
this.publish()
|
|
149
|
+
try {
|
|
150
|
+
const result = await this.api.upgrade()
|
|
151
|
+
this.restartRequired = result.restartRequired
|
|
152
|
+
} catch {
|
|
153
|
+
this.upgradeFailed = true
|
|
154
|
+
}
|
|
155
|
+
this.upgrading = false
|
|
156
|
+
this.publish()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private publish(): void {
|
|
160
|
+
this.store.set(this.projection())
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Browser adapter for the Host-owned conversation-ui settings RPC. */
|
|
2
|
+
|
|
3
|
+
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
|
4
|
+
import {
|
|
5
|
+
CONVERSATION_SETTINGS_RPC,
|
|
6
|
+
CONVERSATION_SETTINGS_RPC_CHANNEL,
|
|
7
|
+
type ConversationSettingsView,
|
|
8
|
+
type ConversationUpgradeView,
|
|
9
|
+
} from '../settings-api.ts'
|
|
10
|
+
|
|
11
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
12
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
13
|
+
? value as Record<string, unknown>
|
|
14
|
+
: undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function settingsView(value: unknown): ConversationSettingsView {
|
|
18
|
+
const data = record(value)
|
|
19
|
+
if (data === undefined
|
|
20
|
+
|| typeof data.version !== 'string'
|
|
21
|
+
|| !['npm', 'development', 'unmanaged'].includes(data.installation as string)
|
|
22
|
+
|| typeof data.writable !== 'boolean'
|
|
23
|
+
|| typeof data.thinkAutoExpand !== 'boolean'
|
|
24
|
+
|| typeof data.canUpgrade !== 'boolean') {
|
|
25
|
+
throw new Error('dsh-conversation-ui: malformed settings response')
|
|
26
|
+
}
|
|
27
|
+
return data as unknown as ConversationSettingsView
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function upgradeView(value: unknown): ConversationUpgradeView {
|
|
31
|
+
const data = record(value)
|
|
32
|
+
if (data?.restartRequired !== true) throw new Error('dsh-conversation-ui: malformed update response')
|
|
33
|
+
return { restartRequired: true }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function accepted(result: Awaited<ReturnType<ConnectionHandle['rpc']['call']>>): unknown {
|
|
37
|
+
if (!result.ok) throw new Error(result.error.message)
|
|
38
|
+
return result.value
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Narrow client contract consumed by the staged settings-card controller. */
|
|
42
|
+
export interface ConversationSettingsApi {
|
|
43
|
+
read(): Promise<ConversationSettingsView>
|
|
44
|
+
write(thinkAutoExpand: boolean): Promise<ConversationSettingsView>
|
|
45
|
+
upgrade(): Promise<ConversationUpgradeView>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Build the typed facade over the generic Connection RPC service. */
|
|
49
|
+
export function createConversationSettingsApi(connection: ConnectionHandle): ConversationSettingsApi {
|
|
50
|
+
return {
|
|
51
|
+
async read(): Promise<ConversationSettingsView> {
|
|
52
|
+
return settingsView(accepted(await connection.rpc.call(CONVERSATION_SETTINGS_RPC_CHANNEL, CONVERSATION_SETTINGS_RPC.read, {})))
|
|
53
|
+
},
|
|
54
|
+
async write(thinkAutoExpand: boolean): Promise<ConversationSettingsView> {
|
|
55
|
+
return settingsView(accepted(await connection.rpc.call(
|
|
56
|
+
CONVERSATION_SETTINGS_RPC_CHANNEL,
|
|
57
|
+
CONVERSATION_SETTINGS_RPC.write,
|
|
58
|
+
{ thinkAutoExpand },
|
|
59
|
+
)))
|
|
60
|
+
},
|
|
61
|
+
async upgrade(): Promise<ConversationUpgradeView> {
|
|
62
|
+
return upgradeView(accepted(await connection.rpc.call(CONVERSATION_SETTINGS_RPC_CHANNEL, CONVERSATION_SETTINGS_RPC.upgrade, {})))
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-runtime/client'
|
|
2
|
+
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client'
|
|
3
|
+
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
4
|
+
|
|
5
|
+
export const DELIVERABLES_DATA_KEY = 'dsh-conversation-ui-deliverables' as const
|
|
6
|
+
|
|
7
|
+
export interface DeliverableEntry {
|
|
8
|
+
readonly path: string
|
|
9
|
+
readonly seq: number
|
|
10
|
+
readonly added: number
|
|
11
|
+
readonly removed: number
|
|
12
|
+
readonly kind: 'file' | 'website'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ConversationDeliverablesData {
|
|
16
|
+
readonly entries: readonly DeliverableEntry[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
declare module '@deepseek-ai/dsh-client-runtime/client' {
|
|
20
|
+
interface ConversationTurnDataMap {
|
|
21
|
+
'dsh-conversation-ui-deliverables': ConversationDeliverablesData
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface CallRecord {
|
|
26
|
+
readonly name: string
|
|
27
|
+
readonly argsRaw: string | undefined
|
|
28
|
+
readonly view: unknown
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface DeliverablesState extends ConversationDeliverablesData {
|
|
32
|
+
readonly turn: number
|
|
33
|
+
readonly calls: ReadonlyMap<string, CallRecord>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type UnknownRecord = Record<string, unknown>
|
|
37
|
+
|
|
38
|
+
function record(value: unknown): UnknownRecord | undefined {
|
|
39
|
+
return value !== null && typeof value === 'object' ? value as UnknownRecord : undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stringValue(value: unknown): string | undefined {
|
|
43
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function diffLines(value: unknown): number {
|
|
47
|
+
const text = stringValue(value)
|
|
48
|
+
if (text === undefined) return 0
|
|
49
|
+
const body = text.endsWith('\n')
|
|
50
|
+
? text.slice(0, -1)
|
|
51
|
+
: text.endsWith('\\n') ? text.slice(0, -2) : text
|
|
52
|
+
return body === '' ? 0 : body.split(/\r?\n|\\n/).length
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function jsonArgs(argsRaw: string | undefined): UnknownRecord {
|
|
56
|
+
if (argsRaw === undefined) return {}
|
|
57
|
+
try {
|
|
58
|
+
const parsed: unknown = JSON.parse(argsRaw)
|
|
59
|
+
return record(parsed) ?? {}
|
|
60
|
+
} catch {
|
|
61
|
+
return {}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function viewEntries(view: unknown): readonly Omit<DeliverableEntry, 'seq'>[] {
|
|
66
|
+
const object = record(view)
|
|
67
|
+
if (object === undefined) return []
|
|
68
|
+
const card = object.card
|
|
69
|
+
const locations = Array.isArray(object.locations) ? object.locations : []
|
|
70
|
+
const locationPaths = locations.flatMap(location => {
|
|
71
|
+
const path = stringValue(record(location)?.path)
|
|
72
|
+
return path === undefined ? [] : [path]
|
|
73
|
+
})
|
|
74
|
+
if (card === 'diff') {
|
|
75
|
+
const diffs = Array.isArray(object.diffs) ? object.diffs : []
|
|
76
|
+
if (diffs.length === 0) return locationPaths.map(path => ({ path, added: 0, removed: 0, kind: 'file' as const }))
|
|
77
|
+
return diffs.flatMap(diff => {
|
|
78
|
+
const item = record(diff)
|
|
79
|
+
const path = stringValue(item?.path)
|
|
80
|
+
if (path === undefined) return []
|
|
81
|
+
return [{
|
|
82
|
+
path,
|
|
83
|
+
added: diffLines(item?.newText),
|
|
84
|
+
removed: diffLines(item?.oldText),
|
|
85
|
+
kind: 'file' as const,
|
|
86
|
+
}]
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
if (card === 'generic' && object.kind === 'edit') {
|
|
90
|
+
return locationPaths.map(path => ({ path, added: 0, removed: 0, kind: 'file' as const }))
|
|
91
|
+
}
|
|
92
|
+
const url = stringValue(object.url) ?? stringValue(object.href)
|
|
93
|
+
return url === undefined || !/^https?:\/\//i.test(url)
|
|
94
|
+
? []
|
|
95
|
+
: [{ path: url, added: 0, removed: 0, kind: 'website' as const }]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function mutationPath(name: string, argsRaw: string | undefined): string | undefined {
|
|
99
|
+
const args = jsonArgs(argsRaw)
|
|
100
|
+
const path = stringValue(args.file_path) ?? stringValue(args.path) ?? stringValue(args.filename)
|
|
101
|
+
if (path === undefined || /^https?:\/\//i.test(path)) return undefined
|
|
102
|
+
const tokens = name.toLowerCase().split(/[^a-z\d]+/).filter(Boolean)
|
|
103
|
+
return tokens.some(token => ['edit', 'write', 'patch', 'replace', 'create', 'insert', 'append'].includes(token))
|
|
104
|
+
? path
|
|
105
|
+
: undefined
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function websiteTool(name: string): boolean {
|
|
109
|
+
const tokens = name.toLowerCase().split(/[^a-z\d]+/).filter(Boolean)
|
|
110
|
+
return tokens.some(token => ['deploy', 'publish', 'preview', 'website', 'site', 'artifact'].includes(token))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resultWebsiteEntries(name: string, value: unknown): readonly Omit<DeliverableEntry, 'seq'>[] {
|
|
114
|
+
if (!websiteTool(name)) return []
|
|
115
|
+
let text = ''
|
|
116
|
+
try {
|
|
117
|
+
text = JSON.stringify(value) ?? ''
|
|
118
|
+
} catch {
|
|
119
|
+
return []
|
|
120
|
+
}
|
|
121
|
+
const urls = text.match(/https?:\/\/[^\s"'<>\\]+/gi) ?? []
|
|
122
|
+
return [...new Set(urls.map(url => url.replace(/[),.;]+$/g, '')))].map(path => ({
|
|
123
|
+
path,
|
|
124
|
+
added: 0,
|
|
125
|
+
removed: 0,
|
|
126
|
+
kind: 'website' as const,
|
|
127
|
+
}))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function websiteUrl(name: string, argsRaw: string | undefined): string | undefined {
|
|
131
|
+
const args = jsonArgs(argsRaw)
|
|
132
|
+
const candidate = stringValue(args.url)
|
|
133
|
+
?? stringValue(args.href)
|
|
134
|
+
?? stringValue(args.preview_url)
|
|
135
|
+
?? stringValue(args.website_url)
|
|
136
|
+
if (candidate === undefined || !/^https?:\/\//i.test(candidate)) return undefined
|
|
137
|
+
const tokens = name.toLowerCase().split(/[^a-z\d]+/).filter(Boolean)
|
|
138
|
+
return tokens.some(token => ['deploy', 'publish', 'preview', 'website', 'site', 'artifact'].includes(token))
|
|
139
|
+
? candidate
|
|
140
|
+
: undefined
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function appendEntries(
|
|
144
|
+
current: readonly DeliverableEntry[],
|
|
145
|
+
next: readonly Omit<DeliverableEntry, 'seq'>[],
|
|
146
|
+
seq: number,
|
|
147
|
+
): readonly DeliverableEntry[] {
|
|
148
|
+
const entries = [...current]
|
|
149
|
+
for (const candidate of next) {
|
|
150
|
+
const existing = entries.findIndex(entry => entry.path === candidate.path && entry.kind === candidate.kind)
|
|
151
|
+
if (existing === -1) {
|
|
152
|
+
entries.push({ ...candidate, seq })
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
const previous = entries[existing]
|
|
156
|
+
if (previous === undefined) continue
|
|
157
|
+
entries[existing] = {
|
|
158
|
+
...previous,
|
|
159
|
+
seq,
|
|
160
|
+
added: previous.added + candidate.added,
|
|
161
|
+
removed: previous.removed + candidate.removed,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return entries
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function entriesForCall(call: CallRecord): readonly Omit<DeliverableEntry, 'seq'>[] {
|
|
168
|
+
const fromView = viewEntries(call.view)
|
|
169
|
+
if (fromView.length > 0) return fromView
|
|
170
|
+
const path = mutationPath(call.name, call.argsRaw)
|
|
171
|
+
if (path !== undefined) {
|
|
172
|
+
const args = jsonArgs(call.argsRaw)
|
|
173
|
+
return [{
|
|
174
|
+
path,
|
|
175
|
+
added: diffLines(args.new_string ?? args.new_text ?? args.content),
|
|
176
|
+
removed: diffLines(args.old_string ?? args.old_text),
|
|
177
|
+
kind: 'file',
|
|
178
|
+
}]
|
|
179
|
+
}
|
|
180
|
+
const url = websiteUrl(call.name, call.argsRaw)
|
|
181
|
+
return url === undefined ? [] : [{ path: url, added: 0, removed: 0, kind: 'website' }]
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Conversation data used by the Codex-style delivery card. */
|
|
185
|
+
export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesState> = {
|
|
186
|
+
kind: DELIVERABLES_DATA_KEY,
|
|
187
|
+
match: (event) => {
|
|
188
|
+
if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
|
|
189
|
+
if (event.type === 'tool/call') return { id: String(event.data.turn), role: 'update' }
|
|
190
|
+
if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) {
|
|
191
|
+
return { id: String(event.data.turn), role: 'update' }
|
|
192
|
+
}
|
|
193
|
+
return null
|
|
194
|
+
},
|
|
195
|
+
start: (_context, match) => {
|
|
196
|
+
if (match.event.type !== 'turn/start') throw new Error('deliverables start requires turn/start')
|
|
197
|
+
return { turn: match.event.data.turn, calls: new Map(), entries: [] }
|
|
198
|
+
},
|
|
199
|
+
update: (context, match) => {
|
|
200
|
+
if (match.event.type === 'tool/call') {
|
|
201
|
+
const calls = new Map(context.state.calls)
|
|
202
|
+
const view = match.view?.for === 'call' ? match.view.view : null
|
|
203
|
+
const name = stringValue(match.event.data.name) ?? ''
|
|
204
|
+
const argsRaw = stringValue(match.event.data.arguments)
|
|
205
|
+
calls.set(String(match.event.data.callId), { name, argsRaw, view })
|
|
206
|
+
return { ...context.state, calls }
|
|
207
|
+
}
|
|
208
|
+
if (match.event.type !== 'tool/result') return context.state
|
|
209
|
+
if (match.event.data.message.content[0]?.isError === true) return context.state
|
|
210
|
+
const callId = String(match.event.data.message.source.callId)
|
|
211
|
+
const call = context.state.calls.get(callId)
|
|
212
|
+
if (call === undefined) return context.state
|
|
213
|
+
const resultView = match.view?.for === 'result' ? match.view.view : call.view
|
|
214
|
+
const fromView = viewEntries(resultView)
|
|
215
|
+
const fromCall = fromView.length > 0 ? fromView : entriesForCall({ ...call, view: resultView })
|
|
216
|
+
const fromResult = resultWebsiteEntries(call.name, match.event.data.message)
|
|
217
|
+
const entries = appendEntries(context.state.entries, [...fromCall, ...fromResult], match.event.seq)
|
|
218
|
+
const unchanged = entries.length === context.state.entries.length
|
|
219
|
+
&& entries.every((entry, index) => {
|
|
220
|
+
const previous = context.state.entries[index]
|
|
221
|
+
return previous?.path === entry.path
|
|
222
|
+
&& previous.seq === entry.seq
|
|
223
|
+
&& previous.added === entry.added
|
|
224
|
+
&& previous.removed === entry.removed
|
|
225
|
+
&& previous.kind === entry.kind
|
|
226
|
+
})
|
|
227
|
+
return unchanged ? context.state : { ...context.state, entries }
|
|
228
|
+
},
|
|
229
|
+
buildLocationData: (context, scope) => scope !== 'turn' || context.state === undefined || context.state.entries.length === 0
|
|
230
|
+
? null
|
|
231
|
+
: {
|
|
232
|
+
kind: 'turn',
|
|
233
|
+
turn: context.state.turn,
|
|
234
|
+
key: DELIVERABLES_DATA_KEY,
|
|
235
|
+
value: { entries: context.state.entries },
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function nativeProducedEntries(owner: TurnTailOwnerProps): readonly DeliverableEntry[] {
|
|
240
|
+
const data = (owner.turn.data as unknown as { get(key: string): unknown }).get('deliverables')
|
|
241
|
+
const produced = record(data)?.produced
|
|
242
|
+
if (!Array.isArray(produced)) return []
|
|
243
|
+
const entries: DeliverableEntry[] = []
|
|
244
|
+
for (const item of produced) {
|
|
245
|
+
const value = record(item)
|
|
246
|
+
const path = stringValue(value?.path)
|
|
247
|
+
const seq = typeof value?.seq === 'number' ? value.seq : Number.POSITIVE_INFINITY
|
|
248
|
+
if (path === undefined || seq > owner.seq || entries.some(entry => entry.path === path)) continue
|
|
249
|
+
entries.push({ path, seq, added: 0, removed: 0, kind: 'file' })
|
|
250
|
+
}
|
|
251
|
+
return entries
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Select entries that were delivered before the closing assistant message. */
|
|
255
|
+
export function selectDeliverables(owner: TurnTailOwnerProps): readonly DeliverableEntry[] | null {
|
|
256
|
+
const value = owner.turn.data.get(DELIVERABLES_DATA_KEY)
|
|
257
|
+
if (value !== undefined) {
|
|
258
|
+
const entries = value.entries.filter(entry => entry.seq <= owner.seq)
|
|
259
|
+
return entries.length === 0 ? null : entries
|
|
260
|
+
}
|
|
261
|
+
const nativeEntries = nativeProducedEntries(owner)
|
|
262
|
+
return nativeEntries.length === 0 ? null : nativeEntries
|
|
263
|
+
}
|