feat(web): render slash-command skill activations as a labeled chip

A user turn whose origin metadata marks it as a skill_activation
(trigger user-slash) no longer dumps the raw XML block into the chat:
the turn shows only the user-provided args plus an "activated skill"
label with the skill name.
This commit is contained in:
qer 2026-06-12 12:40:46 +08:00
parent 719ee4ac76
commit 890db95567
6 changed files with 90 additions and 6 deletions

View file

@ -278,8 +278,16 @@ function turnBlocks(turn: ChatTurn): TurnBlock[] {
loading="lazy"
/>
</div>
<!-- Skill activation card (replaces raw XML) -->
<div v-if="turn.skillActivation" class="skill-act">
<div class="skill-act-head">
<span class="skill-act-arrow"></span>
<span>{{ t('conversation.activatedSkill', { name: turn.skillActivation.name }) }}</span>
</div>
<div v-if="turn.skillActivation.args" class="skill-act-args">{{ turn.skillActivation.args }}</div>
</div>
<!-- User input renders verbatim (pre-wrap), never through Markdown -->
<div class="u-text">{{ turn.text }}</div>
<div v-else class="u-text">{{ turn.text }}</div>
</div>
<!-- Compaction divider prior turns stay untouched; summary opens in
@ -400,7 +408,16 @@ function turnBlocks(turn: ChatTurn): TurnBlock[] {
</div>
<!-- User input renders verbatim (pre-wrap), never through Markdown -->
<div v-if="turn.role === 'user'" class="u-text">{{ turn.text }}</div>
<div v-if="turn.role === 'user'" class="u-text">
<div v-if="turn.skillActivation" class="skill-act">
<div class="skill-act-head">
<span class="skill-act-arrow"></span>
<span>{{ t('conversation.activatedSkill', { name: turn.skillActivation.name }) }}</span>
</div>
<div v-if="turn.skillActivation.args" class="skill-act-args">{{ turn.skillActivation.args }}</div>
</div>
<template v-else>{{ turn.text }}</template>
</div>
<!-- Thinking + message text + tool cards, interleaved in original call order. -->
<template v-else>
@ -730,6 +747,32 @@ function turnBlocks(turn: ChatTurn): TurnBlock[] {
padding-top: 2px;
}
/* Skill activation card (replaces raw <kimi-skill-loaded> XML) */
.skill-act {
display: flex;
flex-direction: column;
gap: 2px;
}
.skill-act-head {
font-size: 13px;
font-weight: 600;
color: var(--blue2);
display: flex;
align-items: center;
gap: 6px;
}
.skill-act-arrow {
color: var(--blue);
font-size: 11px;
}
.skill-act-args {
font-size: 12.5px;
color: var(--muted);
padding-left: 17px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* Mobile font bump (+2px) */
@media (max-width: 640px) {
.u-bub .u-text,

View file

@ -454,10 +454,25 @@ export function messagesToTurns(
// Hide system-injected user turns (TUI parity) — they end the previous
// assistant turn but aren't rendered as a user bubble.
if (!isDisplayableUserMessage(msg)) continue;
const origin = msg.metadata?.['origin'] as
| { kind?: string; skillName?: string; skillArgs?: string; trigger?: string }
| undefined;
const isSkillActivation =
origin?.kind === 'skill_activation' && origin?.trigger === 'user-slash';
const textParts: string[] = [];
const images: { url: string; alt?: string }[] = [];
for (const c of msg.content) {
if (c.type === 'text') textParts.push(c.text);
if (c.type === 'text') {
if (isSkillActivation) {
// Skill activation messages carry the raw XML block; we strip it and
// surface only the user-provided args as the "user input" text.
textParts.push(origin.skillArgs ?? '');
} else {
textParts.push(c.text);
}
}
const url = resolveImageUrl(c);
if (url) images.push({ url, alt: c.type === 'file' ? c.name : undefined });
}
@ -467,6 +482,9 @@ export function messagesToTurns(
no: no++,
text: textParts.join('\n'),
images: images.length > 0 ? images : undefined,
skillActivation: isSkillActivation
? { name: origin.skillName!, args: origin.skillArgs }
: undefined,
});
continue;
}

View file

@ -10,4 +10,5 @@ export default {
viewSummary: 'View summary',
summaryTitle: 'Compaction summary',
manuallyAborted: 'Manually stopped',
activatedSkill: 'Activated skill: {name}',
} as const;

View file

@ -10,4 +10,5 @@ export default {
viewSummary: '查看摘要',
summaryTitle: '压缩摘要',
manuallyAborted: '您已手动终止',
activatedSkill: '已激活技能: {name}',
} as const;

View file

@ -144,6 +144,9 @@ export interface ChatTurn {
prior turns and renders this as a separator line; `text` holds the
LLM-generated summary, opened in the right-side panel on click. */
compaction?: { trigger?: 'manual' | 'auto'; tokensBefore?: number; tokensAfter?: number };
/** Skill activation metadata: when a user turn was triggered by a slash
command (/skill), this holds the skill name and args for display. */
skillActivation?: { name: string; args?: string };
}
/**

View file

@ -36,13 +36,31 @@ describe('user message origin filtering (TUI parity)', () => {
it('shows user-typed slash commands, hides model/nested skill activations', () => {
expect(
shownTexts([
userMsg('/compact', { kind: 'skill_activation', trigger: 'user-slash' }),
userMsg('skill body', { kind: 'skill_activation', trigger: 'model-tool' }),
userMsg('nested', { kind: 'skill_activation', trigger: 'nested-skill' }),
userMsg('body', { kind: 'skill_activation', trigger: 'user-slash', skillName: 'compact', skillArgs: '/compact' }),
userMsg('skill body', { kind: 'skill_activation', trigger: 'model-tool', skillName: 'review' }),
userMsg('nested', { kind: 'skill_activation', trigger: 'nested-skill', skillName: 'brainstorm' }),
]),
).toEqual(['/compact']);
});
it('strips XML body and surfaces skillActivation metadata for slash skills', () => {
const turns = messagesToTurns(
[
userMsg('User activated the skill "review". Follow the loaded skill instructions.\n\n<kimi-skill-loaded name="review" trigger="user-slash" source="project" args="src/app.ts">\nbody\n</kimi-skill-loaded>', {
kind: 'skill_activation',
trigger: 'user-slash',
skillName: 'review',
skillArgs: 'src/app.ts',
}),
],
[],
);
expect(turns).toHaveLength(1);
expect(turns[0]!.role).toBe('user');
expect(turns[0]!.text).toBe('src/app.ts');
expect(turns[0]!.skillActivation).toEqual({ name: 'review', args: 'src/app.ts' });
});
it.each([
['compaction_summary'],
['injection'],