Prechádzať zdrojové kódy

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.
Yichen Jiang 1 mesiac pred
rodič
commit
011e3e4e63

+ 1 - 1
packages/client/runtime/src/client/index.ts

@@ -49,7 +49,7 @@ export type {
   AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
   AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase,
   ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
-  RunningToolCall,
+  RunningToolCall, SkillInvocationNode,
   SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
 } from './sessions/conversation.ts'
 export type {

+ 20 - 0
packages/client/runtime/src/client/sessions/conversation.ts

@@ -129,6 +129,25 @@ export interface ContextMessageNode {
   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. */
 export type ModelRetryNode = LlmRetryEventData & {
   kind: 'model-retry'
@@ -245,6 +264,7 @@ export type ConversationNode =
   | AssistantMessageNode
   | SteeringMessageNode
   | ContextMessageNode
+  | SkillInvocationNode
   | ModelRetryNode
   | TurnErrorNode
   | ToolResultNode

+ 15 - 1
packages/client/runtime/src/client/sessions/transcript-adapter.ts

@@ -57,7 +57,20 @@ function materializeNode(
   stepTimings: ReadonlyMap<string, AssistantStepMetadata>,
 ): ConversationNode {
   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
       // user message; only a direct human prompt is a user node. A compaction
       // checkpoint never reaches here (isCompactCheckpoint routes it away).
@@ -80,6 +93,7 @@ function materializeNode(
         kind: 'user', seq: event.seq, time: event.time,
         content: event.data.content, source: event.data.source,
       }
+    }
     case 'assistant/message':
       return {
         kind: 'assistant', seq: event.seq, time: event.time,

+ 25 - 0
packages/client/runtime/tests/transcript-adapter.spec.ts

@@ -164,6 +164,31 @@ describe('TranscriptAdapter', () => {
     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', () => {
     // 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

+ 27 - 0
packages/client/ui-conversation/src/client/chat/MessageItem.module.css

@@ -256,3 +256,30 @@
   white-space: nowrap;
   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;
+}

+ 37 - 2
packages/client/ui-conversation/src/client/chat/MessageItem.tsx

@@ -7,8 +7,8 @@
 import { memo, useEffect, useMemo, useState } from 'react'
 import type { ReactNode } from 'react'
 import type {
-  CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode,
-  TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
+  CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SkillInvocationNode,
+  SteeringMessageNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
 } from '@deepseek-ai/dsh-client-runtime/client'
 import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatViewSlotProps } from '../contract/slots.ts'
@@ -22,6 +22,7 @@ export interface MessageItemProps {
     | UserMessageNode
     | SteeringMessageNode
     | ContextMessageNode
+    | SkillInvocationNode
     | CompactionSummaryNode
     | ModelRetryNode
     | 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
  * language as its eventual durable transcript node.
@@ -254,6 +287,8 @@ export const MessageItem = memo(function MessageItem({
           t={t}
         />
       )
+    case 'skill-invocation':
+      return <SkillInvocationRow node={node} t={t} />
     case 'compaction':
       return <CompactionItem node={node} t={t} />
     case 'model-retry':

+ 2 - 0
packages/client/ui-conversation/src/client/locales.ts

@@ -79,6 +79,7 @@ export const zh = {
   'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条',
   'message.context.recall.truncated': '已截断',
   'message.steering': '插话',
+  'message.skillInvocation.expand': '查看注入的 skill 内容',
   'message.compaction': '上下文已压缩',
   'message.compaction.expand': '点击查看压缩摘要',
   'message.compaction.unavailable': '压缩摘要不可用',
@@ -219,6 +220,7 @@ export const en = {
   'message.context.recall.counts': '{retained} kept · {omitted} omitted',
   'message.context.recall.truncated': 'truncated',
   'message.steering': 'Interjection',
+  'message.skillInvocation.expand': 'View injected skill content',
   'message.compaction': 'Context compacted',
   'message.compaction.expand': 'View compaction summary',
   'message.compaction.unavailable': 'Compaction summary unavailable',

+ 36 - 0
packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx

@@ -864,6 +864,42 @@ describe('MessageItem arms', () => {
     view.rerender(<MessageItem t={t} node={node} retryActive />)
     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', () => {