@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,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performance guard for the streaming reveal.
|
|
3
|
+
*
|
|
4
|
+
* Feeds an EMA-smoothed frame-rate monitor from a rAF loop while streaming
|
|
5
|
+
* and tracks whether the reply is on-screen. The returned `shouldHoldBack`
|
|
6
|
+
* predicate is true only while the frame rate is below the threshold AND the
|
|
7
|
+
* reply is offscreen — exactly the spec's "skip offscreen DOM updates when
|
|
8
|
+
* FPS < 30" rule. The smoother consumes the predicate as its commit veto.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
12
|
+
|
|
13
|
+
const FPS_THRESHOLD = 30
|
|
14
|
+
const FPS_ALPHA = 0.12
|
|
15
|
+
const RECOVER_FRAMES = 6
|
|
16
|
+
const MAX_FRAME_MS = 100
|
|
17
|
+
|
|
18
|
+
interface MutableFps {
|
|
19
|
+
emaMs: number
|
|
20
|
+
lastMs: number
|
|
21
|
+
healthyRun: number
|
|
22
|
+
degraded: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useFpsGuard(active: boolean): {
|
|
26
|
+
ref: (element: HTMLElement | null) => void
|
|
27
|
+
shouldHoldBack: () => boolean
|
|
28
|
+
} {
|
|
29
|
+
const fpsRef = useRef<MutableFps>({ emaMs: 0, lastMs: 0, healthyRun: 0, degraded: false })
|
|
30
|
+
const visibleRef = useRef(true)
|
|
31
|
+
const elementRef = useRef<HTMLElement | null>(null)
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!active) return
|
|
35
|
+
let rafId = 0
|
|
36
|
+
const frame = (now: number) => {
|
|
37
|
+
rafId = requestAnimationFrame(frame)
|
|
38
|
+
const fps = fpsRef.current
|
|
39
|
+
if (fps.lastMs === 0) {
|
|
40
|
+
fps.lastMs = now
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
const delta = Math.min(MAX_FRAME_MS, Math.max(1, now - fps.lastMs))
|
|
44
|
+
fps.lastMs = now
|
|
45
|
+
fps.emaMs = fps.emaMs === 0 ? delta : fps.emaMs + FPS_ALPHA * (delta - fps.emaMs)
|
|
46
|
+
const currentFps = 1000 / fps.emaMs
|
|
47
|
+
if (currentFps < FPS_THRESHOLD) {
|
|
48
|
+
fps.healthyRun = 0
|
|
49
|
+
fps.degraded = true
|
|
50
|
+
} else if (fps.degraded) {
|
|
51
|
+
fps.healthyRun += 1
|
|
52
|
+
if (fps.healthyRun >= RECOVER_FRAMES) fps.degraded = false
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
rafId = requestAnimationFrame(frame)
|
|
56
|
+
return () => {
|
|
57
|
+
cancelAnimationFrame(rafId)
|
|
58
|
+
fpsRef.current = { emaMs: 0, lastMs: 0, healthyRun: 0, degraded: false }
|
|
59
|
+
}
|
|
60
|
+
}, [active])
|
|
61
|
+
|
|
62
|
+
const ref = useCallback((element: HTMLElement | null) => {
|
|
63
|
+
elementRef.current = element
|
|
64
|
+
}, [])
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const element = elementRef.current
|
|
68
|
+
if (element === null || typeof IntersectionObserver === 'undefined') return
|
|
69
|
+
const observer = new IntersectionObserver(
|
|
70
|
+
(entries) => {
|
|
71
|
+
for (const entry of entries) visibleRef.current = entry.isIntersecting
|
|
72
|
+
},
|
|
73
|
+
{ rootMargin: '120px 0px' },
|
|
74
|
+
)
|
|
75
|
+
observer.observe(element)
|
|
76
|
+
return () => observer.disconnect()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const shouldHoldBack = useCallback(() => {
|
|
80
|
+
return active && fpsRef.current.degraded && !visibleRef.current
|
|
81
|
+
}, [active])
|
|
82
|
+
|
|
83
|
+
return { ref, shouldHoldBack }
|
|
84
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plugin configuration contract. The Host half defines the
|
|
3
|
+
* Schemastery schema over this shape (defaults live here so both halves stay
|
|
4
|
+
* symmetric); the client half falls back to the same defaults when the Host
|
|
5
|
+
* boot-config bridge is absent (client-only composition).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ConversationSmoothingPreset } from './client/useConversationContent.ts'
|
|
9
|
+
|
|
10
|
+
/** Streaming render direction of the assistant node view. */
|
|
11
|
+
export type ConversationMode = 'typewriter' | 'teleprompter'
|
|
12
|
+
|
|
13
|
+
/** Plugin configuration validated by the Host schema and bridged to the browser half. */
|
|
14
|
+
export interface ConversationConfig {
|
|
15
|
+
/** Render direction: horizontal per-character reveal or uniform upward glide. */
|
|
16
|
+
readonly mode: ConversationMode
|
|
17
|
+
/** Smoothing preset for the reveal cadence. */
|
|
18
|
+
readonly preset: ConversationSmoothingPreset
|
|
19
|
+
/** Fixed grapheme reveal rate used by `typewriter` mode. */
|
|
20
|
+
readonly revealCharsPerSec: number
|
|
21
|
+
/** Minimum visual follow speed while the transcript is pinned. */
|
|
22
|
+
readonly scrollSpeedPxPerSec: number
|
|
23
|
+
/**
|
|
24
|
+
* Follow velocity ceiling in CSS pixels per second, so a huge first lag
|
|
25
|
+
* does not teleport. Ordinary wraps stay well below this.
|
|
26
|
+
*/
|
|
27
|
+
readonly maxScrollSpeedPxPerSec: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Defaults shared by the Host schema and the client-side fallback. */
|
|
31
|
+
export const DEFAULT_CONVERSATION_CONFIG: ConversationConfig = {
|
|
32
|
+
mode: 'teleprompter',
|
|
33
|
+
preset: 'balanced',
|
|
34
|
+
revealCharsPerSec: 80,
|
|
35
|
+
scrollSpeedPxPerSec: 48,
|
|
36
|
+
maxScrollSpeedPxPerSec: 1000,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Window global the Host writes into the served index HTML. The browser boot
|
|
41
|
+
* graph carries no per-entry config, so this inline script is the only
|
|
42
|
+
* Host-to-client configuration channel for a composed web plugin.
|
|
43
|
+
*/
|
|
44
|
+
export const CONVERSATION_BOOT_GLOBAL = '__DSH_CONVERSATION_UI_CONFIG__'
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Host-only package metadata. The package manifest remains the source of truth. */
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
interface PackageManifest {
|
|
8
|
+
name?: unknown
|
|
9
|
+
version?: unknown
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function packageManifestPath(): string {
|
|
13
|
+
// Production loads this Node entry from its installed file. Vite's test
|
|
14
|
+
// transform instead supplies an http URL, where the project manifest is the
|
|
15
|
+
// closest equivalent source of truth.
|
|
16
|
+
try {
|
|
17
|
+
return join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
|
|
18
|
+
} catch {
|
|
19
|
+
// Fall through to the project root when a browser-oriented test transform
|
|
20
|
+
// has replaced import.meta.url with a non-file URL.
|
|
21
|
+
}
|
|
22
|
+
return join(process.cwd(), 'package.json')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const manifest = JSON.parse(readFileSync(packageManifestPath(), 'utf8')) as PackageManifest
|
|
26
|
+
|
|
27
|
+
function readRequiredString(field: keyof PackageManifest): string {
|
|
28
|
+
const value = manifest[field]
|
|
29
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
30
|
+
throw new Error(`dsh-conversation-ui: package.json must contain a non-empty ${field}`)
|
|
31
|
+
}
|
|
32
|
+
return value
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** npm package name, read from this plugin's manifest. */
|
|
36
|
+
export const CONVERSATION_PACKAGE_NAME = readRequiredString('name')
|
|
37
|
+
|
|
38
|
+
/** Version of the code currently loaded by the Host, read from its manifest. */
|
|
39
|
+
export const CONVERSATION_PACKAGE_VERSION = readRequiredString('version')
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
3
|
+
import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
|
|
4
|
+
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
5
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
6
|
+
import { DEFAULT_CONVERSATION_CONFIG, type ConversationConfig } from './config.ts'
|
|
7
|
+
import { injectConversationConfig } from './boot-config.ts'
|
|
8
|
+
import { CONVERSATION_PACKAGE_NAME, CONVERSATION_PACKAGE_VERSION } from './package-meta.ts'
|
|
9
|
+
import { inspectProfileInstallation, updateNpmProfilePackage } from './profile-installation.ts'
|
|
10
|
+
import { CONVERSATION_SETTINGS_RPC, CONVERSATION_SETTINGS_RPC_CHANNEL, type ConversationSettingsView } from './settings-api.ts'
|
|
11
|
+
import { DEFAULT_CONVERSATION_SETTINGS, CONVERSATION_SETTINGS_NS, type ConversationSettings } from './settings.ts'
|
|
12
|
+
|
|
13
|
+
/** Display name shown by the Host loader while the plugin is mounted. */
|
|
14
|
+
export const name = 'dsh-conversation-ui'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Plugin configuration accepted from the overlay's `config` section. Cordis
|
|
18
|
+
* validates the value against this schema at load and fills omitted fields
|
|
19
|
+
* from the shared defaults, so an invalid value fails the load loudly.
|
|
20
|
+
*/
|
|
21
|
+
export interface Config extends ConversationConfig {}
|
|
22
|
+
|
|
23
|
+
export const Config: Schema<Config> = Schema.object({
|
|
24
|
+
mode: Schema.union(['typewriter', 'teleprompter'] as const).default(DEFAULT_CONVERSATION_CONFIG.mode),
|
|
25
|
+
preset: Schema.union(['realtime', 'balanced', 'silky'] as const).default(DEFAULT_CONVERSATION_CONFIG.preset),
|
|
26
|
+
revealCharsPerSec: Schema.number()
|
|
27
|
+
.min(5)
|
|
28
|
+
.max(200)
|
|
29
|
+
.default(DEFAULT_CONVERSATION_CONFIG.revealCharsPerSec),
|
|
30
|
+
scrollSpeedPxPerSec: Schema.number()
|
|
31
|
+
.min(1)
|
|
32
|
+
.max(200)
|
|
33
|
+
.default(DEFAULT_CONVERSATION_CONFIG.scrollSpeedPxPerSec),
|
|
34
|
+
maxScrollSpeedPxPerSec: Schema.number()
|
|
35
|
+
.min(1)
|
|
36
|
+
.max(2000)
|
|
37
|
+
.default(DEFAULT_CONVERSATION_CONFIG.maxScrollSpeedPxPerSec),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Schema of the user-owned settings section. The Host keeps it in the durable
|
|
42
|
+
* settings provider while the browser edits it through the plugin RPC below.
|
|
43
|
+
*/
|
|
44
|
+
export const ConversationSettingsSchema: Schema<ConversationSettings> = Schema.object({
|
|
45
|
+
thinkAutoExpand: Schema.boolean().default(DEFAULT_CONVERSATION_SETTINGS.thinkAutoExpand),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Host half: log the resolved configuration and bridge it to the browser
|
|
50
|
+
* half. The web boot graph carries no per-entry config, so the validated
|
|
51
|
+
* value is injected into every served index response as a boot global the
|
|
52
|
+
* client entry reads at apply time.
|
|
53
|
+
* @param ctx - Host context carrying the web server service when composed.
|
|
54
|
+
* @param config - Schema-validated configuration with defaults filled.
|
|
55
|
+
*/
|
|
56
|
+
export function apply(ctx: Context, config: Config): void {
|
|
57
|
+
console.log(
|
|
58
|
+
`[dsh-conversation-ui] plugin loaded! mode=${config.mode} preset=${config.preset} `
|
|
59
|
+
+ `seed=${config.revealCharsPerSec}cps scroll=${config.scrollSpeedPxPerSec}px/s `
|
|
60
|
+
+ `maxScroll=${config.maxScrollSpeedPxPerSec}px/s`,
|
|
61
|
+
)
|
|
62
|
+
ctx.inject(['webServer'], (httpCtx) => {
|
|
63
|
+
httpCtx.effect(
|
|
64
|
+
() => httpCtx.webServer.tapIndex(html => injectConversationConfig(html, config)),
|
|
65
|
+
'dsh-conversation-ui: boot config bridge',
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
// The core settings RPC deliberately filters third-party namespaces. Keep
|
|
69
|
+
// the durable provider as the authority, but expose this one schema through
|
|
70
|
+
// the plugin's own loopback-only connection channel instead.
|
|
71
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
72
|
+
const scope = settingsCtx.settings.register(
|
|
73
|
+
settingsNamespace(CONVERSATION_SETTINGS_NS),
|
|
74
|
+
ConversationSettingsSchema,
|
|
75
|
+
{ applies: 'live' },
|
|
76
|
+
)
|
|
77
|
+
settingsCtx.inject(['connection'], (connectionCtx) => {
|
|
78
|
+
let upgrade: Promise<void> | undefined
|
|
79
|
+
|
|
80
|
+
const view = (): ConversationSettingsView => {
|
|
81
|
+
const installation = inspectProfileInstallation(connectionCtx.baseUrl, CONVERSATION_PACKAGE_NAME)
|
|
82
|
+
return {
|
|
83
|
+
version: CONVERSATION_PACKAGE_VERSION,
|
|
84
|
+
installation: installation.kind,
|
|
85
|
+
writable: connectionCtx.settings.writable,
|
|
86
|
+
thinkAutoExpand: scope.get().thinkAutoExpand,
|
|
87
|
+
canUpgrade: installation.kind === 'npm',
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const handle: ConnectionRpcHandler = async (endpoint, payload) => {
|
|
92
|
+
if (endpoint === CONVERSATION_SETTINGS_RPC.read) return { ok: true, value: view() }
|
|
93
|
+
if (endpoint === CONVERSATION_SETTINGS_RPC.write) {
|
|
94
|
+
if (typeof payload !== 'object' || payload === null
|
|
95
|
+
|| typeof (payload as { thinkAutoExpand?: unknown }).thinkAutoExpand !== 'boolean') {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
error: {
|
|
99
|
+
code: 'settings-rejected',
|
|
100
|
+
message: 'thinkAutoExpand must be a boolean',
|
|
101
|
+
details: { ns: CONVERSATION_SETTINGS_NS },
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!connectionCtx.settings.writable) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
error: {
|
|
109
|
+
code: 'settings-rejected',
|
|
110
|
+
message: 'conversation-ui settings are read-only',
|
|
111
|
+
details: { ns: CONVERSATION_SETTINGS_NS },
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
await scope.update({ thinkAutoExpand: (payload as { thinkAutoExpand: boolean }).thinkAutoExpand })
|
|
117
|
+
} catch {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
error: {
|
|
121
|
+
code: 'settings-rejected',
|
|
122
|
+
message: 'conversation-ui settings update failed',
|
|
123
|
+
details: { ns: CONVERSATION_SETTINGS_NS },
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { ok: true, value: view() }
|
|
128
|
+
}
|
|
129
|
+
if (endpoint === CONVERSATION_SETTINGS_RPC.upgrade) {
|
|
130
|
+
const installation = inspectProfileInstallation(connectionCtx.baseUrl, CONVERSATION_PACKAGE_NAME)
|
|
131
|
+
if (installation.kind !== 'npm') {
|
|
132
|
+
return { ok: false, error: { code: 'internal', message: 'conversation-ui is not an npm profile dependency', details: {} } }
|
|
133
|
+
}
|
|
134
|
+
if (upgrade !== undefined) {
|
|
135
|
+
return { ok: false, error: { code: 'internal', message: 'conversation-ui update is already running', details: {} } }
|
|
136
|
+
}
|
|
137
|
+
upgrade = updateNpmProfilePackage(installation.profileDir, CONVERSATION_PACKAGE_NAME)
|
|
138
|
+
try {
|
|
139
|
+
await upgrade
|
|
140
|
+
} catch {
|
|
141
|
+
return { ok: false, error: { code: 'internal', message: 'conversation-ui update failed', details: {} } }
|
|
142
|
+
} finally {
|
|
143
|
+
upgrade = undefined
|
|
144
|
+
}
|
|
145
|
+
return { ok: true, value: { restartRequired: true } }
|
|
146
|
+
}
|
|
147
|
+
return { ok: false, error: { code: 'internal', message: `unknown conversation-ui endpoint ${JSON.stringify(endpoint)}`, details: {} } }
|
|
148
|
+
}
|
|
149
|
+
connectionCtx.effect(
|
|
150
|
+
() => connectionCtx.connection.rpc.handle(CONVERSATION_SETTINGS_RPC_CHANNEL, handle, { authority: 'loopback' }),
|
|
151
|
+
'dsh-conversation-ui: settings RPC',
|
|
152
|
+
)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** Host-only profile source inspection and fixed npm update runner. */
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { basename, isAbsolute, join } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
/** A profile installation that can be updated through a fixed pnpm invocation. */
|
|
9
|
+
export interface NpmProfileInstallation {
|
|
10
|
+
kind: 'npm'
|
|
11
|
+
profileDir: string
|
|
12
|
+
profileName: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A local source is deliberately not eligible for one-click npm updates. */
|
|
16
|
+
export interface DevelopmentProfileInstallation {
|
|
17
|
+
kind: 'development'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The active context is not a recognized profile dependency. */
|
|
21
|
+
export interface UnmanagedProfileInstallation {
|
|
22
|
+
kind: 'unmanaged'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Installation source visible to the plugin's Host RPC. */
|
|
26
|
+
export type ProfileInstallation = NpmProfileInstallation | DevelopmentProfileInstallation | UnmanagedProfileInstallation
|
|
27
|
+
|
|
28
|
+
interface ProfileManifest {
|
|
29
|
+
dependencies?: unknown
|
|
30
|
+
dsh?: unknown
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
34
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
35
|
+
? value as Record<string, unknown>
|
|
36
|
+
: undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function profileDirectory(baseUrl: string | undefined): string | undefined {
|
|
40
|
+
if (baseUrl === undefined) return undefined
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(baseUrl)
|
|
43
|
+
return url.protocol === 'file:' ? fileURLToPath(url) : undefined
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hasBundle(manifest: ProfileManifest, packageName: string): boolean {
|
|
50
|
+
const dsh = record(manifest.dsh)
|
|
51
|
+
const profile = record(dsh?.profile)
|
|
52
|
+
const bundles = profile?.bundles
|
|
53
|
+
return Array.isArray(bundles) && bundles.includes(packageName)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isLocalSpecifier(specifier: string): boolean {
|
|
57
|
+
return specifier.startsWith('link:')
|
|
58
|
+
|| specifier.startsWith('file:')
|
|
59
|
+
|| specifier.startsWith('.')
|
|
60
|
+
|| isAbsolute(specifier)
|
|
61
|
+
|| /^[A-Za-z]:[\\/]/.test(specifier)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isRegistrySpecifier(specifier: string): boolean {
|
|
65
|
+
// A registry range/tag contains neither a scheme nor a path. Treat every
|
|
66
|
+
// package-manager extension (catalog:, patch:, portal:, workspace:, etc.)
|
|
67
|
+
// as unconfirmed rather than guessing that an update may replace it.
|
|
68
|
+
return specifier.length > 0 && !specifier.includes(':') && !/[\\/]/.test(specifier)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isNpmSpecifier(specifier: string, packageName: string): boolean {
|
|
72
|
+
if (!specifier.startsWith('npm:')) return isRegistrySpecifier(specifier)
|
|
73
|
+
const aliased = specifier.slice('npm:'.length)
|
|
74
|
+
if (aliased === packageName) return true
|
|
75
|
+
const prefix = `${packageName}@`
|
|
76
|
+
return aliased.startsWith(prefix) && isRegistrySpecifier(aliased.slice(prefix.length))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read only the profile manifest anchored by the current Cordis config tree.
|
|
81
|
+
* A malformed or unrelated tree never receives an update affordance.
|
|
82
|
+
*/
|
|
83
|
+
export function inspectProfileInstallation(
|
|
84
|
+
baseUrl: string | undefined,
|
|
85
|
+
packageName: string,
|
|
86
|
+
): ProfileInstallation {
|
|
87
|
+
const profileDir = profileDirectory(baseUrl)
|
|
88
|
+
if (profileDir === undefined) return { kind: 'unmanaged' }
|
|
89
|
+
let manifest: ProfileManifest
|
|
90
|
+
try {
|
|
91
|
+
manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as ProfileManifest
|
|
92
|
+
} catch {
|
|
93
|
+
return { kind: 'unmanaged' }
|
|
94
|
+
}
|
|
95
|
+
const dependencies = record(manifest.dependencies)
|
|
96
|
+
const specifier = dependencies?.[packageName]
|
|
97
|
+
if (typeof specifier !== 'string' || !hasBundle(manifest, packageName)) {
|
|
98
|
+
return { kind: 'unmanaged' }
|
|
99
|
+
}
|
|
100
|
+
if (isLocalSpecifier(specifier)) return { kind: 'development' }
|
|
101
|
+
if (!isNpmSpecifier(specifier, packageName)) return { kind: 'unmanaged' }
|
|
102
|
+
return { kind: 'npm', profileDir, profileName: basename(profileDir) }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Run the same fixed package update operation that a profile user would invoke. */
|
|
106
|
+
export function updateNpmProfilePackage(profileDir: string, packageName: string): Promise<void> {
|
|
107
|
+
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
const child = spawn(command, ['update', packageName], {
|
|
110
|
+
cwd: profileDir,
|
|
111
|
+
stdio: 'ignore',
|
|
112
|
+
shell: false,
|
|
113
|
+
windowsHide: true,
|
|
114
|
+
})
|
|
115
|
+
child.once('error', reject)
|
|
116
|
+
child.once('exit', (code, signal) => {
|
|
117
|
+
if (code === 0) resolve()
|
|
118
|
+
else reject(new Error(`dsh-conversation-ui: pnpm update failed (${signal ?? String(code)})`))
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Shared wire vocabulary for the plugin-owned settings RPC channel. */
|
|
2
|
+
|
|
3
|
+
/** Dedicated, loopback-only RPC channel registered by the Host half. */
|
|
4
|
+
export const CONVERSATION_SETTINGS_RPC_CHANNEL = '/conversation-ui'
|
|
5
|
+
|
|
6
|
+
/** Endpoints accepted by {@link CONVERSATION_SETTINGS_RPC_CHANNEL}. */
|
|
7
|
+
export const CONVERSATION_SETTINGS_RPC = {
|
|
8
|
+
read: 'settings.read',
|
|
9
|
+
write: 'settings.write',
|
|
10
|
+
upgrade: 'plugin.upgrade',
|
|
11
|
+
} as const
|
|
12
|
+
|
|
13
|
+
/** How the active profile supplied this plugin. */
|
|
14
|
+
export type ConversationInstallationKind = 'npm' | 'development' | 'unmanaged'
|
|
15
|
+
|
|
16
|
+
/** Redacted, plugin-owned configuration state returned to the browser. */
|
|
17
|
+
export interface ConversationSettingsView {
|
|
18
|
+
/** Version of the package currently running in the Host process. */
|
|
19
|
+
version: string
|
|
20
|
+
/** Source class of this package in the active profile. */
|
|
21
|
+
installation: ConversationInstallationKind
|
|
22
|
+
/** Whether the Host settings provider permits a write. */
|
|
23
|
+
writable: boolean
|
|
24
|
+
/** Current resolved preference. */
|
|
25
|
+
thinkAutoExpand: boolean
|
|
26
|
+
/** Whether a fixed npm update command is safe to offer. */
|
|
27
|
+
canUpgrade: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Successful package update acknowledgement; loading the new code needs a restart. */
|
|
31
|
+
export interface ConversationUpgradeView {
|
|
32
|
+
restartRequired: true
|
|
33
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-owned settings for the conversation-ui plugin, exposed to the Host
|
|
3
|
+
* settings service and edited from the Web Settings "plugin configuration"
|
|
4
|
+
* page. This is the runtime-editable complement to {@link ConversationConfig}: that
|
|
5
|
+
* contract is composed at load and bridged once through the boot global, while
|
|
6
|
+
* these preferences live in the durable user-settings document and take effect
|
|
7
|
+
* live.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Settings namespace registered by the Host and served through the plugin RPC. */
|
|
11
|
+
export const CONVERSATION_SETTINGS_NS = 'conversation-ui'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Preferences a user may set. Deliberately separate from {@link ConversationConfig}
|
|
15
|
+
* because the two change at different times: composition-time values go
|
|
16
|
+
* through the boot global, a live UI edit goes through the protected plugin RPC.
|
|
17
|
+
*/
|
|
18
|
+
export interface ConversationSettings {
|
|
19
|
+
/**
|
|
20
|
+
* Whether a reasoning ("Think") block auto-expands while it is the
|
|
21
|
+
* streaming tail. Off keeps the block collapsed — the user can still open
|
|
22
|
+
* it by hand — and stops the running state from re-owning the disclosure.
|
|
23
|
+
*/
|
|
24
|
+
thinkAutoExpand: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Defaults shared by the Host schema and the client-side fallback. */
|
|
28
|
+
export const DEFAULT_CONVERSATION_SETTINGS: ConversationSettings = {
|
|
29
|
+
thinkAutoExpand: true,
|
|
30
|
+
}
|