@linxin666/dsh-remote-web-ui 0.3.2 → 0.3.4
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/README.i18n.yaml +2 -2
- package/README.md +22 -8
- package/README.zh.md +12 -7
- package/assets/mobile-service-worker.js +1 -2
- package/lib/client.js +177 -19
- package/lib/client.js.map +1 -1
- package/lib/index.js +222 -15
- package/lib/mobile.js +64 -13
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +2 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +2 -2
- package/lib/types/client/RemoteEntry.js +1 -1
- package/lib/types/client/UpdatePanel.d.ts.map +1 -1
- package/lib/types/client/UpdatePanel.js +20 -3
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +4 -0
- package/lib/types/client/locales.d.ts +12 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +22 -10
- package/lib/types/client/settings-form.d.ts +1 -1
- package/lib/types/client/settings-form.js +1 -1
- package/lib/types/client/telemetry.d.ts +27 -0
- package/lib/types/client/telemetry.d.ts.map +1 -0
- package/lib/types/client/telemetry.js +97 -0
- package/lib/types/dsh-home.d.ts.map +1 -1
- package/lib/types/dsh-home.js +9 -3
- package/lib/types/http.d.ts +1 -1
- package/lib/types/http.js +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +34 -5
- package/lib/types/loopback.d.ts +1 -1
- package/lib/types/loopback.js +1 -1
- package/lib/types/mobile/api.d.ts +7 -0
- package/lib/types/mobile/api.d.ts.map +1 -1
- package/lib/types/mobile/api.js +9 -3
- package/lib/types/mobile/mobile-styles.d.ts +1 -1
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
- package/lib/types/mobile/mobile-styles.js +10 -0
- package/lib/types/mobile/views/ChatView.d.ts +1 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +32 -5
- package/lib/types/mobile/views/ChatView.stream.test.js +28 -2
- package/lib/types/mobile/views/ChatView.test.js +152 -2
- package/lib/types/mobile-api.d.ts +14 -0
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +56 -1
- package/lib/types/mobile-routes.d.ts.map +1 -1
- package/lib/types/mobile-routes.js +25 -2
- package/lib/types/mount-once.d.ts +1 -1
- package/lib/types/mount-once.js +2 -2
- package/lib/types/update.d.ts +35 -4
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +98 -4
- package/package.json +4 -2
- package/src/client/PluginSettingsCard.tsx +4 -2
- package/src/client/RemoteEntry.tsx +1 -1
- package/src/client/UpdatePanel.tsx +43 -3
- package/src/client/index.ts +6 -1
- package/src/client/locales.ts +22 -10
- package/src/client/remote.module.css +56 -3
- package/src/client/settings-card.module.css +5 -0
- package/src/client/settings-form.ts +1 -1
- package/src/client/telemetry.ts +105 -0
- package/src/dsh-home.ts +9 -3
- package/src/http.ts +1 -1
- package/src/index.ts +36 -4
- package/src/loopback.ts +1 -1
- package/src/mobile/api.ts +10 -3
- package/src/mobile/mobile-styles.ts +10 -0
- package/src/mobile/views/ChatView.stream.test.tsx +34 -2
- package/src/mobile/views/ChatView.test.tsx +177 -2
- package/src/mobile/views/ChatView.tsx +44 -6
- package/src/mobile-api.ts +61 -0
- package/src/mobile-routes.ts +24 -2
- package/src/mount-once.ts +2 -2
- package/src/update.ts +117 -4
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
4
|
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
5
5
|
import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
|
|
6
|
-
import { ChatView, MAX_TAIL_BUFFER_EVENTS } from './ChatView.tsx'
|
|
6
|
+
import { ChatView, MAX_TAIL_BUFFER_EVENTS, LONG_TEXT_LIMIT } from './ChatView.tsx'
|
|
7
7
|
import { type SessionView } from './App.tsx'
|
|
8
8
|
import type { HistoryPage } from '../api.ts'
|
|
9
9
|
import type { WireEvent } from '../messages.ts'
|
|
@@ -15,6 +15,8 @@ vi.mock('../api.ts', () => ({
|
|
|
15
15
|
models: vi.fn(),
|
|
16
16
|
selectModel: vi.fn(),
|
|
17
17
|
sendCommand: vi.fn(),
|
|
18
|
+
cancelSession: vi.fn(),
|
|
19
|
+
fetchPending: vi.fn(),
|
|
18
20
|
}))
|
|
19
21
|
vi.mock('./App.tsx', async importOriginal => {
|
|
20
22
|
const actual = await importOriginal<typeof import('./App.tsx')>()
|
|
@@ -24,7 +26,7 @@ vi.mock('./App.tsx', async importOriginal => {
|
|
|
24
26
|
prompt: vi.fn(async () => {}),
|
|
25
27
|
}
|
|
26
28
|
})
|
|
27
|
-
import { fetchMobilePreferences, models, selectModel, sendCommand } from '../api.ts'
|
|
29
|
+
import { fetchMobilePreferences, models, selectModel, sendCommand, cancelSession, fetchPending } from '../api.ts'
|
|
28
30
|
import { loadHistory, prompt } from './App.tsx'
|
|
29
31
|
|
|
30
32
|
const session: SessionView = {
|
|
@@ -45,6 +47,18 @@ function historyPage(events: Array<{ event: WireEvent }>, extra: Record<string,
|
|
|
45
47
|
return { events: events as never, hasMore: false, ...extra } as HistoryPage
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
/** Minimal mux stand-in: captures the ChatView's frame listener for hand-off. */
|
|
51
|
+
class FakeMux {
|
|
52
|
+
listeners = new Set<(frame: unknown) => void>()
|
|
53
|
+
onFrame(listener: (frame: unknown) => void): () => void {
|
|
54
|
+
this.listeners.add(listener)
|
|
55
|
+
return () => { this.listeners.delete(listener) }
|
|
56
|
+
}
|
|
57
|
+
emit(frame: unknown): void {
|
|
58
|
+
for (const listener of this.listeners) listener(frame)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
48
62
|
/** A full turn: user message, reasoning + text chunks, tool calls, final message. */
|
|
49
63
|
function turnEvents(): Array<{ event: WireEvent }> {
|
|
50
64
|
return [
|
|
@@ -72,6 +86,8 @@ const fetchMobilePreferencesMock = vi.mocked(fetchMobilePreferences)
|
|
|
72
86
|
const modelsMock = vi.mocked(models)
|
|
73
87
|
const selectModelMock = vi.mocked(selectModel)
|
|
74
88
|
const sendCommandMock = vi.mocked(sendCommand)
|
|
89
|
+
const cancelSessionMock = vi.mocked(cancelSession)
|
|
90
|
+
const fetchPendingMock = vi.mocked(fetchPending)
|
|
75
91
|
const loadHistoryMock = vi.mocked(loadHistory)
|
|
76
92
|
const promptMock = vi.mocked(prompt)
|
|
77
93
|
|
|
@@ -95,6 +111,8 @@ beforeEach(() => {
|
|
|
95
111
|
} satisfies SessionModels)
|
|
96
112
|
selectModelMock.mockResolvedValue({ selected: { provider: 'fx', model: 'fx-2', reasoningEffort: 'high' } })
|
|
97
113
|
sendCommandMock.mockResolvedValue({})
|
|
114
|
+
cancelSessionMock.mockResolvedValue({ accepted: true })
|
|
115
|
+
fetchPendingMock.mockResolvedValue({ approvals: [], questions: [] })
|
|
98
116
|
})
|
|
99
117
|
|
|
100
118
|
afterEach(() => {
|
|
@@ -639,3 +657,160 @@ describe('ChatView display toggles and context usage', () => {
|
|
|
639
657
|
expect(screen.queryByText(/上下文/)).toBeNull()
|
|
640
658
|
})
|
|
641
659
|
})
|
|
660
|
+
|
|
661
|
+
describe('ChatView stop button (#1041)', () => {
|
|
662
|
+
/** Emit one session lifecycle frame for the chat's session. */
|
|
663
|
+
function emitSessionEvent(mux: FakeMux, type: string, seq: number): void {
|
|
664
|
+
act(() => {
|
|
665
|
+
mux.emit({ type: 'session/event', sessionId: 's-1', event: makeEntry(type, {}, seq).event })
|
|
666
|
+
})
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
it('switches the composer primary to a stop button while running and cancels the turn', async () => {
|
|
670
|
+
loadHistoryMock.mockResolvedValue(historyPage([]))
|
|
671
|
+
const mux = new FakeMux()
|
|
672
|
+
render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
|
|
673
|
+
|
|
674
|
+
// Idle: the primary button is the (empty-draft disabled) send button.
|
|
675
|
+
const sendButton = await screen.findByRole('button', { name: '发送' })
|
|
676
|
+
expect((sendButton as HTMLButtonElement).disabled).toBe(true)
|
|
677
|
+
|
|
678
|
+
// Turn starts: the primary becomes an enabled stop button (square icon).
|
|
679
|
+
emitSessionEvent(mux, 'turn/start', 1)
|
|
680
|
+
const stopButton = (await screen.findByRole('button', { name: '停止' })) as HTMLButtonElement
|
|
681
|
+
expect(stopButton.disabled).toBe(false)
|
|
682
|
+
|
|
683
|
+
fireEvent.click(stopButton)
|
|
684
|
+
await waitFor(() => expect(cancelSessionMock).toHaveBeenCalledWith('s-1'))
|
|
685
|
+
|
|
686
|
+
// Turn ends: the primary flips back to send.
|
|
687
|
+
emitSessionEvent(mux, 'turn/end', 2)
|
|
688
|
+
expect(await screen.findByRole('button', { name: '发送' })).toBeTruthy()
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
it('disables the stop button while the cancel request is in flight', async () => {
|
|
692
|
+
loadHistoryMock.mockResolvedValue(historyPage([]))
|
|
693
|
+
let resolveCancel: (() => void) | undefined
|
|
694
|
+
cancelSessionMock.mockReturnValue(new Promise<{ accepted: true }>((resolve) => {
|
|
695
|
+
resolveCancel = () => { resolve({ accepted: true }) }
|
|
696
|
+
}))
|
|
697
|
+
const mux = new FakeMux()
|
|
698
|
+
render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
|
|
699
|
+
await screen.findByRole('button', { name: '发送' })
|
|
700
|
+
|
|
701
|
+
emitSessionEvent(mux, 'turn/start', 1)
|
|
702
|
+
const stopButton = (await screen.findByRole('button', { name: '停止' })) as HTMLButtonElement
|
|
703
|
+
fireEvent.click(stopButton)
|
|
704
|
+
|
|
705
|
+
// In flight: disabled and re-labeled; a second tap cannot double-submit.
|
|
706
|
+
const inflight = (await screen.findByRole('button', { name: '停止中' })) as HTMLButtonElement
|
|
707
|
+
expect(inflight.disabled).toBe(true)
|
|
708
|
+
fireEvent.click(inflight)
|
|
709
|
+
expect(cancelSessionMock).toHaveBeenCalledTimes(1)
|
|
710
|
+
|
|
711
|
+
await act(async () => { resolveCancel?.() })
|
|
712
|
+
emitSessionEvent(mux, 'turn/end', 2)
|
|
713
|
+
expect(await screen.findByRole('button', { name: '发送' })).toBeTruthy()
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
it('surfaces a cancel failure through the chat error line', async () => {
|
|
717
|
+
loadHistoryMock.mockResolvedValue(historyPage([]))
|
|
718
|
+
cancelSessionMock.mockRejectedValue(new Error('cancel exploded'))
|
|
719
|
+
const mux = new FakeMux()
|
|
720
|
+
render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
|
|
721
|
+
await screen.findByRole('button', { name: '发送' })
|
|
722
|
+
|
|
723
|
+
emitSessionEvent(mux, 'turn/start', 1)
|
|
724
|
+
fireEvent.click(await screen.findByRole('button', { name: '停止' }))
|
|
725
|
+
expect(await screen.findByText(/cancel exploded/)).toBeTruthy()
|
|
726
|
+
})
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
describe('ChatView message visibility and long text folding (#1065)', () => {
|
|
730
|
+
const toolOnlyEvents = (): Array<{ event: WireEvent }> => [
|
|
731
|
+
makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '查询文件' }] }, 0),
|
|
732
|
+
makeEntry('assistant/message', {
|
|
733
|
+
turn: 0,
|
|
734
|
+
step: 0,
|
|
735
|
+
message: { id: 'a-1', role: 'assistant', content: [] },
|
|
736
|
+
}, 1),
|
|
737
|
+
makeEntry('tool/call', { turn: 0, step: 0, callId: 'c1', name: 'read_file', arguments: '{"path":"/a"}' }, 2),
|
|
738
|
+
]
|
|
739
|
+
|
|
740
|
+
it('hides assistant message completely (no air bubble) when only tool calls exist and tool toggle is off', async () => {
|
|
741
|
+
loadHistoryMock.mockResolvedValue(historyPage(toolOnlyEvents()))
|
|
742
|
+
const { container } = render(<ChatView session={session} onBack={() => {}} />)
|
|
743
|
+
|
|
744
|
+
expect(await screen.findByText('查询文件')).toBeTruthy()
|
|
745
|
+
// By default showToolCalls is true, tool disclosure is visible
|
|
746
|
+
expect(await screen.findByRole('button', { name: /工具/ })).toBeTruthy()
|
|
747
|
+
|
|
748
|
+
// Turn off tool-calls toggle
|
|
749
|
+
fireEvent.click(screen.getByRole('button', { name: /显示/ }))
|
|
750
|
+
const toolSwitch = await screen.findByRole('switch', { name: '工具调用' })
|
|
751
|
+
fireEvent.click(toolSwitch)
|
|
752
|
+
|
|
753
|
+
// The tool disclosure is gone, and the entire assistant message bubble is not rendered (no air bubble)
|
|
754
|
+
expect(screen.queryByRole('button', { name: /工具/ })).toBeNull()
|
|
755
|
+
const msgElements = container.querySelectorAll('.chat-msg')
|
|
756
|
+
expect(msgElements.length).toBe(1)
|
|
757
|
+
expect(msgElements[0]?.classList.contains('chat-msg-user')).toBe(true)
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
it('renders failed tag even if assistant message has no text or reasoning', async () => {
|
|
761
|
+
loadHistoryMock.mockResolvedValue(historyPage([
|
|
762
|
+
makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '测试失败' }] }, 0),
|
|
763
|
+
makeEntry('assistant/message', {
|
|
764
|
+
turn: 0,
|
|
765
|
+
step: 0,
|
|
766
|
+
message: { id: 'a-1', role: 'assistant', content: [] },
|
|
767
|
+
}, 1),
|
|
768
|
+
makeEntry('turn/end', { turn: 0, reason: { kind: 'error', message: 'timeout' } }, 2),
|
|
769
|
+
]))
|
|
770
|
+
render(<ChatView session={session} onBack={() => {}} />)
|
|
771
|
+
|
|
772
|
+
expect(await screen.findByText('测试失败')).toBeTruthy()
|
|
773
|
+
expect(await screen.findByText('本次回复失败')).toBeTruthy()
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
it('collapses terminal assistant text exceeding LONG_TEXT_LIMIT and toggles open/close', async () => {
|
|
777
|
+
const longText = 'A'.repeat(LONG_TEXT_LIMIT + 100)
|
|
778
|
+
loadHistoryMock.mockResolvedValue(historyPage([
|
|
779
|
+
makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '生成长文本' }] }, 0),
|
|
780
|
+
makeEntry('assistant/message', {
|
|
781
|
+
turn: 0,
|
|
782
|
+
step: 0,
|
|
783
|
+
message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: longText }] },
|
|
784
|
+
}, 1),
|
|
785
|
+
]))
|
|
786
|
+
const { container } = render(<ChatView session={session} onBack={() => {}} />)
|
|
787
|
+
|
|
788
|
+
expect(await screen.findByText('生成长文本')).toBeTruthy()
|
|
789
|
+
const toggleButton = await screen.findByRole('button', { name: new RegExp(`展开全文(${LONG_TEXT_LIMIT + 100} 字)`) })
|
|
790
|
+
expect(toggleButton).toBeTruthy()
|
|
791
|
+
expect(container.querySelector('.chat-md-collapsed')).not.toBeNull()
|
|
792
|
+
|
|
793
|
+
// Expand
|
|
794
|
+
fireEvent.click(toggleButton)
|
|
795
|
+
expect(await screen.findByRole('button', { name: '收起' })).toBeTruthy()
|
|
796
|
+
expect(container.querySelector('.chat-md-collapsed')).toBeNull()
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
it('does not collapse terminal assistant text within LONG_TEXT_LIMIT', async () => {
|
|
800
|
+
const shortText = 'B'.repeat(LONG_TEXT_LIMIT)
|
|
801
|
+
loadHistoryMock.mockResolvedValue(historyPage([
|
|
802
|
+
makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '生成中等文本' }] }, 0),
|
|
803
|
+
makeEntry('assistant/message', {
|
|
804
|
+
turn: 0,
|
|
805
|
+
step: 0,
|
|
806
|
+
message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: shortText }] },
|
|
807
|
+
}, 1),
|
|
808
|
+
]))
|
|
809
|
+
const { container } = render(<ChatView session={session} onBack={() => {}} />)
|
|
810
|
+
|
|
811
|
+
expect(await screen.findByText('生成中等文本')).toBeTruthy()
|
|
812
|
+
expect(screen.queryByRole('button', { name: /展开全文/ })).toBeNull()
|
|
813
|
+
expect(container.querySelector('.chat-md-collapsed')).toBeNull()
|
|
814
|
+
})
|
|
815
|
+
})
|
|
816
|
+
|
|
@@ -16,7 +16,7 @@ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api/events'
|
|
|
16
16
|
import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
|
|
17
17
|
import { loadHistory, prompt, type SessionView } from './App.tsx'
|
|
18
18
|
import { errorText, formatTime, staleHostHint } from './App.tsx'
|
|
19
|
-
import { fetchMobilePreferences, models, selectModel, sendCommand, fetchPending, respondApproval, respondQuestion } from '../api.ts'
|
|
19
|
+
import { fetchMobilePreferences, models, selectModel, sendCommand, cancelSession, fetchPending, respondApproval, respondQuestion } from '../api.ts'
|
|
20
20
|
import type { PendingApproval, PendingQuestionItem } from '../api.ts'
|
|
21
21
|
import { EventFolder, foldEvents, type RenderMessage, type ToolCallInfo, type WireEvent } from '../messages.ts'
|
|
22
22
|
import { renderMarkdown } from '../markdown.ts'
|
|
@@ -167,6 +167,8 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
167
167
|
const [mobileEnterToSend, setMobileEnterToSend] = useState(true)
|
|
168
168
|
/** Whether the assistant is currently generating (turn/start..turn/end). */
|
|
169
169
|
const [running, setRunning] = useState(false)
|
|
170
|
+
/** Whether a stop request is in flight (guards the composer's stop button). */
|
|
171
|
+
const [stopping, setStopping] = useState(false)
|
|
170
172
|
/** Pending tool approvals awaiting user decision (#1025). */
|
|
171
173
|
const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>([])
|
|
172
174
|
/** Pending questions awaiting user answer (#1025). */
|
|
@@ -441,6 +443,24 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
441
443
|
)
|
|
442
444
|
}, [input, sending, session.sessionId])
|
|
443
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Stop the active turn (desktop parity: the composer's primary button
|
|
448
|
+
* becomes a stop button while running). The turn/end frame arriving over
|
|
449
|
+
* mux flips the button back; a failed request surfaces through the chat
|
|
450
|
+
* error line.
|
|
451
|
+
*/
|
|
452
|
+
const stopTurn = useCallback(() => {
|
|
453
|
+
if (stopping) return
|
|
454
|
+
setStopping(true)
|
|
455
|
+
void cancelSession(session.sessionId).then(
|
|
456
|
+
() => { setStopping(false) },
|
|
457
|
+
(reason: unknown) => {
|
|
458
|
+
setStopping(false)
|
|
459
|
+
setError(errorText(reason))
|
|
460
|
+
},
|
|
461
|
+
)
|
|
462
|
+
}, [stopping, session.sessionId])
|
|
463
|
+
|
|
444
464
|
const modelLabel = currentModel?.model ?? '模型'
|
|
445
465
|
const permissionLabel = permissions === undefined
|
|
446
466
|
? undefined
|
|
@@ -547,8 +567,18 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
547
567
|
}
|
|
548
568
|
}}
|
|
549
569
|
/>
|
|
550
|
-
<button
|
|
551
|
-
|
|
570
|
+
<button
|
|
571
|
+
type="button"
|
|
572
|
+
className={running ? 'chat-send chat-send-stop' : 'chat-send'}
|
|
573
|
+
{...(running ? { 'aria-label': stopping ? '停止中' : '停止' } : {})}
|
|
574
|
+
disabled={running ? stopping : sending || input.trim() === ''}
|
|
575
|
+
onClick={() => { if (running) void stopTurn(); else void send() }}
|
|
576
|
+
>
|
|
577
|
+
{running ? (
|
|
578
|
+
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false">
|
|
579
|
+
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
|
|
580
|
+
</svg>
|
|
581
|
+
) : sending ? '发送中…' : '发送'}
|
|
552
582
|
</button>
|
|
553
583
|
</div>
|
|
554
584
|
{sheet === 'model' && (
|
|
@@ -595,13 +625,21 @@ const MessageRow = memo(function MessageRow({ message, showToolCalls, showSystem
|
|
|
595
625
|
showSystemMessages: boolean
|
|
596
626
|
}) {
|
|
597
627
|
// Injected user messages (sourceKind defined and not 'user') hide behind
|
|
598
|
-
// the system-message toggle.
|
|
628
|
+
// the system-message toggle.
|
|
599
629
|
if (message.kind === 'user'
|
|
600
630
|
&& message.sourceKind !== undefined
|
|
601
631
|
&& message.sourceKind !== 'user'
|
|
602
632
|
&& !showSystemMessages) {
|
|
603
633
|
return null
|
|
604
634
|
}
|
|
635
|
+
const hasReasoning = message.kind === 'assistant' && message.reasoning !== undefined && message.reasoning !== ''
|
|
636
|
+
const hasTools = showToolCalls && message.kind === 'assistant' && message.tools !== undefined && message.tools.length > 0
|
|
637
|
+
const hasText = message.text !== ''
|
|
638
|
+
const hasFailTag = message.failed === true
|
|
639
|
+
|
|
640
|
+
if (!hasReasoning && !hasTools && !hasText && !hasFailTag) {
|
|
641
|
+
return null
|
|
642
|
+
}
|
|
605
643
|
return (
|
|
606
644
|
<div className={`chat-msg chat-msg-${message.kind}${message.pending === true ? ' chat-msg-pending' : ''}${message.failed === true ? ' chat-msg-failed' : ''}`}>
|
|
607
645
|
{message.kind === 'assistant' && message.reasoning !== undefined && message.reasoning !== '' && (
|
|
@@ -750,7 +788,7 @@ function MarkdownText({ text, pending }: { text: string; pending: boolean }) {
|
|
|
750
788
|
if (timerRef.current !== undefined) clearTimeout(timerRef.current)
|
|
751
789
|
}
|
|
752
790
|
}, [])
|
|
753
|
-
const long = text.length > LONG_TEXT_LIMIT
|
|
791
|
+
const long = !pending && text.length > LONG_TEXT_LIMIT
|
|
754
792
|
const collapsed = long && !open
|
|
755
793
|
return (
|
|
756
794
|
<div className={'chat-msg-text chat-md' + (collapsed ? ' chat-md-collapsed' : '')}>
|
|
@@ -781,7 +819,7 @@ function CollapsibleText({ text }: { text: string }) {
|
|
|
781
819
|
)
|
|
782
820
|
}
|
|
783
821
|
|
|
784
|
-
const LONG_TEXT_LIMIT =
|
|
822
|
+
export const LONG_TEXT_LIMIT = 6000
|
|
785
823
|
const LONG_TEXT_PREVIEW = 800
|
|
786
824
|
|
|
787
825
|
/** Latest non-empty line of a streaming reasoning buffer. */
|
package/src/mobile-api.ts
CHANGED
|
@@ -53,6 +53,7 @@ const MOBILE_ALLOWLIST = new Set([
|
|
|
53
53
|
'session.models',
|
|
54
54
|
'session.selectModel',
|
|
55
55
|
'session.rename',
|
|
56
|
+
'session.cancel',
|
|
56
57
|
])
|
|
57
58
|
|
|
58
59
|
/**
|
|
@@ -63,6 +64,8 @@ const MOBILE_ALLOWLIST = new Set([
|
|
|
63
64
|
const MOBILE_PREFERENCES_METHOD = 'mobile.preferences'
|
|
64
65
|
const MOBILE_PENDING_METHOD = 'mobile.pending'
|
|
65
66
|
const MOBILE_RESPOND_METHOD = 'mobile.respond'
|
|
67
|
+
/** Execute a slash command through the host command registry (#1125). */
|
|
68
|
+
const MOBILE_COMMAND_METHOD = 'mobile.command'
|
|
66
69
|
|
|
67
70
|
/** One session.list page (thin phones load incrementally). */
|
|
68
71
|
const SESSION_PAGE_SIZE = 20
|
|
@@ -101,6 +104,19 @@ export interface MobileApiDeps {
|
|
|
101
104
|
mobileEnterToSend: () => boolean
|
|
102
105
|
/** SSE keep-alive ping cadence for the mux stream (default 15000 ms; test seam). */
|
|
103
106
|
eventsHeartbeatMs?: number
|
|
107
|
+
/**
|
|
108
|
+
* Optional command dispatcher for executing slash commands on the mobile
|
|
109
|
+
* surface. When absent, `mobile.command` returns an error; the mobile UI
|
|
110
|
+
* can still fall back to `session.prompt` (which sends the line to the
|
|
111
|
+
* model, not the command registry).
|
|
112
|
+
*/
|
|
113
|
+
commandDispatcher?: {
|
|
114
|
+
execute(
|
|
115
|
+
sessionId: string,
|
|
116
|
+
line: string,
|
|
117
|
+
signal: AbortSignal,
|
|
118
|
+
): Promise<{ ok: true; result: unknown } | { error: string }>
|
|
119
|
+
}
|
|
104
120
|
}
|
|
105
121
|
|
|
106
122
|
/** Mobile API route paths. */
|
|
@@ -160,6 +176,7 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
|
|
|
160
176
|
const local = method === MOBILE_PREFERENCES_METHOD
|
|
161
177
|
|| method === MOBILE_PENDING_METHOD
|
|
162
178
|
|| method === MOBILE_RESPOND_METHOD
|
|
179
|
+
|| method === MOBILE_COMMAND_METHOD
|
|
163
180
|
if (!MOBILE_ALLOWLIST.has(method) && !local) {
|
|
164
181
|
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: `method ${method} is not exposed to the mobile surface` } })
|
|
165
182
|
return
|
|
@@ -212,6 +229,49 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
|
|
|
212
229
|
result: { ok: false, error: { code: 'internal', message } },
|
|
213
230
|
})
|
|
214
231
|
}
|
|
232
|
+
} else if (method === MOBILE_COMMAND_METHOD) {
|
|
233
|
+
const payload = parsed.payload as { sessionId?: string; line?: string } | undefined
|
|
234
|
+
const sessionId = payload?.sessionId
|
|
235
|
+
const line = payload?.line
|
|
236
|
+
if (typeof sessionId !== 'string' || typeof line !== 'string') {
|
|
237
|
+
writeJson(res, 200, {
|
|
238
|
+
type: 'server-response',
|
|
239
|
+
rpcId,
|
|
240
|
+
result: { ok: false, error: { code: 'bad-request', message: 'missing sessionId or line' } },
|
|
241
|
+
})
|
|
242
|
+
} else if (deps.commandDispatcher === undefined) {
|
|
243
|
+
writeJson(res, 200, {
|
|
244
|
+
type: 'server-response',
|
|
245
|
+
rpcId,
|
|
246
|
+
result: { ok: false, error: { code: 'unavailable', message: 'command dispatcher is not available' } },
|
|
247
|
+
})
|
|
248
|
+
} else {
|
|
249
|
+
try {
|
|
250
|
+
const abort = new AbortController()
|
|
251
|
+
res.on('close', () => { if (!res.writableEnded) abort.abort() })
|
|
252
|
+
const outcome = await deps.commandDispatcher.execute(sessionId, line, abort.signal)
|
|
253
|
+
if ('error' in outcome) {
|
|
254
|
+
writeJson(res, 200, {
|
|
255
|
+
type: 'server-response',
|
|
256
|
+
rpcId,
|
|
257
|
+
result: { ok: false, error: { code: outcome.error, message: outcome.error } },
|
|
258
|
+
})
|
|
259
|
+
} else {
|
|
260
|
+
writeJson(res, 200, {
|
|
261
|
+
type: 'server-response',
|
|
262
|
+
rpcId,
|
|
263
|
+
result: { ok: true, value: { matched: true, result: outcome.result } },
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
} catch (error) {
|
|
267
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
268
|
+
writeJson(res, 200, {
|
|
269
|
+
type: 'server-response',
|
|
270
|
+
rpcId,
|
|
271
|
+
result: { ok: false, error: { code: 'internal', message } },
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
}
|
|
215
275
|
}
|
|
216
276
|
return
|
|
217
277
|
}
|
|
@@ -353,5 +413,6 @@ async function dispatch(apiProxy: ApiProxy, method: string, payload: unknown, rp
|
|
|
353
413
|
if (method === 'session.models') return wrap(await apiProxy.sessions.models(request as never))
|
|
354
414
|
if (method === 'session.selectModel') return wrap(await apiProxy.sessions.selectModel(request as never))
|
|
355
415
|
if (method === 'session.rename') return wrap(await apiProxy.sessions.rename(request as never))
|
|
416
|
+
if (method === 'session.cancel') return wrap(await apiProxy.sessions.cancel(request as never))
|
|
356
417
|
throw new Error(`unhandled allowlisted method ${method}`)
|
|
357
418
|
}
|
package/src/mobile-routes.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { readFile } from 'node:fs/promises'
|
|
9
9
|
import { existsSync } from 'node:fs'
|
|
10
10
|
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { gzipSync } from 'node:zlib'
|
|
11
12
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
12
13
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
13
14
|
|
|
@@ -147,7 +148,8 @@ export function makeMobileRoutes(): WebRoute[] {
|
|
|
147
148
|
// The bundle is immutable for the process lifetime (a rebuild requires a
|
|
148
149
|
// host restart), so the body is read from disk once, not per phone.
|
|
149
150
|
let bundleBody: string | undefined
|
|
150
|
-
|
|
151
|
+
let bundleGzip: Buffer | undefined
|
|
152
|
+
const handleBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
151
153
|
if (bundleBody === undefined) {
|
|
152
154
|
const path = mobileBundlePath()
|
|
153
155
|
if (!existsSync(path)) {
|
|
@@ -156,12 +158,32 @@ export function makeMobileRoutes(): WebRoute[] {
|
|
|
156
158
|
}
|
|
157
159
|
try {
|
|
158
160
|
bundleBody = await readFile(path, 'utf8')
|
|
161
|
+
bundleGzip = gzipSync(Buffer.from(bundleBody, 'utf8'))
|
|
159
162
|
} catch {
|
|
160
163
|
writeStatic(res, 500, 'text/plain', 'failed to read the mobile bundle')
|
|
161
164
|
return
|
|
162
165
|
}
|
|
163
166
|
}
|
|
164
|
-
|
|
167
|
+
const acceptEncoding = req.headers['accept-encoding'] ?? ''
|
|
168
|
+
const acceptsGzip = typeof acceptEncoding === 'string' && acceptEncoding.includes('gzip') && bundleGzip !== undefined
|
|
169
|
+
if (acceptsGzip) {
|
|
170
|
+
res.writeHead(200, {
|
|
171
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
172
|
+
'content-encoding': 'gzip',
|
|
173
|
+
'cache-control': 'no-cache',
|
|
174
|
+
vary: 'Accept-Encoding',
|
|
175
|
+
'referrer-policy': 'no-referrer',
|
|
176
|
+
})
|
|
177
|
+
res.end(bundleGzip)
|
|
178
|
+
} else {
|
|
179
|
+
res.writeHead(200, {
|
|
180
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
181
|
+
'cache-control': 'no-cache',
|
|
182
|
+
vary: 'Accept-Encoding',
|
|
183
|
+
'referrer-policy': 'no-referrer',
|
|
184
|
+
})
|
|
185
|
+
res.end(bundleBody)
|
|
186
|
+
}
|
|
165
187
|
}
|
|
166
188
|
|
|
167
189
|
let workerBody: string | undefined
|
package/src/mount-once.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
2
|
/**
|
|
3
3
|
* Host single-instance guard shared by the plugin family. The family bundle
|
|
4
|
-
* (dsh-web-
|
|
4
|
+
* (dsh-web-all / dsh-skins) namespaces every child row id (web-ui-*), so
|
|
5
5
|
* the loader accepts a standalone install of the same package side by side;
|
|
6
6
|
* without this guard the second instance would still re-register the same
|
|
7
7
|
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
const MOUNTED = Symbol.for('dsh-web
|
|
18
|
+
const MOUNTED = Symbol.for('dsh-web.mounted-plugins')
|
|
19
19
|
|
|
20
20
|
interface MountRegistry {
|
|
21
21
|
[MOUNTED]?: Set<string>
|
package/src/update.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Remote update support for the dsh-web
|
|
3
|
-
* installed aggregate package (@linxin666/dsh-web-
|
|
2
|
+
* Remote update support for the dsh-web family — host half. Detects the
|
|
3
|
+
* installed aggregate package (@linxin666/dsh-web-all), or the directly
|
|
4
4
|
* installed family packages when the aggregate is absent, probes npm for newer
|
|
5
5
|
* releases, and runs `pnpm update --latest` inside the owning dsh profile.
|
|
6
6
|
*
|
|
@@ -16,15 +16,21 @@ import { spawn } from 'node:child_process'
|
|
|
16
16
|
/** npm registry base used for version probes. */
|
|
17
17
|
export const NPM_REGISTRY = 'https://registry.npmjs.org'
|
|
18
18
|
|
|
19
|
-
/** The family scope every dsh-web
|
|
19
|
+
/** The family scope every dsh-web package is published under. */
|
|
20
20
|
export const FAMILY_SCOPE = '@linxin666/'
|
|
21
21
|
|
|
22
22
|
/** The aggregate package that is the canonical update entry point. */
|
|
23
|
-
export const AGGREGATE_PACKAGE = '@linxin666/dsh-web-
|
|
23
|
+
export const AGGREGATE_PACKAGE = '@linxin666/dsh-web-all'
|
|
24
24
|
|
|
25
25
|
/** Fallback anchor: this plugin's own package when the aggregate is absent. */
|
|
26
26
|
export const SELF_PACKAGE = '@linxin666/dsh-remote-web-ui'
|
|
27
27
|
|
|
28
|
+
/** GitHub repository used to surface human-readable release notes. */
|
|
29
|
+
export const UPDATE_RELEASE_REPO = 'zhu1090093659/dsh-web'
|
|
30
|
+
|
|
31
|
+
/** Release-notes cache freshness window (host process). */
|
|
32
|
+
export const RELEASE_NOTES_CACHE_TTL_MS = 10 * 60_000
|
|
33
|
+
|
|
28
34
|
/** A profile manifest `name` prefix (e.g. `dsh-profile-web`). */
|
|
29
35
|
const PROFILE_NAME_PREFIX = 'dsh-profile-'
|
|
30
36
|
|
|
@@ -168,6 +174,18 @@ export interface UpdatePackageStatus {
|
|
|
168
174
|
outdated: boolean
|
|
169
175
|
}
|
|
170
176
|
|
|
177
|
+
/** Structured release-note sections shown in the update panel. */
|
|
178
|
+
export interface UpdateReleaseNotes {
|
|
179
|
+
/** Version the notes describe. */
|
|
180
|
+
version: string
|
|
181
|
+
/** New feature bullets. */
|
|
182
|
+
features: string[]
|
|
183
|
+
/** Bug-fix bullets. */
|
|
184
|
+
fixes: string[]
|
|
185
|
+
/** Other-change bullets. */
|
|
186
|
+
other: string[]
|
|
187
|
+
}
|
|
188
|
+
|
|
171
189
|
/** The full update-status snapshot served to the browser half. */
|
|
172
190
|
export interface UpdateStatus {
|
|
173
191
|
/** npm = registry-managed (updatable); link = local dev install; missing = no anchor package. */
|
|
@@ -180,6 +198,8 @@ export interface UpdateStatus {
|
|
|
180
198
|
packages: UpdatePackageStatus[]
|
|
181
199
|
/** True when any package has a newer npm release. */
|
|
182
200
|
outdated: boolean
|
|
201
|
+
/** Human-readable release notes for the target release, when available. */
|
|
202
|
+
notes?: UpdateReleaseNotes
|
|
183
203
|
/** Whole-check failure (e.g. registry unreachable). */
|
|
184
204
|
error?: string
|
|
185
205
|
}
|
|
@@ -192,6 +212,8 @@ export interface UpdateCheckDeps {
|
|
|
192
212
|
resolve(specifier: string): string | undefined
|
|
193
213
|
/** Probe one package's latest npm version; undefined on failure. */
|
|
194
214
|
fetchLatest(name: string): Promise<string | undefined>
|
|
215
|
+
/** Fetch structured release notes for one target version, when available. */
|
|
216
|
+
fetchReleaseNotes?(version: string): Promise<UpdateReleaseNotes | undefined>
|
|
195
217
|
}
|
|
196
218
|
|
|
197
219
|
/**
|
|
@@ -313,6 +335,92 @@ export async function fetchLatestVersion(
|
|
|
313
335
|
}
|
|
314
336
|
}
|
|
315
337
|
|
|
338
|
+
/** Normalize one release-note bullet. */
|
|
339
|
+
function cleanNoteItem(text: string): string {
|
|
340
|
+
// Keep the visible label and drop Markdown link targets; issue references
|
|
341
|
+
// become plain #123 because the panel has no Markdown renderer.
|
|
342
|
+
return text.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/^[-*]\s+/, '').trim()
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Determine the section kind for a Markdown heading line. */
|
|
346
|
+
function noteSectionOf(line: string): keyof Pick<UpdateReleaseNotes, 'features' | 'fixes' | 'other'> | undefined {
|
|
347
|
+
const heading = /^#{1,6}\s+(.+)$/.exec(line.trim())
|
|
348
|
+
if (heading === null) return undefined
|
|
349
|
+
const title = heading[1].replace(/[::].*$/, '').trim().toLowerCase()
|
|
350
|
+
if (title === '新功能' || title === 'new features') return 'features'
|
|
351
|
+
if (title === '修复' || title === 'bug fixes' || title === 'bugfixes') return 'fixes'
|
|
352
|
+
if (title === '其他改动' || title === 'other changes') return 'other'
|
|
353
|
+
return undefined
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Parse the GitHub Release body into the three conventional sections.
|
|
358
|
+
*
|
|
359
|
+
* The committed release notes are bilingual: Chinese is the default view and
|
|
360
|
+
* English lives inside a `<details>` block. Prefer the Chinese sections when
|
|
361
|
+
* present and fall back to the whole body for English-only releases. Bullets
|
|
362
|
+
* are escaped as plain text for the panel (no Markdown renderer).
|
|
363
|
+
*/
|
|
364
|
+
function parseReleaseNotesSource(version: string, source: string): UpdateReleaseNotes | undefined {
|
|
365
|
+
const notes: UpdateReleaseNotes = { version, features: [], fixes: [], other: [] }
|
|
366
|
+
let current: keyof Pick<UpdateReleaseNotes, 'features' | 'fixes' | 'other'> | undefined
|
|
367
|
+
for (const rawLine of source.split(/\r?\n/)) {
|
|
368
|
+
const line = rawLine.trim()
|
|
369
|
+
const section = noteSectionOf(line)
|
|
370
|
+
if (section !== undefined) {
|
|
371
|
+
current = section
|
|
372
|
+
continue
|
|
373
|
+
}
|
|
374
|
+
if (current === undefined || !/^-\s+/.test(line)) continue
|
|
375
|
+
const item = cleanNoteItem(line)
|
|
376
|
+
if (item !== '') notes[current].push(item)
|
|
377
|
+
}
|
|
378
|
+
if (notes.features.length === 0 && notes.fixes.length === 0 && notes.other.length === 0) return undefined
|
|
379
|
+
return notes
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function parseReleaseNotesBody(version: string, body: string): UpdateReleaseNotes | undefined {
|
|
383
|
+
// The committed release notes put Chinese in the default view and English in
|
|
384
|
+
// a <details> block. Parse only the default view first, then fall back to
|
|
385
|
+
// the full body for English-only releases.
|
|
386
|
+
return parseReleaseNotesSource(version, body.split('<details>')[0] ?? body)
|
|
387
|
+
?? parseReleaseNotesSource(version, body)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Fetch and parse the GitHub Release for one `dsh-web` version.
|
|
392
|
+
* @param version - the npm release version (without the `v` prefix).
|
|
393
|
+
* @param fetchImpl - injected fetch; global `fetch` in the host.
|
|
394
|
+
* @param timeoutMs - hard timeout for the GitHub API probe.
|
|
395
|
+
* @returns structured notes, or undefined when unavailable.
|
|
396
|
+
*/
|
|
397
|
+
export async function fetchGitHubReleaseNotes(
|
|
398
|
+
version: string,
|
|
399
|
+
fetchImpl: (url: string, init?: RequestInit) => Promise<{ ok: boolean; json(): Promise<unknown> }> = fetch,
|
|
400
|
+
timeoutMs = 10_000,
|
|
401
|
+
): Promise<UpdateReleaseNotes | undefined> {
|
|
402
|
+
const controller = new AbortController()
|
|
403
|
+
const timer = setTimeout(() => { controller.abort() }, timeoutMs)
|
|
404
|
+
try {
|
|
405
|
+
const response = await fetchImpl(
|
|
406
|
+
`https://api.github.com/repos/${UPDATE_RELEASE_REPO}/releases/tags/v${version}`,
|
|
407
|
+
{
|
|
408
|
+
headers: { accept: 'application/vnd.github+json', 'user-agent': 'dsh-web-update' },
|
|
409
|
+
signal: controller.signal,
|
|
410
|
+
},
|
|
411
|
+
)
|
|
412
|
+
if (!response.ok) return undefined
|
|
413
|
+
const body = await response.json()
|
|
414
|
+
if (typeof body !== 'object' || body === null) return undefined
|
|
415
|
+
const text = (body as Record<string, unknown>).body
|
|
416
|
+
return typeof text === 'string' ? parseReleaseNotesBody(version, text) : undefined
|
|
417
|
+
} catch {
|
|
418
|
+
return undefined
|
|
419
|
+
} finally {
|
|
420
|
+
clearTimeout(timer)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
316
424
|
/**
|
|
317
425
|
* Sentinel for an installed version that could not be read (missing manifest
|
|
318
426
|
* or resolve failure). It is a real-looking version so `checkUpdates` can
|
|
@@ -392,12 +500,17 @@ export async function checkUpdates(deps: UpdateCheckDeps): Promise<UpdateStatus>
|
|
|
392
500
|
// Registry unreachable: every probe failed — report the outage distinctly
|
|
393
501
|
// instead of a misleading "all up to date" (the panel needs the reason).
|
|
394
502
|
const error = probeFailures === names.length && names.length > 0 ? 'registry-unreachable' : undefined
|
|
503
|
+
const targetVersion = packages.find(packageStatus => packageStatus.latest !== undefined)?.latest
|
|
504
|
+
const notes = targetVersion !== undefined && deps.fetchReleaseNotes !== undefined
|
|
505
|
+
? await deps.fetchReleaseNotes(targetVersion).catch(() => undefined)
|
|
506
|
+
: undefined
|
|
395
507
|
return {
|
|
396
508
|
mode: linked ? 'link' : 'npm',
|
|
397
509
|
profileName: profile.name,
|
|
398
510
|
anchor,
|
|
399
511
|
packages,
|
|
400
512
|
outdated: packages.some(packageStatus => packageStatus.outdated),
|
|
513
|
+
...(notes !== undefined ? { notes } : {}),
|
|
401
514
|
...(error !== undefined ? { error } : {}),
|
|
402
515
|
}
|
|
403
516
|
}
|