feat(schedule): add durable after reminders

This commit is contained in:
pku-xht
2026-08-05 19:00:02 +08:00
committed by Tianyi Cui
parent a229b42e24
commit f7e7851e3f
102 changed files with 2619 additions and 122 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-schedule/README.md
README.md: 7a37baf92c6e050e628d97d5926b08bae295137b
README.zh.md: 8acc33cc397f8e0ac4bee016007d635d0021c309

View File

@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-ui-schedule
English | [中文](README.zh.md)
Browser-only renderer for durable Schedule reminder receipts. The plugin registers the `schedule/reminder` key in the conversation-owned `conversation.chat.eventview` slot. The generic runtime continues to carry the durable event identity and its Host-computed JSON sidecar; this package owns only the Schedule card.
The card displays the reminder prompt, Session-local Schedule ID, exact UTC occurrence, and the `session-local` delivery boundary. A malformed or incompatible sidecar remains visible as a contained unavailable receipt instead of crashing the conversation. Unloading the plugin removes only the keyed renderer; `ui-conversation` then shows its generic visible JSON fallback for the same durable event.
## Model Experience
None, as this browser-only renderer registers no model surface; Schedule tools and reminder framing belong to `@deepseek-ai/dsh-tool-schedule`.
#### KV Cache effect
None. The renderer consumes a browser-side presentation sidecar after the durable event is committed.
## Known Limitations and Deferred Work
- **Receipt-only UI** — creating, listing, and deleting reminders remains model-driven through the Schedule tools; this package does not add a management page.
- **Session-local delivery** — the card records a receipt in the original Session. It does not imply a system, browser, email, or other external notification.

View File

@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-ui-schedule
[English](README.md) | 中文
用于渲染持久 Schedule 提醒回执的纯浏览器插件。插件在会话拥有的 `conversation.chat.eventview` slot 中注册 `schedule/reminder` key。通用 runtime 继续携带持久事件身份与 Host 计算的 JSON sidecar;本包只拥有 Schedule 卡片。
卡片显示提醒原文、Session 内的 Schedule ID、精确 UTC 发生时刻,以及 `session-local` 交付边界。若 sidecar 损坏或版本不兼容,组件会显示受控的不可用回执,而不会让会话崩溃。卸载插件只会移除该键控 renderer;`ui-conversation` 随后仍会为同一个持久事件显示通用且可见的 JSON fallback。
## 模型体验
无,因为这个纯浏览器 renderer 不注册模型 surface;Schedule 工具与提醒 framing 由 `@deepseek-ai/dsh-tool-schedule` 拥有。
#### KV Cache 影响
无。renderer 只在持久事件提交后消费浏览器侧 presentation sidecar。
## 已知限制与暂缓事项
- **仅提供回执 UI**:创建、列出和删除提醒仍由模型通过 Schedule 工具完成;本包不增加管理页面。
- **仅在 Session 内交付**:卡片记录的是原 Session 中的回执,并不表示系统、浏览器、邮件或其他外部通知。

View File

