diff --git a/common/grammar.ts b/common/grammar.ts index f0ed182c3..575f84f29 100644 --- a/common/grammar.ts +++ b/common/grammar.ts @@ -135,6 +135,7 @@ MainCharacter "main-character" = ("mainchar"i / "mainbot"i) { return "main-char" Character "character" = ("char"i / "character"i / "bot"i) { return "char" } User "user" = "user"i { return "user" } Scenario "scenario" = "scenario"i { return "scenario" } +SceneClock "scene-clock" = ("scene_clock"i / "sceneclock"i) { return "scene_clock" } Impersonate "impersonating" = ("impersonate"i / "impersonating"i / "impersonality"i) { return "impersonating" } Persona "personality" = ("personality"i / "persona"i) { return "personality" } AllPersona "all_personalities" = ("all_personas"i / "all_personalities"i) { return "all_personalities" } @@ -173,6 +174,7 @@ Interp "interp" / UserEmbed / User / Scenario + / SceneClock / Persona / Impersonate / AllPersona diff --git a/common/prompt-order.ts b/common/prompt-order.ts index 03c096504..6e7caf967 100644 --- a/common/prompt-order.ts +++ b/common/prompt-order.ts @@ -15,9 +15,10 @@ export function promptOrderToTemplate( ) { const parts: string[] = [] const preamble = getOrderHolder(format, 'preamble') + const normalized = withSceneClock(order) if (preamble) parts.push(preamble) - for (const item of order) { + for (const item of normalized) { if (!item.enabled) continue const text = getOrderHolder(format, item.placeholder) @@ -36,7 +37,7 @@ export function promptOrderToTemplate( } export function promptOrderToSections(opts: OrderOptions) { - const order = (opts.order || SIMPLE_ORDER).filter( + const order = withSceneClock(opts.order || SIMPLE_ORDER).filter( (o) => o.placeholder !== 'system_prompt' && o.placeholder !== 'ujb' && @@ -75,9 +76,23 @@ function getOrderHolder(format: string, holder: string) { return formatHolders[format]?.[holder] || formatHolders.Universal[holder] || '' } +function withSceneClock(order: NonNullable) { + if (order.some((item) => item.placeholder === 'scene_clock')) return order + + const index = order.findIndex((item) => item.placeholder === 'scenario') + const sceneClock = { placeholder: 'scene_clock', enabled: true } + + if (index === -1) { + return [sceneClock, ...order] + } + + return order.slice(0, index + 1).concat(sceneClock, order.slice(index + 1)) +} + export const SIMPLE_ORDER: NonNullable = [ 'system_prompt', 'scenario', + 'scene_clock', 'personality', 'chat_embed', 'memory', @@ -89,6 +104,7 @@ export const formatHolders: Record> = { Universal: { system_prompt: neat`{{#if system_prompt}}{{value}}{{#else}}${defaultSystemPrompt}{{/else}}{{/if}}`, scenario: neat`{{#if scenario}}The scenario of the conversation:\n{{scenario}}\n{{/if}}`, + scene_clock: `{{scene_clock}}`, memory: neat`{{#if memory}}"{{char}}'s" memories:\n{{memory}}\n{{/if}}`, personality: neat`{{#if personality}}{{char}}'s personality:\n{{personality}}\n{{/if}}`, impersonating: neat`{{#if impersonating}}{{user}}'s personality:\n{{impersonating}}\n{{/if}}`, @@ -145,6 +161,7 @@ export const formatHolders: Record> = { system_prompt: neat`<|im_start|>system {{#if system_prompt}}{{value}}<|im_end|>{{#else}}${defaultSystemPrompt}<|im_end|>{{/else}}{{/if}}`, scenario: neat`{{#if scenario}}The scenario of the conversation:\n{{scenario}}\n{{/if}}`, + scene_clock: `{{scene_clock}}`, memory: neat`{{#if memory}}"{{char}}'s" memories:\n{{memory}}\n{{/if}}`, personality: neat`{{#if personality}}{{char}}'s personality:\n{{personality}}\n{{/if}}`, impersonating: neat`{{#if impersonating}}{{user}}'s personality:\n{{impersonating}}\n{{/if}}`, @@ -162,6 +179,7 @@ export const formatHolders: Record> = { system_prompt: neat`<|begin_of_text|><|start_header_id|>system {{#if system_prompt}}{{value}}<|eot_id|>{{#else}}${defaultSystemPrompt}<|eot_id|>{{/else}}{{/if}}`, scenario: neat`{{#if scenario}}The scenario of the conversation:\n{{scenario}}\n{{/if}}`, + scene_clock: `{{scene_clock}}`, memory: neat`{{#if memory}}"{{char}}'s" memories:\n{{memory}}\n{{/if}}`, personality: neat`{{#if personality}}"{{char}}'s" personality:\n{{personality}}\n{{/if}}`, impersonating: neat`{{#if impersonating}}"{{user}}'s" personality:\n{{impersonating}}\n{{/if}}`, @@ -178,6 +196,7 @@ export const formatHolders: Record> = { 'Pyg/Simple': { history: `Start of the conversation:\n\n{{history}}`, scenario: `{{#if scenario}}Scenario: {{scenario}}{{/if}}`, + scene_clock: `{{scene_clock}}`, }, } diff --git a/common/prompt.ts b/common/prompt.ts index cb10031b0..40d244508 100644 --- a/common/prompt.ts +++ b/common/prompt.ts @@ -42,6 +42,7 @@ export type PromptLine = { export type PromptPlaceholders = { scenario?: string + sceneClock?: string greeting?: string sampleChat?: string[] persona: string @@ -138,6 +139,7 @@ const HOLDER_NAMES = { memory: 'memory', post: 'post', scenario: 'scenario', + sceneClock: 'scene_clock', history: 'history', systemPrompt: 'system_prompt', linebreak: 'br', @@ -154,6 +156,7 @@ export const HOLDERS = { ujb: /{{ujb}}/gi, sampleChat: /{{example_dialogue}}/gi, scenario: /{{scenario}}/gi, + sceneClock: /{{scene_clock}}/gi, memory: /{{memory}}/gi, persona: /{{personality}}/gi, allPersonas: /{{all_personalities}}/gi, @@ -405,7 +408,7 @@ export function getTemplate(opts: Pick) } // Deprecated - return ensureValidTemplate(template) + return ensureValidTemplate(template, opts.chat.sceneClock?.enabled ? undefined : ['sceneClock']) } type InjectOpts = { @@ -466,15 +469,22 @@ function replaceSectionTags(sections: Record, format: Mo */ export function ensureValidTemplate( template: string, - skip?: Array<'history' | 'post' | 'persona' | 'scenario' | 'userEmbed' | 'chatEmbed'> + skip?: Array< + 'history' | 'post' | 'persona' | 'scenario' | 'sceneClock' | 'userEmbed' | 'chatEmbed' + > ) { const skips = new Set(skip || []) let hasHistory = !!template.match(HOLDERS.history) || !!template.match(/{{\#each msg}}/gi) let hasPost = !!template.match(HOLDERS.post) + let hasSceneClock = !!template.match(HOLDERS.sceneClock) let modified = template + if (!skips.has('sceneClock') && !hasSceneClock) { + modified = insertSceneClockHolder(modified) + } + if (!skips.has('post') && !skips.has('history') && !hasHistory && !hasPost) { modified += `\n{{history}}\n{{post}}` } else if (!skips.has('history') && !hasHistory && hasPost) { @@ -486,6 +496,20 @@ export function ensureValidTemplate( return modified } +function insertSceneClockHolder(template: string) { + const sceneClock = `{{${HOLDER_NAMES.sceneClock}}}` + + if (template.match(HOLDERS.persona)) { + return template.replace(HOLDERS.persona, `${sceneClock}{{${HOLDER_NAMES.persona}}}`) + } + + if (template.match(HOLDERS.history)) { + return template.replace(HOLDERS.history, `${sceneClock}{{${HOLDER_NAMES.history}}}`) + } + + return `${template}${sceneClock}` +} + type PromptPartsOptions = Pick< PromptOpts, | 'kind' @@ -575,6 +599,7 @@ export async function buildPromptPlaceholders( // replyAs instead of the main character // (we always use the main character's scenario, not replyAs) parts.scenario = replace(opts.resolvedScenario, char.name) + parts.sceneClock = getSceneClockPrompt(chat.sceneClock) const sampleChat = replyAs._id === char._id && !!chat.overrides @@ -643,6 +668,43 @@ export async function buildPromptPlaceholders( } } +function getSceneClockPrompt(clock?: AppSchema.SceneClock) { + if (!clock?.enabled) return '' + + const date = clock.date?.trim() + const time = clock.time?.trim() + const current = [date, time].filter(Boolean).join(' ') + if (!current && !clock.dayOfWeek?.trim() && !clock.calendarName?.trim() && !clock.notes?.trim()) + return '' + + const lines = ['[Scene Clock]'] + if (current) { + lines.push(`Current in-scene date and time: ${current}.`) + lines.push( + 'Use this as the current in-scene time, not a fixed value. Advance or change it whenever the narrative or user indicates that time passes.' + ) + } + if (clock.dayOfWeek?.trim()) { + lines.push(`Day of week: ${clock.dayOfWeek.trim()}.`) + } + if (clock.calendarName?.trim()) { + lines.push(`Calendar: ${clock.calendarName.trim()}.`) + } + if (clock.notes?.trim()) { + lines.push(`Notes: ${clock.notes.trim()}`) + } + if (clock.allowAssistantUpdates) { + lines.push( + 'When your response will change the in-scene date or time, begin the response with exactly one hidden control tag before any dialogue:', + '{"date":"NEW_DATE","time":"NEW_TIME"}', + 'The opening and closing scene_clock_update tags are mandatory. Use valid JSON inside them. You may include date, time, dayOfWeek, calendarName, or notes; omit unchanged fields. Place the tag at the very start, never at the end, and do not mention it in the dialogue.' + ) + } + lines.push('[/Scene Clock]') + + return `${lines.join('\n')}\n` +} + function getSupplementaryParts(opts: PromptPartsOptions, replyAs: AppSchema.Character) { const { settings, chat } = opts const parts = { diff --git a/common/scene-clock.ts b/common/scene-clock.ts new file mode 100644 index 000000000..af7331f63 --- /dev/null +++ b/common/scene-clock.ts @@ -0,0 +1,78 @@ +import { AppSchema } from './types' + +export const SCENE_CLOCK_UPDATE_TAG = + /(?:<\s*)?scene[\s_-]*clock[\s_-]*update\s*>\s*([\s\S]*?)\s*(?:<\s*)?\/\s*scene[\s_-]*clock[\s_-]*update\s*>/gi + +const SCENE_CLOCK_UPDATE_MARKDOWN = + /^\s*\*{1,2}\s*scene[\s_-]*clock[\s_-]*update\s*\*{0,2}\s*(\{[^\n]*\})\s*\*{0,2}(?:\s*\n|$)/i + +type SceneClockUpdate = Partial< + Pick +> + +export function parseSceneClockUpdate(response: string): { + text: string + update?: SceneClockUpdate +} { + let update: SceneClockUpdate | undefined + let text = response.replace(SCENE_CLOCK_UPDATE_TAG, (_, json: string) => { + const parsed = parseUpdateJson(json) + if (parsed) update = parsed + return '' + }) + + if (!update) { + const markdownUpdate = text.match(SCENE_CLOCK_UPDATE_MARKDOWN) + if (markdownUpdate) { + update = parseUpdateJson(markdownUpdate[1]) + text = text.slice(markdownUpdate[0].length) + } + } + + if (!update) { + const leadingJson = text.match(/^\s*(\{[^\n]*\})(?:\s*\n|$)/) + if (leadingJson) { + const parsed = parseUpdateJson(leadingJson[1]) + if (parsed) { + update = parsed + text = text.slice(leadingJson[0].length) + } + } + } + + // Keep accepting the previous end-of-response fallback for existing prompts and retries. + if (!update) { + const trailingJson = text.match(/(?:^|\n)\s*(\{[^\n]*\})\s*$/) + if (trailingJson?.index !== undefined) { + const parsed = parseUpdateJson(trailingJson[1]) + if (parsed) { + update = parsed + text = text.slice(0, trailingJson.index) + } + } + } + + return { + text: text.replace(/[ \t]+\n/g, '\n').trim(), + update, + } +} + +function parseUpdateJson(value: string): SceneClockUpdate | undefined { + try { + const parsed = JSON.parse(value) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return + + const update: SceneClockUpdate = {} + for (const key of ['date', 'time', 'dayOfWeek', 'calendarName', 'notes'] as const) { + const next = parsed[key] + if (next === undefined) continue + if (typeof next !== 'string') return + update[key] = next.trim() + } + + return Object.keys(update).length ? update : undefined + } catch { + return + } +} diff --git a/common/template-parser.ts b/common/template-parser.ts index 33a04db6b..f7a0f5202 100644 --- a/common/template-parser.ts +++ b/common/template-parser.ts @@ -164,6 +164,7 @@ type Holder = | 'char' | 'user' | 'scenario' + | 'scene_clock' | 'personality' | 'example_dialogue' | 'history' @@ -1003,6 +1004,9 @@ function getPlaceholder( case 'scenario': return opts.parts?.scenario || opts.chat?.scenario || opts.char?.scenario || '' + case 'scene_clock': + return opts.parts?.sceneClock || '' + case 'memory': return opts.parts?.memory || '' diff --git a/common/types/schema.ts b/common/types/schema.ts index bc52e2eb3..bfe31c6a9 100644 --- a/common/types/schema.ts +++ b/common/types/schema.ts @@ -265,6 +265,23 @@ export namespace AppSchema { kind: 'saga-session' } + export type SceneClockDateFormat = 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD' | 'Long' + export type SceneClockTimeFormat = '12h' | '24h' + + export interface SceneClock { + enabled: boolean + date: string + time: string + dateFormat: SceneClockDateFormat + timeFormat: SceneClockTimeFormat + dayOfWeek?: string + calendarName?: string + notes?: string + allowAssistantUpdates?: boolean + lastUpdatedBy?: 'user' | 'assistant' | 'system' + lastUpdatedAt?: string + } + export interface Chat { _id: string kind: 'chat' @@ -297,6 +314,7 @@ export namespace AppSchema { scenarioIds?: string[] scenarioStates?: string[] + sceneClock?: SceneClock treeLeafId?: string diff --git a/srv/api/chat/edit.ts b/srv/api/chat/edit.ts index ab4d8e237..70a0e9c5d 100644 --- a/srv/api/chat/edit.ts +++ b/srv/api/chat/edit.ts @@ -22,6 +22,19 @@ export const updateChat = handle(async ({ params, body, user, userId }) => { useOverrides: 'boolean?', userEmbedId: 'string?', scenarioStates: ['string?'], + sceneClock: optional({ + enabled: 'boolean', + date: 'string', + time: 'string', + dateFormat: ['DD/MM/YYYY', 'MM/DD/YYYY', 'YYYY-MM-DD', 'Long'], + timeFormat: ['12h', '24h'], + dayOfWeek: 'string?', + calendarName: 'string?', + notes: 'string?', + allowAssistantUpdates: 'boolean?', + lastUpdatedBy: ['user', 'assistant', 'system', null], + lastUpdatedAt: 'string?', + }), systemPrompt: 'string?', postHistoryInstructions: 'string?', imageSource: 'string?', @@ -54,6 +67,7 @@ export const updateChat = handle(async ({ params, body, user, userId }) => { userEmbedId: body.userEmbedId ?? prev.userEmbedId, scenarioIds: body.scenarioIds ?? prev.scenarioIds, scenarioStates: body.scenarioStates ?? prev.scenarioStates, + sceneClock: body.sceneClock ?? prev.sceneClock, imageSource: (body.imageSource as any) ?? prev.imageSource, imageSettings: body.imageSettings, invisible: body.invisible ?? prev.invisible, diff --git a/tests/__snapshots__/prompt.spec.js.snap b/tests/__snapshots__/prompt.spec.js.snap index 24a2a5360..c11fdac14 100644 --- a/tests/__snapshots__/prompt.spec.js.snap +++ b/tests/__snapshots__/prompt.spec.js.snap @@ -203,6 +203,7 @@ Object { "SAMPLECHAT OtherBot", ], "scenario": "MAIN MainChar", + "sceneClock": "", "systemPrompt": undefined, "ujb": "!!UJB_PROMPT!!", "userEmbeds": Array [], diff --git a/tests/prompt.spec.ts b/tests/prompt.spec.ts index fad590d13..0edee1a28 100644 --- a/tests/prompt.spec.ts +++ b/tests/prompt.spec.ts @@ -201,6 +201,58 @@ This is how {{char}} should talk: {{example_dialogue}}`, expect(actual.template.parsed).toMatchSnapshot() }) + it('will include scene clock for saved prompt orders without the scene clock placeholder', async () => { + const actual = await build([botMsg('first')], { + chat: toChat(main, { + sceneClock: { + enabled: true, + date: '2026-06-27', + time: '11:30', + dateFormat: 'YYYY-MM-DD', + timeFormat: '24h', + dayOfWeek: 'Saturday', + calendarName: 'Imperial calendar', + }, + }), + settings: { + useAdvancedPrompt: 'basic', + modelFormat: 'None', + promptOrder: [ + { placeholder: 'system_prompt', enabled: true }, + { placeholder: 'scenario', enabled: true }, + { placeholder: 'personality', enabled: true }, + { placeholder: 'example_dialogue', enabled: true }, + { placeholder: 'history', enabled: true }, + ], + }, + }) + + expect(actual.template.parsed).to.include('[Scene Clock]') + expect(actual.template.parsed).to.include('Current in-scene date and time: 2026-06-27 11:30.') + expect(actual.template.parsed).to.include('Day of week: Saturday.') + expect(actual.template.parsed).to.include('Calendar: Imperial calendar.') + }) + + it('will include scene clock for custom gaslights without the scene clock placeholder', async () => { + const actual = await build([botMsg('first')], { + chat: toChat(main, { + sceneClock: { + enabled: true, + date: '2026-06-27', + time: '11:30', + dateFormat: 'YYYY-MM-DD', + timeFormat: '24h', + }, + }), + settings: { + gaslight: `The scenario of the conversation:\n{{scenario}}\n\n{{char}}'s personality:\n{{personality}}`, + }, + }) + + expect(actual.template.parsed).to.include('[Scene Clock]') + expect(actual.template.parsed).to.include('Current in-scene date and time: 2026-06-27 11:30.') + }) + it('will use currently speaking character book', async () => { const actual = await build([toMsg('TRIGGER')], { char: { ...main, characterBook: toBook('main char book', []) }, diff --git a/tests/scene-clock.spec.ts b/tests/scene-clock.spec.ts new file mode 100644 index 000000000..c0b86fba5 --- /dev/null +++ b/tests/scene-clock.spec.ts @@ -0,0 +1,99 @@ +import { expect } from 'chai' +import { parseSceneClockUpdate } from '../common/scene-clock' + +describe('Scene Clock assistant updates', () => { + it('extracts a valid partial update and removes the markup', () => { + const result = parseSceneClockUpdate( + '{"date":"2026-06-29","time":"00:00","dayOfWeek":"Monday"}\nThe bells ring at midnight.' + ) + + expect(result.text).to.equal('The bells ring at midnight.') + expect(result.update).to.deep.equal({ + date: '2026-06-29', + time: '00:00', + dayOfWeek: 'Monday', + }) + }) + + it('removes malformed markup without updating the clock', () => { + const result = parseSceneClockUpdate( + 'Time passes.{date: tomorrow}' + ) + + expect(result.text).to.equal('Time passes.') + expect(result.update).to.equal(undefined) + }) + + it('accepts an update with a missing opening angle bracket', () => { + const result = parseSceneClockUpdate( + 'scene_clock_update>\n{"date":"11/07/2026","time":"8:52 PM"}\n\nSarah steps into the room.' + ) + + expect(result.text).to.equal('Sarah steps into the room.') + expect(result.update).to.deep.equal({ + date: '11/07/2026', + time: '8:52 PM', + }) + }) + + it('accepts an update with collapsed tag separators', () => { + const result = parseSceneClockUpdate( + 'sceneclockupdate>\n{"date":"2026-07-11","time":"20:52"}\n\nSarah steps into the room.' + ) + + expect(result.text).to.equal('Sarah steps into the room.') + expect(result.update).to.deep.equal({ + date: '2026-07-11', + time: '20:52', + }) + }) + + it('accepts an asterisk-wrapped update label and object', () => { + const result = parseSceneClockUpdate( + '**scene_clock_update*{ "date": "15/02/2008", "time": "6:10PM" }*\nThe evening settles over the city.' + ) + + expect(result.text).to.equal('The evening settles over the city.') + expect(result.update).to.deep.equal({ + date: '15/02/2008', + time: '6:10PM', + }) + }) + + it('rejects non-string field values', () => { + const result = parseSceneClockUpdate('{"time":1230}') + + expect(result.text).to.equal('') + expect(result.update).to.equal(undefined) + }) + + it('accepts a valid standalone update object at the end of a response', () => { + const result = parseSceneClockUpdate( + 'Christmas morning arrives.\n\n{"date":"2026-12-25","time":"08:00"}' + ) + + expect(result.text).to.equal('Christmas morning arrives.') + expect(result.update).to.deep.equal({ date: '2026-12-25', time: '08:00' }) + }) + + it('accepts a valid standalone update object at the start of a response', () => { + const result = parseSceneClockUpdate( + '{"date":"2027-01-01","time":"09:30","dayOfWeek":"Friday"}\nA new morning begins.' + ) + + expect(result.text).to.equal('A new morning begins.') + expect(result.update).to.deep.equal({ + date: '2027-01-01', + time: '09:30', + dayOfWeek: 'Friday', + }) + }) + + it('does not remove an unrelated JSON object', () => { + const response = 'The API returned:\n{"status":"ready"}' + const result = parseSceneClockUpdate(response) + + expect(result.text).to.equal(response) + expect(result.update).to.equal(undefined) + }) +}) diff --git a/web/pages/Chat/ChatMenu.tsx b/web/pages/Chat/ChatMenu.tsx index 397df336c..7e96d769d 100644 --- a/web/pages/Chat/ChatMenu.tsx +++ b/web/pages/Chat/ChatMenu.tsx @@ -25,6 +25,7 @@ import { PresetProvider } from '../Settings/Provider' import { createEmitter } from '/web/shared/util' import { usePresetContext } from '/web/store/preset-context' import { getStore } from '/web/store/create' +import { SceneClockPanel } from './SceneClockPanel' type NavProps = { ctx: ChatContext @@ -188,6 +189,8 @@ const ChatNav: Component = (props) => { + + ) } diff --git a/web/pages/Chat/ChatSettings.tsx b/web/pages/Chat/ChatSettings.tsx index a04532fa3..4e7f63c3f 100644 --- a/web/pages/Chat/ChatSettings.tsx +++ b/web/pages/Chat/ChatSettings.tsx @@ -236,6 +236,7 @@ const ChatSettings: Component<{ } const onSave = () => { + const sceneClock = getInitSceneClock(state.active?.chat?.sceneClock) const payload = { name: edit.name, greeting: edit.greeting, @@ -250,6 +251,12 @@ const ChatSettings: Component<{ imageSource: edit.imageSource, scenarioIds: edit.scenarioId ? [edit.scenarioId] : [], scenarioStates: edit.scenarioStates, + sceneClock: { + ...sceneClock, + enabled: edit.sceneClock.enabled, + lastUpdatedBy: 'user' as const, + lastUpdatedAt: new Date().toISOString(), + }, } chatStore.editChat(state.active?.chat?._id!, payload, { useOverrides: edit.useOverrides, @@ -508,6 +515,15 @@ const ChatSettings: Component<{ + + setEdit('sceneClock', 'enabled', ev)} + label="Scene Clock" + helperText="Keeps the fictional scene date and time with this chat and includes it in prompts. Edit the clock details from the Chat Menu." + /> + + = [ + { value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' }, + { value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' }, + { value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' }, + { value: 'Long', label: 'Long' }, +] + +const timeFormats: Array<{ value: AppSchema.SceneClockTimeFormat; label: string }> = [ + { value: '12h', label: '12 hour' }, + { value: '24h', label: '24 hour' }, +] + +export const SceneClockPanel: Component<{ chat?: AppSchema.Chat }> = (props) => { + const chats = chatStore((state) => ({ + chat: props.chat?._id ? state.details[props.chat._id]?.chat : undefined, + })) + const activeChat = () => chats.chat || props.chat + const [clock, setClock] = createStore(getInitSceneClock(activeChat()?.sceneClock)) + + createEffect(() => { + setClock(getInitSceneClock(activeChat()?.sceneClock)) + }) + + const save = () => { + const chat = activeChat() + if (!chat) return + + chatStore.editChat( + chat._id, + { + sceneClock: { + ...clock, + date: clock.date.trim(), + time: clock.time.trim(), + dayOfWeek: clock.dayOfWeek?.trim(), + calendarName: clock.calendarName?.trim(), + notes: clock.notes?.trim(), + lastUpdatedBy: 'user', + lastUpdatedAt: new Date().toISOString(), + }, + }, + { quiet: true } + ) + } + + return ( + +
+
+
+ + Scene Clock +
+ +
+ + setClock('dayOfWeek', ev.currentTarget.value)} + /> + +
+ setClock('date', ev.currentTarget.value)} + /> + setClock('time', ev.currentTarget.value)} + /> +
+ +
+ setClock('timeFormat', ev.value as AppSchema.SceneClockTimeFormat)} + /> +
+ + setClock('calendarName', ev.currentTarget.value)} + /> + + setClock('notes', ev.currentTarget.value)} + /> + + setClock('allowAssistantUpdates', ev)} + label="Assistant Update Markup" + helperText="Allows assistant responses to update the clock using hidden markup." + /> +
+
+ ) +} + +function getInitSceneClock(clock?: AppSchema.SceneClock): AppSchema.SceneClock { + return { + enabled: clock?.enabled || false, + date: clock?.date || '', + time: clock?.time || '', + dateFormat: clock?.dateFormat || 'YYYY-MM-DD', + timeFormat: clock?.timeFormat || '24h', + dayOfWeek: clock?.dayOfWeek || '', + calendarName: clock?.calendarName || '', + notes: clock?.notes || '', + allowAssistantUpdates: clock?.allowAssistantUpdates || false, + lastUpdatedBy: clock?.lastUpdatedBy, + lastUpdatedAt: clock?.lastUpdatedAt, + } +} diff --git a/web/store/data/bot-generate.ts b/web/store/data/bot-generate.ts index 06de3a9a9..0ae48688f 100644 --- a/web/store/data/bot-generate.ts +++ b/web/store/data/bot-generate.ts @@ -43,6 +43,7 @@ import { debug } from '/common/debug' import { formatJsonSchemaVars, prepareJsonSchema } from '/common/guidance/json-schema' import { getJsonSchema } from '/web/shared/util' import { ResponseSchema } from '/common/types/library' +import { parseSceneClockUpdate } from '/common/scene-clock' iconv.enableStreamingAPI(require('stream')) @@ -438,7 +439,29 @@ async function handlePostStreamResponse(input: { json?: JsonOutput jsonCall?: boolean }) { - const { req, opts, response, json, meta } = input + const { req, opts, json, meta } = input + let { response } = input + + const sceneClock = req.request.chat.sceneClock + if (sceneClock?.enabled && sceneClock.allowAssistantUpdates) { + const parsed = parseSceneClockUpdate(response) + response = parsed.text + + if (parsed.update) { + await getStore('chat').editChat( + req.request.chat._id, + { + sceneClock: { + ...sceneClock, + ...parsed.update, + lastUpdatedBy: 'assistant', + lastUpdatedAt: new Date().toISOString(), + }, + }, + { quiet: true } + ) + } + } if (opts.signal.signal.aborted) { getStore('responses').setState({