feat(client): render user skill invocations as dedicated transcript cards
A user/message carrying the skill-invocation source materializes as its own conversation node (name/args lifted off the source metadata, never re-parsed from the body) and renders as a right-aligned bubble: the /name chip plus the user's trailing text, with the injected <skill_content> collapsed behind a disclosure. A record with an unreadable name degrades to the injected-context row.
This commit is contained in:
@@ -49,7 +49,7 @@ export type {
|
|||||||
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
|
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
|
||||||
AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase,
|
AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase,
|
||||||
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
|
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
|
||||||
RunningToolCall,
|
RunningToolCall, SkillInvocationNode,
|
||||||
SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
|
SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
|
||||||
} from './sessions/conversation.ts'
|
} from './sessions/conversation.ts'
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -129,6 +129,25 @@ export interface ContextMessageNode {
|
|||||||
form: KnownContextForm | null
|
form: KnownContextForm | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A user-explicit skill invocation: the host injected the rendered skill as a
|
||||||
|
* user message carrying the `skill-invocation` source, so the card presents
|
||||||
|
* `/name args` from source metadata and collapses the injected body.
|
||||||
|
*/
|
||||||
|
export interface SkillInvocationNode {
|
||||||
|
kind: 'skill-invocation'
|
||||||
|
seq: number
|
||||||
|
/** Unix epoch ms from the source session event. */
|
||||||
|
time: number
|
||||||
|
/** Invoked skill name read off the message source. */
|
||||||
|
name: string
|
||||||
|
/** Trailing user text read off the message source, when recorded. */
|
||||||
|
args?: string
|
||||||
|
/** Full injected model-facing content (collapsed by default in the UI). */
|
||||||
|
content: readonly ContentBlock[]
|
||||||
|
source: unknown
|
||||||
|
}
|
||||||
|
|
||||||
/** Durable notice that a closed failed step is waiting for a model-request retry. */
|
/** Durable notice that a closed failed step is waiting for a model-request retry. */
|
||||||
export type ModelRetryNode = LlmRetryEventData & {
|
export type ModelRetryNode = LlmRetryEventData & {
|
||||||
kind: 'model-retry'
|
kind: 'model-retry'
|
||||||
@@ -245,6 +264,7 @@ export type ConversationNode =
|
|||||||
| AssistantMessageNode
|
| AssistantMessageNode
|
||||||
| SteeringMessageNode
|
| SteeringMessageNode
|
||||||
| ContextMessageNode
|
| ContextMessageNode
|
||||||
|
| SkillInvocationNode
|
||||||
| ModelRetryNode
|
| ModelRetryNode
|
||||||
| TurnErrorNode
|
| TurnErrorNode
|
||||||
| ToolResultNode
|
| ToolResultNode
|
||||||
|
|||||||
@@ -57,7 +57,20 @@ function materializeNode(
|
|||||||
stepTimings: ReadonlyMap<string, AssistantStepMetadata>,
|
stepTimings: ReadonlyMap<string, AssistantStepMetadata>,
|
||||||
): ConversationNode {
|
): ConversationNode {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'user/message':
|
case 'user/message': {
|
||||||
|
// A user-explicit skill invocation carries its name (and optional args)
|
||||||
|
// on the source; the dedicated node lets the card render `/name args`
|
||||||
|
// from metadata instead of re-parsing the injected body. A record whose
|
||||||
|
// name is unreadable degrades to injected context below.
|
||||||
|
const source = event.data.source as { kind?: unknown; name?: unknown; args?: unknown }
|
||||||
|
if (source.kind === 'skill-invocation' && typeof source.name === 'string') {
|
||||||
|
return {
|
||||||
|
kind: 'skill-invocation', seq: event.seq, time: event.time,
|
||||||
|
name: source.name,
|
||||||
|
...typeof source.args === 'string' ? { args: source.args } : {},
|
||||||
|
content: event.data.content, source: event.data.source,
|
||||||
|
}
|
||||||
|
}
|
||||||
// Injected context (plugin/goal source) folds to a context node, not a
|
// Injected context (plugin/goal source) folds to a context node, not a
|
||||||
// user message; only a direct human prompt is a user node. A compaction
|
// user message; only a direct human prompt is a user node. A compaction
|
||||||
// checkpoint never reaches here (isCompactCheckpoint routes it away).
|
// checkpoint never reaches here (isCompactCheckpoint routes it away).
|
||||||
@@ -80,6 +93,7 @@ function materializeNode(
|
|||||||
kind: 'user', seq: event.seq, time: event.time,
|
kind: 'user', seq: event.seq, time: event.time,
|
||||||
content: event.data.content, source: event.data.source,
|
content: event.data.content, source: event.data.source,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
case 'assistant/message':
|
case 'assistant/message':
|
||||||
return {
|
return {
|
||||||
kind: 'assistant', seq: event.seq, time: event.time,
|
kind: 'assistant', seq: event.seq, time: event.time,
|
||||||
|
|||||||
@@ -164,6 +164,31 @@ describe('TranscriptAdapter', () => {
|
|||||||
expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context'])
|
expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('materializes a skill-invocation source as its dedicated node', () => {
|
||||||
|
const adapter = new TranscriptAdapter()
|
||||||
|
adapter.reset([
|
||||||
|
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||||
|
content: [{ type: 'text', text: '<skill_content name="hidden-demo">body</skill_content>\n\ncheck the fixture' }],
|
||||||
|
source: { kind: 'skill-invocation', name: 'hidden-demo', args: 'check the fixture' } as never,
|
||||||
|
}) }),
|
||||||
|
at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||||
|
content: [{ type: 'text', text: '<skill_content name="bare-skill">body</skill_content>' }],
|
||||||
|
source: { kind: 'skill-invocation', name: 'bare-skill' } as never,
|
||||||
|
}) }),
|
||||||
|
])
|
||||||
|
const nodes = adapter.nodes()
|
||||||
|
expect(nodes.map(node => node.kind)).toEqual(['skill-invocation', 'skill-invocation'])
|
||||||
|
expect(nodes[0]).toMatchObject({ name: 'hidden-demo', args: 'check the fixture' })
|
||||||
|
expect(nodes[1]).toMatchObject({ name: 'bare-skill' })
|
||||||
|
expect((nodes[1] as { args?: string }).args).toBeUndefined()
|
||||||
|
// A malformed record (no readable name) degrades to injected context, not a crash.
|
||||||
|
adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||||
|
content: [{ type: 'text', text: 'odd' }],
|
||||||
|
source: { kind: 'skill-invocation' } as never,
|
||||||
|
}) }))
|
||||||
|
expect(adapter.nodes().at(-1)?.kind).toBe('context')
|
||||||
|
})
|
||||||
|
|
||||||
it('skips events core does not call surface-eligible, marker or not', () => {
|
it('skips events core does not call surface-eligible, marker or not', () => {
|
||||||
// The transcript is the append-origin surface, so log-only events (a chunk,
|
// The transcript is the append-origin surface, so log-only events (a chunk,
|
||||||
// a turn boundary, a compact/* provenance record) and a future type core
|
// a turn boundary, a compact/* provenance record) and a future type core
|
||||||
|
|||||||
@@ -256,3 +256,30 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
vertical-align: baseline;
|
vertical-align: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* User-explicit skill invocation: the injected body collapses behind a
|
||||||
|
disclosure inside the user bubble. */
|
||||||
|
.skillInvocationDetails {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skillInvocationSummary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--dsw-alias-label-secondary);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skillInvocationBody {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding: 8px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--dsw-alias-bg-secondary, rgba(0, 0, 0, 0.06));
|
||||||
|
font-family: var(--dsw-font-mono, monospace);
|
||||||
|
font-size: 0.78em;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
import { memo, useEffect, useMemo, useState } from 'react'
|
import { memo, useEffect, useMemo, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import type {
|
import type {
|
||||||
CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode,
|
CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SkillInvocationNode,
|
||||||
TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
|
SteeringMessageNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
|
||||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||||
@@ -22,6 +22,7 @@ export interface MessageItemProps {
|
|||||||
| UserMessageNode
|
| UserMessageNode
|
||||||
| SteeringMessageNode
|
| SteeringMessageNode
|
||||||
| ContextMessageNode
|
| ContextMessageNode
|
||||||
|
| SkillInvocationNode
|
||||||
| CompactionSummaryNode
|
| CompactionSummaryNode
|
||||||
| ModelRetryNode
|
| ModelRetryNode
|
||||||
| TurnErrorNode
|
| TurnErrorNode
|
||||||
@@ -193,6 +194,38 @@ function UserStyleBubble({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A user-explicit skill invocation: the right-aligned bubble presents the
|
||||||
|
* `/name args` gesture from source metadata (never re-parsed from the body),
|
||||||
|
* and the injected `<skill_content>` collapses behind a disclosure — the
|
||||||
|
* durable content is model-facing bulk, not conversation prose.
|
||||||
|
*/
|
||||||
|
function SkillInvocationRow({ node, t }: {
|
||||||
|
node: SkillInvocationNode
|
||||||
|
t: ChatViewSlotProps['t']
|
||||||
|
}): ReactNode {
|
||||||
|
const { text } = contentText(node.content)
|
||||||
|
return (
|
||||||
|
<div className={css.userRow} data-skill-invocation data-time-hover-root>
|
||||||
|
<div className={css.bubble}>
|
||||||
|
<span className={css.refChip} data-ref-chip="skill">{`/${node.name}`}</span>
|
||||||
|
{node.args !== undefined && <MessageText text={` ${node.args}`} />}
|
||||||
|
<details className={css.skillInvocationDetails}>
|
||||||
|
<summary className={css.skillInvocationSummary}>{t('message.skillInvocation.expand')}</summary>
|
||||||
|
<pre className={css.skillInvocationBody}>{text}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<MessageIconActions
|
||||||
|
text={text}
|
||||||
|
time={node.time}
|
||||||
|
clock="start"
|
||||||
|
className={css.actions}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render one Host-authoritative pending steering item with the same visual
|
* Render one Host-authoritative pending steering item with the same visual
|
||||||
* language as its eventual durable transcript node.
|
* language as its eventual durable transcript node.
|
||||||
@@ -254,6 +287,8 @@ export const MessageItem = memo(function MessageItem({
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
case 'skill-invocation':
|
||||||
|
return <SkillInvocationRow node={node} t={t} />
|
||||||
case 'compaction':
|
case 'compaction':
|
||||||
return <CompactionItem node={node} t={t} />
|
return <CompactionItem node={node} t={t} />
|
||||||
case 'model-retry':
|
case 'model-retry':
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export const zh = {
|
|||||||
'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条',
|
'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条',
|
||||||
'message.context.recall.truncated': '已截断',
|
'message.context.recall.truncated': '已截断',
|
||||||
'message.steering': '插话',
|
'message.steering': '插话',
|
||||||
|
'message.skillInvocation.expand': '查看注入的 skill 内容',
|
||||||
'message.compaction': '上下文已压缩',
|
'message.compaction': '上下文已压缩',
|
||||||
'message.compaction.expand': '点击查看压缩摘要',
|
'message.compaction.expand': '点击查看压缩摘要',
|
||||||
'message.compaction.unavailable': '压缩摘要不可用',
|
'message.compaction.unavailable': '压缩摘要不可用',
|
||||||
@@ -219,6 +220,7 @@ export const en = {
|
|||||||
'message.context.recall.counts': '{retained} kept · {omitted} omitted',
|
'message.context.recall.counts': '{retained} kept · {omitted} omitted',
|
||||||
'message.context.recall.truncated': 'truncated',
|
'message.context.recall.truncated': 'truncated',
|
||||||
'message.steering': 'Interjection',
|
'message.steering': 'Interjection',
|
||||||
|
'message.skillInvocation.expand': 'View injected skill content',
|
||||||
'message.compaction': 'Context compacted',
|
'message.compaction': 'Context compacted',
|
||||||
'message.compaction.expand': 'View compaction summary',
|
'message.compaction.expand': 'View compaction summary',
|
||||||
'message.compaction.unavailable': 'Compaction summary unavailable',
|
'message.compaction.unavailable': 'Compaction summary unavailable',
|
||||||
|
|||||||
@@ -864,6 +864,42 @@ describe('MessageItem arms', () => {
|
|||||||
view.rerender(<MessageItem t={t} node={node} retryActive />)
|
view.rerender(<MessageItem t={t} node={node} retryActive />)
|
||||||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
|
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('skill-invocation renders the /name chip, args, and a collapsed injected body', () => {
|
||||||
|
const body = '<skill_content name="hidden-demo">instructions</skill_content>\n\ncheck the fixture'
|
||||||
|
const view = render(
|
||||||
|
<MessageItem t={t} node={{
|
||||||
|
kind: 'skill-invocation', seq: 4, time: 1_000,
|
||||||
|
name: 'hidden-demo', args: 'check the fixture',
|
||||||
|
content: [{ type: 'text', text: body }] as never,
|
||||||
|
source: null,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const chip = view.container.querySelector('[data-ref-chip="skill"]')
|
||||||
|
expect(chip?.textContent).toBe('/hidden-demo')
|
||||||
|
const details = view.container.querySelector('details')
|
||||||
|
expect(details).toBeTruthy()
|
||||||
|
expect(details?.open).toBe(false)
|
||||||
|
expect(view.getByText('查看注入的 skill 内容')).toBeTruthy()
|
||||||
|
expect(view.container.querySelector('pre')?.textContent).toBe(body)
|
||||||
|
expect(view.container.querySelector('[data-skill-invocation]')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skill-invocation without args renders only the chip line', () => {
|
||||||
|
const view = render(
|
||||||
|
<MessageItem t={t} node={{
|
||||||
|
kind: 'skill-invocation', seq: 5, time: 1_000,
|
||||||
|
name: 'bare-skill',
|
||||||
|
content: [{ type: 'text', text: '<skill_content name="bare-skill">x</skill_content>' }] as never,
|
||||||
|
source: null,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const bubble = view.container.querySelector('[data-skill-invocation]')
|
||||||
|
expect(bubble?.textContent).toContain('/bare-skill')
|
||||||
|
expect(bubble?.textContent).not.toContain('undefined')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('formatMessageClock', () => {
|
describe('formatMessageClock', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user