@@ -0,0 +1,70 @@
{
"name": "@deepseek-ai/dsh-client-ui-schedule",
"description": "Web renderer for durable Schedule reminder receipts in the conversation flow",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,63 @@
.root {
display: grid;
min-width: 0;
gap: 8px;
padding: 12px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-primary);
}
.header {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
}
.icon {
display: inline-flex;
flex: none;
color: var(--dsw-alias-brand-text);
}
.title {
min-width: 0;
flex: 1;
font: 600 13px/18px var(--ds-font-family);
}
.delivery {
flex: none;
color: var(--dsw-alias-label-tertiary);
font: 400 11px/16px var(--ds-font-family);
}
.prompt {
margin: 0;
color: var(--dsw-alias-label-primary);
font: 400 14px/21px var(--ds-font-family);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.meta {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: 4px 12px;
color: var(--dsw-alias-label-tertiary);
font: 400 11px/16px var(--ds-font-family);
}
.id {
font-family: var(--ds-font-family-code);
overflow-wrap: anywhere;
}
.invalid {
margin: 0;
color: var(--dsw-alias-label-secondary);
font: 400 13px/18px var(--ds-font-family);
}

View File

@@ -0,0 +1,61 @@
import { IconSparkle16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { EventRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import css from './ReminderRow.module.css'
interface ReminderPresentation {
scheduleId: string
prompt: string
occurrenceAt: string
deliveryMode: 'session-local'
}
/** Full Schedule row props: event owner/runtime share plus the locale seat. */
export type ReminderRowProps = EventRowProps & PropsLocale<'schedule'>
/** Narrow the domain-owned JSON sidecar without trusting its unknown carrier type. */
function reminderPresentation(value: unknown): ReminderPresentation | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
const record = value as Record<string, unknown>
if (typeof record['scheduleId'] !== 'string' || record['scheduleId'].length === 0) return null
if (typeof record['prompt'] !== 'string') return null
if (typeof record['occurrenceAt'] !== 'string' || record['occurrenceAt'].length === 0) return null
if (record['deliveryMode'] !== 'session-local') return null
return {
scheduleId: record['scheduleId'],
prompt: record['prompt'],
occurrenceAt: record['occurrenceAt'],
deliveryMode: record['deliveryMode'],
}
}
/**
* Render one durable reminder dispatch carried by the generic event sidecar.
* @param props - Keyed event owner payload and the Schedule translator.
* @returns A visible reminder receipt, or a contained invalid-payload row.
*/
export function ReminderRow({ node, t }: ReminderRowProps) {
const reminder = reminderPresentation(node.view)
return (
<section className={css.root} role="note" data-schedule-reminder>
<header className={css.header}>
<span className={css.icon} aria-hidden><IconSparkle16 size={14} /></span>
<span className={css.title}>{t('reminder.title')}</span>
{reminder !== null && <span className={css.delivery}>{t('reminder.delivery')}</span>}
</header>
{reminder === null
? <p className={css.invalid}>{t('reminder.invalid')} · {node.presentationKey}</p>
: (
<>
<p className={css.prompt}>{reminder.prompt}</p>
<footer className={css.meta}>
<span className={css.id}>{t('reminder.id', { id: reminder.scheduleId })}</span>
<time dateTime={reminder.occurrenceAt}>
{t('reminder.occurrence', { time: reminder.occurrenceAt })}
</time>
</footer>
</>
)}
</section>
)
}

View File

@@ -0,0 +1,38 @@
/** Register the Schedule durable-reminder renderer into the conversation event slot. */
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { ReminderRow } from './ReminderRow.tsx'
import { en, NS, zh, type ScheduleKey } from './locales.ts'
export type { ReminderRowProps } from './ReminderRow.tsx'
export type { ScheduleKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Copy for durable Schedule reminder receipts. */
schedule: ScheduleKey
}
}
/**
* `conversation` is an ordering edge: its service is published after the chat
* entry has declared `conversation.chat.eventview`.
*/
export const inject = ['slots', 'conversation', 'locale']
/**
* Register bilingual copy and the Schedule reminder keyed row.
* @param ctx - Client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-schedule: dictionaries')
ctx.effect(
() => ctx.slots.register({
name: 'conversation.chat.eventview',
key: 'schedule/reminder',
locale: NS,
}, ReminderRow),
'ui-schedule: reminder row registration',
)
}

View File

@@ -0,0 +1,25 @@
/** `schedule` namespace dictionaries. */
/** Dictionary namespace owned by this plugin. */
export const NS = 'schedule'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'reminder.title': '定时提醒',
'reminder.delivery': '仅在当前会话中交付',
'reminder.invalid': '提醒回执不可用',
'reminder.id': '编号 {id}',
'reminder.occurrence': '触发时间 {time}',
} satisfies Record<string, string>
/** The Schedule namespace key union. */
export type ScheduleKey = keyof typeof zh
/** English dictionary, checked complete against the Chinese key set. */
export const en = {
'reminder.title': 'Scheduled reminder',
'reminder.delivery': 'Delivered in this session only',
'reminder.invalid': 'Reminder receipt unavailable',
'reminder.id': 'ID {id}',
'reminder.occurrence': 'Due at {time}',
} satisfies Record<ScheduleKey, string>

View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Readonly<Record<string, string>>
export default classes
}

View File

