Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a51f4bc
feat(studio): composer Send⇄Stop swap + empty-state example prompts
iipanda May 15, 2026
419f693
feat(studio): user quote-style + assistant sparkle gutter (chat rhythm)
iipanda May 15, 2026
2a22ad8
feat(studio): two-line proposal cards + blue/amber chip family + lime…
iipanda May 15, 2026
14f8079
chore: ignore .claude/ + .worktrees/ in Nx + Docker workspace
iipanda May 15, 2026
5f11a03
fix(studio,ai): dark-mode Accept contrast + surface real apply errors
iipanda May 15, 2026
7174c06
feat(studio): sticky-at-bottom auto-scroll + sanitize chat error display
iipanda May 15, 2026
d5be75a
feat(ai): orchestrator runChatStream — async iterable of SSE events
iipanda May 15, 2026
317424f
feat(studio,ai): end-to-end chat streaming over SSE with typing indic…
iipanda May 15, 2026
e14b355
fix(db): authorship columns are text, not uuid
iipanda May 15, 2026
1c00765
revert: keep authorship columns as uuid + align AI apply with UI path
iipanda May 15, 2026
ab03821
chore(db): drop cancelling 0012/0013 migrations from the PR
iipanda May 15, 2026
ac2d40a
chore(db): restore trailing newline on drizzle _journal.json
iipanda May 15, 2026
4bcc401
docs(studio): correct KindGlyph render comment + note structural is r…
iipanda May 15, 2026
8cbd566
fix(ai): catch non-UUID reference values at proposal time + nudge model
iipanda May 15, 2026
babe16c
feat(studio): post-accept log line + invisible acceptance signal to a…
iipanda May 15, 2026
ffe9dd7
feat(studio): render assistant turns through streamdown markdown
iipanda May 15, 2026
3817c12
fix(server,ai): keep SSE chat streams alive past Bun's 10s idle timeout
iipanda May 15, 2026
d328756
fix(ai): bump chat agent step limit from 5 to 20 for multi-proposal t…
iipanda May 15, 2026
c1409c5
chore(ai): log per-step chat diagnostics (finishReason, tool calls, u…
iipanda May 15, 2026
43c8000
chore(ai): format orchestrator after diagnostic helper
iipanda May 15, 2026
1d0ba3c
feat(studio): 6s applied banner + hide batch action bar when exhausted
iipanda May 15, 2026
f9c445d
fix(ci): bundle-size budget + changeset for streaming chat surface
iipanda May 15, 2026
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
9 changes: 9 additions & 0 deletions .changeset/cms-streaming-chat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@mdcms/studio": minor
---

Add streaming + markdown rendering to the chat surface client APIs.

- `StudioAiRouteApi.chatMessageStream(input)` opens an SSE connection against `POST /api/v1/ai/chat/messages/stream` and returns an `AsyncIterable<StudioAiChatStreamEvent>` that yields `text-delta`, `done`, and `error` events as the model produces them.
- New `StudioAiChatStreamEvent` discriminated-union export covers the wire-shape of each SSE event the client can observe.
- New runtime dependency on `streamdown` (v2.5.0). The chat panel now uses it under the hood to render assistant prose as markdown — headings, fenced code (shiki highlighting), GFM tables, task lists, links, etc. Streaming-aware: `parseIncompleteMarkdown` keeps mid-stream chunks from leaking unclosed `**`/```` fences while a token batch is in flight.
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
.nx
.vscode
.DS_Store
.claude
.worktrees
node_modules
**/node_modules
dist
Expand Down
6 changes: 6 additions & 0 deletions .nxignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Local git worktrees and CLI scratch dirs live under .claude/ and don't
# represent real workspace projects. Without this Nx walks into the
# worktrees, discovers their package.json files as additional projects,
# and demands matching `references` entries in the root tsconfig.json.
.claude/
.worktrees/
6 changes: 6 additions & 0 deletions apps/server/src/bin/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ type BunRuntime = {
serve: (options: {
port: number;
fetch: (request: Request) => Response | Promise<Response>;
/** Per-connection idle timeout in seconds. Bun defaults to 10s, which
* is far too short for SSE chat streams that sit awaiting the next
* token from the LLM — we set it to Bun's maximum (255s) so any
* realistic generation completes before the socket gets closed. */
idleTimeout?: number;
}) => BunServer;
};

Expand All @@ -22,6 +27,7 @@ const { handler } = await prepareServerRequestHandlerWithModules({
const server = Bun.serve({
port: env.PORT,
fetch: handler,
idleTimeout: 255,
});

let isShuttingDown = false;
Expand Down
18 changes: 16 additions & 2 deletions apps/server/src/lib/runtime-with-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,12 @@ export function createServerRequestHandlerWithModules(
},
requireCsrf: (request) => authService.requireCsrfProtection(request),
emitAudit: (record) => {
logger.info("ai.audit", {
const isFailure =
record.outcome === "apply_failed" ||
record.outcome === "validation_failed" ||
record.outcome === "invalid_output" ||
record.outcome === "provider_error";
const payload = {
outcome: record.outcome,
taskKind: record.taskKind,
provider: record.providerId,
Expand All @@ -427,7 +432,16 @@ export function createServerRequestHandlerWithModules(
environment: record.environment,
documentId: record.documentId,
errorCode: record.errorCode,
});
...(record.errorMessage ? { errorMessage: record.errorMessage } : {}),
};
// Lift failure audits to `error` level so they surface alongside
// request_failed logs — otherwise an apply that 500s leaves no
// breadcrumb at the default log level.
if (isFailure) {
logger.error("ai.audit", payload);
} else {
logger.info("ai.audit", payload);
}
},
contentTypesLookup,
supportedLocalesLookup,
Expand Down
453 changes: 447 additions & 6 deletions bun.lock

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion packages/modules/core.ai/src/server/apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,11 @@ describe("applyAiProposal", () => {
const call = state.updateCalls[0]!;
assert.equal(call.documentId, "doc_1");
assert.equal(call.payload.body, "Hi there!");
assert.equal(call.payload.updatedBy, "user_99");
// `updatedBy` is intentionally NOT set on AI applies — the store
// falls through to its DEFAULT_ACTOR placeholder, matching the
// manual content endpoints. Real actor identity is recorded in
// the AI audit log instead.
assert.equal(call.payload.updatedBy, undefined);
assert.equal(call.options.expectedDraftRevision, 4);
assert.equal(call.options.expectedSchemaHash, "hash_1");
assert.equal(result.body, "Hi there!");
Expand Down
26 changes: 22 additions & 4 deletions packages/modules/core.ai/src/server/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ export type AiApplyContentStore = {
export type AiApplyInput = {
proposal: AiProposal;
expectedSchemaHash: string;
/**
* Authenticated caller for audit purposes. Document writes pass
* through the content store WITHOUT this id — `createdBy`/`updatedBy`
* fall through to the store's `DEFAULT_ACTOR` placeholder, matching
* the behaviour of the manual content endpoints. The real actor is
* captured in the AI audit record instead. See
* `core.ai/server/apply.ts` create/update branches for context.
*/
actorId: string;
store: AiApplyContentStore;
};
Expand Down Expand Up @@ -181,7 +189,12 @@ function mergeFrontmatter(
export async function applyAiProposal(
input: AiApplyInput,
): Promise<AiApplyContentDocument> {
const { proposal, expectedSchemaHash, actorId, store } = input;
const { proposal, expectedSchemaHash, store } = input;
// `actorId` is intentionally not destructured into a local — the
// store calls below fall through to DEFAULT_ACTOR for createdBy/
// updatedBy. The id is still part of the input shape because the
// route handler uses it for audit-record emission.
void input.actorId;
const scope: AiApplyContentScope = {
project: proposal.project,
environment: proposal.environment,
Expand All @@ -205,8 +218,12 @@ export async function applyAiProposal(
format: operation.format,
frontmatter: operation.frontmatter,
body: operation.body,
createdBy: actorId,
updatedBy: actorId,
// Intentionally omit `createdBy`/`updatedBy` so the store
// falls through to the same DEFAULT_ACTOR placeholder the
// manual content endpoints use. The real actor identity is
// captured in the AI audit record — the underlying document
// attribution gap is shared with the manual UI flow and is
// tracked separately as a follow-up.
},
{ expectedSchemaHash },
);
Expand Down Expand Up @@ -306,7 +323,8 @@ export async function applyAiProposal(
{
body: nextBody,
frontmatter: nextFrontmatter,
updatedBy: actorId,
// Omit `updatedBy` so the store falls through to DEFAULT_ACTOR
// — same path the manual `PUT /api/v1/content/:id` route takes.
},
{
expectedSchemaHash,
Expand Down
Loading
Loading