@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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +123 -0
  3. package/README.md +123 -0
  4. package/conversation-ui-off.yml +3 -0
  5. package/cordis.patch.yml +10 -0
  6. package/lib/client.js +3672 -0
  7. package/lib/client.js.map +1 -0
  8. package/lib/index.js +302 -0
  9. package/package.json +182 -0
  10. package/src/boot-config.ts +28 -0
  11. package/src/client/AnimatedDisclosure.tsx +89 -0
  12. package/src/client/ConversationCard.module.css +253 -0
  13. package/src/client/ConversationCard.tsx +122 -0
  14. package/src/client/DeliverablesCard.module.css +187 -0
  15. package/src/client/DeliverablesCard.tsx +142 -0
  16. package/src/client/FollowHost.tsx +25 -0
  17. package/src/client/ToolActivityGroup.tsx +341 -0
  18. package/src/client/TurnPreludeUserNodeView.tsx +258 -0
  19. package/src/client/TurnProcessMember.tsx +92 -0
  20. package/src/client/TypewriterAssistantNodeView.module.css +1260 -0
  21. package/src/client/TypewriterAssistantNodeView.tsx +390 -0
  22. package/src/client/TypewriterToolNodeView.tsx +916 -0
  23. package/src/client/conversation-ui-card-controller.ts +162 -0
  24. package/src/client/conversation-ui-settings-api.ts +65 -0
  25. package/src/client/deliverables.ts +263 -0
  26. package/src/client/index.ts +242 -0
  27. package/src/client/locales.ts +74 -0
  28. package/src/client/teleprompterGlide.ts +590 -0
  29. package/src/client/turnElapsed.ts +27 -0
  30. package/src/client/turnProcessFold.ts +224 -0
  31. package/src/client/useConversationContent.ts +471 -0
  32. package/src/client/useFpsGuard.ts +84 -0
  33. package/src/config.ts +44 -0
  34. package/src/css-modules.d.ts +9 -0
  35. package/src/index.ts +2 -0
  36. package/src/package-meta.ts +39 -0
  37. package/src/plugin.ts +155 -0
  38. package/src/profile-installation.ts +121 -0
  39. package/src/settings-api.ts +33 -0
  40. 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
+ }