Skip to content
Draft
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
6 changes: 6 additions & 0 deletions packages/base/command.gts
Original file line number Diff line number Diff line change
Expand Up @@ -335,8 +335,14 @@ export class CorrectnessResultCard extends CardDef {

export class CreateAIAssistantRoomInput extends CardDef {
@field name = contains(StringField);
// Legacy: skills passed as loaded `Skill` cards. Retained for back-compat.
@field enabledSkills = linksToMany(Skill);
@field disabledSkills = linksToMany(Skill);
// Skills passed by id (kind-agnostic): each id may name a `.md` skill file
// (`boxel.kind: skill`) or a legacy `Skill` card. Resolved via
// `loadSkillSource` at room creation. Preferred over the card fields above.
@field enabledSkillIds = containsMany(StringField);
@field disabledSkillIds = containsMany(StringField);
@field llmMode = contains(StringField); // 'gpt-4o' or 'gpt-4o-mini'
}

Expand Down
36 changes: 36 additions & 0 deletions packages/base/system-card.gts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import BooleanField from './boolean';
import StringField from './string';
import enumField from './enum';
import { MarkdownDef } from './markdown-file-def';
import { getMenuItems, rri } from '@cardstack/runtime-common';
import { type GetMenuItemParams } from './menu-items';
import { type MenuItemOptions, MenuItem } from '@cardstack/boxel-ui/helpers';
Expand Down Expand Up @@ -81,6 +82,16 @@ export class SystemCard extends CardDef {
searchable: true,
});

// Skills enabled by default in new AI assistant rooms. These are `.md` skill
// files (`MarkdownDef` whose `boxel.kind: skill` frontmatter makes them a
// skill source). When set on the user's active system card, they replace the
// host's hardcoded default-skill list for new rooms.
@field defaultSkills = linksToMany(MarkdownDef, {
description:
'Skills enabled by default in new AI assistant rooms (markdown skill files)',
searchable: true,
});

