// dsh-conversation-flat — browser half. // Registered with the web module loader via the package's ./client export; // the cordis loader adopts `exports` (apply/inject) as the plugin object. // // Delivers: // 1. Document-flow conversation layout (CSS): full-width column, user // messages as full-width neutral bars, markdown tables stretching with // the column. (v0.4.0: the conversation minimap was removed — dsh // 0.1.2-alpha.5 ships an official turn rail with hover previews and // click-to-jump, so the plugin no longer needs its own.) // 2. Personal profile (JS): a 个人资料 section in the settings panel to set // a display name + avatar; user messages then show a circular avatar + // name head above the bar. Assistant replies keep the plain DeepSeek // text label. // Values persist host-side in settings.yaml via the settingsScope service; // remote browsers (settings RPCs are loopback-only) and pages without the // service fall back to per-device localStorage. // // All colors come from the theme token layer so every feature adapts to any // theme (kimino, default, ...). Every side effect is registered through // ctx.effect so disable/remove fully reverts the page. // // MAINTENANCE NOTE: selectors match CSS-module content hashes of // @deepseek-ai/dsh-client-ui-chat at dsh 0.1.2-alpha.5 // (cX0H7W = ChatView scroll/column/flowItem, hBZZ9a = MessageItem // userRow/userStack/bubble, boO8a = AssistantMarkdown root/body, // tableScroll = markdown table wrapper — kept as a stem because the hash // suffix after the local name is stable). A dsh upgrade that rebuilds those // packages changes the hashes — re-derive them from the new client bundles // and update the stems below. window.__ModuleLoader__.load({ id: 'dsh-conversation-flat', factory: (require) => { var module = { exports: {} }; var exports = module.exports; Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); // theme: final token values; slots: settings.section registration; // settingsScope: read/write the profile namespace the host registers. const inject = ['theme', 'slots', 'settingsScope']; const apply = (ctx) => { /* ---------------- stylesheet ---------------- */ const styleEl = document.createElement('style'); styleEl.id = 'dsh-conversation-flat'; styleEl.textContent = ` /* ---- conversation column: centered content card -> fill the area ---- */ [class*="cX0H7W_scroll"] [class*="cX0H7W_column"] { max-width: none !important; margin: 0 !important; } /* ---- user messages: right-aligned bubble -> full-width neutral bar ---- */ [class*="cX0H7W_scroll"] [class*="hBZZ9a_userRow"] { align-items: stretch !important; } [class*="cX0H7W_scroll"] [class*="hBZZ9a_userStack"] { max-width: 100% !important; width: 100% !important; align-items: stretch !important; } [class*="cX0H7W_scroll"] [class*="hBZZ9a_bubble"] { border-radius: 10px !important; padding: 12px 16px !important; /* 45% mix keeps it a quiet bar in dark themes and stays subtle in light ones */ background: color-mix(in srgb, var(--dsw-specific-bubble, #8a8a8a) 45%, transparent) !important; } /* ---- markdown tables: stretch with the column, never shrink-wrap/center ---- */ /* The table wrapper keeps its dsh-generated local stem ("_tableScroll_…"); a dsh upgrade only changes the hash suffix, so matching the stem survives it. dsh reserves a scrollbar strip (padding-bottom 8px, overflow hidden) and swaps to auto/0 on :hover — with full-width tables that toggle is a no-op that still jolts layout. Pin the hover state so nothing changes on hover. */ [class*="cX0H7W_scroll"] [class*="_tableScroll_"] { width: 100% !important; margin: 0 !important; overflow-x: auto !important; padding-bottom: 0 !important; } [class*="cX0H7W_scroll"] [class*="_tableScroll_"] table { width: 100% !important; /* the renderer JS sets an inline max-width:max-content to enable its scrollable-table mode — a stylesheet !important beats a plain inline style */ max-width: 100% !important; margin: 0 !important; } /* ---- hide the content-width resize handle (official widthHandle) ---- */ /* dsh 0.1.2-alpha.5 ships a col-resize handle on both sides of the conversation column (dsh-client-ui-conversation, class *_widthHandle). It paints a white gradient line on :hover; the plugin does not offer width resizing, so hide it. Match the stable "_widthHandle" suffix, not the hash prefix. */ [class*="_widthHandle"] { display: none !important; } /* ---- assistant messages: plain sender label above the markdown body ---- */ [class*="cX0H7W_scroll"] [class*="boO8a_root"]::before { content: "DeepSeek"; display: block; font-size: 12px; line-height: 18px; opacity: 0.55; margin-bottom: 4px; } /* ---- profile head: circular avatar + name above the user bar ---- */ .dcf-msg-head { display: flex; align-items: center; gap: 8px; font-size: 12px; line-height: 18px; } .dcf-msg-head .dcf-avatar { flex: none; width: 22px; height: 22px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 600; color: #fff; background-color: var(--dcf-avatar-color, #7a8699); background-size: cover; background-position: center; user-select: none; } .dcf-msg-head .dcf-name { opacity: 0.55; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* ---- settings nav: swap the fallback gear for a person glyph ---- */ [data-dcf-settings-nav] > svg:first-child { display: none; } [data-dcf-settings-nav]::before { content: ''; flex: none; width: 16px; height: 16px; background: currentColor; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='12' cy='7' r='4'/%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='12' cy='7' r='4'/%3E%3C/svg%3E") center / contain no-repeat; } `; document.head.append(styleEl); ctx.effect(() => () => styleEl.remove()); /* ---------------- shared state ---------------- */ // scroller: the conversation scroll container (cX0H7W_scroll in dsh // 0.1.2-alpha.5). Heads are painted inside user message stacks, so the // container is only needed to find them and to re-run after session // switches. let scroller = null; let headTimer = 0; let navTimer = 0; /* ---------------- personal profile: scope ---------------- */ // The host half registers conversation-flat.profile with the settings // service (persisted to ~/.dsh/settings.yaml); settingsScope mirrors it // into the browser. If that service is missing or fails, fall back to a // localStorage-backed shim so the rest of the plugin keeps working. const React = require('react'); const makeMemoryScope = () => { const KEY = 'dcf-profile'; const read = () => { try { return JSON.parse(localStorage.getItem(KEY) || '{}') || {}; } catch { return {}; } }; const listeners = new Set(); return { getSnapshot: () => ({ status: 'ready', value: read(), writable: true, mode: 'memory' }), subscribe: (l) => { listeners.add(l); return () => { listeners.delete(l); }; }, set: async (field, value) => { const v = read(); v[field] = value; localStorage.setItem(KEY, JSON.stringify(v)); listeners.forEach((l) => l()); }, }; }; const memoryScope = makeMemoryScope(); let hostScope = null; try { hostScope = ctx.settingsScope ? ctx.settingsScope.bind({ namespace: 'conversation-flat-profile' }) : null; } catch (err) { console.warn('[dcf] settingsScope unavailable, profile falls back to localStorage:', err); hostScope = null; } // Host settings RPCs are loopback-only: a browser on another device // (tablet/phone over the LAN) gets persistence "memory" with status // "unavailable" right away. Route those pages to the localStorage scope // so the profile still works per-device, while the PC itself keeps the // host document (settings.yaml) as its single store. let memoryActive = false; const activeScope = () => { if (hostScope) { const st = hostScope.getSnapshot().status; if (st === 'ready' || st === 'loading') { memoryActive = false; return hostScope; } } memoryActive = true; return memoryScope; }; const profileScope = { getSnapshot: () => activeScope().getSnapshot(), subscribe(l) { const disposers = []; if (hostScope) disposers.push(hostScope.subscribe(l)); disposers.push(memoryScope.subscribe(l)); return () => disposers.forEach((d) => d()); }, set: (field, value) => activeScope().set(field, value), }; const getProfile = () => { const snap = profileScope.getSnapshot(); const v = snap.value || {}; return { name: typeof v.name === 'string' ? v.name : '', avatar: typeof v.avatar === 'string' ? v.avatar : '', writable: snap.writable !== false, mode: snap.mode, }; }; /* ---------------- personal profile: message heads ---------------- */ let profile = getProfile(); const NAV_MARKER = 'data-dcf-settings-nav'; let navDisposed = false; const nameHue = (name) => { let h = 0; for (const ch of name) h = (h * 31 + (ch.codePointAt(0) || 0)) >>> 0; return h % 360; }; const initialOf = (name) => (Array.from(name.trim())[0] || '?').toUpperCase(); const buildHead = () => { const head = document.createElement('div'); head.className = 'dcf-msg-head dcf-h-user'; const av = document.createElement('span'); av.className = 'dcf-avatar'; const nm = document.createElement('span'); nm.className = 'dcf-name'; head.append(av, nm); return head; }; const paintHead = (head, name, avatar) => { const av = head.children[0]; const nm = head.children[1]; if (nm.textContent !== name) nm.textContent = name; nm.style.display = name ? '' : 'none'; if (avatar) { const want = `url("${avatar}")`; if (av.style.backgroundImage !== want) av.style.backgroundImage = want; if (av.textContent) av.textContent = ''; } else { if (av.style.backgroundImage) av.style.backgroundImage = ''; const letter = initialOf(name); if (av.textContent !== letter) av.textContent = letter; } const color = `hsl(${nameHue(name || '?')} 42% 52%)`; if (av.style.getPropertyValue('--dcf-avatar-color') !== color) { av.style.setProperty('--dcf-avatar-color', color); } }; // Idempotent: React owns these rows and re-renders wipe injected nodes, // so the pass runs on a throttle and re-adds whatever disappeared. // Only changed properties are written, so a steady state causes no // further mutations (no observer feedback loop). const decorateHeads = () => { if (!scroller || !scroller.isConnected) return; const hasProfile = Boolean(profile.name || profile.avatar); for (const stack of scroller.querySelectorAll('[class*="hBZZ9a_userStack"]')) { let head = stack.querySelector(':scope > .dcf-msg-head'); if (!hasProfile) { if (head) head.remove(); continue; } if (!head) { head = buildHead(); stack.prepend(head); } paintHead(head, profile.name, profile.avatar); } }; const scheduleHeads = () => { if (headTimer) return; headTimer = setTimeout(() => { headTimer = 0; decorateHeads(); }, 150); }; // Mark this plugin's row in the settings nav so CSS can swap the shell's // fallback gear for a person glyph (same approach as dsh-better-sidebar; // the settings.section contract exposes no icon field). const syncNav = () => { if (navDisposed) return; const label = (navigator.language || '').toLowerCase().startsWith('zh') ? '个人资料' : 'Profile'; for (const button of document.querySelectorAll('[role="dialog"] nav button')) { if (button.textContent?.trim() === label) button.setAttribute(NAV_MARKER, ''); else button.removeAttribute(NAV_MARKER); } }; const scheduleNav = () => { if (navTimer) return; navTimer = setTimeout(() => { navTimer = 0; syncNav(); }, 400); }; const disposeNav = () => { navDisposed = true; document.querySelectorAll(`[${NAV_MARKER}]`).forEach((el) => el.removeAttribute(NAV_MARKER)); }; const unsubProfile = profileScope.subscribe(() => { profile = getProfile(); scheduleHeads(); }); /* ---------------- settings section: 个人资料 ---------------- */ try { const t = { field: { marginBottom: 20 }, label: { fontSize: 13, opacity: 0.6, marginBottom: 6 }, input: { width: '100%', maxWidth: 360, boxSizing: 'border-box', padding: '8px 12px', borderRadius: 8, fontSize: 14, color: 'var(--dsw-alias-label-primary, #eee)', background: 'color-mix(in srgb, var(--dsw-alias-label-tertiary, #888) 8%, transparent)', border: '1px solid color-mix(in srgb, var(--dsw-alias-label-tertiary, #888) 30%, transparent)', outline: 'none', }, btn: { padding: '6px 14px', borderRadius: 8, fontSize: 13, cursor: 'pointer', color: 'var(--dsw-alias-label-primary, #eee)', background: 'color-mix(in srgb, var(--dsw-alias-label-tertiary, #888) 10%, transparent)', border: '1px solid color-mix(in srgb, var(--dsw-alias-label-tertiary, #888) 30%, transparent)', }, row: { display: 'flex', alignItems: 'center', gap: 10 }, hint: { fontSize: 12, lineHeight: '18px', opacity: 0.45, marginTop: 8 }, }; function ProfileSection() { const [, force] = React.useReducer((n) => n + 1, 0); // The input keeps a local draft while typing; the scope round-trips // through the host, so committing on blur/Enter avoids per-keystroke // writes fighting the async snapshot. const [draft, setDraft] = React.useState(null); const [err, setErr] = React.useState(''); React.useEffect(() => profileScope.subscribe(force), []); const cur = getProfile(); const nameValue = draft !== null ? draft : cur.name; // Debounced auto-save 500ms after the last keystroke — the name // persists even when Enter/blur are swallowed by a dialog key trap. React.useEffect(() => { if (draft === null) return; const value = draft; const id = setTimeout(() => { setDraft(null); if (value !== cur.name) { profileScope.set('name', value).catch((err) => console.warn('[dcf] profile write failed:', err)); } }, 500); return () => clearTimeout(id); }, [draft]); const commitName = () => { if (draft === null) return; const value = draft; setDraft(null); if (value !== cur.name) { profileScope.set('name', value).catch((err) => console.warn('[dcf] profile write failed:', err)); } }; const pickAvatar = (ev) => { const file = ev.target.files && ev.target.files[0]; ev.target.value = ''; if (!file) return; const reader = new FileReader(); reader.onerror = () => setErr('图片读取失败'); reader.onload = () => { const img = new Image(); img.onerror = () => setErr('无法解析该图片'); img.onload = () => { setErr(''); // center-crop to 128x128 and re-encode, so a multi-MB photo // becomes a ~20KB dataURL that fits comfortably in settings.yaml const size = 128; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const g = canvas.getContext('2d'); const side = Math.min(img.naturalWidth, img.naturalHeight); g.drawImage(img, (img.naturalWidth - side) / 2, (img.naturalHeight - side) / 2, side, side, 0, 0, size, size); profileScope.set('avatar', canvas.toDataURL('image/png')).catch((err) => console.warn('[dcf] profile write failed:', err)); }; img.src = String(reader.result); }; reader.readAsDataURL(file); }; return React.createElement( 'div', { style: { maxWidth: 420 } }, React.createElement( 'div', { style: t.field }, React.createElement('div', { style: t.label }, '名字'), React.createElement('input', { style: t.input, value: nameValue, placeholder: '留空则不在消息上显示头像和名字', onChange: (e) => setDraft(e.target.value), onBlur: commitName, onKeyDown: (e) => { if (e.key === 'Enter') { commitName(); e.target.blur(); } }, })), React.createElement( 'div', { style: t.field }, React.createElement('div', { style: t.label }, '头像'), React.createElement( 'div', { style: t.row }, React.createElement('span', { style: { flex: 'none', width: 44, height: 44, borderRadius: '50%', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, fontWeight: 600, color: '#fff', backgroundColor: `hsl(${nameHue(cur.name || '?')} 42% 52%)`, backgroundImage: cur.avatar ? `url("${cur.avatar}")` : 'none', backgroundSize: 'cover', backgroundPosition: 'center', userSelect: 'none', }, }, cur.avatar ? '' : initialOf(cur.name)), React.createElement( 'label', { style: { ...t.btn, display: 'inline-flex', alignItems: 'center' } }, '上传图片', React.createElement('input', { type: 'file', accept: 'image/*', style: { display: 'none' }, onChange: pickAvatar, })), cur.avatar && React.createElement('button', { type: 'button', style: t.btn, onClick: () => profileScope.set('avatar', '').catch(() => {}), }, '恢复默认')), err && React.createElement('div', { style: { ...t.hint, opacity: 1, color: '#e06c6c' } }, err), React.createElement( 'div', { style: t.hint }, '图片会自动居中裁剪为 128×128;未上传时显示名字的首字符。', cur.mode === 'memory' || !cur.writable ? ' 远程访问模式:资料保存在此浏览器的本地存储,仅本设备生效。' : ' 配置保存在服务端 settings.yaml,换浏览器也生效。'))); } ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'conversation-flat', order: 25, label: () => ((navigator.language || '').toLowerCase().startsWith('zh') ? '个人资料' : 'Profile'), }, ProfileSection)); } catch (err) { console.warn('[dcf] settings section unavailable:', err); } /* ---------------- conversation binding ---------------- */ // Re-locate the scroll container (session switches rebuild the DOM), // then re-decorate message heads and keep the nav marker current. const getScroller = () => document.querySelector('[class*="cX0H7W_scroll"]'); const bind = () => { const sc = getScroller(); if (!sc) return; scroller = sc; decorateHeads(); }; const mo = new MutationObserver(() => { bind(); scheduleHeads(); scheduleNav(); }); mo.observe(document.body, { childList: true, subtree: true }); bind(); decorateHeads(); syncNav(); ctx.effect(() => () => { mo.disconnect(); clearTimeout(headTimer); clearTimeout(navTimer); unsubProfile(); disposeNav(); document.querySelectorAll('.dcf-msg-head').forEach((n) => n.remove()); }); }; exports.apply = apply; exports.inject = inject; return module.exports; }, });