/** * 会话重试 (Session retry) —— 浏览器侧。 * * 在 `conversation.chat.assistant-actions` 里注册一个条目:助手消息动作条上的 * 「重新回答」按钮,落在复制按钮与分支按钮之间(order 20,排在反馈条目的 10 之后, * 与截图里的位置一致)。 * * 可用的判定由宿主侧只读接口给出(见 ../index.js):它一次答完整条会话的 * 「锚点消息 → 该轮的提问」,本包按会话缓存一份,每条动作条拿自己的 messageId 查表。 * 只有 ui-chat 认定的「轮次尾部」那条消息才会命中(该轮最后一个产出文本的步骤里的 * 最后一条 assistant/message),其余动作条置灰。 * * 点击后走两步:先 POST 宿主侧写路由 `/api/session-retry/regenerate`,由宿主在活会话的 * 可见面上追加空 system 节点、把该轮提问起的内容整段影掉(旧回答因此退出模型上下文, * 日志与界面历史不动);成功后再把提问提交给会话。 * * 提交走 `sessions.binding(id).session.prompt(text, 'queue')` —— 与 ui-conversation * 的 conversation.send 同一条路径(那里也是 `prompt([{type:'text',text}], 'queue')`)。 * 会话正在回答时直接拒绝:边答边改可见面属于压缩那种受控场景,不在这里做。 * * @module @local/dsh-session-retry/client */ window.__ModuleLoader__.load({ id: '@local/dsh-session-retry', factory: (require) => { var module = { exports: {} } var exports = module.exports Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }) var React = require('react') var h = React.createElement /** 词典命名空间。 */ var NS = 'sessionRetry' /** 样式表标识:只注入一次。 */ var STYLE_ID = '@local/dsh-session-retry/action.css' /** 动作条条目 id。 */ var ENTRY_ID = 'session-retry' /** 动作条里的排序:反馈条目是 10,分支按钮在 extraActions 之后。 */ var ENTRY_ORDER = 20 /** 宿主侧只读接口。 */ var CONTEXT_URL = '/api/session-retry/context' /** 宿主侧写接口:让这一轮及其后的内容退出可见面。 */ var REGEN_URL = '/api/session-retry/regenerate' /** 提示语(已重新回答 / 失败原因)的存活时间。 */ var NOTICE_MS = 6000 var DICT = { zh: { 'action.retry': '重新回答', 'action.unavailable': '这一轮没有可以重发的提问', 'action.loading': '正在读取会话…', 'action.unreadable': '读取会话失败,点击重试', 'action.submitting': '正在提交…', 'action.noSession': '这个会话已经不可用', 'action.running': '这个会话正在回答,先停止再重新回答', 'action.sent': '已重新回答', 'action.noteSep': ';', 'action.notes': '{base}({notes})', 'action.noteShadowed': '旧回答已退出上下文', 'action.noteNoShadow': '这一轮的回答本来就不在上下文里', 'action.noteMerged': '合并 {count} 条提问', 'action.noteOmitted': '{parts} 个非文字内容未重发', 'action.failed': '重试失败:{message}', 'action.notLoaded': '宿主那半还是旧版本(404),重启应用后再试', }, en: { 'action.retry': 'Answer again', 'action.unavailable': 'This turn has no prompt to resend', 'action.loading': 'Reading the session…', 'action.unreadable': 'Could not read the session — click to retry', 'action.submitting': 'Submitting…', 'action.noSession': 'This session is no longer available', 'action.running': 'This session is answering — stop it first', 'action.sent': 'Asked again', 'action.noteSep': '; ', 'action.notes': '{base} ({notes})', 'action.noteShadowed': 'the previous answer left the context', 'action.noteNoShadow': 'the previous answer was not in the context anyway', 'action.noteMerged': 'merged {count} prompts', 'action.noteOmitted': '{parts} non-text parts not resent', 'action.failed': 'Retry failed: {message}', 'action.notLoaded': 'The host half is still the old build (404) — restart the app and retry', }, } /** * 与动作条原生按钮同几何:28px 方形、透明底、悬停浅底;图标尺寸与原生规则一致 * (轮次尾部那条动作条带 data-clock=end,原生按钮在那里就是 17px)。 * 提示气泡照抄 ui-primitives 的 Tooltip 外观(13px、深色底、圆角、下方 6px), * 只保留「悬停/聚焦即显示」这一条行为;状态文字沿用 ui-message-feedback * 在动作条里放状态文字的做法(13px、左侧 4px)。 * 颜色只用主题 token:token 改名最多让样子退化,不会让组件崩。 */ var CSS = [ '.dshretry-tipwrap{display:inline-flex;align-items:center;position:relative}', '.dshretry-action{width:calc(28px + var(--dsh-content-font-delta,0px));height:calc(28px + var(--dsh-content-font-delta,0px));border-radius:var(--dsw-radius-sm,6px);color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:none;justify-content:center;align-items:center;padding:6px;display:inline-flex}', '.dshretry-action svg{width:calc(15px + var(--dsh-content-font-delta,0px));height:calc(15px + var(--dsh-content-font-delta,0px))}', '[data-clock=end] .dshretry-action svg{width:calc(17px + var(--dsh-content-font-delta,0px));height:calc(17px + var(--dsh-content-font-delta,0px))}', '.dshretry-action:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}', '.dshretry-action[data-unavailable]{cursor:default;opacity:.4}', '.dshretry-action[data-unavailable]:hover{background:0 0;color:var(--dsw-alias-label-tertiary)}', '.dshretry-tip{position:absolute;top:calc(100% + 6px);left:50%;z-index:30;width:max-content;max-width:50vw;padding:3px 7px;border-radius:var(--dsw-radius-sm,6px);background:var(--dsw-alias-tooltip-bg,#2f2f2f);color:var(--dsw-static-neutral-bluish-00,#fff);font-size:13px;line-height:20px;white-space:pre-line;overflow-wrap:break-word;pointer-events:none;opacity:0;visibility:hidden;transform:translateX(-50%);transition:opacity 80ms}', '.dshretry-tipwrap:hover .dshretry-tip,.dshretry-tipwrap:focus-within .dshretry-tip{opacity:1;visibility:visible}', '.dshretry-status{color:var(--dsw-alias-label-tertiary);padding-left:4px;font-size:13px;line-height:20px;max-width:360px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', '.dshretry-status[data-tone=failure]{color:var(--dsw-alias-state-error-primary)}', ].join('\n') /** * 原生刷新图标的本地副本:几何与 ui-primitives 的 IconRefreshOutlineRegular * 逐字一致(16×16 viewBox、两条 currentColor 描边路径、strokeWidth 1)。 * 插件实践要求第三方插件不 require Harness 客户端包,需要什么就照抄一份。 */ function RefreshIcon() { return h( 'svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true, strokeWidth: 1, }, h('path', { d: 'M14.5001 8C14.5 9.28552 14.1188 10.5422 13.4045 11.611C12.6903 12.6799 11.6752 13.5129 10.4875 14.0049C9.29982 14.4968 7.99295 14.6255 6.73212 14.3747C5.4713 14.124 4.31314 13.505 3.4041 12.596C2.49514 11.687 1.87614 10.5288 1.62537 9.26798C1.37459 8.00716 1.50331 6.70028 1.99525 5.51261C2.48719 4.32494 3.32025 3.30981 4.3891 2.59557C5.45795 1.88134 6.71458 1.50008 8.0001 1.5C9.9001 1.5 11.7001 2.3 13.0001 3.6L14.5001 5.1', stroke: 'currentColor', }), h('path', { d: 'M14.4999 1.5V5.1H10.8999', stroke: 'currentColor' }), ) } /** * 悬停提示:ui-primitives 的 Tooltip 带浮层测量与 portal,这里只留用户真正依赖的 * 那部分——悬停或键盘聚焦时在控件下方浮出深色气泡。纯 CSS 显隐(.dshretry-tip), * 不写 DOM 到别处,也不 require Harness 客户端包。 * * @param label 提示文字。 * @param control 控件 vnode。 * @returns 包裹控件与气泡的 vnode。 */ function tip(label, control) { return h( 'span', { className: 'dshretry-tipwrap' }, control, h('span', { className: 'dshretry-tip', role: 'tooltip' }, label), ) } /** 注入本插件的样式表(幂等)。 */ function ensureStyle() { if (typeof document === 'undefined') return if (document.querySelector('style[data-plugin-css=' + JSON.stringify(STYLE_ID) + ']') !== null) return var tag = document.createElement('style') tag.dataset.plugin = '@local/dsh-session-retry' tag.dataset.pluginCss = STYLE_ID tag.textContent = CSS document.head.appendChild(tag) } /** * 会话一条的视图:共享宿主判定、提交重试。 * * 说话对象是 ctx 根上下文,所以会话绑定在每次提交时现取 —— 会话关闭重开会 * 换一个新的 binding,缓存下来的 session 对象会失效。 * * @param ctx 客户端插件上下文。 * @param sessionId 会话标识。 * @param t 词典查询函数。 * @returns 视图句柄:getSnapshot / subscribe / attach / ensure / submit / dispose。 */ function createView(ctx, sessionId, t) { var state = Object.freeze({ status: 'cold', anchors: null, pending: false }) var listeners = new Set() /** 最近一次请求(无论成败)完成的时刻。 */ var attemptedAt = 0 var inflight = null function emit(patch) { var next = Object.assign({}, state, patch) var changed = false for (var key in next) { if (next[key] !== state[key]) { changed = true break } } if (changed !== true) return state = Object.freeze(next) for (const listener of Array.from(listeners)) listener() } /** 现取当前会话对象:绑定不存在(会话已关闭)时返回 undefined。 */ function currentSession() { var sessions = ctx.get('sessions') if (sessions === undefined) return undefined var binding = sessions.binding(sessionId) return binding === undefined ? undefined : binding.session } /** * 取一次宿主判定(同一会话同时挂载的多条动作条合并成一趟请求)。 * * `mountedAt` 是调用方的挂载时刻:只要那之后已经跑过一趟(无论成败),本表就是 * 这次挂载能拿到的最新答案,不再重复请求。同一提交里挂载的那些共用一趟(由 * inflight 合并),比上一趟晚挂载的会补一趟 —— 否则新结束的那一轮会永远查不到 * 自己的锚点。 * * @param {number} mountedAt 调用方挂载时刻(Date.now())。 * @param {boolean} [force] 无视「本行是否已经问过」,强制再取一趟。 * @returns {Promise} */ function ensure(mountedAt, force) { if (inflight !== null) return inflight if (force !== true && attemptedAt >= mountedAt) return Promise.resolve(state) if (state.status !== 'ready') emit({ status: 'loading' }) inflight = (async function () { try { var response = await fetch(CONTEXT_URL, { method: 'POST', credentials: 'include', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ sessionId: sessionId }), }) var payload = null try { payload = await response.json() } catch { payload = null } if (payload === null || payload.ok !== true) { var reason = payload !== null && typeof payload.message === 'string' ? payload.message : 'HTTP ' + response.status throw new Error(reason) } var anchors = new Map() var list = Array.isArray(payload.anchors) ? payload.anchors : [] for (var index = 0; index < list.length; index += 1) { var entry = list[index] if (entry !== null && typeof entry === 'object' && typeof entry.messageId === 'string') anchors.set(entry.messageId, entry) } emit({ status: 'ready', anchors: anchors }) } catch { emit({ status: 'error' }) } finally { inflight = null attemptedAt = Date.now() } return state })() return inflight } /** * 提交重新回答:先让旧回答退出模型上下文,再把用户提问重新发进会话。 * * 两步都必须成功才算数:影子没做成就不提交提问 —— 那样只会在旧回答还在上下文里的 * 情况下把同一句提问问第二遍,与这个按钮的承诺不符。 * * @param {string} messageId 动作条所属的助手消息 id。 * @returns {Promise<{ ok: boolean, text: string }>} 结果与一句已本地化的说明。 */ async function submit(messageId) { var anchor = state.anchors === null ? undefined : state.anchors.get(messageId) if (state.status !== 'ready' || anchor === undefined || typeof anchor.text !== 'string' || anchor.text === '') { return { ok: false, text: t('action.unavailable') } } if (state.pending === true) return { ok: false, text: t('action.submitting') } var session = currentSession() if (session === undefined || session === null) return { ok: false, text: t('action.noSession') } var running = false try { running = session.getSnapshot().running === true } catch { running = false } if (running) return { ok: false, text: t('action.running') } var resent = anchor.text var omitted = typeof anchor.omittedParts === 'number' ? anchor.omittedParts : 0 var promptCount = typeof anchor.promptCount === 'number' ? anchor.promptCount : 1 emit({ pending: true }) var shadow try { var response = await fetch(REGEN_URL, { method: 'POST', credentials: 'include', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ sessionId: sessionId, messageId: messageId }), }) shadow = null try { shadow = await response.json() } catch { shadow = null } if (shadow === null || shadow.ok !== true) { var reason = shadow !== null && typeof shadow.message === 'string' ? shadow.message : 'HTTP ' + response.status emit({ pending: false }) // 404 = 宿主那半还是旧版本(宿主插件是裸 import 加载的,改完代码要重启应用才会生效)。 if (response.status === 404) return { ok: false, text: t('action.notLoaded') } return { ok: false, text: t('action.failed', { message: reason }) } } } catch (error) { emit({ pending: false }) return { ok: false, text: t('action.failed', { message: String(error?.message ?? error) }) } } var result try { result = await session.prompt([{ type: 'text', text: resent }], 'queue') } catch (error) { emit({ pending: false }) return { ok: false, text: t('action.failed', { message: String(error?.message ?? error) }) } } emit({ pending: false }) if (result === undefined || result === null || result.ok !== true) { var failure = result !== undefined && result !== null ? result.error : undefined var detail = failure !== undefined && failure !== null ? String(failure.message ?? failure.code) : 'unknown' return { ok: false, text: t('action.failed', { message: detail }) } } // 新一轮已经上路:判定表很快会多出一条锚点,先强制刷新一次。 void ensure(0, true) // 说明语:先给主句,需要交代的细节(旧回答的去向、合并了多条提问、有内容没带上)缀在后面。 var shadowed = typeof shadow.shadowed === 'number' ? shadow.shadowed : 0 var notes = [t(shadowed > 0 ? 'action.noteShadowed' : 'action.noteNoShadow')] if (promptCount > 1) notes.push(t('action.noteMerged', { count: promptCount })) if (omitted > 0) notes.push(t('action.noteOmitted', { parts: omitted })) var notice = t('action.notes', { base: t('action.sent'), notes: notes.join(t('action.noteSep')) }) return { ok: true, text: notice } } return { getSnapshot: function () { return state }, subscribe: function (listener) { listeners.add(listener) return function () { listeners.delete(listener) } }, /** 首次挂载:取一次判定。 */ attach: function (mountedAt) { return ensure(mountedAt, false) }, ensure: ensure, submit: submit, dispose: function () { listeners.clear() }, } } /** 订阅视图快照(视图保证快照引用不变时原样返回,React 得以按引用比较)。 */ function useViewSnapshot(view, mountedAt) { var pair = React.useState(function () { return view.getSnapshot() }) React.useEffect(function () { var sync = function () { pair[1](view.getSnapshot()) } sync() var unsubscribe = view.subscribe(sync) void view.attach(mountedAt) return unsubscribe }, [view, mountedAt]) return pair[0] } /** * 动作条上的「重新回答」按钮。 * * @param props messageId 来自父级 renderSlot;view 与 t 来自本包的 inject。 */ function SessionRetryAction(props) { var messageId = props.messageId var view = props.view var t = props.t // 挂载时刻:本次挂载之前跑过的那趟应答都不作数,之后跑过的才算数。 var mounted = React.useRef(0) if (mounted.current === 0) mounted.current = Date.now() var snapshot = useViewSnapshot(view, mounted.current) var outcome = React.useState(null) var notice = outcome[0] var setNotice = outcome[1] var noticeTimer = React.useRef(null) React.useEffect(function () { return function () { if (noticeTimer.current !== null) clearTimeout(noticeTimer.current) } }, []) var anchor = snapshot.anchors === null ? undefined : snapshot.anchors.get(messageId) var retryable = snapshot.status === 'ready' && anchor !== undefined && typeof anchor.text === 'string' && anchor.text !== '' var disabled = retryable !== true || snapshot.pending === true var label = t('action.retry') if (snapshot.status === 'error') label = t('action.unreadable') else if (snapshot.status !== 'ready') label = t('action.loading') else if (retryable !== true) label = t('action.unavailable') else if (snapshot.pending === true) label = t('action.submitting') var show = React.useCallback( function (result) { if (result === undefined || result === null) return setNotice({ tone: result.ok === true ? 'notice' : 'failure', text: result.text }) if (noticeTimer.current !== null) clearTimeout(noticeTimer.current) noticeTimer.current = setTimeout(function () { noticeTimer.current = null setNotice(null) }, NOTICE_MS) }, [setNotice], ) var onRetry = React.useCallback( function () { if (disabled === true) { // 上下文没读进来时,这一下就是重试读取。 if (snapshot.status === 'error') void view.ensure(mounted.current, true) return } void view.submit(messageId).then(show) }, [disabled, messageId, snapshot.status, view, show], ) return h( React.Fragment, null, // 控件、气泡、状态文字都留在动作条这一行里:条本身的悬停/聚焦显隐规则会一起作用, // 点过按钮之后焦点留在按钮上,提示语因此不会在鼠标移开时立刻消失。 tip( label, h('button', { type: 'button', className: 'dshretry-action', 'aria-label': label, 'aria-disabled': disabled === true ? true : undefined, 'data-unavailable': disabled === true ? '' : undefined, onClick: onRetry, }, h(RefreshIcon, {})), ), notice === null ? null : h( 'span', { className: 'dshretry-status', role: 'status', 'data-tone': notice.tone }, notice.text, ), ) } /** * 客户端插件主体:词典、样式表、一条会话一个视图、动作条条目。 * * @param ctx 客户端根上下文。 */ function apply(ctx) { ensureStyle() var locale = ctx.get('locale') var translate if (locale !== undefined) { ctx.effect(function () { var offZh = locale.register(NS, 'zh', DICT.zh) var offEn = locale.register(NS, 'en', DICT.en) try { translate = locale.bind(NS) } catch { translate = undefined } return function () { translate = undefined offZh() offEn() } }, 'dsh-session-retry: dictionaries') } /** 词典查询:绑定不可用时退回中文字典,并补上参数替换。 */ var t = function (key, params) { var text = translate === undefined ? undefined : translate(key) if (text === undefined || text === null || text === '' || text === key) text = DICT.zh[key] || key if (params !== undefined && params !== null) { for (var name in params) text = text.split('{' + name + '}').join(String(params[name])) } return text } var views = new Map() var viewFor = function (sessionId) { var view = views.get(sessionId) if (view === undefined) { view = createView(ctx, sessionId, t) views.set(sessionId, view) } return view } ctx.effect(function () { return function () { for (const view of views.values()) view.dispose() views.clear() } }, 'dsh-session-retry: per-session views') ctx.slots.inject('conversation.chat.assistant-actions', function () { return ctx.slots.register( { name: 'conversation.chat.assistant-actions', id: ENTRY_ID, order: ENTRY_ORDER, // 业务 share:会话标识由插槽框架给到 inject,视图与词典随之按会话定位。 inject: function (sessionId) { return { view: viewFor(sessionId), t: t } }, }, SessionRetryAction, ) }) } exports.apply = apply exports.inject = ['slots', 'sessions'] return module.exports }, })