mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
fix(experimental): render page cards from the projection view and align fence hiding
This commit is contained in:
1 parent
76be903074
commit
9209b12b9f
29 files changed
+208
-273
No files matched your search
@@ -82,7 +82,7 @@ function styleInjectionModule(
|
||||
* Everything else under @deepseek-ai/* is either a module-table entry
|
||||
* (external) or a leak the purity gate rejects.
|
||||
*/
|
||||
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-action-block|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-util-file-drag$|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$|@deepseek-ai\/dsh-experimental-creation-recommendations\/action-block$)/
|
||||
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-action-block|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-util-file-drag$|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/
|
||||
|
||||
/**
|
||||
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: a05d85ff4949b526
|
||||
zh: 7261ffc98665e9e7
|
||||
/deepseek-ai-dsh-client-ui-chat/hidden-chat-rows:
|
||||
en: cbf1f0a2205046a5
|
||||
zh: 2e59d0815351349f
|
||||
en: e60db44c8a6a016e
|
||||
zh: d563bd5d34da9807
|
||||
/deepseek-ai-dsh-client-ui-chat/command-and-failure-rows:
|
||||
en: 41dcd70e14ebe9cc
|
||||
zh: ededead572b59037
|
||||
|
||||
@@ -56,6 +56,8 @@ Chat omits system-prompt, ordinary Context injection, and `permission` command r
|
||||
|
||||
When an Assistant attempt retires without a visible message, Chat hides its already-published Node instead of removing its key. A retry in the same Step reuses that key when visible content returns. This also applies when the loaded window lacks the Step start.
|
||||
|
||||
Chat hides top-level `dsh-` directive fences, as found by [`dsh-util-action-block`](../../util/action-block/README.md), from rendered answers and from the answer text the Turn footer copies; Trajectory keeps the raw text.
|
||||
|
||||
<a id="command-and-failure-rows"></a>
|
||||
## Command and failure rows
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ Chat 在所有工作步骤展示模式下都不显示系统提示词行、普通
|
||||
|
||||
Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,不移除其 key。同一 Step 的重试再次产生可见内容时,复用该 key。已加载窗口缺少 Step 起点时也遵循此规则。
|
||||
|
||||
Chat 在渲染的回答和轮次页脚复制的回答文本中隐藏 [`dsh-util-action-block`](../../util/action-block/README.zh.md) 识别出的顶层 `dsh-` 指令围栏;Trajectory 保留原始文本。
|
||||
|
||||
<a id="command-and-failure-rows"></a>
|
||||
## 指令与失败行
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-executor-ptc": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-action-block": "workspace:*",
|
||||
"@tanstack/react-virtual": "3.14.10",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
@@ -86,7 +86,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
rendered.push(
|
||||
<MarkdownText
|
||||
key={i}
|
||||
text={hideDirectiveFences(block.text)}
|
||||
text={hideDirectiveFences(block.text, { streaming })}
|
||||
streaming={streaming}
|
||||
labels={labels}
|
||||
fileMentions={mentions}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ChatNodeViewProps, PerformanceUsageInjected, TurnTailOwnerProps }
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { TurnUsagePanel } from './TurnUsagePanel.tsx'
|
||||
import { assistantText } from './turn-assistant.ts'
|
||||
import { hideDirectiveFences } from './directive-fences.ts'
|
||||
import { hasAssistantReplyContent } from '../contract/assistant-content.ts'
|
||||
import type { ChatNode } from '../contract/chat-nodes.ts'
|
||||
import type { ChatSnapshot } from '../contract/snapshot.ts'
|
||||
@@ -62,7 +63,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
|
||||
>
|
||||
{tail}
|
||||
<MessageIconActions
|
||||
text={assistantText(closing.blocks)}
|
||||
text={hideDirectiveFences(assistantText(closing.blocks))}
|
||||
time={closing.time}
|
||||
clock="end"
|
||||
// The branch action owns boundary resolution: it sends the real
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/** Fences in the `dsh-` language namespace carry Host-interpreted blocks, not prose; the chat hides them. */
|
||||
import { hideDirectiveFences as withoutDirectiveFences } from '@deepseek-ai/dsh-util-action-block'
|
||||
|
||||
const FENCE = /(^|\n)```dsh-[^\n]*\n[\s\S]*?(?:\n```[^\S\n]*(?=\n|$)|$)/gu
|
||||
|
||||
/** Remove `dsh-` fences from answer text, including an unclosed fence still streaming.
|
||||
* @param text - assistant text block.
|
||||
* @returns the text without directive fences; surrounding prose is unchanged.
|
||||
/** Remove top-level `dsh-` fences from answer text by the shared CommonMark scan.
|
||||
* @param text - assistant text.
|
||||
* @param options - `streaming` also removes an unclosed last fence; a finished answer keeps it as text.
|
||||
* @returns the text unchanged when nothing is hidden; otherwise without the fences, leading line breaks or trailing whitespace.
|
||||
*/
|
||||
export function hideDirectiveFences(text: string): string {
|
||||
if (!text.includes('```dsh-')) return text
|
||||
return text.replace(FENCE, '$1').replace(/\n{3,}/gu, '\n\n').replace(/^\n+/u, '').trimEnd()
|
||||
export function hideDirectiveFences(text: string, options?: { readonly streaming?: boolean }): string {
|
||||
const shown = withoutDirectiveFences(text, options)
|
||||
return shown === text ? text : shown.replace(/^\n+/u, '').trimEnd()
|
||||
}
|
||||
@@ -2543,6 +2543,17 @@ describe('ChatView', () => {
|
||||
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual([null, null])
|
||||
})
|
||||
|
||||
it('copies the final answer without its dsh- directive blocks', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
const block = '```dsh-action\n{"action":"create_personalized_page","title":"A","brief":"B","slot":"page"}\n```'
|
||||
const h = makeHarness({ nodes: [user(1, 'hi'), assistant(2, `方案在下方。\n\n${block}`)], turnEnds: new Map([[1, 2]]) })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const tail = view.container.querySelector<HTMLElement>('[data-turn-tail]')
|
||||
fireEvent.click(within(tail!).getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('方案在下方。')
|
||||
})
|
||||
|
||||
it('keeps every Assistant reply visible while the whole Turn folds its process', () => {
|
||||
const progress = {
|
||||
...assistant(2, 'stage reply', 1, 1),
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { hideDirectiveFences } from '../src/client/chat/directive-fences.ts'
|
||||
|
||||
it('hides dsh- fences, closed or still streaming, and keeps prose and other fences', () => {
|
||||
const block = '```dsh-action\n{"action":"create_personalized_page","title":"A","brief":"B","slot":"page"}\n```'
|
||||
const block = '```dsh-action\n{"action":"create_personalized_page","title":"A","brief":"B","slot":"page"}\n```'
|
||||
|
||||
it('hides closed top-level dsh- fences and trims the edges they leave', () => {
|
||||
expect(hideDirectiveFences(`方案在下方。\n\n${block}`)).toBe('方案在下方。')
|
||||
expect(hideDirectiveFences(`${block}\n\n后记`)).toBe('后记')
|
||||
expect(hideDirectiveFences('说明\n\n```dsh-action\n{"action":"create_pers')).toBe('说明')
|
||||
expect(hideDirectiveFences(`前\n\n${block}\n\n后`)).toBe('前\n\n后')
|
||||
expect(hideDirectiveFences('```dsh-action json\n{}\n``` \n尾')).toBe('尾')
|
||||
const code = '看代码:\n\n```ts\nconst a = 1\n```\n\n完'
|
||||
expect(hideDirectiveFences(code)).toBe(code)
|
||||
expect(hideDirectiveFences('没有围栏')).toBe('没有围栏')
|
||||
})
|
||||
|
||||
it('keeps an unclosed fence in a finished answer and hides it while streaming', () => {
|
||||
const unclosed = '说明\n\n```dsh-action\n{"action":"create_pers'
|
||||
expect(hideDirectiveFences(unclosed)).toBe(unclosed)
|
||||
expect(hideDirectiveFences(unclosed, { streaming: false })).toBe(unclosed)
|
||||
expect(hideDirectiveFences(unclosed, { streaming: true })).toBe('说明')
|
||||
})
|
||||
|
||||
it('follows CommonMark fences for nested examples, longer fences and fence characters after text', () => {
|
||||
const nested = '示例:\n\n````markdown\n' + block + '\n````'
|
||||
expect(hideDirectiveFences(nested)).toBe(nested)
|
||||
expect(hideDirectiveFences('前\n\n````dsh-action\n{"note":"```"}\n```\n````\n\n后')).toBe('前\n\n后')
|
||||
const glued = '见下方:```dsh-action\n{}\n```'
|
||||
expect(hideDirectiveFences(glued)).toBe(glued)
|
||||
})
|
||||
|
||||
it('keeps other text as written, including blank lines inside other code blocks', () => {
|
||||
const code = '看代码:\n\n```ts\nconst a = 1\n\n\n\nconst b = 2\n```\n\n完'
|
||||
expect(hideDirectiveFences(code)).toBe(code)
|
||||
expect(hideDirectiveFences(`${code}\n\n${block}`)).toBe(code)
|
||||
expect(hideDirectiveFences('\n没有围栏 \n')).toBe('\n没有围栏 \n')
|
||||
})
|
||||
@@ -15,6 +15,9 @@
|
||||
{
|
||||
"path": "../../util/values"
|
||||
},
|
||||
{
|
||||
"path": "../../util/action-block"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
@@ -21,11 +21,11 @@
|
||||
en: fa3e5923560422d3
|
||||
zh: df82b4f84b0a7c15
|
||||
/deepseek-ai-dsh-experimental-client-ui-creation-recommendations/understand-the-implementation:
|
||||
en: d6040e6455c911b4
|
||||
zh: d4b967dc0c6dbb6e
|
||||
en: 3ca6636feb47b6a8
|
||||
zh: 4968753a28b8da19
|
||||
/deepseek-ai-dsh-experimental-client-ui-creation-recommendations/further-exploration:
|
||||
en: 26d9f165035a67d3
|
||||
zh: c66d7fbdca352bf9
|
||||
en: 7b5a577a397a3c0b
|
||||
zh: ea75e74c00ffdce2
|
||||
/deepseek-ai-dsh-experimental-client-ui-creation-recommendations/model-experience:
|
||||
en: 176de8a02931b042
|
||||
zh: b1f1228f011ab056
|
||||
|
||||
@@ -45,7 +45,7 @@ Plan disclosure depends on omitted text, not rendered height. Long previews coll
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
Successful native and PTC `actionCard` metadata for `create_personalized_page` becomes a hidden Conversation node. Existing experiment sessions using `creationRecommendation` metadata remain readable; unrelated action kinds use their own renderers. The completed-Turn tail reads its indexed proposals and displays only the first. The component receives session-bound callbacks and a framework-bound notice preference rather than accessing services. The shared Menu handles portal positioning, keyboard selection and dismissal. Its optional Remote, locale, event definition and slot registration share one plugin lifetime.
|
||||
The Turn tail reads the proposals of the `creationRecommendations` Session projection and shows the one admitted in its Turn once that Turn has ended, for any end reason; the projection admits at most one proposal per Turn. The component receives session-bound callbacks and a framework-bound notice preference rather than accessing services. The shared Menu handles portal positioning, keyboard selection and dismissal. Its optional Remote, locale and slot registration share one plugin lifetime.
|
||||
|
||||
- [Source](src/client/index.ts)
|
||||
- [Tests](tests/)
|
||||
@@ -58,7 +58,7 @@ Successful native and PTC `actionCard` metadata for `create_personalized_page` b
|
||||
## Further Exploration
|
||||
|
||||
- [Creation service](../creation-recommendations/README.md) — creation ownership and persistence.
|
||||
- [Conversation](../../client/ui-conversation/README.md) — event indexing.
|
||||
- [Session projection](../../session/session-projection/README.md) — the proposals and receipts the card reads.
|
||||
- [Slots](../../client/ui-slots/README.md) — completed-Turn presentation.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
|
||||
@@ -45,7 +45,7 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>实现细节 — 点击展开</summary>
|
||||
|
||||
成功的原生及 PTC 结果中,`create_personalized_page` 的 `actionCard` 元数据成为隐藏的 Conversation 节点。已有实验会话的 `creationRecommendation` 元数据仍可读取;其他动作类型使用各自的渲染器。已完成回合的末尾插槽读取该回合的推荐索引,只显示第一项。组件接收绑定会话的回调和框架绑定的提示偏好,不直接访问服务。共享 Menu 处理浮层定位、键盘选择和关闭。可选 Remote、语言字典、事件定义和插槽注册使用同一插件生命周期。
|
||||
回合末尾插槽读取 `creationRecommendations` 会话投影中的提案,在回合结束后(不论结束原因)显示该回合接纳的提案;投影每回合最多接纳一个提案。组件接收绑定会话的回调和框架绑定的提示偏好,不直接访问服务。共享 Menu 处理浮层定位、键盘选择和关闭。可选 Remote、语言字典和插槽注册使用同一插件生命周期。
|
||||
|
||||
- [源码](src/client/index.ts)
|
||||
- [测试](tests/)
|
||||
@@ -58,7 +58,7 @@ kind: "package-reference"
|
||||
## 延伸阅读
|
||||
|
||||
- [创建服务](../creation-recommendations/README.zh.md) — 创建任务与持久化。
|
||||
- [Conversation](../../client/ui-conversation/README.zh.md) — 事件索引。
|
||||
- [Session 投影](../../session/session-projection/README.zh.md) — 卡片读取的提案和操作记录。
|
||||
- [Slots](../../client/ui-slots/README.zh.md) — 回合末尾呈现.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/** Completed-answer creation card with location selection and undo. */
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import { Button, IconChevronDownOutlineRegular, IconInfoOutlineRegular, Menu, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { Button, IconChevronDownOutlineRegular, IconWarningTriangleOutlineRegular, Menu, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { CreationRecommendation, CreationActionResult, CreationSlot } from '@deepseek-ai/dsh-experimental-creation-recommendations/types'
|
||||
import type {
|
||||
CreationActionResult, CreationDecision, CreationRecommendation, CreationSlot, CreationView,
|
||||
} from '@deepseek-ai/dsh-experimental-creation-recommendations/types'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { RecommendationKey } from './locales.ts'
|
||||
import type {} from './definition.ts'
|
||||
import css from './Card.module.css'
|
||||
|
||||
/** Session-bound user actions; installation paths remain Host-owned. */
|
||||
@@ -16,16 +18,18 @@ export interface CardInjected {
|
||||
dismissInfo: () => void
|
||||
}
|
||||
|
||||
/** Proposals indexed by their originating turn. */
|
||||
export interface RecommendationTailInjected extends CardInjected {
|
||||
keyedHooks: { recommendations: (turn: string) => ObservableSnapshot<readonly CreationRecommendation[]> }
|
||||
}
|
||||
|
||||
type CardProps = { recommendation: CreationRecommendation } & Pick<PropsRuntime<'conversation.chat.turnTail'>, 'useProjection'>
|
||||
& PropsLocale<'creationRecommendations'> & InjectFace<CardInjected>
|
||||
|
||||
type CardAction = Parameters<CardInjected['act']>[1]
|
||||
|
||||
type LocationChoice = CreationSlot | 'custom'
|
||||
|
||||
/** Application outcomes have their own copy; the phase copy of failed and cancelled describes creation instead. */
|
||||
const APPLICATION_KEYS: Readonly<Record<Exclude<NonNullable<CreationDecision['application']>, 'applied'>, RecommendationKey>> = {
|
||||
'restart-required': 'restart-required', overridden: 'overridden', failed: 'applicationFailed', cancelled: 'applicationCancelled',
|
||||
}
|
||||
|
||||
function LocationPicker({ value, disabled, onChange, t }: {
|
||||
value: LocationChoice
|
||||
disabled: boolean
|
||||
@@ -62,17 +66,27 @@ function FoldedText({ text, expandLabel, collapseLabel }: { text: string; expand
|
||||
</>
|
||||
}
|
||||
|
||||
/** Place one card after the complete answer.
|
||||
* @param props - Completed turn and its session-bound proposals.
|
||||
* @returns the first proposal, or null.
|
||||
/** The projection admits at most one proposal per turn; should several exist, the lowest call id is shown. */
|
||||
function proposalOf(view: CreationView | undefined, turn: number): CreationRecommendation | undefined {
|
||||
let found: CreationRecommendation | undefined
|
||||
for (const proposal of Object.values(view?.proposals ?? {})) {
|
||||
if (proposal.turn === turn && (found === undefined || proposal.callId < found.callId)) found = proposal
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** Place the proposal admitted in a turn after that turn's final answer, once the turn has ended.
|
||||
* @param props - Turn location, projection reader and session-bound actions.
|
||||
* @returns the creation card, or null.
|
||||
*/
|
||||
// Each optional bundle owns its card and locale contracts; this small tail wrapper does not warrant a shared package.
|
||||
/* jscpd:ignore-start */
|
||||
export function RecommendationTail({ turn, useRecommendations, ...props }: Pick<PropsRuntime<'conversation.chat.turnTail'>, 'turn' | 'useProjection'>
|
||||
& PropsLocale<'creationRecommendations'> & InjectFace<RecommendationTailInjected>) {
|
||||
const recommendation = useRecommendations(String(turn.turn))?.[0]
|
||||
const phase = props.useProjection('creationRecommendations')?.decisions[recommendation?.callId ?? '']?.phase
|
||||
return recommendation === undefined || phase === 'suppressed' || turn.end?.data.reason.kind !== 'completed' || turn.data.get('turn-tail')?.closing == null
|
||||
export function RecommendationTail({ turn, ...props }: Pick<PropsRuntime<'conversation.chat.turnTail'>, 'turn' | 'useProjection'>
|
||||
& PropsLocale<'creationRecommendations'> & InjectFace<CardInjected>) {
|
||||
const view = props.useProjection('creationRecommendations')
|
||||
const recommendation = proposalOf(view, turn.turn)
|
||||
const phase = view?.decisions[recommendation?.callId ?? '']?.phase
|
||||
return recommendation === undefined || phase === 'suppressed' || turn.end === undefined || turn.data.get('turn-tail')?.closing == null
|
||||
? null : <RecommendationCard {...props} recommendation={recommendation} />
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
@@ -90,16 +104,17 @@ export function RecommendationCard({ recommendation, useProjection, act, useInfo
|
||||
const infoDismissed = useInfoDismissed(value => value)
|
||||
const infoId = useId()
|
||||
const customId = useId()
|
||||
const [pending, setPending] = useState(false)
|
||||
const [pending, setPending] = useState<CardAction>()
|
||||
const [error, setError] = useState<RecommendationKey>()
|
||||
const busy = pending !== undefined
|
||||
const slot = selected !== undefined && selected !== 'custom' ? selected : decision?.slot ?? recommendation.slot
|
||||
const location = selected ?? (decision?.customLocation === undefined ? slot : 'custom')
|
||||
const customLocation = custom ?? decision?.customLocation ?? ''
|
||||
const offer = decision === undefined || decision.phase === 'cancelled' || decision.phase === 'failed' || decision.phase === 'interrupted' || decision.phase === 'unconfirmed'
|
||||
const infoOpen = info ?? (offer && !infoDismissed)
|
||||
const installed = decision?.phase === 'installed' || decision?.phase === 'kept'
|
||||
const run = async (action: 'create' | 'dismiss' | 'keep' | 'undo' | 'cancel') => {
|
||||
setPending(true)
|
||||
const run = async (action: CardAction) => {
|
||||
setPending(action)
|
||||
setError(undefined)
|
||||
if (action === 'create' || action === 'dismiss') { dismissInfo(); setInfo(false) }
|
||||
// Independent optional cards keep their action and receipt handling local.
|
||||
@@ -111,7 +126,7 @@ export function RecommendationCard({ recommendation, useProjection, act, useInfo
|
||||
} catch {
|
||||
// A lost reply does not prove failure; replay supplies the durable receipt.
|
||||
setError('transport')
|
||||
} finally { setPending(false) }
|
||||
} finally { setPending(undefined) }
|
||||
/* jscpd:ignore-end */
|
||||
}
|
||||
return <section className={css.card} aria-label={t('heading')} data-creation-recommendation={recommendation.callId}>
|
||||
@@ -122,7 +137,7 @@ export function RecommendationCard({ recommendation, useProjection, act, useInfo
|
||||
setInfo(!infoOpen)
|
||||
if (infoOpen) dismissInfo()
|
||||
}}>
|
||||
<IconInfoOutlineRegular size={14} />
|
||||
<IconWarningTriangleOutlineRegular size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</span>
|
||||
@@ -141,36 +156,36 @@ export function RecommendationCard({ recommendation, useProjection, act, useInfo
|
||||
<span className={css.contentLabel}>{t('initialContent')}</span>
|
||||
<FoldedText text={recommendation.initialContent} expandLabel={t('expandContent')} collapseLabel={t('collapseContent')} />
|
||||
</div>}
|
||||
{decision?.application !== undefined && decision.application !== 'applied'
|
||||
&& <p role="status" className={css.status}>{t(decision.application)}</p>}
|
||||
{decision?.application !== undefined && decision.application !== 'applied' && decision.phase !== 'failed'
|
||||
&& <p role="status" className={css.status}>{t(APPLICATION_KEYS[decision.application])}</p>}
|
||||
{error !== undefined && <p role="alert" className={css.status}>{t(error)}</p>}
|
||||
{offer ? <div className={css.controls}>
|
||||
<div className={css.locationRow}>
|
||||
<span className={css.locationLabel}>{t('location')}</span>
|
||||
<LocationPicker value={location} disabled={pending} onChange={setSelected} t={t} />
|
||||
<LocationPicker value={location} disabled={busy} onChange={setSelected} t={t} />
|
||||
</div>
|
||||
{location === 'custom' && <div className={css.customLocation}>
|
||||
<label htmlFor={customId} className={css.locationLabel}>{t('customLabel')}</label>
|
||||
<textarea id={customId} value={customLocation} rows={2} maxLength={1000} disabled={pending}
|
||||
<textarea id={customId} value={customLocation} rows={2} maxLength={1000} disabled={busy}
|
||||
placeholder={t('customPlaceholder')} onChange={(event) => { setCustom(event.target.value) }} />
|
||||
</div>}
|
||||
<footer className={css.footer}>
|
||||
<div className={css.actions}>
|
||||
<Button variant="ghost" size="sm" disabled={pending || view === undefined} onClick={() => { void run('dismiss') }}>{t('dismiss')}</Button>
|
||||
<Button variant="primary" size="sm" disabled={pending || view === undefined || location === 'custom' && customLocation.trim() === ''} onClick={() => { void run('create') }}>
|
||||
{t(pending ? 'starting' : decision === undefined ? 'create' : 'retry')}
|
||||
<Button variant="ghost" size="sm" disabled={busy || view === undefined} onClick={() => { void run('dismiss') }}>{t('dismiss')}</Button>
|
||||
<Button variant="primary" size="sm" disabled={busy || view === undefined || location === 'custom' && customLocation.trim() === ''} onClick={() => { void run('create') }}>
|
||||
{t(pending === 'create' ? 'starting' : decision === undefined ? 'create' : 'retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</div> : <p className={css.description}>{t('location')} · {location === 'custom' ? customLocation : t(slot)}</p>}
|
||||
</div> : <p className={css.description}>{t('location')} · {decision.customLocation ?? t(decision.slot)}</p>}
|
||||
{decision?.phase === 'creating' && <footer className={css.footer}>
|
||||
<Button variant="ghost" size="sm" disabled={pending} onClick={() => { void run('cancel') }}>{t(pending ? 'cancelling' : 'cancel')}</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => { void run('cancel') }}>{t(pending === 'cancel' ? 'cancelling' : 'cancel')}</Button>
|
||||
</footer>}
|
||||
{installed && <footer className={css.footer}>
|
||||
<span className={css.scope}>{t('scope')}</span>
|
||||
<div className={css.actions}>
|
||||
<Button variant="ghost" size="sm" disabled={pending} onClick={() => { void run('undo') }}>{t('undo')}</Button>
|
||||
{decision.phase !== 'kept' && <Button variant="primary" size="sm" disabled={pending} onClick={() => { void run('keep') }}>{t('keep')}</Button>}
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => { void run('undo') }}>{t('undo')}</Button>
|
||||
{decision.phase !== 'kept' && <Button variant="primary" size="sm" disabled={busy} onClick={() => { void run('keep') }}>{t('keep')}</Button>}
|
||||
</div>
|
||||
</footer>}
|
||||
</div>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/** Turn-indexed creation proposals from native and PTC results. */
|
||||
import type { CreationRecommendation } from '@deepseek-ai/dsh-experimental-creation-recommendations/types'
|
||||
import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ChatNode } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import { actionBlocksOf } from '@deepseek-ai/dsh-experimental-creation-recommendations/action-block'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-chat/client' {
|
||||
interface ChatNodeDataMap { 'creation-recommendation': CreationRecommendation }
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function proposalFields(value: unknown): Omit<CreationRecommendation, 'callId'> | undefined {
|
||||
if (!record(value) || typeof value.title !== 'string' || value.title.trim() === '' || typeof value.brief !== 'string' || value.brief.trim() === ''
|
||||
|| value.slot !== 'sidebar-right' && value.slot !== 'page' && value.slot !== 'sidebar-left'
|
||||
|| value.initialContent !== undefined && typeof value.initialContent !== 'string') return undefined
|
||||
return {
|
||||
title: value.title.trim(), brief: value.brief.trim(), slot: value.slot,
|
||||
...value.initialContent === undefined ? {} : { initialContent: value.initialContent },
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a proposal from a logged event: the last valid `dsh-action` block of an assistant answer,
|
||||
* or earlier sessions' tool result metadata.
|
||||
* @param event - Persisted assistant message, native or PTC result.
|
||||
* @returns validated creation proposal, if present.
|
||||
*/
|
||||
export function recommendationOf(event: { readonly type: string; readonly data: unknown }): CreationRecommendation | undefined {
|
||||
if (!record(event.data)) return undefined
|
||||
if (event.type === 'assistant/message') {
|
||||
const message = event.data.message
|
||||
if (!record(message) || !Array.isArray(message.content) || typeof message.id !== 'string' || message.id === '') return undefined
|
||||
const text = message.content.flatMap(block => record(block) && block.type === 'text' && typeof block.text === 'string' ? [block.text] : []).join('\n')
|
||||
const blocks = actionBlocksOf(text).flatMap(value => record(value) && value.action === 'create_personalized_page' ? [proposalFields(value)] : [])
|
||||
.filter((fields): fields is Omit<CreationRecommendation, 'callId'> => fields !== undefined)
|
||||
const last = blocks.at(-1)
|
||||
return last === undefined ? undefined : { callId: `block:${message.id}` as ToolCallId, ...last }
|
||||
}
|
||||
const result = event.type === 'tool/result' && record(event.data.message) ? { ...event.data, isError: event.data.message.isError }
|
||||
: event.type === 'tool/ptc-dispatch' ? event.data : undefined
|
||||
if (!record(result) || result.isError || !record(result.meta)) return undefined
|
||||
const card = result.meta.actionCard
|
||||
const value = record(card) && card.action === 'create_personalized_page' ? card.data : result.meta.creationRecommendation
|
||||
if (!record(value) || typeof value.callId !== 'string' || value.callId === '') return undefined
|
||||
const fields = proposalFields(value)
|
||||
return fields === undefined ? undefined : { callId: value.callId as ToolCallId, ...fields }
|
||||
}
|
||||
|
||||
/** Retain proposal data without rendering inside the tool sequence. */
|
||||
export const recommendationDefinition = {
|
||||
kind: 'creation-recommendation', target: 'chat',
|
||||
match: (event) => {
|
||||
const value = recommendationOf(event)
|
||||
return value === undefined ? null : { id: value.callId, role: 'start' }
|
||||
},
|
||||
start: (_context, match) => recommendationOf(match.event) as CreationRecommendation,
|
||||
update: context => context.state,
|
||||
buildViewNode: (context) => {
|
||||
const start = context.start
|
||||
if (context.state === undefined || start === undefined) return null
|
||||
return {
|
||||
key: context.key, kind: 'creation-recommendation', id: context.id, target: 'chat',
|
||||
anchorSeq: start.event.seq, location: start.location, visibility: 'hidden', data: context.state,
|
||||
} satisfies ChatNode<'creation-recommendation'>
|
||||
},
|
||||
} satisfies ConversationNodeDefinition<CreationRecommendation>
|
||||
@@ -26,12 +26,15 @@ export const en = {
|
||||
'invalid-location': 'Choose a location or describe it in up to 1,000 characters.',
|
||||
'restart-required': 'The change is saved. Restart DSH to apply it.',
|
||||
overridden: 'Another configuration overrides this change.', cancelled: 'Cancelled',
|
||||
applicationFailed: 'The last change did not apply. Check Plugins.',
|
||||
applicationCancelled: 'The last change was cancelled.',
|
||||
transport: 'Could not confirm the result. Reconnect and check the card before retrying.',
|
||||
unknown: 'This recommendation is no longer available.',
|
||||
unavailable: 'This action is unavailable. Check the plugin in Plugins.',
|
||||
'access-denied': 'This connection cannot change plugins.',
|
||||
'not-live-root': 'Open the original conversation to use this card.',
|
||||
closing: 'The creation plugin is stopping. Try again after it reloads.',
|
||||
'read-only': 'This Session is read-only. Switch the permission preset to create pages.',
|
||||
}
|
||||
|
||||
/** Keys shared by both dictionaries. */
|
||||
@@ -64,11 +67,14 @@ export const zh: Record<RecommendationKey, string> = {
|
||||
'invalid-location': '请选择位置,或填写不超过 1,000 字的位置说明',
|
||||
'restart-required': '变更已保存,重启 DSH 后生效',
|
||||
overridden: '其他配置覆盖了本次变更', cancelled: '已取消',
|
||||
applicationFailed: '上一步更改未生效,请到插件页查看',
|
||||
applicationCancelled: '上一步更改已取消',
|
||||
transport: '暂时无法确认结果,请重新连接并查看卡片状态后再重试',
|
||||
unknown: '这条推荐已不可用', unavailable: '暂时无法执行,请到插件页面查看',
|
||||
'access-denied': '当前连接没有修改插件的权限',
|
||||
'not-live-root': '请打开原会话后使用这张卡片',
|
||||
closing: '创建插件正在停止,请等待重新加载后再试',
|
||||
'read-only': '当前会话为只读模式,创建页面需要切换权限预设',
|
||||
}
|
||||
|
||||
/** Japanese creation and recovery copy. */
|
||||
@@ -99,10 +105,13 @@ export const ja: Record<RecommendationKey, string> = {
|
||||
'invalid-location': '場所を選択するか、1,000文字以内で説明してください。',
|
||||
'restart-required': '変更は保存されました。適用するにはDSHを再起動してください。',
|
||||
overridden: '別の設定がこの変更を上書きしています。', cancelled: 'キャンセル済み',
|
||||
applicationFailed: '前回の変更は適用されませんでした。プラグイン画面で確認してください。',
|
||||
applicationCancelled: '前回の変更はキャンセルされました。',
|
||||
transport: '結果を確認できませんでした。再接続してカードを確認してから再試行してください。',
|
||||
unknown: 'この提案は利用できなくなりました。',
|
||||
unavailable: 'この操作は利用できません。プラグイン画面でプラグインを確認してください。',
|
||||
'access-denied': 'この接続ではプラグインを変更できません。',
|
||||
'not-live-root': 'このカードを使うには元の会話を開いてください。',
|
||||
closing: '作成プラグインが停止中です。再読み込み後に再試行してください。',
|
||||
'read-only': 'このセッションは読み取り専用です。ページを作成するには権限プリセットを切り替えてください。',
|
||||
}
|
||||
@@ -8,8 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-experimental-creation-recommendations/remote'
|
||||
import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type {} from '@deepseek-ai/dsh-experimental-creation-recommendations/types'
|
||||
import { RecommendationTail, type RecommendationTailInjected } from './Card.tsx'
|
||||
import { recommendationDefinition } from './definition.ts'
|
||||
import { RecommendationTail, type CardInjected } from './Card.tsx'
|
||||
import { en, ja, zh, type RecommendationKey } from './locales.ts'
|
||||
import { createCreationInfoStore } from './info-store.ts'
|
||||
|
||||
@@ -18,7 +17,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
|
||||
/** Registries available before this plugin mounts its optional Remote. */
|
||||
export const inject = ['slots', 'uiConversation', 'sessions', 'locale', 'remote']
|
||||
export const inject = ['slots', 'locale', 'remote']
|
||||
|
||||
/** Mount the optional Remote and UI for the same plugin lifetime.
|
||||
* @param ctx - Client plugin context.
|
||||
@@ -38,26 +37,15 @@ export async function mountRecommendations(ctx: Context, contribution: TypertRem
|
||||
function registerUi(ctx: Context): void {
|
||||
const infoDismissed = createCreationInfoStore()
|
||||
ctx.effect(() => ctx.locale.register('creationRecommendations', { en, zh, ja }), 'creation-recommendations: locale')
|
||||
ctx.effect(() => ctx.uiConversation.events.register(recommendationDefinition), 'creation-recommendations: chat')
|
||||
ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
|
||||
name: 'conversation.chat.turnTail', id: 'creation-recommendation', locale: 'creationRecommendations',
|
||||
inject: (sessionId: SessionId): RecommendationTailInjected => {
|
||||
const binding = ctx.sessions.binding(sessionId)
|
||||
if (binding === undefined) throw new Error(`creation-recommendations: unknown session "${sessionId}"`)
|
||||
const chat = ctx.uiConversation.binding(binding).target('chat')
|
||||
return {
|
||||
hooks: { infoDismissed },
|
||||
dismissInfo: () => { infoDismissed.set(true) },
|
||||
keyedHooks: { recommendations: (turn) => {
|
||||
const snapshot = chat.getSnapshot()
|
||||
if (snapshot === undefined) throw new Error('creation-recommendations: Chat target is unavailable')
|
||||
return snapshot.nodes.turnDataSource(Number(turn), 'creation-recommendation')
|
||||
} },
|
||||
act: async (callId, action, slot, customLocation) => {
|
||||
const result = await ctx.remote.creationRecommendations.act(sessionId, callId, action, slot, customLocation)
|
||||
return result.ok ? result.value : undefined
|
||||
},
|
||||
}
|
||||
},
|
||||
inject: (sessionId: SessionId): CardInjected => ({
|
||||
hooks: { infoDismissed },
|
||||
dismissInfo: () => { infoDismissed.set(true) },
|
||||
act: async (callId, action, slot, customLocation) => {
|
||||
const result = await ctx.remote.creationRecommendations.act(sessionId, callId, action, slot, customLocation)
|
||||
return result.ok ? result.value : undefined
|
||||
},
|
||||
}),
|
||||
}, RecommendationTail))
|
||||
}
|
||||
+4
-19
@@ -1,6 +1,5 @@
|
||||
/** Real registries validate keyed rendering, session binding, and HMR removal. */
|
||||
/** Real registries validate session binding and HMR removal. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { ConversationEventRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
@@ -9,20 +8,12 @@ import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { mountRecommendations, inject } from '../src/client/mount.ts'
|
||||
const apply = (ctx: Context) => mountRecommendations(ctx, { package: '@deepseek-ai/dsh-experimental-creation-recommendations', descriptors: [] })
|
||||
import { apply as hostApply } from '../src/index.ts'
|
||||
import type { RecommendationTailInjected } from '../src/client/Card.tsx'
|
||||
import type { CardInjected } from '../src/client/Card.tsx'
|
||||
|
||||
it('binds actions to the displayed session and removes registrations on unload', async () => {
|
||||
hostApply()
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const events = new ConversationEventRegistry(ctx)
|
||||
const source = { getSnapshot: () => [], subscribe: () => () => {} }
|
||||
const turnDataSource = vi.fn(() => source)
|
||||
const snapshot = { nodes: { turnDataSource } }
|
||||
const getSnapshot = vi.fn<() => typeof snapshot | undefined>(() => snapshot)
|
||||
ctx.provide('uiConversation', { events, binding: () => ({ target: () => ({ getSnapshot }) }) })
|
||||
const binding = vi.fn<() => object | undefined>(() => ({}))
|
||||
ctx.provide('sessions', { binding })
|
||||
await ctx.plugin(SlotRegistry)
|
||||
const slots = ctx.slots
|
||||
slots.register({ name: 'root', children: { 'conversation.chat.turnTail': {
|
||||
@@ -36,14 +27,8 @@ it('binds actions to the displayed session and removes registrations on unload',
|
||||
ctx.provide('locale', locale)
|
||||
const fiber = await ctx.plugin({ inject, apply })
|
||||
const entry = slots.entries('conversation.chat.turnTail')[0]!
|
||||
const resolve = entry.inject as NonNullable<typeof entry.inject> & ((sessionId: SessionId) => RecommendationTailInjected)
|
||||
const resolve = entry.inject as NonNullable<typeof entry.inject> & ((sessionId: SessionId) => CardInjected)
|
||||
const bound = resolve(SessionId('session-one'))
|
||||
expect(bound.keyedHooks.recommendations('3')).toBe(source)
|
||||
expect(turnDataSource).toHaveBeenCalledWith(3, 'creation-recommendation')
|
||||
getSnapshot.mockReturnValueOnce(undefined)
|
||||
expect(() => bound.keyedHooks.recommendations('3')).toThrow('Chat target is unavailable')
|
||||
binding.mockReturnValueOnce(undefined)
|
||||
expect(() => resolve(SessionId('missing'))).toThrow('unknown session')
|
||||
expect(await bound.act(ToolCallId('proposal'), 'create', 'page')).toEqual({ status: 'accepted' })
|
||||
expect(act).toHaveBeenCalledWith('session-one', 'proposal', 'create', 'page', undefined)
|
||||
await bound.act(ToolCallId('custom'), 'create', 'page', '左侧入口打开日历')
|
||||
@@ -55,7 +40,7 @@ it('binds actions to the displayed session and removes registrations on unload',
|
||||
await fiber.dispose()
|
||||
expect(unmount).toHaveBeenCalledOnce()
|
||||
expect(slots.entries('conversation.chat.turnTail')).toHaveLength(0)
|
||||
vi.spyOn(events, 'register').mockImplementation(() => { throw new Error('registration failed') })
|
||||
vi.spyOn(locale, 'register').mockImplementation(() => { throw new Error('registration failed') })
|
||||
const failed = ctx.plugin({ inject, apply })
|
||||
await expect(failed.await()).rejects.toThrow('registration failed')
|
||||
await failed.dispose()
|
||||
|
||||
+63
-22
@@ -4,7 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
|
||||
import type { CreationCardState } from '@deepseek-ai/dsh-experimental-creation-recommendations/types'
|
||||
import type { CreationCardState, CreationView } from '@deepseek-ai/dsh-experimental-creation-recommendations/types'
|
||||
import { RecommendationCard, RecommendationTail } from '../src/client/Card.tsx'
|
||||
import { en, zh, type RecommendationKey } from '../src/client/locales.ts'
|
||||
|
||||
@@ -20,24 +20,32 @@ function props(decision?: CreationCardState, chinese = true): Props {
|
||||
} as Props
|
||||
}
|
||||
|
||||
it('places only the first proposal at its turn tail and leaves unrelated turns empty', () => {
|
||||
it('places the proposal admitted in its turn once the turn has ended, and leaves other turns empty', () => {
|
||||
type Tail = ComponentProps<typeof RecommendationTail>
|
||||
const value = props()
|
||||
const useRecommendations = vi.fn<(turn: string) => readonly Props['recommendation'][] | undefined>(() => undefined)
|
||||
const tail: Tail = { ...value, useRecommendations,
|
||||
turn: { turn: 3, start: undefined,
|
||||
end: { type: 'turn/end', seq: SessionSeq(1), time: 0, data: { turn: 3, reason: { kind: 'completed' } } },
|
||||
status: 'closed', steps: [],
|
||||
data: { get: vi.fn().mockReturnValue({ closing: {} }),
|
||||
source: () => ({ getSnapshot: () => undefined, subscribe: () => () => {} }) } } }
|
||||
const { rerender } = render(<RecommendationTail {...tail} />)
|
||||
const proposal = (id: string, turn: number) => ({ ...value.recommendation, callId: ToolCallId(id), turn })
|
||||
let view: CreationView | undefined
|
||||
const turnData = vi.fn().mockReturnValue({ closing: {} })
|
||||
const ended = { type: 'turn/end', seq: SessionSeq(1), time: 0, data: { turn: 3, reason: { kind: 'max-tokens' } } } as const
|
||||
const tail = (end: typeof ended | undefined): Tail => ({ ...value, useProjection: () => view,
|
||||
turn: { turn: 3, start: undefined, end, status: end === undefined ? 'open' : 'closed', steps: [],
|
||||
data: { get: turnData, source: () => ({ getSnapshot: () => undefined, subscribe: () => () => {} }) } } })
|
||||
const { rerender } = render(<RecommendationTail {...tail(ended)} />)
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
useRecommendations.mockReturnValue([value.recommendation, { ...value.recommendation, callId: ToolCallId('second') }])
|
||||
rerender(<RecommendationTail {...tail} />)
|
||||
expect(screen.getAllByRole('region')).toHaveLength(1)
|
||||
expect(useRecommendations).toHaveBeenCalledWith('3')
|
||||
const suppressed: Tail = { ...tail, useProjection: () => ({ decisions: { [callId]: { callId, phase: 'suppressed', slot: 'sidebar-right' } } }) }
|
||||
rerender(<RecommendationTail {...suppressed} />)
|
||||
view = { decisions: {}, proposals: { earlier: proposal('earlier', 2) } }
|
||||
rerender(<RecommendationTail {...tail(ended)} />)
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
view = { decisions: {}, proposals: { ...view.proposals, b: proposal('b', 3), a: proposal('a', 3), c: proposal('c', 3) } }
|
||||
rerender(<RecommendationTail {...tail(ended)} />)
|
||||
expect(screen.getAllByRole('region').map(region => region.getAttribute('data-creation-recommendation'))).toEqual(['a'])
|
||||
expect(turnData).toHaveBeenCalledWith('turn-tail')
|
||||
rerender(<RecommendationTail {...tail(undefined)} />)
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
turnData.mockReturnValueOnce({ closing: null })
|
||||
rerender(<RecommendationTail {...tail(ended)} />)
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
view = { ...view, decisions: { a: { callId: ToolCallId('a'), phase: 'suppressed', slot: 'sidebar-right' } } }
|
||||
rerender(<RecommendationTail {...tail(ended)} />)
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -80,6 +88,17 @@ it('requires a written location, preserves its draft, and passes only the chosen
|
||||
expect(screen.getByRole<HTMLTextAreaElement>('textbox').value).toBe(' 左侧入口打开全屏日历 ')
|
||||
})
|
||||
|
||||
it('shows the recorded placement once settled, not the location picked before the decision', () => {
|
||||
const value = props(undefined, false)
|
||||
const { rerender } = render(<RecommendationCard {...value} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add to: Right sidebar' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Write your own/ }))
|
||||
fireEvent.change(screen.getByRole('textbox', { name: en.customLabel }), { target: { value: 'Next to the composer' } })
|
||||
rerender(<RecommendationCard {...props({ callId, phase: 'dismissed', slot: 'sidebar-right' }, false)} />)
|
||||
expect(screen.getByText('Add to · Right sidebar')).toBeTruthy()
|
||||
expect(screen.queryByText(/Next to the composer/)).toBeNull()
|
||||
})
|
||||
|
||||
it('restores a written location after cancellation and preserves it on retry', async () => {
|
||||
const value = props({ callId, phase: 'cancelled', slot: 'page', customLocation: '右侧查看当前会话的 Context' })
|
||||
const { rerender } = render(<RecommendationCard {...value} />)
|
||||
@@ -108,19 +127,26 @@ it('opens the cost notice until dismissed, remembers dismissal, and allows reope
|
||||
expect(value.act).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables pending decisions and supports dismissal without starting a child', async () => {
|
||||
it('disables pending decisions, labels only a pending Create as starting, and supports dismissal without starting a child', async () => {
|
||||
const value = props()
|
||||
const pending = Promise.withResolvers<Awaited<ReturnType<Props['act']>>>()
|
||||
value.act = vi.fn(() => pending.promise)
|
||||
const dismissal = Promise.withResolvers<Awaited<ReturnType<Props['act']>>>()
|
||||
const creation = Promise.withResolvers<Awaited<ReturnType<Props['act']>>>()
|
||||
value.act = vi.fn().mockReturnValueOnce(dismissal.promise).mockReturnValueOnce(creation.promise)
|
||||
render(<RecommendationCard {...value} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '暂不需要' }))
|
||||
expect(screen.getByRole('button', { name: '添加到: 右侧栏' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('button', { name: '暂不需要' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('button', { name: '开始创建…' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('button', { name: '创建' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.queryByRole('button', { name: '开始创建…' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: zh.creationInfo }))
|
||||
expect(screen.getByRole('note').textContent).toContain(zh.creationCost)
|
||||
expect(value.act).toHaveBeenCalledWith(callId, 'dismiss', 'sidebar-right', undefined)
|
||||
await act(async () => { pending.resolve({ status: 'settled', decision: { callId, phase: 'dismissed', slot: 'sidebar-right' } }) })
|
||||
await act(async () => { dismissal.resolve({ status: 'settled', decision: { callId, phase: 'dismissed', slot: 'sidebar-right' } }) })
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建' }))
|
||||
expect(screen.getByRole('button', { name: '开始创建…' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('button', { name: '暂不需要' }).hasAttribute('disabled')).toBe(true)
|
||||
await act(async () => { creation.resolve({ status: 'accepted' }) })
|
||||
expect(screen.getByRole('button', { name: '创建' }).hasAttribute('disabled')).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['installing', 'dismissed', 'removed', 'replaced'] as const)('restores %s without repeat actions', (phase) => {
|
||||
@@ -192,12 +218,27 @@ it('retains installed and kept plugins with an Undo action and the actual applic
|
||||
await waitFor(() => { expect(kept.act).toHaveBeenCalledWith(callId, 'undo', 'page', undefined) })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['installed', 'failed', zh.applicationFailed],
|
||||
['kept', 'cancelled', zh.applicationCancelled],
|
||||
['removed', 'overridden', zh.overridden],
|
||||
] as const)('describes a %s receipt whose change ended %s with application copy, not phase copy', (phase, application, text) => {
|
||||
render(<RecommendationCard {...props({ callId, phase, slot: 'page', application })} />)
|
||||
expect(screen.getAllByRole('status').map(status => status.textContent)).toEqual([zh[phase], text])
|
||||
})
|
||||
|
||||
it('leaves a failed creation to its phase line when the change also failed to apply', () => {
|
||||
render(<RecommendationCard {...props({ callId, phase: 'failed', slot: 'page', application: 'failed' })} />)
|
||||
expect(screen.getAllByRole('status').map(status => status.textContent)).toEqual([zh.failed])
|
||||
})
|
||||
|
||||
it('keeps uncertain actions recoverable on refusal and connection loss', async () => {
|
||||
const value = props()
|
||||
value.act = vi.fn().mockResolvedValueOnce({ status: 'refused', reason: 'unavailable' })
|
||||
.mockResolvedValueOnce({ status: 'refused', reason: 'read-only' })
|
||||
.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('disconnected'))
|
||||
render(<RecommendationCard {...value} />)
|
||||
for (const text of [zh.unavailable, zh.transport, zh.transport]) {
|
||||
for (const text of [zh.unavailable, zh['read-only'], zh.transport, zh.transport]) {
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建' }))
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe(text) })
|
||||
}
|
||||
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
|
||||
import { createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { recommendationDefinition, recommendationOf } from '../src/client/definition.ts'
|
||||
|
||||
const value = { callId: ToolCallId('rec-1'), title: 'Counter', brief: 'Show a counter', slot: 'sidebar-right' as const }
|
||||
|
||||
it('reads identical proposal facts from native and nested PTC results', () => {
|
||||
const native = { type: 'tool/result', data: { message: { isError: false }, meta: { creationRecommendation: value } } }
|
||||
const ptc = { type: 'tool/ptc-dispatch', data: { isError: false, meta: { creationRecommendation: value } } }
|
||||
expect(recommendationOf(native)).toEqual(value)
|
||||
expect(recommendationOf(ptc)).toEqual(value)
|
||||
for (const event of [native, ptc]) {
|
||||
expect(recommendationOf({ ...event, data: { ...event.data, meta: { actionCard: { action: 'create_personalized_page', callId: value.callId, data: value } } } })).toEqual(value)
|
||||
expect(recommendationOf({ ...event, data: { ...event.data, meta: { actionCard: { action: 'another_action', callId: value.callId, data: value } } } })).toBeUndefined()
|
||||
const withContent = { ...value, initialContent: '1. a\n2. b' }
|
||||
expect(recommendationOf({ ...event, data: { ...event.data, meta: { actionCard: { action: 'create_personalized_page', callId: value.callId, data: withContent } } } })).toEqual(withContent)
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back for unrelated, failed, missing, and malformed data', () => {
|
||||
for (const event of [
|
||||
{ type: 'other', data: null }, { type: 'other', data: {} },
|
||||
{ type: 'tool/result', data: {} },
|
||||
{ type: 'tool/result', data: { message: { isError: true } } },
|
||||
{ type: 'tool/result', data: { message: {}, meta: [] } },
|
||||
{ type: 'tool/result', data: { message: {}, meta: {} } },
|
||||
...[null, [], {}, { ...value, brief: 3 }, { ...value, callId: '' }, { ...value, title: '' }, { ...value, brief: '' }, { ...value, slot: 'other' }, { ...value, initialContent: 3 }].map(data => ({
|
||||
type: 'tool/result', data: { message: {}, meta: { creationRecommendation: data } },
|
||||
})),
|
||||
]) expect(recommendationOf(event)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('indexes proposal data while keeping the tool position free of cards', () => {
|
||||
type Match = Parameters<typeof recommendationDefinition.start>[1]
|
||||
type Context = Parameters<typeof recommendationDefinition.buildViewNode>[0]
|
||||
const match: Match = { role: 'start', event: { type: 'tool/result', seq: SessionSeq(7), time: 0, surfaceOp: 'append', data: {
|
||||
turn: 1, step: 1, message: createToolResultMessage({ callId: value.callId, content: [], isError: false }),
|
||||
meta: { creationRecommendation: value },
|
||||
} }, location: { kind: 'session' } }
|
||||
expect(recommendationDefinition.match(match.event)).toEqual({ id: 'rec-1', role: 'start' })
|
||||
expect(recommendationDefinition.match({ type: 'turn/start', seq: SessionSeq(1), time: 0, data: { turn: 1 } })).toBeNull()
|
||||
const context = { key: 'rec:rec-1', id: 'rec-1', start: match, state: value } as Context
|
||||
expect(recommendationDefinition.start(context, match)).toEqual(value)
|
||||
expect(recommendationDefinition.update({ ...context, state: value })).toEqual(value)
|
||||
expect(recommendationDefinition.buildViewNode?.(context)).toMatchObject({
|
||||
key: 'rec:rec-1', anchorSeq: 7, visibility: 'hidden', data: value,
|
||||
})
|
||||
expect(recommendationDefinition.buildViewNode?.({ ...context, state: undefined })).toBeNull()
|
||||
expect(recommendationDefinition.buildViewNode?.({ ...context, start: undefined })).toBeNull()
|
||||
})
|
||||
|
||||
it('reads the last valid dsh-action block from an assistant answer and ignores malformed ones', () => {
|
||||
const block = (fields: object) => '```dsh-action\n' + JSON.stringify({ action: 'create_personalized_page', ...fields }) + '\n```'
|
||||
const fields = { title: 'Counter', brief: 'Show a counter', slot: 'sidebar-right' as const }
|
||||
const answer = (id: string, text: string) => ({ type: 'assistant/message', data: { message: { id, role: 'assistant', content: [{ type: 'text', text }] } } })
|
||||
expect(recommendationOf(answer('m1', '方案在下方。\n\n' + block(fields)))).toEqual({ callId: 'block:m1', ...fields })
|
||||
expect(recommendationOf(answer('m2', block({ ...fields, title: '' }) + '\n' + block({ ...fields, initialContent: '1. a' })))).toEqual({ callId: 'block:m2', ...fields, initialContent: '1. a' })
|
||||
for (const text of ['没有围栏', '```dsh-action\n{broken\n```', block({ ...fields, action: 'other' }), block({ ...fields, slot: 'elsewhere' }), block({ ...fields, initialContent: 2 })]) {
|
||||
expect(recommendationOf(answer('m3', text))).toBeUndefined()
|
||||
}
|
||||
expect(recommendationOf({ type: 'assistant/message', data: { message: { id: '', role: 'assistant', content: [{ type: 'text', text: block(fields) }] } } })).toBeUndefined()
|
||||
})
|
||||
@@ -25,10 +25,6 @@
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./action-block": {
|
||||
"types": "./lib/types/action-block.d.ts",
|
||||
"default": "./lib/types/action-block.js"
|
||||
},
|
||||
"./typert": {
|
||||
"types": "./lib/typert.host.d.ts",
|
||||
"default": "./lib/typert.host.js"
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
/** The `./action-block` subpath entry: the `dsh-action` parser of `@deepseek-ai/dsh-util-action-block`. */
|
||||
export { ACTION_BLOCK_LANGUAGE, actionBlocksOf } from '@deepseek-ai/dsh-util-action-block'
|
||||
+2
-1
@@ -3,7 +3,7 @@
|
||||
exports[`interface creation > loads page planning with a live duplicate-check catalog and withdraws it on unload > skill discovery 1`] = `
|
||||
[
|
||||
{
|
||||
"description": "Plan an optional new page or small tool when the user asks for a page, tab, panel, tool or interface in this app (even for one-off use), wants something to keep, revisit or update later, or will keep changing data shown in your answer. Load this before reading any files or source; it tells you how to submit the proposal. Not for requested files or code, ordinary answers, existing-feature questions, quoted wishes or declined recommendations.",
|
||||
"description": "Plan an optional new page or small tool when the user asks for a page, tab, panel, tool or interface in this app (even for one-off use), wants something to keep, revisit or update later, will keep changing data shown in your answer, or wants a live view of this app’s own data (Sessions, tasks, logs, workspace files). Load this before reading any files or source; it tells you how to submit the proposal. Not for requested files or code, ordinary answers, existing-feature questions, quoted wishes or declined recommendations.",
|
||||
"invocation": {
|
||||
"modelInvocable": true,
|
||||
"userInvocable": false,
|
||||
@@ -27,6 +27,7 @@ Propose a page in these cases:
|
||||
|
||||
- The user asks for a page, tab, panel, tool or interface in this app, or says they want something to keep, revisit or update later (“以后每次”, “能不能一直”, “希望能有”). One-off use still qualifies when the interface is explicitly requested.
|
||||
- Your answer is a table, list or chart, and the user has said the data will keep changing or that they will interact with it (“每周更新”, “我来改状态”, “要勾选”). The continuation must come from the user's own words; a table alone, or data that merely looks dynamic, is not enough. Deliver the requested answer first, then offer the page.
|
||||
- The user wants to keep watching live data of this app itself (a Session's usage, a running task's output, files changing in the workspace). A text answer cannot stay current, so a page qualifies even without the words above; name the data source in the brief and leave how it is read to the Creator.
|
||||
|
||||
Do not propose a page in these cases:
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@
|
||||
"src/types.ts",
|
||||
"src/projection.ts",
|
||||
"src/generated-bundle.ts",
|
||||
"src/skill.ts",
|
||||
"src/action-block.ts"
|
||||
"src/skill.ts"
|
||||
]
|
||||
}
|
||||
Generated
+3
@@ -3892,6 +3892,9 @@ importers:
|
||||
'@deepseek-ai/dsh-tool-executor-ptc':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/tool-executor-ptc
|
||||
'@deepseek-ai/dsh-util-action-block':
|
||||
specifier: workspace:*
|
||||
version: link:../../util/action-block
|
||||
'@deepseek-ai/dsh-util-values':
|
||||
specifier: workspace:*
|
||||
version: link:../../util/values
|
||||
|
||||
@@ -234,12 +234,6 @@ describe('client bundle purity gate', () => {
|
||||
expect(() => resolveId('@deepseek-ai/dsh-spill-policy/notice/internal')).toThrow(/purity/)
|
||||
})
|
||||
|
||||
it('admits only the pure proposal block parser of the creation experiment, not its Host plugin', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-experimental-creation-recommendations/action-block')).toBeNull()
|
||||
expect(() => resolveId('@deepseek-ai/dsh-experimental-creation-recommendations')).toThrow(/purity/)
|
||||
expect(() => resolveId('@deepseek-ai/dsh-experimental-creation-recommendations/action-block/internal')).toThrow(/purity/)
|
||||
})
|
||||
|
||||
it('inlines the zero-dependency dsh-action fence utility', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-util-action-block')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"I wish I had a small counter that stays visible between conversations."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nCurrent DeepSeek Harness implementation checkout: \"{{sourceRoot}}\".\n\nCurrent DeepSeek Harness Web GUI URL: \"{{webUrl}}\"."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"harness:source","text":"Current DeepSeek Harness implementation checkout: \"{{sourceRoot}}\"."},{"name":"app:web-surface","text":"Current DeepSeek Harness Web GUI URL: \"{{webUrl}}\"."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"<system-reminder>\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n<available_skills>\n- `personalized-pages`: Plan an optional new page or small tool when the user asks for a page, tab, panel, tool or interface in this app (even for one-off use), wants something to keep, revisit or update later, or will keep changing data shown in your answer. Load this before reading any files or source; it tells you how to submit the proposal. Not for requested files or code, ordinary answers, existing-feature questions, quoted wishes or declined recommendations.\n</available_skills>\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n</system-reminder>"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"personalized-pages","description":"Plan an optional new page or small tool when the user asks for a page, tab, panel, tool or interface in this app (even for one-off use), wants something to keep, revisit or update later, or will keep changing data shown in your answer. Load this before reading any files or source; it tells you how to submit the proposal. Not for requested files or code, ordinary answers, existing-feature questions, quoted wishes or declined recommendations."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"<system-reminder>\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n<available_skills>\n- `personalized-pages`: Plan an optional new page or small tool when the user asks for a page, tab, panel, tool or interface in this app (even for one-off use), wants something to keep, revisit or update later, will keep changing data shown in your answer, or wants a live view of this app’s own data (Sessions, tasks, logs, workspace files). Load this before reading any files or source; it tells you how to submit the proposal. Not for requested files or code, ordinary answers, existing-feature questions, quoted wishe...\n</available_skills>\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n</system-reminder>"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"personalized-pages","description":"Plan an optional new page or small tool when the user asks for a page, tab, panel, tool or interface in this app (even for one-off use), wants something to keep, revisit or update later, will keep changing data shown in your answer, or wants a live view of this app’s own data (Sessions, tasks, logs, workspace files). Load this before reading any files or source; it tells you how to submit the proposal. Not for requested files or code, ordinary answers, existing-feature questions, quoted wishe..."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Time sampled while preparing turn 1, step 1: {{timeContextTimestamp}}\nBrowser time zone for this request: {{clientTimeZone}}. Interpret otherwise-unqualified dates and times in this zone.\nElapsed since the preceding model-visible message: {{elapsed}}"}],"source":{"kind":"time-context","form":"snapshot","sections":[{"name":"time-context","text":"Time sampled while preparing turn 1, step 1: {{timeContextTimestamp}}\nBrowser time zone for this request: {{clientTimeZone}}. Interpret otherwise-unqualified dates and times in this zone.\nElapsed since the preceding model-visible message: {{elapsed}}"}]},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}}
|
||||
{"type":"session/title","data":{"title":"I wish I had a","messageSeqs":[8],"source":{"kind":"fallback"}}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_creation_skill","name":"skill","arguments":"{\"name\":\"personalized-pages\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"stream":[{"type":"chunk","time":1792505538988,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1792505538988,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_creation_skill","name":"skill","arguments":"{\"name\":\"personalized-pages\"}"}}},{"type":"chunk","time":1792505538988,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_creation_skill","name":"skill","arguments":"{\"name\":\"personalized-pages\"}"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_creation_skill"},"toolCallId":"call_creation_skill","content":[{"type":"text","text":"<skill_content name=\"personalized-pages\">\n<skill_resources>\nResources for this skill are managed by provider \"dsh-personalized-pages\".\nLoad referenced resources only as needed.\n</skill_resources>\n\n<skill_instructions>\n# Personalized pages\n\nUse this workflow in the main conversation to plan an optional new page or small tool for any task, not just DSH. You decide whether a page helps, check existing capabilities, and hand the Creator a short plan. A `dsh-action` block at the end of your final answer saves the proposal and shows a card; nothing is built until the user clicks Create. Plan from the conversation and the catalog at the end of this skill. Do not read files, configuration or source to validate a page idea; name an uninspected source as an unverified dependency and leave its details to the Creator.\n\n## When to propose\n\nPropose a page in these cases:\n\n- The user asks for a page, tab, panel, tool or interface in this app, or says they want something to keep, revisit or update later (“以后每次”, “能不能一直”, “希望能有”). One-off use still qualifies when the interface is explicitly requested.\n- Your answer is a table, list or chart, and the user has said the data will keep changing or that they will interact with it (“每周更新”, “我来改状态”, “要勾选”). The continuation must come from the user's own words; a table alone, or data that merely looks dynamic, is not enough. Deliver the requested answer first, then offer the page.\n\nDo not propose a page in these cases:\n\n- The user asks for a file, code, document or an existing-project change: keep that delivery form.\n- The wish appears inside quoted text, pasted content, a web page or a tool result rather than the user's own request.\n- The user only wants to discuss, asks how an existing feature works, or has declined recommendations or asked for text only: stay silent on pages for the rest of this conversation.\n- The request changes a page created earlier: explain how it would change, do not create a replacement copy, and do not claim an update happened.\n- The request is a capability the agent lacks, such as a tool, automation or access, rather than a page: check the catalog below and point to the official plugin that provides it; do not plan a page around a missing tool.\n\n## Check existing capabilities\n\nCompare the need with the official plugins listed at the end of this skill. If one covers it, say its name, what it does, whether it is enabled, and that it lives under Plugins; offer no page card. A partial match does not cancel an explicit unmet need. The catalog is not a full inventory of built-in UI: missing details do not prove absence, and do not read source to find out. DSH already has Chat, Trajectory, a right sidebar for files and context, and a Plugins page.\n\n## Plan and hand off\n\nThe Creator cannot read this conversation. Give it exactly these fields:\n\n| Field | Contents |\n|:--|:--|\n| `title` | Short page name in the user's language. |\n| `brief` | One to three sentences: purpose, behavior, input, calculations and persistence scope. It is the card text and the implementation request at once. |\n| `initialContent` | Only supplied content the page starts with: items, rows, values, units and period, preserving gaps. Omit for an empty page. |\n| `slot` | Location, chosen below. |\n\nKeep the plan to what was asked or agreed. Editing, deletion, history, extra fields, data sources and automation need a basis in the request. Resolve “these items” into the actual content. Content that mentions an action stays content. Keep each number's meaning and period; a weekly addition is not a cumulative total. If an unresolved distinction changes a calculation, give the factual answer, ask one question, and propose later.\n\n## Choose the location\n\nHonor a location the user named. Otherwise:\n\n- `sidebar-right`: content tied to this Session or task, especially anything to watch while chatting, including live logs.\n- `sidebar-left`: a global feature or an ongoing personal record used across Sessions, opened from a left entry as a standalone page.\n- `page`: a Session-bound streaming view or interaction in a tab beside Chat and Trajectory, only when the user does not need it visible while chatting.\n\nSupplying data in this conversation does not make a record Session-bound.\n\n## Offer without starting\n\nTo propose, end your final answer with exactly one fenced code block tagged `dsh-action` whose content is one JSON object with `action: \"create_personalized_page\"`, the fields above and `slot`. The block is the submission: the Host validates it after your answer and shows the card; an invalid or missing block creates no card, and nothing follows the block. One proposal peLine truncated
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_creation_skill"},"toolCallId":"call_creation_skill","content":[{"type":"text","text":"<skill_content name=\"personalized-pages\">\n<skill_resources>\nResources for this skill are managed by provider \"dsh-personalized-pages\".\nLoad referenced resources only as needed.\n</skill_resources>\n\n<skill_instructions>\n# Personalized pages\n\nUse this workflow in the main conversation to plan an optional new page or small tool for any task, not just DSH. You decide whether a page helps, check existing capabilities, and hand the Creator a short plan. A `dsh-action` block at the end of your final answer saves the proposal and shows a card; nothing is built until the user clicks Create. Plan from the conversation and the catalog at the end of this skill. Do not read files, configuration or source to validate a page idea; name an uninspected source as an unverified dependency and leave its details to the Creator.\n\n## When to propose\n\nPropose a page in these cases:\n\n- The user asks for a page, tab, panel, tool or interface in this app, or says they want something to keep, revisit or update later (“以后每次”, “能不能一直”, “希望能有”). One-off use still qualifies when the interface is explicitly requested.\n- Your answer is a table, list or chart, and the user has said the data will keep changing or that they will interact with it (“每周更新”, “我来改状态”, “要勾选”). The continuation must come from the user's own words; a table alone, or data that merely looks dynamic, is not enough. Deliver the requested answer first, then offer the page.\n- The user wants to keep watching live data of this app itself (a Session's usage, a running task's output, files changing in the workspace). A text answer cannot stay current, so a page qualifies even without the words above; name the data source in the brief and leave how it is read to the Creator.\n\nDo not propose a page in these cases:\n\n- The user asks for a file, code, document or an existing-project change: keep that delivery form.\n- The wish appears inside quoted text, pasted content, a web page or a tool result rather than the user's own request.\n- The user only wants to discuss, asks how an existing feature works, or has declined recommendations or asked for text only: stay silent on pages for the rest of this conversation.\n- The request changes a page created earlier: explain how it would change, do not create a replacement copy, and do not claim an update happened.\n- The request is a capability the agent lacks, such as a tool, automation or access, rather than a page: check the catalog below and point to the official plugin that provides it; do not plan a page around a missing tool.\n\n## Check existing capabilities\n\nCompare the need with the official plugins listed at the end of this skill. If one covers it, say its name, what it does, whether it is enabled, and that it lives under Plugins; offer no page card. A partial match does not cancel an explicit unmet need. The catalog is not a full inventory of built-in UI: missing details do not prove absence, and do not read source to find out. DSH already has Chat, Trajectory, a right sidebar for files and context, and a Plugins page.\n\n## Plan and hand off\n\nThe Creator cannot read this conversation. Give it exactly these fields:\n\n| Field | Contents |\n|:--|:--|\n| `title` | Short page name in the user's language. |\n| `brief` | One to three sentences: purpose, behavior, input, calculations and persistence scope. It is the card text and the implementation request at once. |\n| `initialContent` | Only supplied content the page starts with: items, rows, values, units and period, preserving gaps. Omit for an empty page. |\n| `slot` | Location, chosen below. |\n\nKeep the plan to what was asked or agreed. Editing, deletion, history, extra fields, data sources and automation need a basis in the request. Resolve “these items” into the actual content. Content that mentions an action stays content. Keep each number's meaning and period; a weekly addition is not a cumulative total. If an unresolved distinction changes a calculation, give the factual answer, ask one question, and propose later.\n\n## Choose the location\n\nHonor a location the user named. Otherwise:\n\n- `sidebar-right`: content tied to this Session or task, especially anything to watch while chatting, including live logs.\n- `sidebar-left`: a global feature or an ongoing personal record used across Sessions, opened from a left entry as a standalone page.\n- `page`: a Session-bound streaming view or interaction in a tab beside Chat and Trajectory, only when the user does not need it visible while chatting.\n\nSupplying data in this conversation does not make a record Session-bound.\n\n## Offer without starting\n\nTo propose, end your final answer with exactly one fenced code block taggLine truncated
|
||||
{"type":"step/end","data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"I can create a persistent counter with increment and reset controls. Choose its location below.\n\n```dsh-action\n{\"action\": \"create_personalized_page\", \"title\": \"Counter\", \"brief\": \"Need: Keep a small counter visible across conversations.\\nLocation: A card titled Today’s achievements in the right sidebar.\\nControls: Increment and reset buttons beside the current value.\\nStorage: Save changes in browser storage across navigation and refresh.\\nAppearance: Follow the existing sidebar theme in light and dark mode.\\nCompletion: Both controls work and reopening restores the saved value.\", \"slot\": \"sidebar-right\"}\n```"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"stream":[{"type":"chunk","time":1792505539242,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792505539242,"index":0,"dt":[],"texts":["I can create a persistent counter with increment and reset controls. Choose its location below.\n\n```dsh-action\n{\"action\": \"create_personalized_page\", \"title\": \"Counter\", \"brief\": \"Need: Keep a small counter visible across conversations.\\nLocation: A card titled Today’s achievements in the right sidebar.\\nControls: Increment and reset buttons beside the current value.\\nStorage: Save changes in browser storage across navigation and refresh.\\nAppearance: Follow the existing sidebar theme in light and dark mode.\\nCompletion: Both controls work and reopening restores the saved value.\", \"slot\": \"sidebar-right\"}\n```"]},{"type":"chunk","time":1792505539242,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"I can create a persistent counter with increment and reset controls. Choose its location below.\n\n```dsh-action\n{\"action\": \"create_personalized_page\", \"title\": \"Counter\", \"brief\": \"Need: Keep a small counter visible across conversations.\\nLocation: A card titled Today’s achievements in the right sidebar.\\nControls: Increment and reset buttons beside the current value.\\nStorage: Save changes in browser storage across navigation and refresh.\\nAppearance: Follow the existing sidebar theme in light and dark mode.\\nCompletion: Both controls work and reopening restores the saved value.\", \"slot\": \"sidebar-right\"}\n```"}}},{"type":"chunk","time":1792505539242,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
"@deepseek-ai/dsh-experimental-client-ui-cot-translation": ["./packages/experimental/client-ui-cot-translation/src/index.ts"],
|
||||
"@deepseek-ai/dsh-experimental-creation-recommendations": ["./packages/experimental/creation-recommendations/src/index.ts"],
|
||||
"@deepseek-ai/dsh-experimental-creation-recommendations/types": ["./packages/experimental/creation-recommendations/src/types.ts"],
|
||||
"@deepseek-ai/dsh-experimental-creation-recommendations/action-block": ["./packages/experimental/creation-recommendations/src/action-block.ts"],
|
||||
"@deepseek-ai/dsh-experimental-client-ui-creation-recommendations": ["./packages/experimental/client-ui-creation-recommendations/src/index.ts"],
|
||||
"@deepseek-ai/dsh-experimental-client-ui-creation-recommendations/client": ["./packages/experimental/client-ui-creation-recommendations/src/client/index.ts"],
|
||||
"@deepseek-ai/dsh-experimental-client-ui-cot-translation/types": ["./packages/experimental/client-ui-cot-translation/src/types.ts"],
|
||||
|
||||
Reference in new issue
Block a user