[getMenuItems](params: GetMenuItemParams): MenuItemOptions[] {
let menuItems = super[getMenuItems](params);
menuItems = [
Expand Down Expand Up @@ -359,6 +370,15 @@ class SystemCardIsolated extends Component<typeof SystemCard> {
<div class='system-card-content'>
<@fields.defaultModelConfiguration />
<@fields.modelConfigurations />

<section class='default-skills'>
<h3 class='section-heading'>Default Skills</h3>
<p class='section-hint'>
Skills enabled automatically in new AI assistant sessions when this
is your active system card.
</p>
<@fields.defaultSkills @format='fitted' />
</section>
</div>
</div>

Expand Down Expand Up @@ -484,6 +504,22 @@ class SystemCardIsolated extends Component<typeof SystemCard> {
.system-card-content {
padding-top: var(--boxel-sp-sm);
}

.default-skills {
margin-top: var(--boxel-sp-lg);
}

.section-heading {
margin: 0 0 var(--boxel-sp-4xs);
font-size: var(--boxel-font-size);
font-weight: 600;
}

.section-hint {
margin: 0 0 var(--boxel-sp-sm);
font-size: var(--boxel-font-size-sm);
color: var(--boxel-500, #6b7280);
}
</style>
</template>
}
Expand Down
4 changes: 2 additions & 2 deletions packages/host/app/commands/ask-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@ export default class AskAiCommand extends HostBaseCommand<
this.commandContext,
);

let [skills, openCards] = await Promise.all([
let [skillIds, openCards] = await Promise.all([
this.matrixService.loadDefaultSkills('code') || Promise.resolve([]),
this.operatorModeStateService.getOpenCards.perform() ||
Promise.resolve([]),
]);
let { roomId } = await createRoomCommand.execute({
name: 'AI App Generator Assistant',
enabledSkills: skills,
enabledSkillIds: skillIds,
llmMode: input.llmMode,
});
await sendMessageCommand.execute({
Expand Down
140 changes: 109 additions & 31 deletions packages/host/app/commands/create-ai-assistant-room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,30 @@ import {
DEFAULT_FALLBACK_MODEL_ID,
} from '@cardstack/runtime-common/matrix-constants';

import type { CardDef } from 'https://cardstack.com/base/card-api';
import type * as BaseCommandModule from 'https://cardstack.com/base/command';

import type { FileDef } from 'https://cardstack.com/base/file-api';
import type * as SkillModule from 'https://cardstack.com/base/skill';

import { isSkillCard } from '../lib/file-def-manager';

import HostBaseCommand from '../lib/host-base-command';
import {
getSkillSourceCommands,
loadSkillSource,
type SkillSource,
} from '../lib/skill-commands';

import type MatrixService from '../services/matrix-service';
import type StoreService from '../services/store';

export default class CreateAiAssistantRoomCommand extends HostBaseCommand<
typeof BaseCommandModule.CreateAIAssistantRoomInput,
typeof BaseCommandModule.CreateAIAssistantRoomResult
> {
@service declare private matrixService: MatrixService;
@service declare private store: StoreService;

static actionVerb = 'Create';

Expand All @@ -48,6 +59,83 @@ export default class CreateAiAssistantRoomCommand extends HostBaseCommand<
return CreateAIAssistantRoomInput;
}

// Collect skill ids from both the id fields (preferred, kind-agnostic) and
// the legacy loaded-`Skill`-card fields, de-duped. A skill listed as both
// enabled and disabled is treated as enabled.
private collectSkillIds(
input: BaseCommandModule.CreateAIAssistantRoomInput,
): { enabledIds: string[]; disabledIds: string[] } {
let idsOf = (
ids: string[] | undefined,
cards: SkillModule.Skill[] | undefined,
): string[] => [
...(ids ?? []),
...(cards ?? [])
.map((c) => c.id)
.filter((id): id is NonNullable<typeof id> => Boolean(id)),
];

let enabledIds = Array.from(
new Set(idsOf(input.enabledSkillIds, input.enabledSkills)),
);
let enabledSet = new Set(enabledIds);
let disabledIds = Array.from(
new Set(idsOf(input.disabledSkillIds, input.disabledSkills)),
).filter((id) => !enabledSet.has(id));

return { enabledIds, disabledIds };
}

// Resolve skill ids to their room-skills-config file defs, uploading skill
// cards and `.md` skill files by their respective paths (mirrors the split in
// `UpdateRoomSkillsCommand`). Returns the uploaded FileDefs plus the resolved
// skill sources so the caller can gather commands.
private async resolveSkills(ids: string[]): Promise<{
fileDefs: FileDef[];
sources: SkillSource[];
}> {
let skillCardsToUpload: SkillModule.Skill[] = [];
let markdownSkillsToUpload: FileDef[] = [];
let sources: SkillSource[] = [];

await Promise.all(
ids.map(async (id) => {
try {
let source = await loadSkillSource(this.store, id);
if (!source) {
console.warn(
`[CreateAiAssistantRoomCommand] skipping skill "${id}": not a skill card or skill markdown file`,
);
return;
}
sources.push(source);
if (isSkillCard in source) {
skillCardsToUpload.push(source as SkillModule.Skill);
} else {
markdownSkillsToUpload.push(source as FileDef);
}
} catch (e) {
console.warn(
`[CreateAiAssistantRoomCommand] skipping skill "${id}": ${e}`,
);
}
}),
);

let fileDefs: FileDef[] = [];
if (skillCardsToUpload.length) {
fileDefs = fileDefs.concat(
await this.matrixService.uploadCards(skillCardsToUpload as CardDef[]),
);
}
if (markdownSkillsToUpload.length) {
fileDefs = fileDefs.concat(
await this.matrixService.uploadFiles(markdownSkillsToUpload),
);
}
return { fileDefs, sources };
}

protected async run(
input: BaseCommandModule.CreateAIAssistantRoomInput,
): Promise<BaseCommandModule.CreateAIAssistantRoomResult> {
Expand All @@ -60,28 +148,21 @@ export default class CreateAiAssistantRoomCommand extends HostBaseCommand<
'Requires userId to execute CreateAiAssistantRoomCommand',
);
}
let { enabledSkills, disabledSkills } = input;
let enabledSkillFileDefs: FileDef[] | undefined;
let commandFileDefs: FileDef[] | undefined;
let disabledSkillFileDefs: FileDef[] | undefined;

if (enabledSkills?.length) {
enabledSkillFileDefs = await matrixService.uploadCards(enabledSkills);
}
if (disabledSkills?.length) {
disabledSkillFileDefs = await matrixService.uploadCards(disabledSkills);
} else {
disabledSkillFileDefs = [];
}

const commandDefinitions = [
...(enabledSkills?.flatMap((skill) => skill.commands) || []),
...(disabledSkills?.flatMap((skill) => skill.commands) || []),
];
let { enabledIds, disabledIds } = this.collectSkillIds(input);
let [enabled, disabled] = await Promise.all([
this.resolveSkills(enabledIds),
this.resolveSkills(disabledIds),
]);

let commandDefinitions = [...enabled.sources, ...disabled.sources].flatMap(
(source) => getSkillSourceCommands(source),
);
let commandFileDefs: FileDef[] = [];
if (commandDefinitions.length) {
commandFileDefs =
await matrixService.uploadCommandDefinitions(commandDefinitions);
commandFileDefs = await matrixService.uploadCommandDefinitions(
matrixService.getUniqueCommandDefinitions(commandDefinitions),
);
}

// Run room creation and module loading in parallel
Expand Down Expand Up @@ -118,18 +199,15 @@ export default class CreateAiAssistantRoomCommand extends HostBaseCommand<
{
type: APP_BOXEL_ROOM_SKILLS_EVENT_TYPE,
content: {
enabledSkillCards:
enabledSkillFileDefs?.map((skillFileDef) =>
skillFileDef.serialize(),
) ?? [],
disabledSkillCards:
disabledSkillFileDefs?.map((skillFileDef) =>
skillFileDef.serialize(),
) ?? [],
commandDefinitions:
commandFileDefs?.map((commandFileDef) =>
commandFileDef.serialize(),
) ?? [],
enabledSkillCards: enabled.fileDefs.map((fileDef) =>
fileDef.serialize(),
),
disabledSkillCards: disabled.fileDefs.map((fileDef) =>
fileDef.serialize(),
),
commandDefinitions: commandFileDefs.map((commandFileDef) =>
commandFileDef.serialize(),
),
},
},
],
Expand Down
12 changes: 12 additions & 0 deletions packages/host/app/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,17 @@ export function skillCardURL(skillId: string): string {
return `@cardstack/skills/Skill/${skillId}`;
}

/**
* Constructs a universal @cardstack/skills/ reference to a `.md` skill file
* (`skills/<name>/SKILL.md`) — the markdown skill form, resolved as a
* `MarkdownDef` whose `boxel.kind: skill` frontmatter makes it a skill source.
*
* @example
* skillFileURL('source-code-editing') // '@cardstack/skills/skills/source-code-editing/SKILL.md'
*/
export function skillFileURL(skillName: string): string {
return `@cardstack/skills/skills/${skillName}/SKILL.md`;
}

export const devSkillId = `@cardstack/skills/${devSkillLocalPath}`;
export const envSkillId = `@cardstack/skills/${envSkillLocalPath}`;
Loading
Loading