Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,16 @@
"@inkandswitch/patchwork-elements": "^6.0.2",
"@inkandswitch/patchwork-filesystem": "^0.2.9",
"cute.txt": "^0.0.6",
"dompurify": "^3.4.16",
"marked": "^18.1.0",
"solid-automerge": "^2.0.1",
"solid-js": "^1.9.11"
},
"devDependencies": {
"@chee/patchwork-bundles": "^0.2.0",
"@inkandswitch/patchwork-bootloader": "^0.7.2",
"happy-dom": "^15.11.7",
"jsdom": "^30.1.2",
"typescript": "^5.8.3",
"vite": "^5.4.19",
"vite-plugin-css-injected-by-js": "^3.5.2",
Expand Down
341 changes: 339 additions & 2 deletions chat/pnpm-lock.yaml

Large diffs are not rendered by default.

181 changes: 159 additions & 22 deletions chat/src/components/ChatRoot.tsx

Large diffs are not rendered by default.

67 changes: 64 additions & 3 deletions chat/src/components/MessageBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {highlightCode} from "../lib/highlighter"
import {ensureFontLoaded} from "../lib/blob-cache"
import {resolveNamedColor} from "../lib/named-colors"
import {generateId} from "../lib/helpers"
import {markdownChunks} from "../lib/markdown"
import {useChat} from "../context/ChatContext"
import {useIdentity} from "../context/IdentityContext"
import {usePresence} from "../context/PresenceContext"
Expand Down Expand Up @@ -118,6 +119,58 @@ function MessageNodes(props: {nodes: any[]}) {
return <Index each={props.nodes}>{(node) => <TopNode node={node} />}</Index>
}

const THINK = /<think>([\s\S]*?)(?:<\/think>|$)/g

// The computer's replies render as markdown, with <think> sections pulled out
// first (when the schema allows them) so they keep their collapsible block.
function ComputerText(props: {text: string; allowThink: boolean}) {
const parts = createMemo(() => (props.allowThink ? splitThink(props.text) : [{think: false, text: props.text}]))
return (
<Index each={parts()}>
{(part) => (
<Show when={part().think} fallback={<MarkdownText text={part().text} />}>
<ThinkBlock content={part().text} />
</Show>
)}
</Index>
)
}

function MarkdownText(props: {text: string}) {
const chunks = createMemo(() => markdownChunks(props.text))
return (
<Index each={chunks()}>
{(chunk) => {
const code = () => {
const c = chunk()
return c.kind === "code" ? c : undefined
}
const html = () => {
const c = chunk()
return c.kind === "html" ? c.html : ""
}
return (
<Show when={code()} fallback={<div innerHTML={html()} />}>
{(code) => <CodeBlock lang={code().lang} code={code().code} />}
</Show>
)
}}
</Index>
)
}

function splitThink(text: string): {think: boolean; text: string}[] {
const parts: {think: boolean; text: string}[] = []
let last = 0
for (const m of text.matchAll(THINK)) {
if (m.index > last) parts.push({think: false, text: text.slice(last, m.index)})
parts.push({think: true, text: m[1].trim()})
last = m.index + m[0].length
}
if (last < text.length) parts.push({think: false, text: text.slice(last)})
return parts
}

export function MessageBody(props: {
msg: ChatMessage
schema: CuteSchema
Expand All @@ -138,9 +191,16 @@ export function MessageBody(props: {
})

const segments = createMemo(() =>
props.msg.text ? parseMarkup(props.msg.text, props.schema) : []
props.msg.text && !props.msg.isComputer ? parseMarkup(props.msg.text, props.schema) : []
)

const body = () =>
props.msg.isComputer ? (
<ComputerText text={props.msg.text || ""} allowThink={!!props.schema.blocks.think} />
) : (
<MessageNodes nodes={segments()} />
)

const emojiOnly = createMemo(() =>
props.msg.text ? isEmojiOnly(props.msg.text) : false
)
Expand All @@ -159,6 +219,7 @@ export function MessageBody(props: {
classList={{
"emoji-only": emojiOnly(),
streaming: props.msg.streaming,
markdown: !!props.msg.isComputer,
}}
style={{
...(props.msg.font ? {"font-family": props.msg.font} : {}),
Expand All @@ -167,10 +228,10 @@ export function MessageBody(props: {
on:click={onSpoilerClick}
>
<Show when={props.msg.marquee}>
<marquee><MessageNodes nodes={segments()} /></marquee>
<marquee>{body()}</marquee>
</Show>
<Show when={!props.msg.marquee}>
<MessageNodes nodes={segments()} />
{body()}
</Show>
</div>
</Show>
Expand Down
3 changes: 1 addition & 2 deletions chat/src/context-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,7 @@ async function ensureChitchat(
plugins: defaultPlugins.slice(),
"@patchwork": {type: "chat"},
// Auto-invite the computer (ChatRoot's onMount claims the host when
// hasComputer is set) — but it stays off nosey, so it only replies when
// @mentioned or replied to.
// hasComputer is set).
hasComputer: true,
} as any)
// Resolve through find so a draft forks the new doc into this draft's clones.
Expand Down
22 changes: 22 additions & 0 deletions chat/src/feature-descriptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type {FeaturePlugin} from "./features"

// The built-in feature declarations, as metadata only. This module is part of
// index.ts's (worker) static graph, so keep it free of runtime imports: the host
// reads `plugins` in a module worker, which has no importmap, so a static path to
// `solid-js` there fails the whole package. Slots stay behind `load()`.
export const featureMetadata: Omit<FeaturePlugin, "slots">[] = [
{type: "chat:feature", id: "presence", name: "Presence", tier: "core"},
{type: "chat:feature", id: "typing", name: "Typing indicator", tier: "core"},
{type: "chat:feature", id: "computer", name: "Computer (AI)", tier: "full"},
{type: "chat:feature", id: "selection-mention", name: "Selection mention", tier: "core"},
]

// Serializable registry descriptions (the same pattern as slash/messageaction/
// emojipack descriptions): function-valued `slots` can't be DataClone'd raw.
export const featureDescriptions = featureMetadata.map((meta) => ({
...meta,
async load() {
const {featurePlugins} = await import("./features")
return {slots: featurePlugins.find((p) => p.id === meta.id)?.slots}
},
}))
33 changes: 14 additions & 19 deletions chat/src/features.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {PluginSelector} from "./lib/registry"
import {selectionFeature} from "./features/selection-mention"
import {featureMetadata} from "./feature-descriptions"

// Features are NOT a hardcoded struct on the component. Each big feature is a
// `chat:feature` plugin declaration (host-registrable, like the other four types);
Expand All @@ -26,25 +27,19 @@ export interface FeaturePlugin {
slots?: Record<string, (ctx: any, extra?: any) => any>
}

// The built-in feature declarations (also the registry fallback). Message send,
// contact avatars + names, inline `code`/`*bold*`/`_italic_`/fences, image send and
// patchwork-tool embedding are ALWAYS on (not gated) — they're the chat itself.
// Replies ride the (core-tier) `reply` message-action, so no flag here.
// The built-in feature declarations with their slots (also the registry fallback).
// Message send, contact avatars + names, inline `code`/`*bold*`/`_italic_`/fences,
// image send and patchwork-tool embedding are ALWAYS on (not gated) — they're the
// chat itself. Replies ride the (core-tier) `reply` message-action, so no flag here.
// The base `chat` tool owns only the core features plus the computer. Everything
// else (reactions, sidebar, voice, gifSelfie, emoticons, call, notifications) is
// contributed by the `chitter` bundle via the registry.
export const featurePlugins: FeaturePlugin[] = [
{type: "chat:feature", id: "presence", name: "Presence", tier: "core"},
{type: "chat:feature", id: "typing", name: "Typing indicator", tier: "core"},
{type: "chat:feature", id: "computer", name: "Computer (AI)", tier: "full"},
selectionFeature,
]
// contributed by the `chitter` bundle via the registry. The metadata lives in
// feature-descriptions.ts, which index.ts registers.
const featureSlots: Record<string, FeaturePlugin["slots"]> = {
[selectionFeature.id]: selectionFeature.slots,
}

// Serializable registry descriptions: metadata only, with `slots` deferred behind
// `async load()` (the same pattern as slash/messageaction/emojipack descriptions).
// Raw `...featurePlugins` can only be registered while they're pure data; the moment
// a feature carries `slots`, register the descriptions instead.
export const featureDescriptions = featurePlugins.map((p) => {
const {slots, ...meta} = p
return {...meta, async load() { return {slots} }}
})
export const featurePlugins: FeaturePlugin[] = featureMetadata.map((meta) => ({
...meta,
...(featureSlots[meta.id] && {slots: featureSlots[meta.id]}),
}))
6 changes: 5 additions & 1 deletion chat/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {featureDescriptions} from "./features"
import {featureDescriptions} from "./feature-descriptions"
import {syntaxPluginDescriptions} from "./lib/syntax"
import {slashPluginDescriptions} from "./lib/slash-plugins"
import {messageActionDescriptions} from "./lib/message-actions"
import {emojiPackDescriptions} from "./lib/emoji-packs"
import {skillDescriptions} from "./skills"

export const plugins = [
{
Expand Down Expand Up @@ -73,4 +74,7 @@ export const plugins = [
...slashPluginDescriptions,
...messageActionDescriptions,
...emojiPackDescriptions,
// `llm:skill` instruction packs for the computer. Any bundle can register
// more; these are the ones chat ships with.
...skillDescriptions,
]
Loading