Skip to content

Commit c0010b1

Browse files
committed
feat(cli): add mxs author and switch admin Streamdown to lobehub
Replace mxs preview with a vendored author SPA, and render agent markdown with @lobehub/streamdown.
1 parent 6bbf007 commit c0010b1

38 files changed

Lines changed: 1893 additions & 497 deletions

.claude/skills/mxs-cli-ai-author/references/content-authoring.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ Use this reference when drafting or modifying posts, notes, or pages.
2222
└───────────┬─────────────┘
2323
2424
┌─────────────────────────┐
25+
│ mxs author <file> │
26+
│ wait for the human │
27+
│ then read <file>.diff │
28+
└───────────┬─────────────┘
29+
30+
┌─────────────────────────┐
2531
│ Dry-run create/update │
2632
└───────────┬─────────────┘
2733
@@ -34,6 +40,8 @@ Use this reference when drafting or modifying posts, notes, or pages.
3440
└─────────────────────────┘
3541
```
3642

43+
After writing the envelope, run `mxs author <file>` and show the human the URL. Stop. When they say they are done, read `<file>.diff` for their edits — do not rescan the full article unless the diff is missing.
44+
3745
## Content Sources
3846

3947
| Spec | Meaning |

.claude/skills/release-core/scripts/release-package.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ if [ "$PKG" = "cli" ]; then
4040
echo "--- npm pack --dry-run ---"
4141
echo "$pack_out" | tail -40
4242
echo "$pack_out" | grep -q 'bin/mxs.cjs' || { echo "RED bin/mxs.cjs missing from tarball"; exit 1; }
43+
echo "$pack_out" | grep -q 'dist/vendor/author/index.html' || { echo "RED dist/vendor/author/index.html missing from tarball"; exit 1; }
4344
fi
4445

4546
git add "$DIR/package.json"

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ scripts/workflow/data
5555
bin/process-reporter
5656

5757
dist
58+
dist-author
5859
dev/
5960

6061
.eslintcache

apps/admin/author.html

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<!doctype html>
2+
<html lang="zh-CN">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta
6+
name="viewport"
7+
content="width=device-width, initial-scale=1, viewport-fit=cover"
8+
/>
9+
<title>mxs author</title>
10+
<script>
11+
window.injectData = window.injectData || {}
12+
window.version = 'N/A'
13+
;(function () {
14+
var dark = window.matchMedia('(prefers-color-scheme: dark)').matches
15+
if (dark) document.documentElement.classList.add('dark')
16+
})()
17+
</script>
18+
</head>
19+
<body>
20+
<div id="root"></div>
21+
<script type="module" src="/src/author/main.tsx"></script>
22+
</body>
23+
</html>

apps/admin/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"scripts": {
66
"dev": "NODE_ENV=development vite --mode development --open --host",
77
"build": "NODE_ENV=production vite build --mode production",
8+
"build:author": "NODE_ENV=production vite build --config vite.author.config.mts --mode production",
89
"preview": "vite preview --port 2323",
910
"lint": "eslint src",
1011
"lint:fix": "eslint src --fix",
@@ -61,6 +62,7 @@
6162
"@lexical/react": "^0.49.0",
6263
"@lexical/rich-text": "^0.49.0",
6364
"@lezer/highlight": "1.2.3",
65+
"@lobehub/streamdown": "^1.3.1",
6466
"@monaco-editor/react": "4.7.0",
6567
"@mx-space/ai": "workspace:*",
6668
"@mx-space/editor": "workspace:*",
@@ -102,9 +104,9 @@
102104
"react-resizable-panels": "4.12.4",
103105
"react-router": "8.3.1",
104106
"recharts": "3.10.1",
107+
"remark-gfm": "^4.0.1",
105108
"shiki": "^4.4.3",
106109
"sonner": "2.0.8",
107-
"streamdown": "^2.6.0",
108110
"tailwind-merge": "^3.6.0",
109111
"thumbhash": "0.1.1",
110112
"tinykeys": "4.0.0",
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import type { SerializedEditorState } from 'lexical'
2+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3+
4+
import { RichEditor } from '../vendor/rich-editor/core/RichEditor'
5+
6+
type Variant = 'article' | 'note'
7+
8+
interface DocumentResponse {
9+
lexical: SerializedEditorState
10+
variant: Variant
11+
fileName: string
12+
}
13+
14+
type Theme = 'light' | 'dark'
15+
16+
const isMac =
17+
typeof navigator !== 'undefined' &&
18+
/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)
19+
20+
const saveLabel = isMac ? '⌘S' : 'Ctrl+S'
21+
22+
const useTheme = (): Theme => {
23+
const [theme, setTheme] = useState<Theme>(() =>
24+
window.matchMedia('(prefers-color-scheme: dark)').matches
25+
? 'dark'
26+
: 'light',
27+
)
28+
useEffect(() => {
29+
const mq = window.matchMedia('(prefers-color-scheme: dark)')
30+
const onChange = () => {
31+
const next = mq.matches ? 'dark' : 'light'
32+
setTheme(next)
33+
document.documentElement.classList.toggle('dark', next === 'dark')
34+
}
35+
mq.addEventListener('change', onChange)
36+
return () => mq.removeEventListener('change', onChange)
37+
}, [])
38+
return theme
39+
}
40+
41+
export function AuthorApp() {
42+
const theme = useTheme()
43+
const [doc, setDoc] = useState<DocumentResponse | null>(null)
44+
const [loadError, setLoadError] = useState<string | null>(null)
45+
const [state, setState] = useState<SerializedEditorState | null>(null)
46+
const [saved, setSaved] = useState('')
47+
const [saveError, setSaveError] = useState<string | null>(null)
48+
const [saving, setSaving] = useState(false)
49+
const hydrated = useRef(false)
50+
51+
useEffect(() => {
52+
let cancelled = false
53+
void fetch('/api/document')
54+
.then(async (res) => {
55+
const json = (await res.json()) as
56+
DocumentResponse | { error?: { message?: string } }
57+
if (!res.ok) {
58+
throw new Error(
59+
'error' in json && json.error?.message
60+
? json.error.message
61+
: `load failed (${res.status})`,
62+
)
63+
}
64+
return json as DocumentResponse
65+
})
66+
.then((next) => {
67+
if (cancelled) return
68+
setDoc(next)
69+
setState(next.lexical)
70+
setSaved(JSON.stringify(next.lexical))
71+
hydrated.current = false
72+
})
73+
.catch((err: unknown) => {
74+
if (cancelled) return
75+
setLoadError(err instanceof Error ? err.message : String(err))
76+
})
77+
return () => {
78+
cancelled = true
79+
}
80+
}, [])
81+
82+
const dirty = useMemo(
83+
() => state !== null && JSON.stringify(state) !== saved,
84+
[state, saved],
85+
)
86+
87+
const save = useCallback(async () => {
88+
if (!state) return
89+
setSaving(true)
90+
setSaveError(null)
91+
try {
92+
const res = await fetch('/api/document', {
93+
method: 'PUT',
94+
headers: { 'content-type': 'application/json' },
95+
body: JSON.stringify({ lexical: state }),
96+
})
97+
const json = (await res.json()) as { error?: { message?: string } }
98+
if (!res.ok) {
99+
throw new Error(json.error?.message ?? `save failed (${res.status})`)
100+
}
101+
setSaved(JSON.stringify(state))
102+
} catch (err) {
103+
setSaveError(err instanceof Error ? err.message : String(err))
104+
} finally {
105+
setSaving(false)
106+
}
107+
}, [state])
108+
109+
useEffect(() => {
110+
const onKey = (event: KeyboardEvent) => {
111+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
112+
event.preventDefault()
113+
void save()
114+
}
115+
}
116+
window.addEventListener('keydown', onKey)
117+
return () => window.removeEventListener('keydown', onKey)
118+
}, [save])
119+
120+
useEffect(() => {
121+
if (!dirty) return
122+
const onUnload = (event: BeforeUnloadEvent) => {
123+
event.preventDefault()
124+
event.returnValue = ''
125+
}
126+
window.addEventListener('beforeunload', onUnload)
127+
return () => window.removeEventListener('beforeunload', onUnload)
128+
}, [dirty])
129+
130+
if (loadError) {
131+
return (
132+
<div className="flex min-h-dvh items-center justify-center bg-surface-page text-sm text-red-700 dark:text-red-400">
133+
{loadError}
134+
</div>
135+
)
136+
}
137+
138+
if (!doc || !state) {
139+
return (
140+
<div className="flex min-h-dvh items-center justify-center bg-surface-page text-sm text-fg-muted">
141+
加载中…
142+
</div>
143+
)
144+
}
145+
146+
const canSave = (dirty || Boolean(saveError)) && !saving
147+
148+
return (
149+
<div className="flex min-h-dvh flex-col bg-surface-page text-fg">
150+
<header className="flex h-11 shrink-0 items-center gap-2.5 border-b border-border bg-surface-card px-3">
151+
{dirty ? (
152+
<span className="size-1.5 shrink-0 rounded-full bg-accent" />
153+
) : null}
154+
<span className="truncate text-sm text-fg-muted">{doc.fileName}</span>
155+
{saveError ? (
156+
<span className="min-w-0 flex-1 truncate text-sm text-red-700 dark:text-red-400">
157+
{saveError}
158+
</span>
159+
) : (
160+
<span className="flex-1" />
161+
)}
162+
<button
163+
type="button"
164+
disabled={!canSave}
165+
onClick={() => void save()}
166+
className="rounded-sm bg-accent px-2.5 py-1 text-sm font-medium text-white disabled:bg-surface-inset disabled:text-fg-subtle"
167+
>
168+
{saveError ? '重试' : '保存'}
169+
<span className="ml-1 text-[10px] font-normal opacity-70">
170+
{saveLabel}
171+
</span>
172+
</button>
173+
</header>
174+
<div className="min-h-0 flex-1 overflow-auto">
175+
<RichEditor
176+
theme={theme}
177+
variant={doc.variant}
178+
initialValue={doc.lexical}
179+
onChange={(value) => {
180+
setState(value)
181+
if (!hydrated.current) {
182+
hydrated.current = true
183+
setSaved(JSON.stringify(value))
184+
}
185+
}}
186+
/>
187+
</div>
188+
</div>
189+
)
190+
}

apps/admin/src/author/main.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import '../index.css'
2+
import '../vendor/rich-editor/core/style'
3+
4+
import { StrictMode } from 'react'
5+
import { createRoot } from 'react-dom/client'
6+
7+
import { AuthorApp } from './AuthorApp'
8+
9+
Object.assign(window, {
10+
global: window,
11+
process: { env: {} },
12+
module: { exports: {} },
13+
})
14+
15+
const root = document.getElementById('root')
16+
if (!root) throw new Error('missing #root')
17+
18+
createRoot(root).render(
19+
<StrictMode>
20+
<AuthorApp />
21+
</StrictMode>,
22+
)

apps/admin/src/features/write/components/agent/messages.tsx

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import type {
33
ReviewBatch,
44
ToolCallGroupItem,
55
} from '@haklex/rich-agent-core'
6+
import { Streamdown } from '@lobehub/streamdown'
67
import { Check, X } from 'lucide-react'
7-
import { Streamdown } from 'streamdown'
8+
import remarkGfm from 'remark-gfm'
89

910
import { useI18n } from '~/i18n'
1011
import { Button } from '~/ui/primitives/button'
@@ -13,6 +14,8 @@ import { ThinkingBlock } from './ThinkingBlock'
1314
import { ToolCallGroupView } from './ToolCallView'
1415
import type { UserChatBubble } from './types'
1516

17+
const remarkPlugins = [remarkGfm]
18+
1619
interface AgentMessageItemProps {
1720
actionsLocked: boolean
1821
bubble: ChatBubble
@@ -54,16 +57,10 @@ export function AgentMessageItem(props: AgentMessageItemProps) {
5457
return (
5558
<div className="prose prose-sm max-w-none text-sm leading-relaxed text-fg dark:prose-invert">
5659
<Streamdown
57-
animated={{
58-
animation: 'fadeIn',
59-
duration: 220,
60-
easing: 'ease-out',
61-
sep: 'char',
62-
}}
63-
isAnimating={Boolean(bubble.streaming)}
64-
>
65-
{bubble.content}
66-
</Streamdown>
60+
content={bubble.content}
61+
granularity="char"
62+
remarkPlugins={remarkPlugins}
63+
/>
6764
</div>
6865
)
6966
}

apps/admin/src/index.css

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33
@import './styles/tokens.css';
44
@import 'sonner/dist/styles.css';
55

6-
@source "../node_modules/streamdown/dist/index.js";
7-
86
@custom-variant dark (&:where(.dark, .dark *));
97

108
/* Responsive variants — phone / tablet (max-width) and desktop (min-width). */

apps/admin/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
"src/**/*.ts",
2828
"src/**/*.tsx",
2929
"src/**/*.d.ts",
30-
"vite.config.mts"
30+
"vite.config.mts",
31+
"vite.author.config.mts"
3132
],
3233
"exclude": ["node_modules", "assets", "dist/**"]
3334
}

0 commit comments

Comments
 (0)