Compare commits

...
Author SHA1 Message Date
centdixandClaude Opus 4.5 1ea54ca592 refactor: simplify global ai draft items
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-05 17:20:27 +02:00
centdixandClaude Opus 4.5 d50f8adc44 fix: scope global ai mode to scripts and flows
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-05 16:57:53 +02:00
centdixandClaude Opus 4.5 a3cdbc4920 feat: add global ai draft mode
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-05 14:03:29 +02:00
centdixandClaude Opus 4.5 3d512a7d88 docs: add global ai mode plan
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-05 13:52:17 +02:00
6 changed files with 771 additions and 4 deletions
+193
View File
@@ -0,0 +1,193 @@
# Global AI Chat Mode POC Plan
## Goal
Add a POC "global" AI chat mode that can reason across workspace scripts and flows and create draft changes for them. For this POC, write and modify tools must not call save/update APIs. They should write to a frontend global draft store that can later feed the corresponding editor or viewer.
## Non-Goals For POC
- Do not persist AI-created or AI-modified items to the backend.
- Do not implement resources, triggers, schedules, variables, or datatables yet.
- Do not build a full diff/review UI beyond exposing enough draft state for follow-up rendering.
- Do not replace existing script, flow, app-mode, or API-mode chat behavior.
## Core Design
Global mode should be a new AI chat mode with a small curated tool surface:
1. `get_instructions`
- Returns focused instructions for a specific subject.
- Initial subjects: `script`, `flow`.
- The global mode system prompt should instruct the model to call this before writing or modifying that subject type.
2. `list_workspace_items`
- Lists current workspace scripts, flows, and AI drafts.
- Returns metadata only: `type`, `path`, `summary`, `language`, and `isDraft`.
- Does not include source code or flow JSON.
3. `read_workspace_item`
- Reads one item by `type` and `path`.
- If a draft exists, returns the draft item.
- Otherwise reads from the workspace through generated service clients.
- Initial supported types: `script`, `flow`.
4. `write_workspace_item`
- Creates a new draft item in the global AI draft store.
- Fails if the item exists in the real workspace or the draft store unless an explicit overwrite flag is provided.
- Does not call backend create/update APIs.
5. `modify_workspace_item`
- Creates or updates a draft overlay for an existing item.
- Requires the existing item to be read first so the model can produce a complete replacement value.
- Does not call backend create/update APIs.
## Draft Store Shape
Create a frontend store for AI workspace drafts. Suggested file:
- `frontend/src/lib/components/copilot/chat/global/draftStore.svelte.ts`
Suggested item/draft model:
```ts
type GlobalWorkspaceItemType = 'script' | 'flow'
type GlobalWorkspaceItem = {
type: GlobalWorkspaceItemType
path: string
summary?: string
value: unknown // script source code or OpenFlow value
language?: ScriptLang // required for scripts, omitted for flows
isDraft: boolean
}
```
Store operations:
1. `listDrafts()`
2. `getDraft(type, path)`
3. `setNewDraft(item, overwrite?)`
4. `setModifiedDraft(item)`
5. `deleteDraft(type, path)`
6. `clearDrafts()`
## Implementation Steps
### Step 1: Add Global Mode Skeleton
1. Add a new `AIMode.GLOBAL` entry in `AIChatManager.svelte.ts`.
2. Add mode wiring in `AIChatManager.changeMode`.
3. Create `frontend/src/lib/components/copilot/chat/global/core.ts`.
4. Export:
- `prepareGlobalSystemMessage(customPrompt?: string)`
- `prepareGlobalUserMessage(instructions: string)`
- `globalTools`
5. Keep the first system prompt short and demand-driven:
- list before broad reads;
- read before modify;
- call `get_instructions` before writing/modifying scripts or flows;
- clearly state that write/modify creates drafts only.
### Step 2: Add Draft Store
1. Create `global/draftStore.svelte.ts`.
2. Store drafts keyed by `${type}:${path}`.
3. Store drafts as simple `GlobalWorkspaceItem` values with `isDraft: true`.
4. Keep draft operations synchronous and frontend-only.
5. Add small utility functions for item keys.
### Step 3: Implement Instruction Tool
1. Add `get_instructions` with a Zod schema:
- `subject: 'script' | 'flow'`
2. Return concise, practical instructions:
- script: language, source-code value, main function expectations, path conventions;
- flow: `OpenFlow` value shape, modules, IDs, summaries, validation expectations.
3. Keep detailed examples short. This tool can grow later into a workspace skill system.
### Step 4: Implement Listing Tool
1. Add `list_workspace_items`.
2. Fetch workspace items with generated clients:
- `ScriptService.listScripts`
- `FlowService.listFlows`
3. Merge drafts from the draft store into the result.
4. Mark drafts with `isDraft: true`.
5. Return compact metadata only without `value`.
### Step 5: Implement Read Tool
1. Add `read_workspace_item`.
2. If a draft exists, return draft state first.
3. If no draft exists:
- scripts: `ScriptService.getScriptByPath`
- flows: `FlowService.getFlowByPath`
4. Normalize output as `{ type, path, summary, value, language, isDraft }` where `value` is script source code or the OpenFlow value.
### Step 6: Implement Write Draft Tool
1. Add `write_workspace_item`.
2. Validate item type and path.
3. Check for conflicts:
- existing draft with same key;
- existing workspace item with same type/path.
4. If conflict exists and `overwrite` is not true, return a clear error.
5. Store the draft as a `GlobalWorkspaceItem` with `isDraft: true`.
6. Return a short summary that explicitly says the workspace was not saved.
### Step 7: Implement Modify Draft Tool
1. Add `modify_workspace_item`.
2. Require existing draft or existing workspace item.
3. Store the modified item as a draft with `isDraft: true`.
4. Return a clear "draft only" message.
5. Consider adding base version/hash checks in a follow-up after the POC to reduce stale edits.
### Step 8: Wire UI Entry Point
1. Add Global mode wherever AI modes are selectable.
2. Ensure opening global mode does not clear script/flow editor-specific helper state unexpectedly.
3. Label the mode clearly as draft-based, for example `Workspace` or `Global`.
4. Make draft write/modify tool execution visible in the existing tool display.
### Step 9: Prepare For Editor Handoff
1. Add helper selectors in the draft store:
- `getScriptDraft(path)`
- `getFlowDraft(path)`
2. Do not wire full editor hydration in this POC unless trivial.
3. Document expected future handoff:
- script draft opens ScriptBuilder with draft content;
- flow draft opens FlowBuilder with draft `OpenFlow`.
### Step 10: Validation
Since this POC is frontend chat code:
1. Run `npm run check:fast` from `frontend/` during iteration.
2. Run `npm run check` from `frontend/` before finalizing.
3. Manually test in the running frontend:
- switch to global/workspace mode;
- list scripts and flows;
- read one script and one flow;
- create a new draft script;
- modify an existing item into a draft;
- verify no backend create/update API was called for write/modify.
## Suggested File Changes
- `frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts`
- `frontend/src/lib/components/copilot/chat/global/core.ts`
- `frontend/src/lib/components/copilot/chat/global/draftStore.svelte.ts`
- Any mode selector/display component that enumerates `AIMode`
- Focused frontend tests if existing chat tool tests can cover draft-store behavior
## Follow-Up After POC
1. Add draft review and diff UI.
2. Add editor handoff actions.
3. Add draft persistence if users need drafts to survive reloads.
4. Add other workspace item types if needed later.
5. Add resources, triggers, schedules, variables, and datatables.
6. Add permission-aware read filtering and secret redaction.
7. Add backend-backed apply/deploy flows through existing editors, not directly from the global tool.
@@ -69,6 +69,8 @@
return 'Navigate Windmill UI...'
case AIMode.API:
return 'Make API calls...'
case AIMode.GLOBAL:
return 'Work across scripts and flows...'
case AIMode.ASK:
return 'Ask questions about Windmill...'
default:
@@ -60,6 +60,7 @@ import { runChatLoop } from './chatLoop'
import type { ReviewChangesOpts } from './monaco-adapter'
import { getCurrentModel, tryGetCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore'
import type { WorkspaceMutationTarget } from './workspaceTools'
import { globalTools, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './global/core'
// If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message
const MAX_TOKENS_THRESHOLD_PERCENTAGE = 0.05
@@ -71,6 +72,7 @@ export enum AIMode {
APP = 'app',
NAVIGATOR = 'navigator',
API = 'API',
GLOBAL = 'global',
ASK = 'ask'
}
@@ -133,7 +135,8 @@ class AIChatManager {
app: this.appAiChatHelpers !== undefined,
navigator: true,
ask: true,
API: true
API: true,
global: true
})
open = $derived(chatState.size > 0)
@@ -319,6 +322,11 @@ class AIChatManager {
this.systemMessage = prepareApiSystemMessage(customPrompt)
this.tools = [...this.apiTools]
this.helpers = {}
} else if (mode === AIMode.GLOBAL) {
const customPrompt = getCombinedCustomPrompt(mode)
this.systemMessage = prepareGlobalSystemMessage(customPrompt)
this.tools = [...globalTools]
this.helpers = {}
} else if (mode === AIMode.APP) {
const customPrompt = getCombinedCustomPrompt(mode)
this.systemMessage = prepareAppSystemMessage(customPrompt)
@@ -335,14 +343,14 @@ class AIChatManager {
function: {
name: 'change_mode',
description:
'Change the AI mode to the one specified. Script mode is used to create scripts. Flow mode is used to create flows. Navigator mode is used to navigate the application and help the user find what they are looking for. API mode is used to make API calls to the Windmill backend.',
'Change the AI mode to the one specified. Script mode is used to create scripts. Flow mode is used to create flows. Global mode is used to inspect workspace scripts and flows and create draft changes. Navigator mode is used to navigate the application and help the user find what they are looking for. API mode is used to make API calls to the Windmill backend.',
parameters: {
type: 'object',
properties: {
mode: {
type: 'string',
description: 'The mode to change to',
enum: ['script', 'flow', 'navigator', 'API']
enum: ['script', 'flow', 'global', 'navigator', 'API']
},
pendingPrompt: {
type: 'string',
@@ -488,6 +496,8 @@ class AIChatManager {
)
} else if (this.mode === AIMode.NAVIGATOR) {
return prepareNavigatorUserMessage(pendingPrompt)
} else if (this.mode === AIMode.GLOBAL) {
return prepareGlobalUserMessage(pendingPrompt)
}
return undefined
},
@@ -698,6 +708,9 @@ class AIChatManager {
case AIMode.API:
userMessage = prepareApiUserMessage(oldInstructions)
break
case AIMode.GLOBAL:
userMessage = prepareGlobalUserMessage(oldInstructions)
break
case AIMode.APP:
userMessage = prepareAppUserMessage(
oldInstructions,
@@ -0,0 +1,480 @@
import { $ScriptLang, FlowService, ScriptService } from '$lib/gen'
import { getFlowPrompt, getScriptPrompt } from '$system_prompts'
import type { Flow, Script, ScriptLang } from '$lib/gen/types.gen'
import type {
ChatCompletionSystemMessageParam,
ChatCompletionUserMessageParam
} from 'openai/resources/chat/completions.mjs'
import { z } from 'zod'
import { createToolDef, type Tool } from '../shared'
import {
globalDraftStore,
getGlobalDraftKey,
type GlobalWorkspaceItem,
type GlobalWorkspaceItemType
} from './draftStore.svelte'
const ITEM_TYPES = ['script', 'flow'] as const satisfies readonly GlobalWorkspaceItemType[]
const MAX_LIST_LIMIT = 100
const DEFAULT_SCRIPT_LANGUAGE: ScriptLang = 'bun'
const itemTypeSchema = z.enum(ITEM_TYPES)
const scriptLanguageSchema = z.enum($ScriptLang.enum)
const getInstructionsSchema = z.object({
subject: itemTypeSchema.describe('The workspace item type to get authoring instructions for.'),
language: scriptLanguageSchema
.optional()
.describe(
'Required when subject is script. Use the existing script language when modifying, or the requested target language when creating.'
)
})
const listWorkspaceItemsSchema = z.object({
types: z
.array(itemTypeSchema)
.optional()
.describe('Optional item types to list. Defaults to scripts and flows.'),
query: z.string().optional().describe('Optional case-insensitive path or summary search string.'),
path_prefix: z
.string()
.optional()
.describe('Optional path prefix filter, such as f/ or u/user/.'),
limit: z
.number()
.int()
.min(1)
.max(MAX_LIST_LIMIT)
.optional()
.describe('Maximum number of items to return. Defaults to 50 and is capped at 100.')
})
const readWorkspaceItemSchema = z.object({
type: itemTypeSchema,
path: z.string().describe('Workspace path of the item to read.')
})
const workspaceItemDraftBaseSchema = z.object({
type: itemTypeSchema,
path: z.string().describe('Workspace path of the item.'),
summary: z.string().optional().describe('Short summary of the item.'),
value: z
.any()
.describe('For scripts, the complete script source code. For flows, the complete OpenFlow value.'),
language: scriptLanguageSchema
.optional()
.describe('Required for script items. Omit for flow items.')
})
type WorkspaceItemDraftInput = z.infer<typeof workspaceItemDraftBaseSchema>
function validateWorkspaceItemDraft(
item: WorkspaceItemDraftInput,
ctx: z.RefinementCtx
): void {
if (item.type === 'script' && !item.language) {
ctx.addIssue({
code: 'custom',
path: ['language'],
message: 'language is required for script items'
})
}
}
const writeWorkspaceItemSchema = workspaceItemDraftBaseSchema
.extend({
overwrite: z
.boolean()
.optional()
.describe('If true, replace an existing AI draft with the same type and path.')
})
.superRefine(validateWorkspaceItemDraft)
const modifyWorkspaceItemSchema = workspaceItemDraftBaseSchema.superRefine(validateWorkspaceItemDraft)
type WorkspaceItemMetadata = Omit<GlobalWorkspaceItem, 'value'>
const GLOBAL_SYSTEM_PROMPT = `You are Windmill's global workspace assistant.
You can inspect workspace scripts and flows, then create draft changes in the frontend AI draft store.
Important rules:
- Writes and modifications are drafts only. They do not save, deploy, or mutate backend workspace items.
- A workspace item is { type, path, summary, value, language, isDraft }.
- For scripts, value is the complete script source code and language is required.
- For flows, value is the complete OpenFlow value and language is omitted.
- list_workspace_items returns metadata only and omits value. Use read_workspace_item for the current value.
- Use list_workspace_items before broad reads.
- Use read_workspace_item before modifying an existing item unless the user already provided the complete current item.
- Use get_instructions before writing or modifying a script or flow. For scripts, pass the target language; when modifying, use the language from the item you read.
- Keep context targeted. Do not read unrelated items.
- Be explicit with the user when you create or update a draft.`
function getRequestedTypes(
types: GlobalWorkspaceItemType[] | undefined
): GlobalWorkspaceItemType[] {
return types && types.length > 0 ? types : [...ITEM_TYPES]
}
function itemMatches(
item: Pick<WorkspaceItemMetadata, 'path' | 'summary'>,
query: string | undefined,
pathPrefix: string | undefined
): boolean {
if (pathPrefix && !item.path.startsWith(pathPrefix)) {
return false
}
const normalizedQuery = query?.trim().toLowerCase()
if (!normalizedQuery) {
return true
}
return (
item.path.toLowerCase().includes(normalizedQuery) ||
(item.summary?.toLowerCase().includes(normalizedQuery) ?? false)
)
}
function scriptToWorkspaceItem(script: Script): GlobalWorkspaceItem {
return {
type: 'script',
path: script.path,
summary: script.summary,
value: script.content,
language: script.language,
isDraft: false
}
}
function flowToWorkspaceItem(flow: Flow): GlobalWorkspaceItem {
return {
type: 'flow',
path: flow.path,
summary: flow.summary,
value: flow.value,
isDraft: false
}
}
function toMetadata(item: GlobalWorkspaceItem): WorkspaceItemMetadata {
return {
type: item.type,
path: item.path,
summary: item.summary,
language: item.language,
isDraft: item.isDraft
}
}
function toDraftInput(item: WorkspaceItemDraftInput): Omit<GlobalWorkspaceItem, 'isDraft'> {
return {
type: item.type,
path: item.path,
summary: item.summary,
value: item.value,
language: item.type === 'script' ? item.language : undefined
}
}
async function workspaceItemExists(
type: GlobalWorkspaceItemType,
path: string,
workspace: string
): Promise<boolean> {
switch (type) {
case 'script':
return ScriptService.existsScriptByPath({ workspace, path })
case 'flow':
return FlowService.existsFlowByPath({ workspace, path })
}
}
async function readWorkspaceItem(
type: GlobalWorkspaceItemType,
path: string,
workspace: string
): Promise<GlobalWorkspaceItem> {
switch (type) {
case 'script':
return scriptToWorkspaceItem(await ScriptService.getScriptByPath({ workspace, path }))
case 'flow':
return flowToWorkspaceItem(await FlowService.getFlowByPath({ workspace, path }))
}
}
async function listWorkspaceMetadata(
types: GlobalWorkspaceItemType[],
workspace: string,
pathPrefix: string | undefined,
perPage: number
): Promise<Map<string, WorkspaceItemMetadata>> {
const items = new Map<string, WorkspaceItemMetadata>()
if (types.includes('script')) {
const scripts = await ScriptService.listScripts({
workspace,
pathStart: pathPrefix,
perPage,
includeDraftOnly: true,
withoutDescription: true
})
for (const script of scripts) {
items.set(getGlobalDraftKey('script', script.path), toMetadata(scriptToWorkspaceItem(script)))
}
}
if (types.includes('flow')) {
const flows = await FlowService.listFlows({
workspace,
pathStart: pathPrefix,
perPage,
includeDraftOnly: true,
withoutDescription: true
})
for (const flow of flows) {
items.set(getGlobalDraftKey('flow', flow.path), toMetadata(flowToWorkspaceItem(flow)))
}
}
return items
}
function getScriptInstructions(language: ScriptLang | undefined): string {
const selectedLanguage = language ?? DEFAULT_SCRIPT_LANGUAGE
const defaultLanguageNote = language
? ''
: `\n- No script language was provided. Default to \`${DEFAULT_SCRIPT_LANGUAGE}\` only for new TypeScript scripts; if the user requested another language or you read an existing script, call get_instructions again with that language.`
return `# Global draft script instructions
- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.
- Draft payloads for scripts are workspace items: \`{ type: "script", path, summary, value, language }\`.
- For scripts, \`value\` is the complete script source code and \`language\` is required.
- Use workspace paths such as \`f/folder/name\` or \`u/username/name\`. Preserve the current path/language when modifying unless the user asked to change them.
- Return the full desired script draft to \`write_workspace_item\` or \`modify_workspace_item\`, not a patch or partial object.${defaultLanguageNote}
# Windmill script authoring reference (${selectedLanguage})
${getScriptPrompt(selectedLanguage)}`
}
function getFlowInstructions(): string {
return `# Global draft flow instructions
- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.
- Draft payloads for flows are workspace items: \`{ type: "flow", path, summary, value }\`.
- For flows, \`value\` is the complete OpenFlow value. Omit \`language\`.
- \`value.modules\` contains normal sequential modules. Use top-level \`value.preprocessor_module\` and \`value.failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`value.modules\`.
- Every module needs a stable unique \`id\` and a useful \`summary\` when the schema supports it.
- Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed.
- When writing rawscript module code, call \`get_instructions\` with \`subject: "script"\` and the rawscript language first.
- Return the full desired flow draft to \`write_workspace_item\` or \`modify_workspace_item\`, not a patch or partial object.
# Windmill flow authoring reference
${getFlowPrompt()}`
}
function getInstructions(
subject: GlobalWorkspaceItemType,
language?: ScriptLang
): string {
switch (subject) {
case 'script':
return getScriptInstructions(language)
case 'flow':
return getFlowInstructions()
}
}
export const globalTools: Tool<{}>[] = [
{
def: createToolDef(
getInstructionsSchema,
'get_instructions',
'Get Windmill authoring instructions for scripts or flows. For scripts, pass the target language.'
),
fn: async ({ args, toolId, toolCallbacks }) => {
const parsedArgs = getInstructionsSchema.parse(args)
const subjectLabel =
parsedArgs.subject === 'script' && parsedArgs.language
? `${parsedArgs.subject} (${parsedArgs.language})`
: parsedArgs.subject
toolCallbacks.setToolStatus(toolId, {
content: `Loaded ${subjectLabel} instructions`
})
return getInstructions(parsedArgs.subject, parsedArgs.language)
}
},
{
def: createToolDef(
listWorkspaceItemsSchema,
'list_workspace_items',
'List workspace scripts, flows, and AI drafts. Returns metadata only.'
),
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
const parsedArgs = listWorkspaceItemsSchema.parse(args)
const types = getRequestedTypes(parsedArgs.types)
const limit = parsedArgs.limit ?? 50
toolCallbacks.setToolStatus(toolId, { content: 'Listing workspace items...' })
const workspaceItems = await listWorkspaceMetadata(
types,
workspace,
parsedArgs.path_prefix,
Math.min(limit, MAX_LIST_LIMIT)
)
for (const draft of globalDraftStore.listDrafts()) {
if (!types.includes(draft.type)) continue
const key = getGlobalDraftKey(draft.type, draft.path)
const existing = workspaceItems.get(key)
const draftMetadata = toMetadata(draft)
workspaceItems.set(key, {
...draftMetadata,
summary: draftMetadata.summary ?? existing?.summary
})
}
const results = Array.from(workspaceItems.values())
.filter((item) => itemMatches(item, parsedArgs.query, parsedArgs.path_prefix))
.slice(0, limit)
toolCallbacks.setToolStatus(toolId, {
content: `Listed ${results.length} workspace item(s)`
})
return JSON.stringify(results, null, 2)
}
},
{
def: createToolDef(
readWorkspaceItemSchema,
'read_workspace_item',
'Read one workspace item or AI draft by type and path.'
),
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
const parsedArgs = readWorkspaceItemSchema.parse(args)
const draft = globalDraftStore.getDraft(parsedArgs.type, parsedArgs.path)
if (draft) {
toolCallbacks.setToolStatus(toolId, {
content: `Read AI draft ${parsedArgs.type} "${parsedArgs.path}"`
})
return JSON.stringify(draft, null, 2)
}
toolCallbacks.setToolStatus(toolId, {
content: `Reading ${parsedArgs.type} "${parsedArgs.path}"...`
})
const item = await readWorkspaceItem(parsedArgs.type, parsedArgs.path, workspace)
toolCallbacks.setToolStatus(toolId, {
content: `Read ${parsedArgs.type} "${parsedArgs.path}"`
})
return JSON.stringify(item, null, 2)
}
},
{
def: createToolDef(
writeWorkspaceItemSchema,
'write_workspace_item',
'Create a new AI draft workspace item. This does not save or deploy anything to the workspace.'
),
showDetails: true,
streamArguments: true,
showFade: true,
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
const parsedArgs = writeWorkspaceItemSchema.parse(args)
const draftInput = toDraftInput(parsedArgs)
toolCallbacks.setToolStatus(toolId, {
content: `Creating draft ${draftInput.type} "${draftInput.path}"...`
})
if (await workspaceItemExists(draftInput.type, draftInput.path, workspace)) {
const message = `${draftInput.type} "${draftInput.path}" already exists in the workspace. Use modify_workspace_item to create a draft overlay.`
toolCallbacks.setToolStatus(toolId, { content: message, error: message })
return JSON.stringify({ success: false, error: message })
}
try {
const draft = globalDraftStore.setNewDraft(draftInput, parsedArgs.overwrite ?? false)
toolCallbacks.setToolStatus(toolId, {
content: `Created AI draft ${draft.type} "${draft.path}"`,
result: 'Draft created'
})
return JSON.stringify(
{
success: true,
message: `Created AI draft ${draft.type} "${draft.path}". The workspace was not saved or deployed.`,
draft
},
null,
2
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
toolCallbacks.setToolStatus(toolId, { content: message, error: message })
return JSON.stringify({ success: false, error: message })
}
}
},
{
def: createToolDef(
modifyWorkspaceItemSchema,
'modify_workspace_item',
'Create or update an AI draft overlay for an existing workspace item or draft. This does not save or deploy anything.'
),
showDetails: true,
streamArguments: true,
showFade: true,
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
const parsedArgs = modifyWorkspaceItemSchema.parse(args)
const draftInput = toDraftInput(parsedArgs)
toolCallbacks.setToolStatus(toolId, {
content: `Updating draft ${draftInput.type} "${draftInput.path}"...`
})
const existingDraft = globalDraftStore.getDraft(draftInput.type, draftInput.path)
if (!existingDraft && !(await workspaceItemExists(draftInput.type, draftInput.path, workspace))) {
const message = `${draftInput.type} "${draftInput.path}" does not exist in the workspace or AI draft store. Use write_workspace_item for new items.`
toolCallbacks.setToolStatus(toolId, { content: message, error: message })
return JSON.stringify({ success: false, error: message })
}
const draft = globalDraftStore.setModifiedDraft(draftInput)
toolCallbacks.setToolStatus(toolId, {
content: `Updated AI draft ${draft.type} "${draft.path}"`,
result: 'Draft updated'
})
return JSON.stringify(
{
success: true,
message: `Updated AI draft ${draft.type} "${draft.path}". The workspace was not saved or deployed.`,
draft
},
null,
2
)
}
}
]
export function prepareGlobalSystemMessage(
customPrompt?: string
): ChatCompletionSystemMessageParam {
let content = GLOBAL_SYSTEM_PROMPT
if (customPrompt?.trim()) {
content = `${content}\n\nUSER GIVEN INSTRUCTIONS:\n${customPrompt.trim()}`
}
return {
role: 'system',
content
}
}
export function prepareGlobalUserMessage(instructions: string): ChatCompletionUserMessageParam {
return {
role: 'user',
content: instructions
}
}
@@ -0,0 +1,77 @@
import type { ScriptLang } from '$lib/gen/types.gen'
export type GlobalWorkspaceItemType = 'script' | 'flow'
export type GlobalWorkspaceItem = {
type: GlobalWorkspaceItemType
path: string
summary?: string
value: unknown
language?: ScriptLang
isDraft: boolean
}
export function getGlobalDraftKey(type: GlobalWorkspaceItemType, path: string): string {
return `${type}:${path}`
}
function cloneValue<T>(value: T): T {
return structuredClone($state.snapshot(value)) as T
}
type DraftInput = Omit<GlobalWorkspaceItem, 'isDraft'>
function toDraftItem(input: DraftInput): GlobalWorkspaceItem {
return {
...cloneValue(input),
isDraft: true
}
}
class GlobalDraftStore {
private drafts = $state<Record<string, GlobalWorkspaceItem>>({})
listDrafts(): GlobalWorkspaceItem[] {
return Object.values(this.drafts).map((draft) => cloneValue(draft))
}
getDraft(type: GlobalWorkspaceItemType, path: string): GlobalWorkspaceItem | undefined {
const draft = this.drafts[getGlobalDraftKey(type, path)]
return draft ? cloneValue(draft) : undefined
}
setNewDraft(input: DraftInput, overwrite = false): GlobalWorkspaceItem {
const key = getGlobalDraftKey(input.type, input.path)
if (this.drafts[key] && !overwrite) {
throw new Error(`A draft already exists for ${input.type} "${input.path}".`)
}
const item = toDraftItem(input)
this.drafts[key] = item
return cloneValue(item)
}
setModifiedDraft(input: DraftInput): GlobalWorkspaceItem {
const item = toDraftItem(input)
this.drafts[getGlobalDraftKey(input.type, input.path)] = item
return cloneValue(item)
}
deleteDraft(type: GlobalWorkspaceItemType, path: string): void {
delete this.drafts[getGlobalDraftKey(type, path)]
}
clearDrafts(): void {
this.drafts = {}
}
getScriptDraft(path: string): GlobalWorkspaceItem | undefined {
return this.getDraft('script', path)
}
getFlowDraft(path: string): GlobalWorkspaceItem | undefined {
return this.getDraft('flow', path)
}
}
export const globalDraftStore = new GlobalDraftStore()
@@ -31,6 +31,7 @@
[AIMode.APP]: 'Enter custom instructions for UI and app development',
[AIMode.NAVIGATOR]: 'Enter custom instructions for navigation and guidance',
[AIMode.API]: 'Enter custom instructions for API interactions and integrations',
[AIMode.GLOBAL]: 'Enter custom instructions for workspace-wide draft assistance',
[AIMode.ASK]: 'Enter custom instructions for general questions and assistance'
}
@@ -40,6 +41,7 @@
[AIMode.APP]: 'App Mode',
[AIMode.NAVIGATOR]: 'Navigator Mode',
[AIMode.API]: 'API Mode',
[AIMode.GLOBAL]: 'Global Mode',
[AIMode.ASK]: 'Ask Mode'
}
@@ -75,7 +77,7 @@
{/if}
</div>
{#each Object.values(AIMode) as mode}
{#each Object.values(AIMode) as mode (mode)}
<div class="flex flex-col gap-2 pb-4 last:border-b-0">
<Label label={modeLabels[mode]} for={`custom-prompt-${mode}`}>
<TextInput