mirror of
https://github.com/multipleof4/dsh-user-markdown-plugin.git
synced 2026-09-18 20:05:44 +00:00
Initial commit: add user markdown rendering plugin
This commit is contained in:
15
README.md
Normal file
15
README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# dsh-user-markdown-plugin
|
||||
|
||||
A Cordis plugin for DeepSeek Harness (DSH) Web GUI that extends user message rendering to support full Markdown formatting (headings, bold, italic, inline code, code blocks, lists, blockquotes, tables, links, and reference chips).
|
||||
|
||||
## Features
|
||||
|
||||
- **Markdown Rendering for User Messages**: Renders code blocks, inline code, lists, headings, tables, blockquotes, bold/italic text, links, and `@file`/`@session` reference chips in user message bubbles.
|
||||
- **Keyed Slot Registration**: Injects into the `conversation.chat.node` slot for keys `user` and `steering`.
|
||||
- **Copy Action & Image Support**: Preserves image attachments and adds a hover copy action button.
|
||||
|
||||
## Usage
|
||||
|
||||
### As a Dynamic Cordis Plugin
|
||||
|
||||
In a running DSH session, define and activate the plugin using `cordis_define` and `cordis_run`.
|
||||
346
lib/client.js
Normal file
346
lib/client.js
Normal file
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Client half of dsh-user-markdown-plugin.
|
||||
* Registers a user message Chat Node renderer for 'user' and 'steering' keys
|
||||
* that parses and renders Markdown formatted text.
|
||||
*/
|
||||
export function apply(ctx) {
|
||||
const slots = ctx.get('slots');
|
||||
if (!slots) return;
|
||||
|
||||
function contentParts(content) {
|
||||
if (!Array.isArray(content)) {
|
||||
if (typeof content === 'string') return { text: content, images: [], rest: [] };
|
||||
return { text: '', images: [], rest: [] };
|
||||
}
|
||||
const texts = [];
|
||||
const images = [];
|
||||
const rest = [];
|
||||
for (const block of content) {
|
||||
if (block && block.type === 'text' && typeof block.text === 'string') {
|
||||
texts.push(block.text);
|
||||
} else if (block && block.type === 'image' && block.attachment !== undefined) {
|
||||
images.push({ attachment: block.attachment });
|
||||
} else if (block) {
|
||||
rest.push(block);
|
||||
}
|
||||
}
|
||||
return { text: texts.join(''), images, rest };
|
||||
}
|
||||
|
||||
function renderInline(text, referenceLabels) {
|
||||
if (!text) return null;
|
||||
const refLabels = Array.isArray(referenceLabels) ? referenceLabels : [];
|
||||
const tokens = [];
|
||||
const regex = /(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*\*([^*]+)\*\*\*|___([^_]+)___)|(\*\*([^*]+)\*\*|__([^_]+)__)|(\*([^*]+)\*|_([^_]+)_)|(~~([^~]+)~~)|((?:^|\s)(?:\/[\w-]+|@"[^"\n]+"|@[^\s]+))/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
tokens.push({ type: 'text', content: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
if (match[1]) {
|
||||
tokens.push({ type: 'code', content: match[1].slice(1, -1) });
|
||||
} else if (match[2]) {
|
||||
tokens.push({ type: 'link', label: match[3], url: match[4] });
|
||||
} else if (match[5]) {
|
||||
tokens.push({ type: 'bold_italic', content: match[6] || match[7] });
|
||||
} else if (match[8]) {
|
||||
tokens.push({ type: 'bold', content: match[9] || match[10] });
|
||||
} else if (match[11]) {
|
||||
tokens.push({ type: 'italic', content: match[12] || match[13] });
|
||||
} else if (match[14]) {
|
||||
tokens.push({ type: 'strike', content: match[15] });
|
||||
} else if (match[16]) {
|
||||
const raw = match[16];
|
||||
const leadingSpace = raw.match(/^\s*/)[0];
|
||||
if (leadingSpace) {
|
||||
tokens.push({ type: 'text', content: leadingSpace });
|
||||
}
|
||||
tokens.push({ type: 'ref', content: raw.trim() });
|
||||
}
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
tokens.push({ type: 'text', content: text.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return tokens.map((token, idx) => {
|
||||
switch (token.type) {
|
||||
case 'code':
|
||||
return React.createElement('code', { key: idx, className: 'dsh-user-inline-code' }, token.content);
|
||||
case 'link':
|
||||
return React.createElement('a', {
|
||||
key: idx,
|
||||
href: token.url,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
className: 'dsh-user-link'
|
||||
}, token.label);
|
||||
case 'bold_italic':
|
||||
return React.createElement('strong', { key: idx }, React.createElement('em', null, token.content));
|
||||
case 'bold':
|
||||
return React.createElement('strong', { key: idx }, token.content);
|
||||
case 'italic':
|
||||
return React.createElement('em', { key: idx }, token.content);
|
||||
case 'strike':
|
||||
return React.createElement('del', { key: idx }, token.content);
|
||||
case 'ref': {
|
||||
const label = token.content;
|
||||
const isSession = refLabels.includes(label.slice(1));
|
||||
const refKind = isSession ? 'session' : label.startsWith('@') ? (label.endsWith('/') ? 'folder' : 'file') : 'command';
|
||||
const displayLabel = refKind === 'session' ? label.slice(1) : label.startsWith('@') ? label.slice(1).replace(/^"|"$/g, '').split(/[\\/]/).filter(Boolean).pop() || label.slice(1) : label;
|
||||
return React.createElement('span', {
|
||||
key: idx,
|
||||
className: 'dsh-user-ref-chip',
|
||||
'data-ref-chip': refKind,
|
||||
title: label
|
||||
}, displayLabel);
|
||||
}
|
||||
case 'text':
|
||||
default:
|
||||
return token.content;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseMarkdownBlocks(text) {
|
||||
const blocks = [];
|
||||
const lines = text.split('\n');
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.trim().startsWith('```')) {
|
||||
const lang = line.trim().slice(3).trim();
|
||||
const codeLines = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].trim().startsWith('```')) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (i < lines.length) i++;
|
||||
blocks.push({ type: 'code', lang, code: codeLines.join('\n') });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim() === '') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (headingMatch) {
|
||||
blocks.push({ type: 'heading', level: headingMatch[1].length, text: headingMatch[2] });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) {
|
||||
blocks.push({ type: 'hr' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*>\s?(.*)$/.test(line)) {
|
||||
const quoteLines = [];
|
||||
while (i < lines.length && /^\s*>\s?(.*)$/.test(lines[i])) {
|
||||
quoteLines.push(lines[i].replace(/^\s*>\s?/, ''));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'blockquote', text: quoteLines.join('\n') });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*[-*+]\s+(.*)$/.test(line) || /^\s*\d+\.\s+(.*)$/.test(line)) {
|
||||
const isOrdered = /^\s*\d+\.\s+(.*)$/.test(line);
|
||||
const items = [];
|
||||
const itemRegex = isOrdered ? /^\s*\d+\.\s+(.*)$/ : /^\s*[-*+]\s+(.*)$/;
|
||||
while (i < lines.length && itemRegex.test(lines[i])) {
|
||||
const m = lines[i].match(itemRegex);
|
||||
if (m) items.push(m[1]);
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: isOrdered ? 'ol' : 'ul', items });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim().startsWith('|') && line.trim().endsWith('|') && i + 1 < lines.length && /^\s*\|?\s*:?-+:?\s*\|/.test(lines[i + 1])) {
|
||||
const parseRow = (l) => l.trim().slice(1, -1).split('|').map(c => c.trim());
|
||||
const headers = parseRow(lines[i]);
|
||||
i += 2;
|
||||
const rows = [];
|
||||
while (i < lines.length && lines[i].trim().startsWith('|')) {
|
||||
rows.push(parseRow(lines[i]));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'table', headers, rows });
|
||||
continue;
|
||||
}
|
||||
|
||||
const paraLines = [];
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim() !== '' &&
|
||||
!lines[i].trim().startsWith('```') &&
|
||||
!lines[i].match(/^#{1,6}\s+/) &&
|
||||
!/^\s*(---|\*\*\*|___)\s*$/.test(lines[i]) &&
|
||||
!/^\s*>\s?/.test(lines[i]) &&
|
||||
!/^\s*[-*+]\s+/.test(lines[i]) &&
|
||||
!/^\s*\d+\.\s+/.test(lines[i])
|
||||
) {
|
||||
paraLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (paraLines.length > 0) {
|
||||
blocks.push({ type: 'p', text: paraLines.join('\n') });
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function UserMarkdownRenderer({ text, referenceLabels }) {
|
||||
const blocks = parseMarkdownBlocks(text);
|
||||
return React.createElement('div', { className: 'dsh-user-markdown-body' },
|
||||
blocks.map((block, index) => {
|
||||
switch (block.type) {
|
||||
case 'code':
|
||||
return React.createElement('pre', { key: index, className: 'dsh-user-code-block' },
|
||||
block.lang ? React.createElement('div', { className: 'dsh-user-code-lang' }, block.lang) : null,
|
||||
React.createElement('code', null, block.code)
|
||||
);
|
||||
case 'heading': {
|
||||
const tag = 'h' + block.level;
|
||||
return React.createElement(tag, { key: index, className: 'dsh-user-h' + block.level },
|
||||
renderInline(block.text, referenceLabels)
|
||||
);
|
||||
}
|
||||
case 'hr':
|
||||
return React.createElement('hr', { key: index, className: 'dsh-user-hr' });
|
||||
case 'blockquote':
|
||||
return React.createElement('blockquote', { key: index, className: 'dsh-user-blockquote' },
|
||||
renderInline(block.text, referenceLabels)
|
||||
);
|
||||
case 'ul':
|
||||
return React.createElement('ul', { key: index, className: 'dsh-user-ul' },
|
||||
block.items.map((item, itemIdx) =>
|
||||
React.createElement('li', { key: itemIdx }, renderInline(item, referenceLabels))
|
||||
)
|
||||
);
|
||||
case 'ol':
|
||||
return React.createElement('ol', { key: index, className: 'dsh-user-ol' },
|
||||
block.items.map((item, itemIdx) =>
|
||||
React.createElement('li', { key: itemIdx }, renderInline(item, referenceLabels))
|
||||
)
|
||||
);
|
||||
case 'table':
|
||||
return React.createElement('table', { key: index, className: 'dsh-user-table' },
|
||||
React.createElement('thead', null,
|
||||
React.createElement('tr', null,
|
||||
block.headers.map((h, hIdx) =>
|
||||
React.createElement('th', { key: hIdx }, renderInline(h, referenceLabels))
|
||||
)
|
||||
)
|
||||
),
|
||||
React.createElement('tbody', null,
|
||||
block.rows.map((r, rIdx) =>
|
||||
React.createElement('tr', { key: rIdx },
|
||||
r.map((cell, cIdx) =>
|
||||
React.createElement('td', { key: cIdx }, renderInline(cell, referenceLabels))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
case 'p':
|
||||
default:
|
||||
return React.createElement('p', { key: index, className: 'dsh-user-p' },
|
||||
renderInline(block.text, referenceLabels)
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function UserMarkdownMessageNodeView(props) {
|
||||
const node = props ? props.node : null;
|
||||
const data = node ? node.data : {};
|
||||
const content = data ? data.content : [];
|
||||
const referenceLabels = data ? data.referenceLabels : [];
|
||||
const parts = contentParts(content);
|
||||
const text = parts.text;
|
||||
const images = parts.images;
|
||||
const rest = parts.rest;
|
||||
const renderMessageImages = props ? props.renderMessageImages : null;
|
||||
|
||||
const showBubble = text !== '' || (rest && rest.length > 0);
|
||||
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const handleCopy = React.useCallback(function() {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
setCopied(true);
|
||||
setTimeout(function() { setCopied(false); }, 2000);
|
||||
});
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
return React.createElement('div', {
|
||||
className: 'dsh-user-msg-row',
|
||||
'data-time-hover-root': true
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'stack',
|
||||
className: 'dsh-user-msg-stack'
|
||||
}, [
|
||||
renderMessageImages ? renderMessageImages({ images: images, align: 'end' }) : null,
|
||||
showBubble ? React.createElement('div', {
|
||||
key: 'bubble',
|
||||
className: 'dsh-user-msg-bubble'
|
||||
}, [
|
||||
React.createElement(UserMarkdownRenderer, {
|
||||
key: 'md',
|
||||
text: text,
|
||||
referenceLabels: referenceLabels
|
||||
}),
|
||||
rest && rest.length > 0 ? rest.map(function(block, i) {
|
||||
return React.createElement('pre', { key: 'rest-' + i, className: 'dsh-user-rest-block' },
|
||||
JSON.stringify(block, null, 2)
|
||||
);
|
||||
}) : null
|
||||
]) : null,
|
||||
referenceLabels && referenceLabels.length > 0 ? React.createElement('div', {
|
||||
key: 'ref-summary',
|
||||
className: 'dsh-user-ref-summary'
|
||||
}, referenceLabels.join(', ')) : null
|
||||
]),
|
||||
React.createElement('div', {
|
||||
key: 'actions',
|
||||
className: 'dsh-user-msg-actions'
|
||||
}, [
|
||||
React.createElement('button', {
|
||||
key: 'copy',
|
||||
type: 'button',
|
||||
onClick: handleCopy,
|
||||
className: 'dsh-user-copy-btn',
|
||||
title: copied ? 'Copied!' : 'Copy message'
|
||||
}, copied ? '✓' : '📋')
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
slots.inject('conversation.chat.node', () => {
|
||||
slots.register({
|
||||
name: 'conversation.chat.node',
|
||||
key: 'user'
|
||||
}, UserMarkdownMessageNodeView);
|
||||
|
||||
slots.register({
|
||||
name: 'conversation.chat.node',
|
||||
key: 'steering'
|
||||
}, UserMarkdownMessageNodeView);
|
||||
});
|
||||
}
|
||||
7
lib/index.js
Normal file
7
lib/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Host half of dsh-user-markdown-plugin.
|
||||
* User message markdown rendering is entirely browser-side.
|
||||
*/
|
||||
export function apply(ctx) {
|
||||
// No host side effects required.
|
||||
}
|
||||
17
package.json
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "dsh-user-markdown-plugin",
|
||||
"version": "0.1.0",
|
||||
"description": "Cordis plugin for DeepSeek Harness that applies Markdown rendering to user messages in the Web GUI",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
".": "./lib/index.js",
|
||||
"./client": "./lib/client.js"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
Reference in New Issue
Block a user