@@ -0,0 +1,4 @@
/** Host loader entry for the browser-only Schedule receipt renderer. */
/** Provides no host-side behavior. */
export function apply(): void {}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-schedule`.
* @module @deepseek-ai/dsh-client-ui-schedule/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-schedule'
/** Cordis companion plugin name. */
export const name = 'client-ui-schedule-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the keyed slot registry owns contribution lifecycle,
* and the component has no state outside its immutable owner payload.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns The installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,75 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '../src/client/index.ts'
import { ReminderRow } from '../src/client/ReminderRow.tsx'
import { apply as nodeApply } from '../src/index.ts'
import {
apply as invariantApply,
inject as invariantInject,
name as invariantName,
} from '../src/invariant.ts'
interface CapturedEntry {
name: string
key?: string
locale?: string
component: unknown
}
function bench() {
const ctx = new Context()
let entry: CapturedEntry | undefined
ctx.provide('slots', {
register(options: Omit<CapturedEntry, 'component'>, component: unknown) {
entry = { ...options, component }
return () => { entry = undefined }
},
})
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
const fiber = ctx.plugin({ inject: [...inject], apply })
return { ctx, fiber, entry: () => entry }
}
describe('ui-schedule browser plugin', () => {
it('registers the keyed reminder renderer and unloads it with the fiber', async () => {
const b = bench()
await b.fiber.await()
expect(b.entry()).toEqual({
name: 'conversation.chat.eventview',
key: 'schedule/reminder',
locale: 'schedule',
component: ReminderRow,
})
await b.fiber.dispose()
expect(b.entry()).toBeUndefined()
})
})
describe('ui-schedule node and invariant companions', () => {
it('keeps the node half inert', () => {
expect(() => { nodeApply() }).not.toThrow()
})
it('registers exact package ownership and returns its disposer', async () => {
const ctx = new Context()
let owner: string | undefined
let disposed = false
ctx.provide('invariants', {
register(packageName: string, install: unknown) {
expect(install).toBeTypeOf('function')
owner = packageName
return () => { disposed = true }
},
})
expect(invariantName).toBe('client-ui-schedule-invariant')
expect(invariantInject).toEqual(['invariants'])
const dispose = await invariantApply(ctx)
expect(owner).toBe('@deepseek-ai/dsh-client-ui-schedule')
dispose()
expect(disposed).toBe(true)
})
})

View File

@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { PresentedEventNode } from '@deepseek-ai/dsh-client-runtime/client'
import { ReminderRow, type ReminderRowProps } from '../src/client/ReminderRow.tsx'
import { zh } from '../src/client/locales.ts'
const t: ReminderRowProps['t'] = makeTranslate(zh)
afterEach(cleanup)
function props(view: unknown): ReminderRowProps {
const node: PresentedEventNode = {
kind: 'presented-event',
seq: 4,
time: Date.parse('2026-08-05T08:00:00.000Z'),
presentationKey: 'schedule/reminder',
view,
}
return { node, t } as ReminderRowProps
}
describe('ReminderRow', () => {
it('shows the durable reminder payload and its session-local boundary', () => {
render(<ReminderRow {...props({
scheduleId: 'schedule-7',
prompt: 'Check the deploy',
occurrenceAt: '2026-08-05T08:00:00.000Z',
deliveryMode: 'session-local',
})} />)
expect(screen.getByRole('note')).toBeTruthy()
expect(screen.getByText('定时提醒')).toBeTruthy()
expect(screen.getByText('仅在当前会话中交付')).toBeTruthy()
expect(screen.getByText('Check the deploy')).toBeTruthy()
expect(screen.getByText('编号 schedule-7')).toBeTruthy()
const time = screen.getByText('触发时间 2026-08-05T08:00:00.000Z')
expect(time.getAttribute('datetime')).toBe('2026-08-05T08:00:00.000Z')
})
it('contains an incompatible sidecar as a visible unavailable receipt', () => {
render(<ReminderRow {...props({
scheduleId: '',
prompt: 'not trusted',
occurrenceAt: 123,
deliveryMode: 'external',
})} />)
expect(screen.getByText('提醒回执不可用 · schedule/reminder')).toBeTruthy()
expect(screen.queryByText('not trusted')).toBeNull()
expect(screen.queryByText('仅在当前会话中交付')).toBeNull()
})
})

View File

@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-schedule', ['lib/types/index.js', 'lib/types/invariant.js'])