feat(ui): add user name and avatar customization
This commit is contained in:
parent
6859e1e6a6
commit
1320611421
@ -275,7 +275,10 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"commands.useAccessGroups": "Use Access Groups",
|
"commands.useAccessGroups": "Use Access Groups",
|
||||||
"ui.seamColor": "Accent Color",
|
"ui.seamColor": "Accent Color",
|
||||||
"ui.assistant.name": "Assistant Name",
|
"ui.assistant.name": "Assistant Name",
|
||||||
|
"ui.assistant.name": "Assistant Name",
|
||||||
"ui.assistant.avatar": "Assistant Avatar",
|
"ui.assistant.avatar": "Assistant Avatar",
|
||||||
|
"ui.user.name": "User Name",
|
||||||
|
"ui.user.avatar": "User Avatar",
|
||||||
"browser.controlUrl": "Browser Control URL",
|
"browser.controlUrl": "Browser Control URL",
|
||||||
"browser.snapshotDefaults": "Browser Snapshot Defaults",
|
"browser.snapshotDefaults": "Browser Snapshot Defaults",
|
||||||
"browser.snapshotDefaults.mode": "Browser Snapshot Mode",
|
"browser.snapshotDefaults.mode": "Browser Snapshot Mode",
|
||||||
@ -875,9 +878,9 @@ function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]):
|
|||||||
const pluginSchema = asSchemaObject(plugin.configSchema);
|
const pluginSchema = asSchemaObject(plugin.configSchema);
|
||||||
const nextConfigSchema =
|
const nextConfigSchema =
|
||||||
baseConfigSchema &&
|
baseConfigSchema &&
|
||||||
pluginSchema &&
|
pluginSchema &&
|
||||||
isObjectSchema(baseConfigSchema) &&
|
isObjectSchema(baseConfigSchema) &&
|
||||||
isObjectSchema(pluginSchema)
|
isObjectSchema(pluginSchema)
|
||||||
? mergeObjectSchema(baseConfigSchema, pluginSchema)
|
? mergeObjectSchema(baseConfigSchema, pluginSchema)
|
||||||
: cloneSchema(plugin.configSchema);
|
: cloneSchema(plugin.configSchema);
|
||||||
|
|
||||||
|
|||||||
@ -177,6 +177,13 @@ export const ClawdbotSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
user: z
|
||||||
|
.object({
|
||||||
|
name: z.string().max(50).optional(),
|
||||||
|
avatar: z.string().max(200).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@ -111,6 +111,11 @@ export function renderApp(state: AppViewState) {
|
|||||||
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
|
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
|
||||||
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
|
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
|
||||||
|
|
||||||
|
// Resolve user identity from config
|
||||||
|
const uiConfig = (state.configSnapshot?.config as any)?.ui;
|
||||||
|
const userName = uiConfig?.user?.name;
|
||||||
|
const userAvatar = uiConfig?.user?.avatar;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}">
|
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@ -118,10 +123,10 @@ export function renderApp(state: AppViewState) {
|
|||||||
<button
|
<button
|
||||||
class="nav-collapse-toggle"
|
class="nav-collapse-toggle"
|
||||||
@click=${() =>
|
@click=${() =>
|
||||||
state.applySettings({
|
state.applySettings({
|
||||||
...state.settings,
|
...state.settings,
|
||||||
navCollapsed: !state.settings.navCollapsed,
|
navCollapsed: !state.settings.navCollapsed,
|
||||||
})}
|
})}
|
||||||
title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
|
title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
|
||||||
aria-label="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
|
aria-label="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
|
||||||
>
|
>
|
||||||
@ -148,20 +153,20 @@ export function renderApp(state: AppViewState) {
|
|||||||
</header>
|
</header>
|
||||||
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
|
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
|
||||||
${TAB_GROUPS.map((group) => {
|
${TAB_GROUPS.map((group) => {
|
||||||
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
|
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
|
||||||
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
|
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
|
||||||
return html`
|
return html`
|
||||||
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
|
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
|
||||||
<button
|
<button
|
||||||
class="nav-label"
|
class="nav-label"
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
const next = { ...state.settings.navGroupsCollapsed };
|
const next = { ...state.settings.navGroupsCollapsed };
|
||||||
next[group.label] = !isGroupCollapsed;
|
next[group.label] = !isGroupCollapsed;
|
||||||
state.applySettings({
|
state.applySettings({
|
||||||
...state.settings,
|
...state.settings,
|
||||||
navGroupsCollapsed: next,
|
navGroupsCollapsed: next,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
aria-expanded=${!isGroupCollapsed}
|
aria-expanded=${!isGroupCollapsed}
|
||||||
>
|
>
|
||||||
<span class="nav-label__text">${group.label}</span>
|
<span class="nav-label__text">${group.label}</span>
|
||||||
@ -172,7 +177,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
<div class="nav-group nav-group--links">
|
<div class="nav-group nav-group--links">
|
||||||
<div class="nav-label nav-label--static">
|
<div class="nav-label nav-label--static">
|
||||||
<span class="nav-label__text">Resources</span>
|
<span class="nav-label__text">Resources</span>
|
||||||
@ -199,383 +204,385 @@ export function renderApp(state: AppViewState) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="page-meta">
|
<div class="page-meta">
|
||||||
${state.lastError
|
${state.lastError
|
||||||
? html`<div class="pill danger">${state.lastError}</div>`
|
? html`<div class="pill danger">${state.lastError}</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
${isChat ? renderChatControls(state) : nothing}
|
${isChat ? renderChatControls(state) : nothing}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
${state.tab === "overview"
|
${state.tab === "overview"
|
||||||
? renderOverview({
|
? renderOverview({
|
||||||
connected: state.connected,
|
connected: state.connected,
|
||||||
hello: state.hello,
|
hello: state.hello,
|
||||||
settings: state.settings,
|
settings: state.settings,
|
||||||
password: state.password,
|
password: state.password,
|
||||||
lastError: state.lastError,
|
lastError: state.lastError,
|
||||||
presenceCount,
|
presenceCount,
|
||||||
sessionsCount,
|
sessionsCount,
|
||||||
cronEnabled: state.cronStatus?.enabled ?? null,
|
cronEnabled: state.cronStatus?.enabled ?? null,
|
||||||
cronNext,
|
cronNext,
|
||||||
lastChannelsRefresh: state.channelsLastSuccess,
|
lastChannelsRefresh: state.channelsLastSuccess,
|
||||||
onSettingsChange: (next) => state.applySettings(next),
|
onSettingsChange: (next) => state.applySettings(next),
|
||||||
onPasswordChange: (next) => (state.password = next),
|
onPasswordChange: (next) => (state.password = next),
|
||||||
onSessionKeyChange: (next) => {
|
onSessionKeyChange: (next) => {
|
||||||
state.sessionKey = next;
|
state.sessionKey = next;
|
||||||
state.chatMessage = "";
|
state.chatMessage = "";
|
||||||
state.resetToolStream();
|
state.resetToolStream();
|
||||||
state.applySettings({
|
state.applySettings({
|
||||||
...state.settings,
|
...state.settings,
|
||||||
sessionKey: next,
|
sessionKey: next,
|
||||||
lastActiveSessionKey: next,
|
lastActiveSessionKey: next,
|
||||||
});
|
});
|
||||||
void state.loadAssistantIdentity();
|
void state.loadAssistantIdentity();
|
||||||
},
|
},
|
||||||
onConnect: () => state.connect(),
|
onConnect: () => state.connect(),
|
||||||
onRefresh: () => state.loadOverview(),
|
onRefresh: () => state.loadOverview(),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "channels"
|
${state.tab === "channels"
|
||||||
? renderChannels({
|
? renderChannels({
|
||||||
connected: state.connected,
|
connected: state.connected,
|
||||||
loading: state.channelsLoading,
|
loading: state.channelsLoading,
|
||||||
snapshot: state.channelsSnapshot,
|
snapshot: state.channelsSnapshot,
|
||||||
lastError: state.channelsError,
|
lastError: state.channelsError,
|
||||||
lastSuccessAt: state.channelsLastSuccess,
|
lastSuccessAt: state.channelsLastSuccess,
|
||||||
whatsappMessage: state.whatsappLoginMessage,
|
whatsappMessage: state.whatsappLoginMessage,
|
||||||
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
|
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
|
||||||
whatsappConnected: state.whatsappLoginConnected,
|
whatsappConnected: state.whatsappLoginConnected,
|
||||||
whatsappBusy: state.whatsappBusy,
|
whatsappBusy: state.whatsappBusy,
|
||||||
configSchema: state.configSchema,
|
configSchema: state.configSchema,
|
||||||
configSchemaLoading: state.configSchemaLoading,
|
configSchemaLoading: state.configSchemaLoading,
|
||||||
configForm: state.configForm,
|
configForm: state.configForm,
|
||||||
configUiHints: state.configUiHints,
|
configUiHints: state.configUiHints,
|
||||||
configSaving: state.configSaving,
|
configSaving: state.configSaving,
|
||||||
configFormDirty: state.configFormDirty,
|
configFormDirty: state.configFormDirty,
|
||||||
nostrProfileFormState: state.nostrProfileFormState,
|
nostrProfileFormState: state.nostrProfileFormState,
|
||||||
nostrProfileAccountId: state.nostrProfileAccountId,
|
nostrProfileAccountId: state.nostrProfileAccountId,
|
||||||
onRefresh: (probe) => loadChannels(state, probe),
|
onRefresh: (probe) => loadChannels(state, probe),
|
||||||
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
|
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
|
||||||
onWhatsAppWait: () => state.handleWhatsAppWait(),
|
onWhatsAppWait: () => state.handleWhatsAppWait(),
|
||||||
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
|
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
|
||||||
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
|
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||||
onConfigSave: () => state.handleChannelConfigSave(),
|
onConfigSave: () => state.handleChannelConfigSave(),
|
||||||
onConfigReload: () => state.handleChannelConfigReload(),
|
onConfigReload: () => state.handleChannelConfigReload(),
|
||||||
onNostrProfileEdit: (accountId, profile) =>
|
onNostrProfileEdit: (accountId, profile) =>
|
||||||
state.handleNostrProfileEdit(accountId, profile),
|
state.handleNostrProfileEdit(accountId, profile),
|
||||||
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
|
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
|
||||||
onNostrProfileFieldChange: (field, value) =>
|
onNostrProfileFieldChange: (field, value) =>
|
||||||
state.handleNostrProfileFieldChange(field, value),
|
state.handleNostrProfileFieldChange(field, value),
|
||||||
onNostrProfileSave: () => state.handleNostrProfileSave(),
|
onNostrProfileSave: () => state.handleNostrProfileSave(),
|
||||||
onNostrProfileImport: () => state.handleNostrProfileImport(),
|
onNostrProfileImport: () => state.handleNostrProfileImport(),
|
||||||
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
|
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "instances"
|
${state.tab === "instances"
|
||||||
? renderInstances({
|
? renderInstances({
|
||||||
loading: state.presenceLoading,
|
loading: state.presenceLoading,
|
||||||
entries: state.presenceEntries,
|
entries: state.presenceEntries,
|
||||||
lastError: state.presenceError,
|
lastError: state.presenceError,
|
||||||
statusMessage: state.presenceStatus,
|
statusMessage: state.presenceStatus,
|
||||||
onRefresh: () => loadPresence(state),
|
onRefresh: () => loadPresence(state),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "sessions"
|
${state.tab === "sessions"
|
||||||
? renderSessions({
|
? renderSessions({
|
||||||
loading: state.sessionsLoading,
|
loading: state.sessionsLoading,
|
||||||
result: state.sessionsResult,
|
result: state.sessionsResult,
|
||||||
error: state.sessionsError,
|
error: state.sessionsError,
|
||||||
activeMinutes: state.sessionsFilterActive,
|
activeMinutes: state.sessionsFilterActive,
|
||||||
limit: state.sessionsFilterLimit,
|
limit: state.sessionsFilterLimit,
|
||||||
includeGlobal: state.sessionsIncludeGlobal,
|
includeGlobal: state.sessionsIncludeGlobal,
|
||||||
includeUnknown: state.sessionsIncludeUnknown,
|
includeUnknown: state.sessionsIncludeUnknown,
|
||||||
basePath: state.basePath,
|
basePath: state.basePath,
|
||||||
onFiltersChange: (next) => {
|
onFiltersChange: (next) => {
|
||||||
state.sessionsFilterActive = next.activeMinutes;
|
state.sessionsFilterActive = next.activeMinutes;
|
||||||
state.sessionsFilterLimit = next.limit;
|
state.sessionsFilterLimit = next.limit;
|
||||||
state.sessionsIncludeGlobal = next.includeGlobal;
|
state.sessionsIncludeGlobal = next.includeGlobal;
|
||||||
state.sessionsIncludeUnknown = next.includeUnknown;
|
state.sessionsIncludeUnknown = next.includeUnknown;
|
||||||
},
|
},
|
||||||
onRefresh: () => loadSessions(state),
|
onRefresh: () => loadSessions(state),
|
||||||
onPatch: (key, patch) => patchSession(state, key, patch),
|
onPatch: (key, patch) => patchSession(state, key, patch),
|
||||||
onDelete: (key) => deleteSession(state, key),
|
onDelete: (key) => deleteSession(state, key),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "cron"
|
${state.tab === "cron"
|
||||||
? renderCron({
|
? renderCron({
|
||||||
loading: state.cronLoading,
|
loading: state.cronLoading,
|
||||||
status: state.cronStatus,
|
status: state.cronStatus,
|
||||||
jobs: state.cronJobs,
|
jobs: state.cronJobs,
|
||||||
error: state.cronError,
|
error: state.cronError,
|
||||||
busy: state.cronBusy,
|
busy: state.cronBusy,
|
||||||
form: state.cronForm,
|
form: state.cronForm,
|
||||||
channels: state.channelsSnapshot?.channelMeta?.length
|
channels: state.channelsSnapshot?.channelMeta?.length
|
||||||
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
|
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
|
||||||
: state.channelsSnapshot?.channelOrder ?? [],
|
: state.channelsSnapshot?.channelOrder ?? [],
|
||||||
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
|
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
|
||||||
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
|
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
|
||||||
runsJobId: state.cronRunsJobId,
|
runsJobId: state.cronRunsJobId,
|
||||||
runs: state.cronRuns,
|
runs: state.cronRuns,
|
||||||
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
|
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
|
||||||
onRefresh: () => state.loadCron(),
|
onRefresh: () => state.loadCron(),
|
||||||
onAdd: () => addCronJob(state),
|
onAdd: () => addCronJob(state),
|
||||||
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
|
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
|
||||||
onRun: (job) => runCronJob(state, job),
|
onRun: (job) => runCronJob(state, job),
|
||||||
onRemove: (job) => removeCronJob(state, job),
|
onRemove: (job) => removeCronJob(state, job),
|
||||||
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
|
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "skills"
|
${state.tab === "skills"
|
||||||
? renderSkills({
|
? renderSkills({
|
||||||
loading: state.skillsLoading,
|
loading: state.skillsLoading,
|
||||||
report: state.skillsReport,
|
report: state.skillsReport,
|
||||||
error: state.skillsError,
|
error: state.skillsError,
|
||||||
filter: state.skillsFilter,
|
filter: state.skillsFilter,
|
||||||
edits: state.skillEdits,
|
edits: state.skillEdits,
|
||||||
messages: state.skillMessages,
|
messages: state.skillMessages,
|
||||||
busyKey: state.skillsBusyKey,
|
busyKey: state.skillsBusyKey,
|
||||||
onFilterChange: (next) => (state.skillsFilter = next),
|
onFilterChange: (next) => (state.skillsFilter = next),
|
||||||
onRefresh: () => loadSkills(state, { clearMessages: true }),
|
onRefresh: () => loadSkills(state, { clearMessages: true }),
|
||||||
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
|
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
|
||||||
onEdit: (key, value) => updateSkillEdit(state, key, value),
|
onEdit: (key, value) => updateSkillEdit(state, key, value),
|
||||||
onSaveKey: (key) => saveSkillApiKey(state, key),
|
onSaveKey: (key) => saveSkillApiKey(state, key),
|
||||||
onInstall: (skillKey, name, installId) =>
|
onInstall: (skillKey, name, installId) =>
|
||||||
installSkill(state, skillKey, name, installId),
|
installSkill(state, skillKey, name, installId),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "nodes"
|
${state.tab === "nodes"
|
||||||
? renderNodes({
|
? renderNodes({
|
||||||
loading: state.nodesLoading,
|
loading: state.nodesLoading,
|
||||||
nodes: state.nodes,
|
nodes: state.nodes,
|
||||||
devicesLoading: state.devicesLoading,
|
devicesLoading: state.devicesLoading,
|
||||||
devicesError: state.devicesError,
|
devicesError: state.devicesError,
|
||||||
devicesList: state.devicesList,
|
devicesList: state.devicesList,
|
||||||
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
|
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
|
||||||
configLoading: state.configLoading,
|
configLoading: state.configLoading,
|
||||||
configSaving: state.configSaving,
|
configSaving: state.configSaving,
|
||||||
configDirty: state.configFormDirty,
|
configDirty: state.configFormDirty,
|
||||||
configFormMode: state.configFormMode,
|
configFormMode: state.configFormMode,
|
||||||
execApprovalsLoading: state.execApprovalsLoading,
|
execApprovalsLoading: state.execApprovalsLoading,
|
||||||
execApprovalsSaving: state.execApprovalsSaving,
|
execApprovalsSaving: state.execApprovalsSaving,
|
||||||
execApprovalsDirty: state.execApprovalsDirty,
|
execApprovalsDirty: state.execApprovalsDirty,
|
||||||
execApprovalsSnapshot: state.execApprovalsSnapshot,
|
execApprovalsSnapshot: state.execApprovalsSnapshot,
|
||||||
execApprovalsForm: state.execApprovalsForm,
|
execApprovalsForm: state.execApprovalsForm,
|
||||||
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
|
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
|
||||||
execApprovalsTarget: state.execApprovalsTarget,
|
execApprovalsTarget: state.execApprovalsTarget,
|
||||||
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
|
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
|
||||||
onRefresh: () => loadNodes(state),
|
onRefresh: () => loadNodes(state),
|
||||||
onDevicesRefresh: () => loadDevices(state),
|
onDevicesRefresh: () => loadDevices(state),
|
||||||
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
|
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
|
||||||
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
|
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
|
||||||
onDeviceRotate: (deviceId, role, scopes) =>
|
onDeviceRotate: (deviceId, role, scopes) =>
|
||||||
rotateDeviceToken(state, { deviceId, role, scopes }),
|
rotateDeviceToken(state, { deviceId, role, scopes }),
|
||||||
onDeviceRevoke: (deviceId, role) =>
|
onDeviceRevoke: (deviceId, role) =>
|
||||||
revokeDeviceToken(state, { deviceId, role }),
|
revokeDeviceToken(state, { deviceId, role }),
|
||||||
onLoadConfig: () => loadConfig(state),
|
onLoadConfig: () => loadConfig(state),
|
||||||
onLoadExecApprovals: () => {
|
onLoadExecApprovals: () => {
|
||||||
const target =
|
const target =
|
||||||
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
||||||
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
||||||
: { kind: "gateway" as const };
|
: { kind: "gateway" as const };
|
||||||
return loadExecApprovals(state, target);
|
return loadExecApprovals(state, target);
|
||||||
},
|
},
|
||||||
onBindDefault: (nodeId) => {
|
onBindDefault: (nodeId) => {
|
||||||
if (nodeId) {
|
if (nodeId) {
|
||||||
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
|
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
|
||||||
} else {
|
} else {
|
||||||
removeConfigFormValue(state, ["tools", "exec", "node"]);
|
removeConfigFormValue(state, ["tools", "exec", "node"]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onBindAgent: (agentIndex, nodeId) => {
|
onBindAgent: (agentIndex, nodeId) => {
|
||||||
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
|
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
|
||||||
if (nodeId) {
|
if (nodeId) {
|
||||||
updateConfigFormValue(state, basePath, nodeId);
|
updateConfigFormValue(state, basePath, nodeId);
|
||||||
} else {
|
} else {
|
||||||
removeConfigFormValue(state, basePath);
|
removeConfigFormValue(state, basePath);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSaveBindings: () => saveConfig(state),
|
onSaveBindings: () => saveConfig(state),
|
||||||
onExecApprovalsTargetChange: (kind, nodeId) => {
|
onExecApprovalsTargetChange: (kind, nodeId) => {
|
||||||
state.execApprovalsTarget = kind;
|
state.execApprovalsTarget = kind;
|
||||||
state.execApprovalsTargetNodeId = nodeId;
|
state.execApprovalsTargetNodeId = nodeId;
|
||||||
state.execApprovalsSnapshot = null;
|
state.execApprovalsSnapshot = null;
|
||||||
state.execApprovalsForm = null;
|
state.execApprovalsForm = null;
|
||||||
state.execApprovalsDirty = false;
|
state.execApprovalsDirty = false;
|
||||||
state.execApprovalsSelectedAgent = null;
|
state.execApprovalsSelectedAgent = null;
|
||||||
},
|
},
|
||||||
onExecApprovalsSelectAgent: (agentId) => {
|
onExecApprovalsSelectAgent: (agentId) => {
|
||||||
state.execApprovalsSelectedAgent = agentId;
|
state.execApprovalsSelectedAgent = agentId;
|
||||||
},
|
},
|
||||||
onExecApprovalsPatch: (path, value) =>
|
onExecApprovalsPatch: (path, value) =>
|
||||||
updateExecApprovalsFormValue(state, path, value),
|
updateExecApprovalsFormValue(state, path, value),
|
||||||
onExecApprovalsRemove: (path) =>
|
onExecApprovalsRemove: (path) =>
|
||||||
removeExecApprovalsFormValue(state, path),
|
removeExecApprovalsFormValue(state, path),
|
||||||
onSaveExecApprovals: () => {
|
onSaveExecApprovals: () => {
|
||||||
const target =
|
const target =
|
||||||
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
||||||
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
||||||
: { kind: "gateway" as const };
|
: { kind: "gateway" as const };
|
||||||
return saveExecApprovals(state, target);
|
return saveExecApprovals(state, target);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "chat"
|
${state.tab === "chat"
|
||||||
? renderChat({
|
? renderChat({
|
||||||
sessionKey: state.sessionKey,
|
sessionKey: state.sessionKey,
|
||||||
onSessionKeyChange: (next) => {
|
onSessionKeyChange: (next) => {
|
||||||
state.sessionKey = next;
|
state.sessionKey = next;
|
||||||
state.chatMessage = "";
|
state.chatMessage = "";
|
||||||
state.chatAttachments = [];
|
state.chatAttachments = [];
|
||||||
state.chatStream = null;
|
state.chatStream = null;
|
||||||
state.chatStreamStartedAt = null;
|
state.chatStreamStartedAt = null;
|
||||||
state.chatRunId = null;
|
state.chatRunId = null;
|
||||||
state.chatQueue = [];
|
state.chatQueue = [];
|
||||||
state.resetToolStream();
|
state.resetToolStream();
|
||||||
state.resetChatScroll();
|
state.resetChatScroll();
|
||||||
state.applySettings({
|
state.applySettings({
|
||||||
...state.settings,
|
...state.settings,
|
||||||
sessionKey: next,
|
sessionKey: next,
|
||||||
lastActiveSessionKey: next,
|
lastActiveSessionKey: next,
|
||||||
});
|
});
|
||||||
void state.loadAssistantIdentity();
|
void state.loadAssistantIdentity();
|
||||||
void loadChatHistory(state);
|
void loadChatHistory(state);
|
||||||
void refreshChatAvatar(state);
|
void refreshChatAvatar(state);
|
||||||
},
|
},
|
||||||
thinkingLevel: state.chatThinkingLevel,
|
thinkingLevel: state.chatThinkingLevel,
|
||||||
showThinking,
|
showThinking,
|
||||||
loading: state.chatLoading,
|
loading: state.chatLoading,
|
||||||
sending: state.chatSending,
|
sending: state.chatSending,
|
||||||
compactionStatus: state.compactionStatus,
|
compactionStatus: state.compactionStatus,
|
||||||
assistantAvatarUrl: chatAvatarUrl,
|
assistantAvatarUrl: chatAvatarUrl,
|
||||||
messages: state.chatMessages,
|
messages: state.chatMessages,
|
||||||
toolMessages: state.chatToolMessages,
|
toolMessages: state.chatToolMessages,
|
||||||
stream: state.chatStream,
|
stream: state.chatStream,
|
||||||
streamStartedAt: state.chatStreamStartedAt,
|
streamStartedAt: state.chatStreamStartedAt,
|
||||||
draft: state.chatMessage,
|
draft: state.chatMessage,
|
||||||
queue: state.chatQueue,
|
queue: state.chatQueue,
|
||||||
connected: state.connected,
|
connected: state.connected,
|
||||||
canSend: state.connected,
|
canSend: state.connected,
|
||||||
disabledReason: chatDisabledReason,
|
disabledReason: chatDisabledReason,
|
||||||
error: state.lastError,
|
error: state.lastError,
|
||||||
sessions: state.sessionsResult,
|
sessions: state.sessionsResult,
|
||||||
focusMode: chatFocus,
|
focusMode: chatFocus,
|
||||||
onRefresh: () => {
|
onRefresh: () => {
|
||||||
state.resetToolStream();
|
state.resetToolStream();
|
||||||
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
|
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
|
||||||
},
|
},
|
||||||
onToggleFocusMode: () => {
|
onToggleFocusMode: () => {
|
||||||
if (state.onboarding) return;
|
if (state.onboarding) return;
|
||||||
state.applySettings({
|
state.applySettings({
|
||||||
...state.settings,
|
...state.settings,
|
||||||
chatFocusMode: !state.settings.chatFocusMode,
|
chatFocusMode: !state.settings.chatFocusMode,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onChatScroll: (event) => state.handleChatScroll(event),
|
onChatScroll: (event) => state.handleChatScroll(event),
|
||||||
onDraftChange: (next) => (state.chatMessage = next),
|
onDraftChange: (next) => (state.chatMessage = next),
|
||||||
attachments: state.chatAttachments,
|
attachments: state.chatAttachments,
|
||||||
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
||||||
onSend: () => state.handleSendChat(),
|
onSend: () => state.handleSendChat(),
|
||||||
canAbort: Boolean(state.chatRunId),
|
canAbort: Boolean(state.chatRunId),
|
||||||
onAbort: () => void state.handleAbortChat(),
|
onAbort: () => void state.handleAbortChat(),
|
||||||
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
||||||
onNewSession: () =>
|
onNewSession: () =>
|
||||||
state.handleSendChat("/new", { restoreDraft: true }),
|
state.handleSendChat("/new", { restoreDraft: true }),
|
||||||
// Sidebar props for tool output viewing
|
// Sidebar props for tool output viewing
|
||||||
sidebarOpen: state.sidebarOpen,
|
sidebarOpen: state.sidebarOpen,
|
||||||
sidebarContent: state.sidebarContent,
|
sidebarContent: state.sidebarContent,
|
||||||
sidebarError: state.sidebarError,
|
sidebarError: state.sidebarError,
|
||||||
splitRatio: state.splitRatio,
|
splitRatio: state.splitRatio,
|
||||||
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
|
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
|
||||||
onCloseSidebar: () => state.handleCloseSidebar(),
|
onCloseSidebar: () => state.handleCloseSidebar(),
|
||||||
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
||||||
assistantName: state.assistantName,
|
assistantName: state.assistantName,
|
||||||
assistantAvatar: state.assistantAvatar,
|
assistantAvatar: state.assistantAvatar,
|
||||||
})
|
userName,
|
||||||
: nothing}
|
userAvatar,
|
||||||
|
})
|
||||||
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "config"
|
${state.tab === "config"
|
||||||
? renderConfig({
|
? renderConfig({
|
||||||
raw: state.configRaw,
|
raw: state.configRaw,
|
||||||
originalRaw: state.configRawOriginal,
|
originalRaw: state.configRawOriginal,
|
||||||
valid: state.configValid,
|
valid: state.configValid,
|
||||||
issues: state.configIssues,
|
issues: state.configIssues,
|
||||||
loading: state.configLoading,
|
loading: state.configLoading,
|
||||||
saving: state.configSaving,
|
saving: state.configSaving,
|
||||||
applying: state.configApplying,
|
applying: state.configApplying,
|
||||||
updating: state.updateRunning,
|
updating: state.updateRunning,
|
||||||
connected: state.connected,
|
connected: state.connected,
|
||||||
schema: state.configSchema,
|
schema: state.configSchema,
|
||||||
schemaLoading: state.configSchemaLoading,
|
schemaLoading: state.configSchemaLoading,
|
||||||
uiHints: state.configUiHints,
|
uiHints: state.configUiHints,
|
||||||
formMode: state.configFormMode,
|
formMode: state.configFormMode,
|
||||||
formValue: state.configForm,
|
formValue: state.configForm,
|
||||||
originalValue: state.configFormOriginal,
|
originalValue: state.configFormOriginal,
|
||||||
searchQuery: state.configSearchQuery,
|
searchQuery: state.configSearchQuery,
|
||||||
activeSection: state.configActiveSection,
|
activeSection: state.configActiveSection,
|
||||||
activeSubsection: state.configActiveSubsection,
|
activeSubsection: state.configActiveSubsection,
|
||||||
onRawChange: (next) => {
|
onRawChange: (next) => {
|
||||||
state.configRaw = next;
|
state.configRaw = next;
|
||||||
},
|
},
|
||||||
onFormModeChange: (mode) => (state.configFormMode = mode),
|
onFormModeChange: (mode) => (state.configFormMode = mode),
|
||||||
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||||
onSearchChange: (query) => (state.configSearchQuery = query),
|
onSearchChange: (query) => (state.configSearchQuery = query),
|
||||||
onSectionChange: (section) => {
|
onSectionChange: (section) => {
|
||||||
state.configActiveSection = section;
|
state.configActiveSection = section;
|
||||||
state.configActiveSubsection = null;
|
state.configActiveSubsection = null;
|
||||||
},
|
},
|
||||||
onSubsectionChange: (section) => (state.configActiveSubsection = section),
|
onSubsectionChange: (section) => (state.configActiveSubsection = section),
|
||||||
onReload: () => loadConfig(state),
|
onReload: () => loadConfig(state),
|
||||||
onSave: () => saveConfig(state),
|
onSave: () => saveConfig(state),
|
||||||
onApply: () => applyConfig(state),
|
onApply: () => applyConfig(state),
|
||||||
onUpdate: () => runUpdate(state),
|
onUpdate: () => runUpdate(state),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "debug"
|
${state.tab === "debug"
|
||||||
? renderDebug({
|
? renderDebug({
|
||||||
loading: state.debugLoading,
|
loading: state.debugLoading,
|
||||||
status: state.debugStatus,
|
status: state.debugStatus,
|
||||||
health: state.debugHealth,
|
health: state.debugHealth,
|
||||||
models: state.debugModels,
|
models: state.debugModels,
|
||||||
heartbeat: state.debugHeartbeat,
|
heartbeat: state.debugHeartbeat,
|
||||||
eventLog: state.eventLog,
|
eventLog: state.eventLog,
|
||||||
callMethod: state.debugCallMethod,
|
callMethod: state.debugCallMethod,
|
||||||
callParams: state.debugCallParams,
|
callParams: state.debugCallParams,
|
||||||
callResult: state.debugCallResult,
|
callResult: state.debugCallResult,
|
||||||
callError: state.debugCallError,
|
callError: state.debugCallError,
|
||||||
onCallMethodChange: (next) => (state.debugCallMethod = next),
|
onCallMethodChange: (next) => (state.debugCallMethod = next),
|
||||||
onCallParamsChange: (next) => (state.debugCallParams = next),
|
onCallParamsChange: (next) => (state.debugCallParams = next),
|
||||||
onRefresh: () => loadDebug(state),
|
onRefresh: () => loadDebug(state),
|
||||||
onCall: () => callDebugMethod(state),
|
onCall: () => callDebugMethod(state),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${state.tab === "logs"
|
${state.tab === "logs"
|
||||||
? renderLogs({
|
? renderLogs({
|
||||||
loading: state.logsLoading,
|
loading: state.logsLoading,
|
||||||
error: state.logsError,
|
error: state.logsError,
|
||||||
file: state.logsFile,
|
file: state.logsFile,
|
||||||
entries: state.logsEntries,
|
entries: state.logsEntries,
|
||||||
filterText: state.logsFilterText,
|
filterText: state.logsFilterText,
|
||||||
levelFilters: state.logsLevelFilters,
|
levelFilters: state.logsLevelFilters,
|
||||||
autoFollow: state.logsAutoFollow,
|
autoFollow: state.logsAutoFollow,
|
||||||
truncated: state.logsTruncated,
|
truncated: state.logsTruncated,
|
||||||
onFilterTextChange: (next) => (state.logsFilterText = next),
|
onFilterTextChange: (next) => (state.logsFilterText = next),
|
||||||
onLevelToggle: (level, enabled) => {
|
onLevelToggle: (level, enabled) => {
|
||||||
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
|
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
|
||||||
},
|
},
|
||||||
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
|
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
|
||||||
onRefresh: () => loadLogs(state, { reset: true }),
|
onRefresh: () => loadLogs(state, { reset: true }),
|
||||||
onExport: (lines, label) => state.exportLogs(lines, label),
|
onExport: (lines, label) => state.exportLogs(lines, label),
|
||||||
onScroll: (event) => state.handleLogsScroll(event),
|
onScroll: (event) => state.handleLogsScroll(event),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
</main>
|
</main>
|
||||||
${renderExecApprovalPrompt(state)}
|
${renderExecApprovalPrompt(state)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -87,14 +87,14 @@ export function renderStreamingGroup(
|
|||||||
${renderAvatar("assistant", assistant)}
|
${renderAvatar("assistant", assistant)}
|
||||||
<div class="chat-group-messages">
|
<div class="chat-group-messages">
|
||||||
${renderGroupedMessage(
|
${renderGroupedMessage(
|
||||||
{
|
{
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: [{ type: "text", text }],
|
content: [{ type: "text", text }],
|
||||||
timestamp: startedAt,
|
timestamp: startedAt,
|
||||||
},
|
},
|
||||||
{ isStreaming: true, showReasoning: false },
|
{ isStreaming: true, showReasoning: false },
|
||||||
onOpenSidebar,
|
onOpenSidebar,
|
||||||
)}
|
)}
|
||||||
<div class="chat-group-footer">
|
<div class="chat-group-footer">
|
||||||
<span class="chat-sender-name">${name}</span>
|
<span class="chat-sender-name">${name}</span>
|
||||||
<span class="chat-group-timestamp">${timestamp}</span>
|
<span class="chat-group-timestamp">${timestamp}</span>
|
||||||
@ -111,13 +111,15 @@ export function renderMessageGroup(
|
|||||||
showReasoning: boolean;
|
showReasoning: boolean;
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
assistantAvatar?: string | null;
|
assistantAvatar?: string | null;
|
||||||
|
userName?: string;
|
||||||
|
userAvatar?: string | null;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const normalizedRole = normalizeRoleForGrouping(group.role);
|
const normalizedRole = normalizeRoleForGrouping(group.role);
|
||||||
const assistantName = opts.assistantName ?? "Assistant";
|
const assistantName = opts.assistantName ?? "Assistant";
|
||||||
const who =
|
const who =
|
||||||
normalizedRole === "user"
|
normalizedRole === "user"
|
||||||
? "You"
|
? (opts.userName || "You")
|
||||||
: normalizedRole === "assistant"
|
: normalizedRole === "assistant"
|
||||||
? assistantName
|
? assistantName
|
||||||
: normalizedRole;
|
: normalizedRole;
|
||||||
@ -135,21 +137,24 @@ export function renderMessageGroup(
|
|||||||
return html`
|
return html`
|
||||||
<div class="chat-group ${roleClass}">
|
<div class="chat-group ${roleClass}">
|
||||||
${renderAvatar(group.role, {
|
${renderAvatar(group.role, {
|
||||||
name: assistantName,
|
name: assistantName,
|
||||||
avatar: opts.assistantAvatar ?? null,
|
avatar: opts.assistantAvatar ?? null,
|
||||||
})}
|
}, {
|
||||||
|
name: opts.userName,
|
||||||
|
avatar: opts.userAvatar,
|
||||||
|
})}
|
||||||
<div class="chat-group-messages">
|
<div class="chat-group-messages">
|
||||||
${group.messages.map((item, index) =>
|
${group.messages.map((item, index) =>
|
||||||
renderGroupedMessage(
|
renderGroupedMessage(
|
||||||
item.message,
|
item.message,
|
||||||
{
|
{
|
||||||
isStreaming:
|
isStreaming:
|
||||||
group.isStreaming && index === group.messages.length - 1,
|
group.isStreaming && index === group.messages.length - 1,
|
||||||
showReasoning: opts.showReasoning,
|
showReasoning: opts.showReasoning,
|
||||||
},
|
},
|
||||||
opts.onOpenSidebar,
|
opts.onOpenSidebar,
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
<div class="chat-group-footer">
|
<div class="chat-group-footer">
|
||||||
<span class="chat-sender-name">${who}</span>
|
<span class="chat-sender-name">${who}</span>
|
||||||
<span class="chat-group-timestamp">${timestamp}</span>
|
<span class="chat-group-timestamp">${timestamp}</span>
|
||||||
@ -162,13 +167,17 @@ export function renderMessageGroup(
|
|||||||
function renderAvatar(
|
function renderAvatar(
|
||||||
role: string,
|
role: string,
|
||||||
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
|
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
|
||||||
|
user?: { name?: string; avatar?: string | null },
|
||||||
) {
|
) {
|
||||||
const normalized = normalizeRoleForGrouping(role);
|
const normalized = normalizeRoleForGrouping(role);
|
||||||
const assistantName = assistant?.name?.trim() || "Assistant";
|
const assistantName = assistant?.name?.trim() || "Assistant";
|
||||||
const assistantAvatar = assistant?.avatar?.trim() || "";
|
const assistantAvatar = assistant?.avatar?.trim() || "";
|
||||||
|
const userName = user?.name?.trim() || "You";
|
||||||
|
const userAvatar = user?.avatar?.trim() || "";
|
||||||
|
|
||||||
const initial =
|
const initial =
|
||||||
normalized === "user"
|
normalized === "user"
|
||||||
? "U"
|
? (userName === "You" ? "U" : userName.charAt(0).toUpperCase())
|
||||||
: normalized === "assistant"
|
: normalized === "assistant"
|
||||||
? assistantName.charAt(0).toUpperCase() || "A"
|
? assistantName.charAt(0).toUpperCase() || "A"
|
||||||
: normalized === "tool"
|
: normalized === "tool"
|
||||||
@ -179,7 +188,7 @@ function renderAvatar(
|
|||||||
? "user"
|
? "user"
|
||||||
: normalized === "assistant"
|
: normalized === "assistant"
|
||||||
? "assistant"
|
? "assistant"
|
||||||
: normalized === "tool"
|
: normalized === "tool"
|
||||||
? "tool"
|
? "tool"
|
||||||
: "other";
|
: "other";
|
||||||
|
|
||||||
@ -194,6 +203,16 @@ function renderAvatar(
|
|||||||
return html`<div class="chat-avatar ${className}">${assistantAvatar}</div>`;
|
return html`<div class="chat-avatar ${className}">${assistantAvatar}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (userAvatar && normalized === "user") {
|
||||||
|
if (isAvatarUrl(userAvatar)) {
|
||||||
|
return html`<img
|
||||||
|
class="chat-avatar ${className}"
|
||||||
|
src="${userAvatar}"
|
||||||
|
alt="${userName}"
|
||||||
|
/>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return html`<div class="chat-avatar ${className}">${initial}</div>`;
|
return html`<div class="chat-avatar ${className}">${initial}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,7 +230,7 @@ function renderMessageImages(images: ImageBlock[]) {
|
|||||||
return html`
|
return html`
|
||||||
<div class="chat-message-images">
|
<div class="chat-message-images">
|
||||||
${images.map(
|
${images.map(
|
||||||
(img) => html`
|
(img) => html`
|
||||||
<img
|
<img
|
||||||
src=${img.url}
|
src=${img.url}
|
||||||
alt=${img.alt ?? "Attached image"}
|
alt=${img.alt ?? "Attached image"}
|
||||||
@ -219,7 +238,7 @@ function renderMessageImages(images: ImageBlock[]) {
|
|||||||
@click=${() => window.open(img.url, "_blank")}
|
@click=${() => window.open(img.url, "_blank")}
|
||||||
/>
|
/>
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -277,13 +296,13 @@ function renderGroupedMessage(
|
|||||||
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
|
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
|
||||||
${renderMessageImages(images)}
|
${renderMessageImages(images)}
|
||||||
${reasoningMarkdown
|
${reasoningMarkdown
|
||||||
? html`<div class="chat-thinking">${unsafeHTML(
|
? html`<div class="chat-thinking">${unsafeHTML(
|
||||||
toSanitizedMarkdownHtml(reasoningMarkdown),
|
toSanitizedMarkdownHtml(reasoningMarkdown),
|
||||||
)}</div>`
|
)}</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
${markdown
|
${markdown
|
||||||
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
|
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
|
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -52,6 +52,8 @@ export type ChatProps = {
|
|||||||
splitRatio?: number;
|
splitRatio?: number;
|
||||||
assistantName: string;
|
assistantName: string;
|
||||||
assistantAvatar: string | null;
|
assistantAvatar: string | null;
|
||||||
|
userName?: string;
|
||||||
|
userAvatar?: string | null;
|
||||||
// Image attachments
|
// Image attachments
|
||||||
attachments?: ChatAttachment[];
|
attachments?: ChatAttachment[];
|
||||||
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
|
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
|
||||||
@ -147,7 +149,7 @@ function renderAttachmentPreview(props: ChatProps) {
|
|||||||
return html`
|
return html`
|
||||||
<div class="chat-attachments">
|
<div class="chat-attachments">
|
||||||
${attachments.map(
|
${attachments.map(
|
||||||
(att) => html`
|
(att) => html`
|
||||||
<div class="chat-attachment">
|
<div class="chat-attachment">
|
||||||
<img
|
<img
|
||||||
src=${att.dataUrl}
|
src=${att.dataUrl}
|
||||||
@ -159,17 +161,17 @@ function renderAttachmentPreview(props: ChatProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="Remove attachment"
|
aria-label="Remove attachment"
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
const next = (props.attachments ?? []).filter(
|
const next = (props.attachments ?? []).filter(
|
||||||
(a) => a.id !== att.id,
|
(a) => a.id !== att.id,
|
||||||
);
|
);
|
||||||
props.onAttachmentsChange?.(next);
|
props.onAttachmentsChange?.(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
${icons.x}
|
${icons.x}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -206,47 +208,49 @@ export function renderChat(props: ChatProps) {
|
|||||||
>
|
>
|
||||||
${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing}
|
${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing}
|
||||||
${repeat(buildChatItems(props), (item) => item.key, (item) => {
|
${repeat(buildChatItems(props), (item) => item.key, (item) => {
|
||||||
if (item.kind === "reading-indicator") {
|
if (item.kind === "reading-indicator") {
|
||||||
return renderReadingIndicatorGroup(assistantIdentity);
|
return renderReadingIndicatorGroup(assistantIdentity);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.kind === "stream") {
|
if (item.kind === "stream") {
|
||||||
return renderStreamingGroup(
|
return renderStreamingGroup(
|
||||||
item.text,
|
item.text,
|
||||||
item.startedAt,
|
item.startedAt,
|
||||||
props.onOpenSidebar,
|
props.onOpenSidebar,
|
||||||
assistantIdentity,
|
assistantIdentity,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.kind === "group") {
|
if (item.kind === "group") {
|
||||||
return renderMessageGroup(item, {
|
return renderMessageGroup(item, {
|
||||||
onOpenSidebar: props.onOpenSidebar,
|
onOpenSidebar: props.onOpenSidebar,
|
||||||
showReasoning,
|
showReasoning,
|
||||||
assistantName: props.assistantName,
|
assistantName: props.assistantName,
|
||||||
assistantAvatar: assistantIdentity.avatar,
|
assistantAvatar: assistantIdentity.avatar,
|
||||||
});
|
userName: props.userName,
|
||||||
}
|
userAvatar: props.userAvatar,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return nothing;
|
return nothing;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<section class="card chat">
|
<section class="card chat">
|
||||||
${props.disabledReason
|
${props.disabledReason
|
||||||
? html`<div class="callout">${props.disabledReason}</div>`
|
? html`<div class="callout">${props.disabledReason}</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${props.error
|
${props.error
|
||||||
? html`<div class="callout danger">${props.error}</div>`
|
? html`<div class="callout danger">${props.error}</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
${renderCompactionIndicator(props.compactionStatus)}
|
${renderCompactionIndicator(props.compactionStatus)}
|
||||||
|
|
||||||
${props.focusMode
|
${props.focusMode
|
||||||
? html`
|
? html`
|
||||||
<button
|
<button
|
||||||
class="chat-focus-exit"
|
class="chat-focus-exit"
|
||||||
type="button"
|
type="button"
|
||||||
@ -257,7 +261,7 @@ export function renderChat(props: ChatProps) {
|
|||||||
${icons.x}
|
${icons.x}
|
||||||
</button>
|
</button>
|
||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}"
|
class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}"
|
||||||
@ -270,40 +274,40 @@ export function renderChat(props: ChatProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
${sidebarOpen
|
${sidebarOpen
|
||||||
? html`
|
? html`
|
||||||
<resizable-divider
|
<resizable-divider
|
||||||
.splitRatio=${splitRatio}
|
.splitRatio=${splitRatio}
|
||||||
@resize=${(e: CustomEvent) =>
|
@resize=${(e: CustomEvent) =>
|
||||||
props.onSplitRatioChange?.(e.detail.splitRatio)}
|
props.onSplitRatioChange?.(e.detail.splitRatio)}
|
||||||
></resizable-divider>
|
></resizable-divider>
|
||||||
<div class="chat-sidebar">
|
<div class="chat-sidebar">
|
||||||
${renderMarkdownSidebar({
|
${renderMarkdownSidebar({
|
||||||
content: props.sidebarContent ?? null,
|
content: props.sidebarContent ?? null,
|
||||||
error: props.sidebarError ?? null,
|
error: props.sidebarError ?? null,
|
||||||
onClose: props.onCloseSidebar!,
|
onClose: props.onCloseSidebar!,
|
||||||
onViewRawText: () => {
|
onViewRawText: () => {
|
||||||
if (!props.sidebarContent || !props.onOpenSidebar) return;
|
if (!props.sidebarContent || !props.onOpenSidebar) return;
|
||||||
props.onOpenSidebar(`\`\`\`\n${props.sidebarContent}\n\`\`\``);
|
props.onOpenSidebar(`\`\`\`\n${props.sidebarContent}\n\`\`\``);
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${props.queue.length
|
${props.queue.length
|
||||||
? html`
|
? html`
|
||||||
<div class="chat-queue" role="status" aria-live="polite">
|
<div class="chat-queue" role="status" aria-live="polite">
|
||||||
<div class="chat-queue__title">Queued (${props.queue.length})</div>
|
<div class="chat-queue__title">Queued (${props.queue.length})</div>
|
||||||
<div class="chat-queue__list">
|
<div class="chat-queue__list">
|
||||||
${props.queue.map(
|
${props.queue.map(
|
||||||
(item) => html`
|
(item) => html`
|
||||||
<div class="chat-queue__item">
|
<div class="chat-queue__item">
|
||||||
<div class="chat-queue__text">
|
<div class="chat-queue__text">
|
||||||
${item.text ||
|
${item.text ||
|
||||||
(item.attachments?.length
|
(item.attachments?.length
|
||||||
? `Image (${item.attachments.length})`
|
? `Image (${item.attachments.length})`
|
||||||
: "")}
|
: "")}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="btn chat-queue__remove"
|
class="btn chat-queue__remove"
|
||||||
@ -315,11 +319,11 @@ export function renderChat(props: ChatProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
<div class="chat-compose">
|
<div class="chat-compose">
|
||||||
${renderAttachmentPreview(props)}
|
${renderAttachmentPreview(props)}
|
||||||
@ -330,15 +334,15 @@ export function renderChat(props: ChatProps) {
|
|||||||
.value=${props.draft}
|
.value=${props.draft}
|
||||||
?disabled=${!props.connected}
|
?disabled=${!props.connected}
|
||||||
@keydown=${(e: KeyboardEvent) => {
|
@keydown=${(e: KeyboardEvent) => {
|
||||||
if (e.key !== "Enter") return;
|
if (e.key !== "Enter") return;
|
||||||
if (e.isComposing || e.keyCode === 229) return;
|
if (e.isComposing || e.keyCode === 229) return;
|
||||||
if (e.shiftKey) return; // Allow Shift+Enter for line breaks
|
if (e.shiftKey) return; // Allow Shift+Enter for line breaks
|
||||||
if (!props.connected) return;
|
if (!props.connected) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (canCompose) props.onSend();
|
if (canCompose) props.onSend();
|
||||||
}}
|
}}
|
||||||
@input=${(e: Event) =>
|
@input=${(e: Event) =>
|
||||||
props.onDraftChange((e.target as HTMLTextAreaElement).value)}
|
props.onDraftChange((e.target as HTMLTextAreaElement).value)}
|
||||||
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
|
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
|
||||||
placeholder=${composePlaceholder}
|
placeholder=${composePlaceholder}
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user