mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(daemon): introduce session mode for conversations
- Added a new `session_mode` column to the `conversations` table with a default value of 'design'. - Implemented logic to handle `session_mode` in conversation creation, updates, and retrieval. - Enhanced the API to support `session_mode` in conversation requests, allowing for 'chat' or 'design' modes. - Updated the web application to include a session mode toggle, enabling users to switch between chat and design modes seamlessly. - Adjusted system prompts to reflect the current session mode, providing context-aware responses. This feature enhances the user experience by allowing for more flexible conversation management, catering to different interaction styles.
This commit is contained in:
parent
8cc11e38cc
commit
79c039efdf
21 changed files with 408 additions and 14 deletions
|
|
@ -15,6 +15,7 @@ import { migratePlugins } from './plugins/persistence.js';
|
||||||
type SqliteDb = Database.Database;
|
type SqliteDb = Database.Database;
|
||||||
type DbRow = Record<string, any>;
|
type DbRow = Record<string, any>;
|
||||||
type JsonObject = Record<string, unknown>;
|
type JsonObject = Record<string, unknown>;
|
||||||
|
type ChatSessionMode = 'design' | 'chat';
|
||||||
|
|
||||||
let dbInstance: SqliteDb | null = null;
|
let dbInstance: SqliteDb | null = null;
|
||||||
let dbFile: string | null = null;
|
let dbFile: string | null = null;
|
||||||
|
|
@ -75,6 +76,7 @@ function migrate(db: SqliteDb): void {
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
project_id TEXT NOT NULL,
|
project_id TEXT NOT NULL,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
|
session_mode TEXT NOT NULL DEFAULT 'design',
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL,
|
updated_at INTEGER NOT NULL,
|
||||||
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
|
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||||
|
|
@ -222,6 +224,10 @@ function migrate(db: SqliteDb): void {
|
||||||
if (!cols.some((c: DbRow) => c.name === 'custom_instructions')) {
|
if (!cols.some((c: DbRow) => c.name === 'custom_instructions')) {
|
||||||
db.exec(`ALTER TABLE projects ADD COLUMN custom_instructions TEXT`);
|
db.exec(`ALTER TABLE projects ADD COLUMN custom_instructions TEXT`);
|
||||||
}
|
}
|
||||||
|
const conversationCols = db.prepare(`PRAGMA table_info(conversations)`).all() as DbRow[];
|
||||||
|
if (!conversationCols.some((c: DbRow) => c.name === 'session_mode')) {
|
||||||
|
db.exec(`ALTER TABLE conversations ADD COLUMN session_mode TEXT NOT NULL DEFAULT 'design'`);
|
||||||
|
}
|
||||||
const messageCols = db.prepare(`PRAGMA table_info(messages)`).all() as DbRow[];
|
const messageCols = db.prepare(`PRAGMA table_info(messages)`).all() as DbRow[];
|
||||||
if (!messageCols.some((c: DbRow) => c.name === 'agent_id')) {
|
if (!messageCols.some((c: DbRow) => c.name === 'agent_id')) {
|
||||||
db.exec(`ALTER TABLE messages ADD COLUMN agent_id TEXT`);
|
db.exec(`ALTER TABLE messages ADD COLUMN agent_id TEXT`);
|
||||||
|
|
@ -725,7 +731,7 @@ export function listConversations(db: SqliteDb, projectId: string) {
|
||||||
return rows(db
|
return rows(db
|
||||||
.prepare(
|
.prepare(
|
||||||
`WITH project_conversations AS (
|
`WITH project_conversations AS (
|
||||||
SELECT id, project_id AS projectId, title,
|
SELECT id, project_id AS projectId, title, session_mode AS sessionMode,
|
||||||
created_at AS createdAt, updated_at AS updatedAt
|
created_at AS createdAt, updated_at AS updatedAt
|
||||||
FROM conversations
|
FROM conversations
|
||||||
WHERE project_id = ?
|
WHERE project_id = ?
|
||||||
|
|
@ -753,7 +759,7 @@ export function listConversations(db: SqliteDb, projectId: string) {
|
||||||
)
|
)
|
||||||
WHERE rn = 1
|
WHERE rn = 1
|
||||||
)
|
)
|
||||||
SELECT c.id, c.projectId, c.title, c.createdAt, c.updatedAt,
|
SELECT c.id, c.projectId, c.title, c.sessionMode, c.createdAt, c.updatedAt,
|
||||||
lr.latestRunStatus, lr.latestRunStartedAt,
|
lr.latestRunStatus, lr.latestRunStartedAt,
|
||||||
lr.latestRunEndedAt, lr.latestRunEventsJson
|
lr.latestRunEndedAt, lr.latestRunEventsJson
|
||||||
FROM project_conversations c
|
FROM project_conversations c
|
||||||
|
|
@ -766,7 +772,7 @@ export function listConversations(db: SqliteDb, projectId: string) {
|
||||||
export function getConversation(db: SqliteDb, id: string) {
|
export function getConversation(db: SqliteDb, id: string) {
|
||||||
const r = db
|
const r = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, project_id AS projectId, title,
|
`SELECT id, project_id AS projectId, title, session_mode AS sessionMode,
|
||||||
created_at AS createdAt, updated_at AS updatedAt
|
created_at AS createdAt, updated_at AS updatedAt
|
||||||
FROM conversations WHERE id = ?`,
|
FROM conversations WHERE id = ?`,
|
||||||
)
|
)
|
||||||
|
|
@ -789,12 +795,17 @@ function normalizeConversation(r: DbRow) {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
projectId: r.projectId,
|
projectId: r.projectId,
|
||||||
title: r.title ?? null,
|
title: r.title ?? null,
|
||||||
|
sessionMode: normalizeConversationSessionMode(r.sessionMode),
|
||||||
createdAt: Number(r.createdAt),
|
createdAt: Number(r.createdAt),
|
||||||
updatedAt: Number(r.updatedAt),
|
updatedAt: Number(r.updatedAt),
|
||||||
latestRun: latestRun ?? undefined,
|
latestRun: latestRun ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeConversationSessionMode(value: unknown): ChatSessionMode {
|
||||||
|
return value === 'chat' ? 'chat' : 'design';
|
||||||
|
}
|
||||||
|
|
||||||
function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
|
function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|
@ -858,9 +869,16 @@ function latestUsageDurationMs(eventsJson: unknown): number | undefined {
|
||||||
export function insertConversation(db: SqliteDb, c: DbRow) {
|
export function insertConversation(db: SqliteDb, c: DbRow) {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO conversations
|
`INSERT INTO conversations
|
||||||
(id, project_id, title, created_at, updated_at)
|
(id, project_id, title, session_mode, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
).run(c.id, c.projectId, c.title ?? null, c.createdAt, c.updatedAt);
|
).run(
|
||||||
|
c.id,
|
||||||
|
c.projectId,
|
||||||
|
c.title ?? null,
|
||||||
|
normalizeConversationSessionMode(c.sessionMode),
|
||||||
|
c.createdAt,
|
||||||
|
c.updatedAt,
|
||||||
|
);
|
||||||
return getConversation(db, c.id);
|
return getConversation(db, c.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -870,12 +888,15 @@ export function updateConversation(db: SqliteDb, id: string, patch: DbRow) {
|
||||||
const merged = {
|
const merged = {
|
||||||
...existing,
|
...existing,
|
||||||
...patch,
|
...patch,
|
||||||
|
sessionMode: Object.prototype.hasOwnProperty.call(patch, 'sessionMode')
|
||||||
|
? normalizeConversationSessionMode(patch.sessionMode)
|
||||||
|
: existing.sessionMode,
|
||||||
updatedAt: typeof patch.updatedAt === 'number' ? patch.updatedAt : Date.now(),
|
updatedAt: typeof patch.updatedAt === 'number' ? patch.updatedAt : Date.now(),
|
||||||
};
|
};
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE conversations
|
`UPDATE conversations
|
||||||
SET title = ?, updated_at = ? WHERE id = ?`,
|
SET title = ?, session_mode = ?, updated_at = ? WHERE id = ?`,
|
||||||
).run(merged.title ?? null, merged.updatedAt, id);
|
).run(merged.title ?? null, merged.sessionMode, merged.updatedAt, id);
|
||||||
return getConversation(db, id);
|
return getConversation(db, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Express } from 'express';
|
import type { Express } from 'express';
|
||||||
import {
|
import {
|
||||||
defaultScenarioPluginIdForProjectMetadata,
|
defaultScenarioPluginIdForProjectMetadata,
|
||||||
|
type ChatSessionMode,
|
||||||
type PluginManifest,
|
type PluginManifest,
|
||||||
} from '@open-design/contracts';
|
} from '@open-design/contracts';
|
||||||
import { createProjectArtifactFile } from './artifact-create.js';
|
import { createProjectArtifactFile } from './artifact-create.js';
|
||||||
|
|
@ -121,6 +122,10 @@ function injectUrlPreviewScrollBridge(html: string): string {
|
||||||
return `${html}${URL_PREVIEW_SCROLL_BRIDGE}`;
|
return `${html}${URL_PREVIEW_SCROLL_BRIDGE}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeChatSessionMode(value: unknown): ChatSessionMode {
|
||||||
|
return value === 'chat' ? 'chat' : 'design';
|
||||||
|
}
|
||||||
|
|
||||||
export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDeps) {
|
export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDeps) {
|
||||||
const { db, design } = ctx;
|
const { db, design } = ctx;
|
||||||
const { sendApiError, createSseResponse } = ctx.http;
|
const { sendApiError, createSseResponse } = ctx.http;
|
||||||
|
|
@ -322,6 +327,9 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
id: cid,
|
id: cid,
|
||||||
projectId: id,
|
projectId: id,
|
||||||
title: null,
|
title: null,
|
||||||
|
sessionMode: normalizeChatSessionMode(
|
||||||
|
req.body?.conversationMode ?? req.body?.sessionMode,
|
||||||
|
),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
|
|
@ -599,10 +607,24 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
}
|
}
|
||||||
const { title, seedFromConversationId } = req.body || {};
|
const { title, seedFromConversationId } = req.body || {};
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const hasExplicitSessionMode = Boolean(
|
||||||
|
req.body && Object.prototype.hasOwnProperty.call(req.body, 'sessionMode'),
|
||||||
|
);
|
||||||
|
const sourceConversation =
|
||||||
|
typeof seedFromConversationId === 'string' && seedFromConversationId
|
||||||
|
? getConversation(db, seedFromConversationId)
|
||||||
|
: null;
|
||||||
|
const sessionMode =
|
||||||
|
hasExplicitSessionMode
|
||||||
|
? normalizeChatSessionMode(req.body.sessionMode)
|
||||||
|
: sourceConversation && sourceConversation.projectId === req.params.id
|
||||||
|
? normalizeChatSessionMode(sourceConversation.sessionMode)
|
||||||
|
: 'design';
|
||||||
const conv = insertConversation(db, {
|
const conv = insertConversation(db, {
|
||||||
id: randomId(),
|
id: randomId(),
|
||||||
projectId: req.params.id,
|
projectId: req.params.id,
|
||||||
title: typeof title === 'string' ? title.trim() || null : null,
|
title: typeof title === 'string' ? title.trim() || null : null,
|
||||||
|
sessionMode,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
|
|
@ -610,7 +632,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
||||||
// messages into the fresh conversation. Be defensive — a missing or
|
// messages into the fresh conversation. Be defensive — a missing or
|
||||||
// cross-project source id silently yields an empty conversation.
|
// cross-project source id silently yields an empty conversation.
|
||||||
if (conv && typeof seedFromConversationId === 'string' && seedFromConversationId) {
|
if (conv && typeof seedFromConversationId === 'string' && seedFromConversationId) {
|
||||||
const source = getConversation(db, seedFromConversationId);
|
const source = sourceConversation;
|
||||||
if (source && source.projectId === req.params.id) {
|
if (source && source.projectId === req.params.id) {
|
||||||
for (const m of listMessages(db, seedFromConversationId)) {
|
for (const m of listMessages(db, seedFromConversationId)) {
|
||||||
// Fresh id per copied message; upsertMessage assigns the next
|
// Fresh id per copied message; upsertMessage assigns the next
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ import { renderMediaGenerationContract } from './media-contract.js';
|
||||||
import { IMAGE_MODELS } from '../media-models.js';
|
import { IMAGE_MODELS } from '../media-models.js';
|
||||||
import { renderPanelPrompt } from './panel.js';
|
import { renderPanelPrompt } from './panel.js';
|
||||||
import { defaultCritiqueConfig, type CritiqueConfig } from '@open-design/contracts/critique';
|
import { defaultCritiqueConfig, type CritiqueConfig } from '@open-design/contracts/critique';
|
||||||
import type { MediaExecutionPolicy, MediaSurface } from '@open-design/contracts';
|
import type { ChatSessionMode, MediaExecutionPolicy, MediaSurface } from '@open-design/contracts';
|
||||||
|
|
||||||
const ELEVENLABS_VOICE_PROMPT_OPTION_LIMIT = 100;
|
const ELEVENLABS_VOICE_PROMPT_OPTION_LIMIT = 100;
|
||||||
const ELEVENLABS_VOICE_OPTIONS_PROMPT_PREFIX = 'ElevenLabs voice list could not be loaded';
|
const ELEVENLABS_VOICE_OPTIONS_PROMPT_PREFIX = 'ElevenLabs voice list could not be loaded';
|
||||||
|
|
@ -372,6 +372,10 @@ export interface ComposeInput {
|
||||||
// UI locale selected by the client. User-visible generated form copy
|
// UI locale selected by the client. User-visible generated form copy
|
||||||
// must follow this locale even when the user's initial prompt is brief.
|
// must follow this locale even when the user's initial prompt is brief.
|
||||||
locale?: string | undefined;
|
locale?: string | undefined;
|
||||||
|
// Per-conversation mode. Design mode keeps the artifact-first agent
|
||||||
|
// workflow; chat mode keeps the same context/tools but answers like a
|
||||||
|
// standard multi-turn assistant unless the user explicitly asks to build.
|
||||||
|
sessionMode?: ChatSessionMode | undefined;
|
||||||
// Run-scoped media policy. Defaults to enabled when omitted so existing
|
// Run-scoped media policy. Defaults to enabled when omitted so existing
|
||||||
// local OD behavior keeps the same media prompt contract.
|
// local OD behavior keeps the same media prompt contract.
|
||||||
mediaExecution?: MediaExecutionPolicy | undefined;
|
mediaExecution?: MediaExecutionPolicy | undefined;
|
||||||
|
|
@ -407,6 +411,7 @@ export function composeSystemPrompt({
|
||||||
activeStageBlocks,
|
activeStageBlocks,
|
||||||
streamFormat,
|
streamFormat,
|
||||||
locale,
|
locale,
|
||||||
|
sessionMode,
|
||||||
userInstructions,
|
userInstructions,
|
||||||
projectInstructions,
|
projectInstructions,
|
||||||
mediaExecution,
|
mediaExecution,
|
||||||
|
|
@ -439,6 +444,11 @@ export function composeSystemPrompt({
|
||||||
parts.push('\n\n---\n\n');
|
parts.push('\n\n---\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sessionMode === 'chat') {
|
||||||
|
parts.push(CHAT_MODE_OVERRIDE);
|
||||||
|
parts.push('\n\n---\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
if (metadata?.skipDiscoveryBrief === true) {
|
if (metadata?.skipDiscoveryBrief === true) {
|
||||||
parts.push(SKIP_DISCOVERY_BRIEF_OVERRIDE);
|
parts.push(SKIP_DISCOVERY_BRIEF_OVERRIDE);
|
||||||
parts.push('\n\n---\n\n');
|
parts.push('\n\n---\n\n');
|
||||||
|
|
@ -708,6 +718,14 @@ Every later instruction in this prompt that tells you to "call TodoWrite", "run
|
||||||
|
|
||||||
If the rules below tell you to plan with TodoWrite, write the plan as prose instead. If they tell you to read skill side files before writing, describe in one sentence which patterns/conventions you're going to apply and proceed. If they tell you to run brand-spec extraction via Bash + Read + WebFetch, ask the user the missing brand questions in the discovery form instead.`;
|
If the rules below tell you to plan with TodoWrite, write the plan as prose instead. If they tell you to read skill side files before writing, describe in one sentence which patterns/conventions you're going to apply and proceed. If they tell you to run brand-spec extraction via Bash + Read + WebFetch, ask the user the missing brand questions in the discovery form instead.`;
|
||||||
|
|
||||||
|
const CHAT_MODE_OVERRIDE = `# Chat mode — standard conversation (read first — overrides every rule below)
|
||||||
|
|
||||||
|
This conversation is in Open Design Chat mode. Open Design is the open-source Claude Design alternative and a native Figma counterpart. Official links: GitHub https://github.com/nexu-io/open-design, website https://open-design.ai/, Discord https://discord.com/invite/9ptkbbqRu.
|
||||||
|
|
||||||
|
Use the same available context, files, attachments, connectors, MCP servers, project memory, and model capabilities as Design mode. The difference is behavior: answer like a fast, direct, multi-turn desktop chat assistant. Prefer concise prose, explanations, comparisons, debugging help, and follow-up questions only when needed.
|
||||||
|
|
||||||
|
Override artifact-first discovery rules below: do not emit a default discovery \`<question-form>\`, do not call TodoWrite just to plan a chat answer, and do not create or edit project files, HTML, PPT, slide decks, images, video, or audio unless the user explicitly asks you to generate/build/design/export/modify something. When the user does ask for a design artifact or file change, you may use the normal Open Design agent workflow and the same tools/capabilities available in Design mode.`;
|
||||||
|
|
||||||
// Defense-in-depth against Claude Code's synthetic OAuth tools.
|
// Defense-in-depth against Claude Code's synthetic OAuth tools.
|
||||||
//
|
//
|
||||||
// When Claude Code's built-in HTTP MCP transport gets a 401 on its first
|
// When Claude Code's built-in HTTP MCP transport gets a 401 on its first
|
||||||
|
|
|
||||||
|
|
@ -389,6 +389,7 @@ import {
|
||||||
listTemplates,
|
listTemplates,
|
||||||
getLatestRoutineRun,
|
getLatestRoutineRun,
|
||||||
getRoutine,
|
getRoutine,
|
||||||
|
normalizeConversationSessionMode,
|
||||||
deleteRoutine as dbDeleteRoutine,
|
deleteRoutine as dbDeleteRoutine,
|
||||||
openDatabase,
|
openDatabase,
|
||||||
setTabs,
|
setTabs,
|
||||||
|
|
@ -10117,6 +10118,7 @@ export async function startServer({
|
||||||
designSystemId,
|
designSystemId,
|
||||||
streamFormat,
|
streamFormat,
|
||||||
locale,
|
locale,
|
||||||
|
sessionMode,
|
||||||
connectedExternalMcp,
|
connectedExternalMcp,
|
||||||
appliedPluginSnapshotId,
|
appliedPluginSnapshotId,
|
||||||
mediaExecution,
|
mediaExecution,
|
||||||
|
|
@ -10567,6 +10569,7 @@ export async function startServer({
|
||||||
critiqueBrand: critiqueShouldRun ? critiqueBrand : undefined,
|
critiqueBrand: critiqueShouldRun ? critiqueBrand : undefined,
|
||||||
critiqueSkill: critiqueShouldRun ? critiqueSkill : undefined,
|
critiqueSkill: critiqueShouldRun ? critiqueSkill : undefined,
|
||||||
locale: typeof locale === 'string' ? locale : undefined,
|
locale: typeof locale === 'string' ? locale : undefined,
|
||||||
|
sessionMode: normalizeConversationSessionMode(sessionMode),
|
||||||
mediaExecution,
|
mediaExecution,
|
||||||
streamFormat,
|
streamFormat,
|
||||||
connectedExternalMcp: Array.isArray(connectedExternalMcp)
|
connectedExternalMcp: Array.isArray(connectedExternalMcp)
|
||||||
|
|
@ -10675,6 +10678,7 @@ export async function startServer({
|
||||||
skillId,
|
skillId,
|
||||||
skillIds,
|
skillIds,
|
||||||
designSystemId,
|
designSystemId,
|
||||||
|
sessionMode,
|
||||||
attachments = [],
|
attachments = [],
|
||||||
commentAttachments = [],
|
commentAttachments = [],
|
||||||
model,
|
model,
|
||||||
|
|
@ -10702,6 +10706,14 @@ export async function startServer({
|
||||||
if (typeof skillId === 'string' && skillId) run.skillId = skillId;
|
if (typeof skillId === 'string' && skillId) run.skillId = skillId;
|
||||||
if (typeof designSystemId === 'string' && designSystemId)
|
if (typeof designSystemId === 'string' && designSystemId)
|
||||||
run.designSystemId = designSystemId;
|
run.designSystemId = designSystemId;
|
||||||
|
const conversationSession =
|
||||||
|
typeof conversationId === 'string' && conversationId
|
||||||
|
? getConversation(db, conversationId)
|
||||||
|
: null;
|
||||||
|
const runSessionMode =
|
||||||
|
sessionMode === 'chat' || sessionMode === 'design'
|
||||||
|
? normalizeConversationSessionMode(sessionMode)
|
||||||
|
: normalizeConversationSessionMode(conversationSession?.sessionMode);
|
||||||
const def = getAgentDef(agentId);
|
const def = getAgentDef(agentId);
|
||||||
if (!def)
|
if (!def)
|
||||||
return design.runs.fail(
|
return design.runs.fail(
|
||||||
|
|
@ -10937,6 +10949,7 @@ export async function startServer({
|
||||||
designSystemId,
|
designSystemId,
|
||||||
streamFormat: def?.streamFormat ?? 'plain',
|
streamFormat: def?.streamFormat ?? 'plain',
|
||||||
locale,
|
locale,
|
||||||
|
sessionMode: runSessionMode,
|
||||||
connectedExternalMcp,
|
connectedExternalMcp,
|
||||||
mediaExecution: run?.mediaExecution,
|
mediaExecution: run?.mediaExecution,
|
||||||
// Plan §3.M2 / §3.V1 — forward the run's snapshot id so the
|
// Plan §3.M2 / §3.V1 — forward the run's snapshot id so the
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
// node-pty is a native module; its TypeScript types resolve after
|
// node-pty is a native module; its TypeScript types resolve after
|
||||||
// `pnpm install` compiles the addon. The dynamic import keeps the daemon
|
// `pnpm install` compiles the addon. The dynamic import keeps the daemon
|
||||||
// bootable even on a platform where the prebuilt binary is missing — a
|
// bootable even on a platform where the prebuilt binary is missing — a
|
||||||
|
|
@ -7,6 +10,63 @@ import os from 'node:os';
|
||||||
// instead of crashing the process at module-eval time.
|
// instead of crashing the process at module-eval time.
|
||||||
import type * as NodePty from 'node-pty';
|
import type * as NodePty from 'node-pty';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the candidate paths to node-pty's `spawn-helper` binary. On
|
||||||
|
* macOS/Linux, `pty.fork` shells out to this helper via `posix_spawn`; node-pty
|
||||||
|
* looks for the native artifacts under `build/Release` first and a
|
||||||
|
* platform-tagged `prebuilds/<platform>-<arch>` dir second (see its
|
||||||
|
* `loadNativeModule`). We return both so the executable-bit repair below covers
|
||||||
|
* whichever one a given install produced. Empty on win32 (ConPTY has no helper)
|
||||||
|
* or when node-pty can't be resolved at all.
|
||||||
|
*/
|
||||||
|
export function spawnHelperCandidatePaths(): string[] {
|
||||||
|
if (process.platform === 'win32') return [];
|
||||||
|
let pkgRoot: string;
|
||||||
|
try {
|
||||||
|
// `node-pty`'s "main" is `lib/index.js`, so the package root is two levels
|
||||||
|
// up from the resolved entry. createRequire anchors resolution at this
|
||||||
|
// module regardless of how the daemon is bundled/run.
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
pkgRoot = path.dirname(path.dirname(require.resolve('node-pty')));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
path.join(pkgRoot, 'build', 'Release', 'spawn-helper'),
|
||||||
|
path.join(pkgRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore the executable bit on node-pty's `spawn-helper`.
|
||||||
|
*
|
||||||
|
* pnpm unpacks node-pty's prebuilt binaries into `prebuilds/<platform>-<arch>/`
|
||||||
|
* with mode 0644 — and node-pty's own `post-install.js` only chmods files under
|
||||||
|
* `build/Release`, which a prebuild-based install never creates. The result is a
|
||||||
|
* non-executable `spawn-helper`, so the very first `pty.spawn()` dies with
|
||||||
|
* "posix_spawnp failed." (surfaced to the user as "无法启动终端会话" / "Could not
|
||||||
|
* start the terminal session"). Re-adding +x before the first fork makes the
|
||||||
|
* terminal self-heal across reinstalls without depending on an install hook.
|
||||||
|
*
|
||||||
|
* Best-effort and idempotent: a missing file (addon not installed for this
|
||||||
|
* platform) or a read-only filesystem (e.g. a packaged app bundle) is swallowed,
|
||||||
|
* and the subsequent spawn surfaces the real error instead.
|
||||||
|
*/
|
||||||
|
export function ensureSpawnHelperExecutable(): void {
|
||||||
|
for (const file of spawnHelperCandidatePaths()) {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(file);
|
||||||
|
// Owner already has the execute bit — nothing to repair.
|
||||||
|
if (stat.mode & 0o100) continue;
|
||||||
|
// OR in execute-for-all, preserving the existing read/write bits.
|
||||||
|
fs.chmodSync(file, stat.mode | 0o111);
|
||||||
|
} catch {
|
||||||
|
// Candidate not present on this install, or fs is read-only: ignore and
|
||||||
|
// let the spawn attempt report the underlying failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory interactive Terminal session manager. Mirrors the chat-run
|
* In-memory interactive Terminal session manager. Mirrors the chat-run
|
||||||
* lifecycle in `runs.ts`: each session keeps a bounded event ring-buffer so a
|
* lifecycle in `runs.ts`: each session keeps a bounded event ring-buffer so a
|
||||||
|
|
@ -67,6 +127,9 @@ export function createTerminalService({
|
||||||
|
|
||||||
const loadPty = async (): Promise<typeof NodePty> => {
|
const loadPty = async (): Promise<typeof NodePty> => {
|
||||||
if (ptyModule) return ptyModule;
|
if (ptyModule) return ptyModule;
|
||||||
|
// Repair the prebuilt spawn-helper's executable bit before the first fork;
|
||||||
|
// otherwise spawn() fails with "posix_spawnp failed." on macOS/Linux.
|
||||||
|
ensureSpawnHelperExecutable();
|
||||||
ptyModule = (await import('node-pty')) as typeof NodePty;
|
ptyModule = (await import('node-pty')) as typeof NodePty;
|
||||||
return ptyModule;
|
return ptyModule;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
51
apps/daemon/tests/terminals.spawn-helper.test.ts
Normal file
51
apps/daemon/tests/terminals.spawn-helper.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createTerminalService,
|
||||||
|
ensureSpawnHelperExecutable,
|
||||||
|
spawnHelperCandidatePaths,
|
||||||
|
} from '../src/terminals.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression for the "无法启动终端会话" / "Could not start the terminal session"
|
||||||
|
* report: pnpm unpacks node-pty's prebuilt `spawn-helper` without the execute
|
||||||
|
* bit, so the first `pty.spawn()` dies with "posix_spawnp failed." and the
|
||||||
|
* launcher toast fires. `terminals.create()` must self-heal that bit so a fresh
|
||||||
|
* PTY actually spawns.
|
||||||
|
*
|
||||||
|
* macOS/Linux only — on win32 ConPTY has no spawn-helper and there is nothing to
|
||||||
|
* repair (`spawnHelperCandidatePaths()` is empty there).
|
||||||
|
*/
|
||||||
|
const helperPath = spawnHelperCandidatePaths().find((p) => fs.existsSync(p)) ?? null;
|
||||||
|
const describeIfHelper = helperPath ? describe : describe.skip;
|
||||||
|
|
||||||
|
describeIfHelper('terminal spawn-helper executable repair', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
// Leave the shared node_modules artifact executable regardless of outcome,
|
||||||
|
// so a failure here can't cascade into every other terminal test.
|
||||||
|
if (helperPath) ensureSpawnHelperExecutable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spawns a PTY even when the prebuilt spawn-helper lost its +x bit', async () => {
|
||||||
|
// Reproduce the post-`pnpm install` state: helper present but mode 0644.
|
||||||
|
fs.chmodSync(helperPath!, 0o644);
|
||||||
|
expect(fs.statSync(helperPath!).mode & 0o111).toBe(0);
|
||||||
|
|
||||||
|
const terminals = createTerminalService();
|
||||||
|
const session = await terminals.create({
|
||||||
|
projectId: 'p-spawn-helper',
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(session.status).toBe('running');
|
||||||
|
expect(typeof session.pty.pid).toBe('number');
|
||||||
|
// The repair ran as part of create() → loadPty().
|
||||||
|
expect(fs.statSync(helperPath!).mode & 0o100).not.toBe(0);
|
||||||
|
} finally {
|
||||||
|
terminals.kill(session, 'SIGTERM');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
projectKindToTracking,
|
projectKindToTracking,
|
||||||
fidelityToTracking,
|
fidelityToTracking,
|
||||||
} from '@open-design/contracts/analytics';
|
} from '@open-design/contracts/analytics';
|
||||||
|
import type { ChatSessionMode } from '@open-design/contracts';
|
||||||
import { EntryView } from './components/EntryView';
|
import { EntryView } from './components/EntryView';
|
||||||
import type { IntegrationTab } from './components/IntegrationsView';
|
import type { IntegrationTab } from './components/IntegrationsView';
|
||||||
import { MarketplaceView } from './components/MarketplaceView';
|
import { MarketplaceView } from './components/MarketplaceView';
|
||||||
|
|
@ -829,6 +830,7 @@ function AppInner() {
|
||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
appliedPluginSnapshotId?: string;
|
appliedPluginSnapshotId?: string;
|
||||||
pluginInputs?: Record<string, unknown>;
|
pluginInputs?: Record<string, unknown>;
|
||||||
|
conversationMode?: ChatSessionMode;
|
||||||
autoSendFirstMessage?: boolean;
|
autoSendFirstMessage?: boolean;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
pendingFiles?: File[];
|
pendingFiles?: File[];
|
||||||
|
|
@ -852,6 +854,7 @@ function AppInner() {
|
||||||
designSystemId: input.designSystemId,
|
designSystemId: input.designSystemId,
|
||||||
pendingPrompt: derivedPendingPrompt,
|
pendingPrompt: derivedPendingPrompt,
|
||||||
metadata: input.metadata,
|
metadata: input.metadata,
|
||||||
|
...(input.conversationMode ? { conversationMode: input.conversationMode } : {}),
|
||||||
...(input.pluginId ? { pluginId: input.pluginId } : {}),
|
...(input.pluginId ? { pluginId: input.pluginId } : {}),
|
||||||
...(input.appliedPluginSnapshotId
|
...(input.appliedPluginSnapshotId
|
||||||
? { appliedPluginSnapshotId: input.appliedPluginSnapshotId }
|
? { appliedPluginSnapshotId: input.appliedPluginSnapshotId }
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import { listPlugins } from "../state/projects";
|
||||||
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ProjectFile, ProjectMetadata, SkillSummary } from "../types";
|
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ProjectFile, ProjectMetadata, SkillSummary } from "../types";
|
||||||
import type {
|
import type {
|
||||||
ContextItem,
|
ContextItem,
|
||||||
|
ChatSessionMode,
|
||||||
ConnectorDetail,
|
ConnectorDetail,
|
||||||
InstalledPluginRecord,
|
InstalledPluginRecord,
|
||||||
PluginSourceKind,
|
PluginSourceKind,
|
||||||
|
|
@ -38,6 +39,7 @@ import type {
|
||||||
} from '@open-design/contracts';
|
} from '@open-design/contracts';
|
||||||
import { buildVisualAnnotationAttachment, commentTargetDisplayName } from '../comments';
|
import { buildVisualAnnotationAttachment, commentTargetDisplayName } from '../comments';
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
import { SessionModeToggle } from './SessionModeToggle';
|
||||||
import { PluginDetailsModal } from "./PluginDetailsModal";
|
import { PluginDetailsModal } from "./PluginDetailsModal";
|
||||||
import { PluginsSection, type PluginsSectionHandle } from "./PluginsSection";
|
import { PluginsSection, type PluginsSectionHandle } from "./PluginsSection";
|
||||||
import { BUILT_IN_PETS, CUSTOM_PET_ID } from "./pet/pets";
|
import { BUILT_IN_PETS, CUSTOM_PET_ID } from "./pet/pets";
|
||||||
|
|
@ -96,6 +98,8 @@ interface Props {
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
projectFiles: ProjectFile[];
|
projectFiles: ProjectFile[];
|
||||||
streaming: boolean;
|
streaming: boolean;
|
||||||
|
sessionMode?: ChatSessionMode;
|
||||||
|
onSessionModeChange?: (mode: ChatSessionMode) => void;
|
||||||
sendDisabled?: boolean;
|
sendDisabled?: boolean;
|
||||||
initialDraft?: string;
|
initialDraft?: string;
|
||||||
draftStorageKey?: string;
|
draftStorageKey?: string;
|
||||||
|
|
@ -195,6 +199,8 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
||||||
projectId,
|
projectId,
|
||||||
projectFiles,
|
projectFiles,
|
||||||
streaming,
|
streaming,
|
||||||
|
sessionMode = 'design',
|
||||||
|
onSessionModeChange,
|
||||||
sendDisabled = false,
|
sendDisabled = false,
|
||||||
initialDraft,
|
initialDraft,
|
||||||
draftStorageKey,
|
draftStorageKey,
|
||||||
|
|
@ -1864,6 +1870,10 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
||||||
<Icon name="attach" size={15} />
|
<Icon name="attach" size={15} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<SessionModeToggle
|
||||||
|
mode={sessionMode}
|
||||||
|
onChange={onSessionModeChange}
|
||||||
|
/>
|
||||||
{footerAccessory}
|
{footerAccessory}
|
||||||
<span className="composer-spacer" />
|
<span className="composer-spacer" />
|
||||||
{showStopButton ? (
|
{showStopButton ? (
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import type { Dict } from '../i18n/types';
|
||||||
import { copyToClipboard } from '../lib/copy-to-clipboard';
|
import { copyToClipboard } from '../lib/copy-to-clipboard';
|
||||||
import { projectRawUrl } from '../providers/registry';
|
import { projectRawUrl } from '../providers/registry';
|
||||||
import type { TodoItem } from '../runtime/todos';
|
import type { TodoItem } from '../runtime/todos';
|
||||||
import type { AppliedPluginSnapshot } from '@open-design/contracts';
|
import type { AppliedPluginSnapshot, ChatSessionMode } from '@open-design/contracts';
|
||||||
import type { TrackingProjectKind } from '@open-design/contracts/analytics';
|
import type { TrackingProjectKind } from '@open-design/contracts/analytics';
|
||||||
import {
|
import {
|
||||||
DESIGN_SYSTEM_WORKSPACE_DISPLAY_DESCRIPTION,
|
DESIGN_SYSTEM_WORKSPACE_DISPLAY_DESCRIPTION,
|
||||||
|
|
@ -215,6 +215,8 @@ interface Props {
|
||||||
streaming: boolean;
|
streaming: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
|
sessionMode?: ChatSessionMode;
|
||||||
|
onSessionModeChange?: (mode: ChatSessionMode) => void;
|
||||||
// Analytics-only — forwarded to AssistantMessage so the feedback
|
// Analytics-only — forwarded to AssistantMessage so the feedback
|
||||||
// events know which project surface the rating applies to. Optional
|
// events know which project surface the rating applies to. Optional
|
||||||
// (defaults to null/'prototype') so unit tests can mount ChatPane
|
// (defaults to null/'prototype') so unit tests can mount ChatPane
|
||||||
|
|
@ -347,6 +349,8 @@ export function ChatPane({
|
||||||
queuedItems = [],
|
queuedItems = [],
|
||||||
error,
|
error,
|
||||||
projectId,
|
projectId,
|
||||||
|
sessionMode = 'design',
|
||||||
|
onSessionModeChange,
|
||||||
projectKindForTracking = null,
|
projectKindForTracking = null,
|
||||||
projectFiles,
|
projectFiles,
|
||||||
hasActiveDesignSystem = false,
|
hasActiveDesignSystem = false,
|
||||||
|
|
@ -1290,6 +1294,8 @@ export function ChatPane({
|
||||||
ref={composerRef}
|
ref={composerRef}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
projectFiles={projectFiles}
|
projectFiles={projectFiles}
|
||||||
|
sessionMode={sessionMode}
|
||||||
|
onSessionModeChange={onSessionModeChange}
|
||||||
skills={skills}
|
skills={skills}
|
||||||
streaming={streaming}
|
streaming={streaming}
|
||||||
sendDisabled={sendDisabled}
|
sendDisabled={sendDisabled}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
defaultScenarioPluginIdForProjectMetadata,
|
defaultScenarioPluginIdForProjectMetadata,
|
||||||
|
type ChatSessionMode,
|
||||||
type ConnectorDetail,
|
type ConnectorDetail,
|
||||||
type InstalledPluginRecord,
|
type InstalledPluginRecord,
|
||||||
} from '@open-design/contracts';
|
} from '@open-design/contracts';
|
||||||
|
|
@ -260,6 +261,7 @@ interface Props {
|
||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
appliedPluginSnapshotId?: string;
|
appliedPluginSnapshotId?: string;
|
||||||
pluginInputs?: Record<string, unknown>;
|
pluginInputs?: Record<string, unknown>;
|
||||||
|
conversationMode?: ChatSessionMode;
|
||||||
autoSendFirstMessage?: boolean;
|
autoSendFirstMessage?: boolean;
|
||||||
pendingFiles?: File[];
|
pendingFiles?: File[];
|
||||||
},
|
},
|
||||||
|
|
@ -521,6 +523,7 @@ export function EntryShell({
|
||||||
? { appliedPluginSnapshotId: payload.appliedPluginSnapshotId }
|
? { appliedPluginSnapshotId: payload.appliedPluginSnapshotId }
|
||||||
: {}),
|
: {}),
|
||||||
...(payload.pluginInputs ? { pluginInputs: payload.pluginInputs } : {}),
|
...(payload.pluginInputs ? { pluginInputs: payload.pluginInputs } : {}),
|
||||||
|
...(payload.conversationMode ? { conversationMode: payload.conversationMode } : {}),
|
||||||
...(payload.attachments && payload.attachments.length > 0
|
...(payload.attachments && payload.attachments.length > 0
|
||||||
? { pendingFiles: payload.attachments }
|
? { pendingFiles: payload.attachments }
|
||||||
: {}),
|
: {}),
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import type {
|
||||||
RefObject,
|
RefObject,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import type {
|
import type {
|
||||||
|
ChatSessionMode,
|
||||||
ConnectorDetail,
|
ConnectorDetail,
|
||||||
InputFieldSpec,
|
InputFieldSpec,
|
||||||
InstalledPluginRecord,
|
InstalledPluginRecord,
|
||||||
|
|
@ -56,6 +57,7 @@ import {
|
||||||
import { PreviewSurface } from './plugins-home/cards/PreviewSurface';
|
import { PreviewSurface } from './plugins-home/cards/PreviewSurface';
|
||||||
import { curatedPluginPriorityForChip } from './plugins-home/curatedPriority';
|
import { curatedPluginPriorityForChip } from './plugins-home/curatedPriority';
|
||||||
import { inferPluginPreview } from './plugins-home/preview';
|
import { inferPluginPreview } from './plugins-home/preview';
|
||||||
|
import { SessionModeToggle } from './SessionModeToggle';
|
||||||
|
|
||||||
export interface HomeHeroSubmitHandler {
|
export interface HomeHeroSubmitHandler {
|
||||||
(): void;
|
(): void;
|
||||||
|
|
@ -65,6 +67,8 @@ interface Props {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
onPromptChange: (value: string) => void;
|
onPromptChange: (value: string) => void;
|
||||||
onSubmit: HomeHeroSubmitHandler;
|
onSubmit: HomeHeroSubmitHandler;
|
||||||
|
sessionMode: ChatSessionMode;
|
||||||
|
onSessionModeChange: (mode: ChatSessionMode) => void;
|
||||||
activePluginTitle: string | null;
|
activePluginTitle: string | null;
|
||||||
activePluginRecord?: InstalledPluginRecord | null;
|
activePluginRecord?: InstalledPluginRecord | null;
|
||||||
activeChipId: string | null;
|
activeChipId: string | null;
|
||||||
|
|
@ -150,6 +154,8 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
|
||||||
prompt,
|
prompt,
|
||||||
onPromptChange,
|
onPromptChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
sessionMode,
|
||||||
|
onSessionModeChange,
|
||||||
activePluginTitle,
|
activePluginTitle,
|
||||||
activePluginRecord = null,
|
activePluginRecord = null,
|
||||||
activeSkillId = null,
|
activeSkillId = null,
|
||||||
|
|
@ -1078,6 +1084,11 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
|
||||||
>
|
>
|
||||||
<Icon name="attach" size={15} />
|
<Icon name="attach" size={15} />
|
||||||
</button>
|
</button>
|
||||||
|
<SessionModeToggle
|
||||||
|
mode={sessionMode}
|
||||||
|
onChange={onSessionModeChange}
|
||||||
|
disabled={Boolean(submitDisabled)}
|
||||||
|
/>
|
||||||
{activeCreateChip ? (
|
{activeCreateChip ? (
|
||||||
<ActiveTypeChip chip={activeCreateChip} onClear={onClearActiveChip} />
|
<ActiveTypeChip chip={activeCreateChip} onClear={onClearActiveChip} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type {
|
import type {
|
||||||
ApplyResult,
|
ApplyResult,
|
||||||
|
ChatSessionMode,
|
||||||
ConnectorDetail,
|
ConnectorDetail,
|
||||||
InputFieldSpec,
|
InputFieldSpec,
|
||||||
McpServerConfig,
|
McpServerConfig,
|
||||||
|
|
@ -231,6 +232,7 @@ export function HomeView({
|
||||||
const [fallbackProjectMetadata, setFallbackProjectMetadata] =
|
const [fallbackProjectMetadata, setFallbackProjectMetadata] =
|
||||||
useState<ProjectMetadata | null>(null);
|
useState<ProjectMetadata | null>(null);
|
||||||
const [active, setActive] = useState<ActivePlugin | null>(null);
|
const [active, setActive] = useState<ActivePlugin | null>(null);
|
||||||
|
const [sessionMode, setSessionMode] = useState<ChatSessionMode>('design');
|
||||||
const [activeSkill, setActiveSkill] = useState<SkillSummary | null>(null);
|
const [activeSkill, setActiveSkill] = useState<SkillSummary | null>(null);
|
||||||
const [selectedPluginContexts, setSelectedPluginContexts] = useState<SelectedPluginContext[]>([]);
|
const [selectedPluginContexts, setSelectedPluginContexts] = useState<SelectedPluginContext[]>([]);
|
||||||
const [selectedMcpContexts, setSelectedMcpContexts] = useState<SelectedMcpContext[]>([]);
|
const [selectedMcpContexts, setSelectedMcpContexts] = useState<SelectedMcpContext[]>([]);
|
||||||
|
|
@ -1256,9 +1258,13 @@ export function HomeView({
|
||||||
// Scenario plugins (chips / preset cards) and explicit skill picks are
|
// Scenario plugins (chips / preset cards) and explicit skill picks are
|
||||||
// mutually exclusive routing sources — never send both (#2972).
|
// mutually exclusive routing sources — never send both (#2972).
|
||||||
const resolvedSkillId = submittedActive ? null : activeSkill?.id ?? null;
|
const resolvedSkillId = submittedActive ? null : activeSkill?.id ?? null;
|
||||||
|
const routedPluginId =
|
||||||
|
sessionMode === 'design'
|
||||||
|
? submittedActive?.record.id ?? DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID
|
||||||
|
: submittedActive?.record.id ?? null;
|
||||||
onSubmit({
|
onSubmit({
|
||||||
prompt: trimmed,
|
prompt: trimmed,
|
||||||
pluginId: submittedActive?.record.id ?? DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID,
|
pluginId: routedPluginId,
|
||||||
skillId: resolvedSkillId,
|
skillId: resolvedSkillId,
|
||||||
appliedPluginSnapshotId: submittedActive?.result?.appliedPlugin?.snapshotId ?? null,
|
appliedPluginSnapshotId: submittedActive?.result?.appliedPlugin?.snapshotId ?? null,
|
||||||
pluginTitle: submittedActive?.record.title ?? null,
|
pluginTitle: submittedActive?.record.title ?? null,
|
||||||
|
|
@ -1271,6 +1277,7 @@ export function HomeView({
|
||||||
contextMcpServers,
|
contextMcpServers,
|
||||||
contextConnectors,
|
contextConnectors,
|
||||||
attachments: stagedFiles,
|
attachments: stagedFiles,
|
||||||
|
conversationMode: sessionMode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1281,6 +1288,8 @@ export function HomeView({
|
||||||
prompt={prompt}
|
prompt={prompt}
|
||||||
onPromptChange={handlePromptChange}
|
onPromptChange={handlePromptChange}
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
|
sessionMode={sessionMode}
|
||||||
|
onSessionModeChange={setSessionMode}
|
||||||
activePluginTitle={activeBadgeTitle}
|
activePluginTitle={activeBadgeTitle}
|
||||||
activePluginRecord={active?.record ?? null}
|
activePluginRecord={active?.record ?? null}
|
||||||
activeSkillId={activeSkill?.id ?? null}
|
activeSkillId={activeSkill?.id ?? null}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type {
|
import type {
|
||||||
ApplyResult,
|
ApplyResult,
|
||||||
|
ChatSessionMode,
|
||||||
InstalledPluginRecord,
|
InstalledPluginRecord,
|
||||||
ProjectMetadata,
|
ProjectMetadata,
|
||||||
} from '@open-design/contracts';
|
} from '@open-design/contracts';
|
||||||
|
|
@ -41,6 +42,7 @@ export interface PluginLoopSubmit {
|
||||||
projectKind?: 'prototype' | 'deck' | 'template' | 'image' | 'video' | 'audio' | 'other' | null;
|
projectKind?: 'prototype' | 'deck' | 'template' | 'image' | 'video' | 'audio' | 'other' | null;
|
||||||
projectMetadata?: ProjectMetadata | null;
|
projectMetadata?: ProjectMetadata | null;
|
||||||
workingDir?: string | null;
|
workingDir?: string | null;
|
||||||
|
conversationMode?: ChatSessionMode;
|
||||||
// Files staged on Home before the project exists. App uploads them
|
// Files staged on Home before the project exists. App uploads them
|
||||||
// into the created project's Design Files before the first auto-send.
|
// into the created project's Design Files before the first auto-send.
|
||||||
attachments?: File[];
|
attachments?: File[];
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,7 @@ import type {
|
||||||
Artifact,
|
Artifact,
|
||||||
ChatAttachment,
|
ChatAttachment,
|
||||||
ChatCommentAttachment,
|
ChatCommentAttachment,
|
||||||
|
ChatSessionMode,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatMessageFeedbackChange,
|
ChatMessageFeedbackChange,
|
||||||
Conversation,
|
Conversation,
|
||||||
|
|
@ -178,6 +179,7 @@ import {
|
||||||
|
|
||||||
type ProjectChatSendMeta = ChatSendMeta & {
|
type ProjectChatSendMeta = ChatSendMeta & {
|
||||||
retryOfAssistantId?: string;
|
retryOfAssistantId?: string;
|
||||||
|
sessionMode?: ChatSessionMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -555,6 +557,11 @@ export function ProjectView({
|
||||||
const [activeConversationId, setActiveConversationId] = useState<string | null>(
|
const [activeConversationId, setActiveConversationId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const activeConversation = useMemo(
|
||||||
|
() => conversations.find((conversation) => conversation.id === activeConversationId) ?? null,
|
||||||
|
[conversations, activeConversationId],
|
||||||
|
);
|
||||||
|
const activeSessionMode = activeConversation?.sessionMode ?? 'design';
|
||||||
const [messagesConversationId, setMessagesConversationId] = useState<string | null>(null);
|
const [messagesConversationId, setMessagesConversationId] = useState<string | null>(null);
|
||||||
const [failedMessagesConversationId, setFailedMessagesConversationId] = useState<string | null>(null);
|
const [failedMessagesConversationId, setFailedMessagesConversationId] = useState<string | null>(null);
|
||||||
const [conversationLoadError, setConversationLoadError] = useState<string | null>(null);
|
const [conversationLoadError, setConversationLoadError] = useState<string | null>(null);
|
||||||
|
|
|
||||||
58
apps/web/src/components/SessionModeToggle.tsx
Normal file
58
apps/web/src/components/SessionModeToggle.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import type { ChatSessionMode } from '@open-design/contracts';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode: ChatSessionMode;
|
||||||
|
onChange?: (mode: ChatSessionMode) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE_META: Array<{
|
||||||
|
mode: ChatSessionMode;
|
||||||
|
label: string;
|
||||||
|
icon: 'comment' | 'sparkles';
|
||||||
|
title: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
mode: 'chat',
|
||||||
|
label: 'Chat',
|
||||||
|
icon: 'comment',
|
||||||
|
title:
|
||||||
|
'Chat mode: fast multi-turn answers with the same files, connectors, MCP servers, and attachments.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: 'design',
|
||||||
|
label: 'Design',
|
||||||
|
icon: 'sparkles',
|
||||||
|
title:
|
||||||
|
'Design mode: agent mode for generating HTML, PPT, slides, images, video, audio, and project files.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SessionModeToggle({ mode, onChange, disabled = false }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="session-mode-toggle" role="tablist" aria-label="Conversation mode">
|
||||||
|
{MODE_META.map((item) => {
|
||||||
|
const active = item.mode === mode;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.mode}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
className={`session-mode-toggle__option${active ? ' is-active' : ''}`}
|
||||||
|
disabled={disabled || !onChange}
|
||||||
|
title={item.title}
|
||||||
|
aria-label={item.title}
|
||||||
|
onClick={() => {
|
||||||
|
if (!active) onChange?.(item.mode);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={item.icon} size={13} />
|
||||||
|
<span className="session-mode-toggle__label">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import type {
|
import type {
|
||||||
AppliedPluginSnapshot,
|
AppliedPluginSnapshot,
|
||||||
ApplyResult,
|
ApplyResult,
|
||||||
|
ChatSessionMode,
|
||||||
CreateConversationRequest,
|
CreateConversationRequest,
|
||||||
CreatePluginShareProjectResponse,
|
CreatePluginShareProjectResponse,
|
||||||
CreateTerminalRequest,
|
CreateTerminalRequest,
|
||||||
|
|
@ -60,6 +61,7 @@ export async function createProject(input: {
|
||||||
designSystemId: string | null;
|
designSystemId: string | null;
|
||||||
pendingPrompt?: string;
|
pendingPrompt?: string;
|
||||||
metadata?: ProjectMetadata;
|
metadata?: ProjectMetadata;
|
||||||
|
conversationMode?: ChatSessionMode;
|
||||||
// Plan §3.A1 / spec §11.5 — POST /api/projects accepts a pluginId
|
// Plan §3.A1 / spec §11.5 — POST /api/projects accepts a pluginId
|
||||||
// (or pre-applied snapshot id) to resolve and pin a plugin to the new
|
// (or pre-applied snapshot id) to resolve and pin a plugin to the new
|
||||||
// project. Used by the PluginLoopHome flow on Home.
|
// project. Used by the PluginLoopHome flow on Home.
|
||||||
|
|
@ -246,10 +248,13 @@ export async function createConversation(
|
||||||
title?: string,
|
title?: string,
|
||||||
// Side Chat: seed the new conversation with another conversation's context
|
// Side Chat: seed the new conversation with another conversation's context
|
||||||
// by copying its messages. The daemon ignores a missing/foreign source id.
|
// by copying its messages. The daemon ignores a missing/foreign source id.
|
||||||
opts?: { seedFromConversationId?: string | null },
|
opts?: { seedFromConversationId?: string | null; sessionMode?: ChatSessionMode },
|
||||||
): Promise<Conversation | null> {
|
): Promise<Conversation | null> {
|
||||||
try {
|
try {
|
||||||
const body: CreateConversationRequest = { title };
|
const body: CreateConversationRequest = { title };
|
||||||
|
if (opts?.sessionMode) {
|
||||||
|
body.sessionMode = opts.sessionMode;
|
||||||
|
}
|
||||||
if (opts?.seedFromConversationId) {
|
if (opts?.seedFromConversationId) {
|
||||||
body.seedFromConversationId = opts.seedFromConversationId;
|
body.seedFromConversationId = opts.seedFromConversationId;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -576,6 +576,59 @@
|
||||||
}
|
}
|
||||||
.composer-row .icon-btn:hover:not(:disabled) { background: var(--bg-subtle); color: var(--text); }
|
.composer-row .icon-btn:hover:not(:disabled) { background: var(--bg-subtle); color: var(--text); }
|
||||||
.composer-spacer { flex: 1; }
|
.composer-spacer { flex: 1; }
|
||||||
|
.session-mode-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
height: 28px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
.session-mode-toggle__option {
|
||||||
|
appearance: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 22px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 7px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
background 160ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||||
|
color 160ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||||
|
box-shadow 160ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
}
|
||||||
|
.session-mode-toggle__option:hover:not(:disabled) {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.session-mode-toggle__option.is-active {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-strong);
|
||||||
|
box-shadow: 0 1px 2px color-mix(in srgb, var(--text) 10%, transparent);
|
||||||
|
}
|
||||||
|
.session-mode-toggle__option:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.session-mode-toggle__label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
.composer-import {
|
.composer-import {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type { RunContextSelection } from './context.js';
|
||||||
import type { MediaExecutionPolicy } from './media.js';
|
import type { MediaExecutionPolicy } from './media.js';
|
||||||
|
|
||||||
export type ChatRole = 'user' | 'assistant';
|
export type ChatRole = 'user' | 'assistant';
|
||||||
|
export type ChatSessionMode = 'design' | 'chat';
|
||||||
export type ChatCommentSelectionKind = PreviewCommentSelectionKind | 'visual';
|
export type ChatCommentSelectionKind = PreviewCommentSelectionKind | 'visual';
|
||||||
|
|
||||||
export interface ChatRequest {
|
export interface ChatRequest {
|
||||||
|
|
@ -21,6 +22,7 @@ export interface ChatRequest {
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
conversationId?: string | null;
|
conversationId?: string | null;
|
||||||
|
sessionMode?: ChatSessionMode;
|
||||||
assistantMessageId?: string | null;
|
assistantMessageId?: string | null;
|
||||||
clientRequestId?: string | null;
|
clientRequestId?: string | null;
|
||||||
skillId?: string | null;
|
skillId?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ChatMessage, ChatRunStatus } from './chat.js';
|
import type { ChatMessage, ChatRunStatus, ChatSessionMode } from './chat.js';
|
||||||
import type {
|
import type {
|
||||||
ProjectContextConnectorRef,
|
ProjectContextConnectorRef,
|
||||||
ProjectContextMcpServerRef,
|
ProjectContextMcpServerRef,
|
||||||
|
|
@ -193,6 +193,7 @@ export interface Conversation {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
sessionMode: ChatSessionMode;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
latestRun?: {
|
latestRun?: {
|
||||||
|
|
@ -212,6 +213,8 @@ export interface CreateProjectRequest {
|
||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
appliedPluginSnapshotId?: string;
|
appliedPluginSnapshotId?: string;
|
||||||
pluginInputs?: Record<string, unknown>;
|
pluginInputs?: Record<string, unknown>;
|
||||||
|
/** Session mode for the default conversation seeded with the project. */
|
||||||
|
conversationMode?: ChatSessionMode;
|
||||||
customInstructions?: string;
|
customInstructions?: string;
|
||||||
/** Persisted to metadata.skipDiscoveryBrief for automated project runs. */
|
/** Persisted to metadata.skipDiscoveryBrief for automated project runs. */
|
||||||
skipDiscoveryBrief?: boolean;
|
skipDiscoveryBrief?: boolean;
|
||||||
|
|
@ -287,6 +290,7 @@ export interface ConversationResponse {
|
||||||
|
|
||||||
export interface CreateConversationRequest {
|
export interface CreateConversationRequest {
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
|
sessionMode?: ChatSessionMode;
|
||||||
/**
|
/**
|
||||||
* Seed the new conversation with another conversation's context by copying
|
* Seed the new conversation with another conversation's context by copying
|
||||||
* its messages. The source must belong to the same project; a missing or
|
* its messages. The source must belong to the same project; a missing or
|
||||||
|
|
@ -299,6 +303,7 @@ export interface CreateConversationRequest {
|
||||||
|
|
||||||
export interface UpdateConversationRequest {
|
export interface UpdateConversationRequest {
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
|
sessionMode?: ChatSessionMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessagesResponse {
|
export interface MessagesResponse {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
* The composed string is what the daemon sees as `systemPrompt` and what
|
* The composed string is what the daemon sees as `systemPrompt` and what
|
||||||
* the Anthropic path sends as `system`.
|
* the Anthropic path sends as `system`.
|
||||||
*/
|
*/
|
||||||
|
import type { ChatSessionMode } from '../api/chat.js';
|
||||||
import type { ProjectMetadata, ProjectTemplate } from '../api/projects.js';
|
import type { ProjectMetadata, ProjectTemplate } from '../api/projects.js';
|
||||||
import { OFFICIAL_DESIGNER_PROMPT } from './official-system.js';
|
import { OFFICIAL_DESIGNER_PROMPT } from './official-system.js';
|
||||||
import { DISCOVERY_AND_PHILOSOPHY } from './discovery.js';
|
import { DISCOVERY_AND_PHILOSOPHY } from './discovery.js';
|
||||||
|
|
@ -191,6 +192,10 @@ export interface ComposeInput {
|
||||||
// When set to 'plain', suppresses tool_calls so API/BYOK-mode models
|
// When set to 'plain', suppresses tool_calls so API/BYOK-mode models
|
||||||
// only emit <artifact> blocks (they cannot execute tools).
|
// only emit <artifact> blocks (they cannot execute tools).
|
||||||
streamFormat?: string | undefined;
|
streamFormat?: string | undefined;
|
||||||
|
// Per-conversation mode. Design mode keeps the artifact-first agent
|
||||||
|
// workflow; chat mode keeps the same context/tools but answers like a
|
||||||
|
// standard multi-turn assistant unless the user explicitly asks to build.
|
||||||
|
sessionMode?: ChatSessionMode | undefined;
|
||||||
// UI locale selected by the client. User-visible generated form copy
|
// UI locale selected by the client. User-visible generated form copy
|
||||||
// must follow this locale even when the user's initial prompt is brief.
|
// must follow this locale even when the user's initial prompt is brief.
|
||||||
locale?: string | undefined;
|
locale?: string | undefined;
|
||||||
|
|
@ -216,6 +221,7 @@ export function composeSystemPrompt({
|
||||||
audioVoiceOptions,
|
audioVoiceOptions,
|
||||||
audioVoiceOptionsError,
|
audioVoiceOptionsError,
|
||||||
streamFormat,
|
streamFormat,
|
||||||
|
sessionMode,
|
||||||
locale,
|
locale,
|
||||||
userInstructions,
|
userInstructions,
|
||||||
projectInstructions,
|
projectInstructions,
|
||||||
|
|
@ -240,6 +246,11 @@ export function composeSystemPrompt({
|
||||||
parts.push('\n\n---\n\n');
|
parts.push('\n\n---\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sessionMode === 'chat') {
|
||||||
|
parts.push(CHAT_MODE_OVERRIDE);
|
||||||
|
parts.push('\n\n---\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
if (metadata?.skipDiscoveryBrief === true) {
|
if (metadata?.skipDiscoveryBrief === true) {
|
||||||
parts.push(SKIP_DISCOVERY_BRIEF_OVERRIDE);
|
parts.push(SKIP_DISCOVERY_BRIEF_OVERRIDE);
|
||||||
parts.push('\n\n---\n\n');
|
parts.push('\n\n---\n\n');
|
||||||
|
|
@ -398,6 +409,14 @@ Every later instruction in this prompt that tells you to "call TodoWrite", "run
|
||||||
|
|
||||||
If the rules below tell you to plan with TodoWrite, write the plan as prose instead. If they tell you to read skill side files before writing, describe in one sentence which patterns/conventions you're going to apply and proceed. If they tell you to run brand-spec extraction via Bash + Read + WebFetch, ask the user the missing brand questions in the discovery form instead.`;
|
If the rules below tell you to plan with TodoWrite, write the plan as prose instead. If they tell you to read skill side files before writing, describe in one sentence which patterns/conventions you're going to apply and proceed. If they tell you to run brand-spec extraction via Bash + Read + WebFetch, ask the user the missing brand questions in the discovery form instead.`;
|
||||||
|
|
||||||
|
const CHAT_MODE_OVERRIDE = `# Chat mode — standard conversation (read first — overrides every rule below)
|
||||||
|
|
||||||
|
This conversation is in Open Design Chat mode. Open Design is the open-source Claude Design alternative and a native Figma counterpart. Official links: GitHub https://github.com/nexu-io/open-design, website https://open-design.ai/, Discord https://discord.com/invite/9ptkbbqRu.
|
||||||
|
|
||||||
|
Use the same available context, files, attachments, connectors, MCP servers, project memory, and model capabilities as Design mode. The difference is behavior: answer like a fast, direct, multi-turn desktop chat assistant. Prefer concise prose, explanations, comparisons, debugging help, and follow-up questions only when needed.
|
||||||
|
|
||||||
|
Override artifact-first discovery rules below: do not emit a default discovery \`<question-form>\`, do not call TodoWrite just to plan a chat answer, and do not create or edit project files, HTML, PPT, slide decks, images, video, or audio unless the user explicitly asks you to generate/build/design/export/modify something. When the user does ask for a design artifact or file change, you may use the normal Open Design agent workflow and the same tools/capabilities available in Design mode.`;
|
||||||
|
|
||||||
function renderMetadataBlock(
|
function renderMetadataBlock(
|
||||||
metadata: ProjectMetadata | undefined,
|
metadata: ProjectMetadata | undefined,
|
||||||
template: ProjectTemplate | undefined,
|
template: ProjectTemplate | undefined,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,19 @@ describe('DISCOVERY_AND_PHILOSOPHY (contracts copy) — TodoWrite plan item coun
|
||||||
const prompt = composeSystemPrompt({});
|
const prompt = composeSystemPrompt({});
|
||||||
expect(prompt).not.toMatch(/5[–\-]10\s+short\s+imperative/);
|
expect(prompt).not.toMatch(/5[–\-]10\s+short\s+imperative/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses a top-level Chat mode override for conversational sessions', () => {
|
||||||
|
const prompt = composeSystemPrompt({ sessionMode: 'chat' });
|
||||||
|
|
||||||
|
expect(prompt).toContain('# Chat mode — standard conversation');
|
||||||
|
expect(prompt).toContain('https://github.com/nexu-io/open-design');
|
||||||
|
expect(prompt).toContain('https://open-design.ai/');
|
||||||
|
expect(prompt).toContain('https://discord.com/invite/9ptkbbqRu');
|
||||||
|
expect(prompt).toContain('do not emit a default discovery `<question-form>`');
|
||||||
|
expect(prompt.indexOf('# Chat mode — standard conversation')).toBeLessThan(
|
||||||
|
prompt.indexOf(DISCOVERY_AND_PHILOSOPHY),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('composeSystemPrompt', () => {
|
describe('composeSystemPrompt', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue