Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
aae6420
feat(assistant): coordinate work through a durable inbox bot
xxhZs Oct 15, 2026
712d531
fix(assistant): reconcile durable wakes and canonical outbox retries
xxhZs Oct 15, 2026
b4ce59d
fix(assistant): clarify bot duties and separate discovery configuration
xxhZs Oct 16, 2026
f1a5fcb
refactor(assistant): focus bot instructions on duties and mechanics
xxhZs Oct 16, 2026
d6ff911
refactor(assistant): clarify bot duties and delegation
xxhZs Oct 16, 2026
619d936
fix(memory): share and parallelize source refreshes in index listings
xxhZs Oct 16, 2026
01546d7
feat(assistant): add expandable message summaries and typing indicator
xxhZs Oct 16, 2026
5bb4956
fix(assistant): recover interrupted wakes and resolve review checks
xxhZs Oct 16, 2026
bac660d
fix(browser): allow selected sessions to operate hidden browser panels
xxhZs Oct 16, 2026
788bbb1
fix(ui): render summary markdown and unify expanded body colors
xxhZs Oct 16, 2026
e781f3f
fix(assistant): use Astryx summary button and update SendUser fixtures
xxhZs Oct 16, 2026
af03f0d
Merge remote-tracking branch 'origin/main' into feat/assistant-inbox-bot
xxhZs Oct 17, 2026
9cba5c2
fix(assistant): preserve chat empty states and fence browser and mess…
xxhZs Oct 17, 2026
db11126
fix(desktop): remove unused personal assistant re-exports
xxhZs Oct 17, 2026
f471bce
fix(assistant): address remaining provenance, safety and renderer nits
xxhZs Oct 18, 2026
c59ff5a
fix(bot): retain production connector provenance through turn admission
xxhZs Oct 18, 2026
df549e5
fix(assistant): retry failed initiative binding resolution
xxhZs Oct 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 20 additions & 11 deletions apps/desktop/src/main/__tests__/browser-ipc-main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,42 +220,43 @@ test('browser IPC isolates owned renderer documents and their native parents', a
assert.deepEqual(mainController.navigations, ['https://main.example/']);
assert.deepEqual(workHubController.navigations, ['https://workhub.example/']);

// An ordinary session remains foreground-only even in an auxiliary renderer.
// Hiding also revokes a read already waiting on the page.
// An ordinary selected Session can operate with its browser hidden.
// Hiding does not revoke a read already waiting on the page.
setBridgeFactoryForTest(() => ({
connect: async () => ({ getCurrentUrl: async () => 'https://workhub.example/' }) as never,
close: async () => undefined,
send: async () => undefined,
waitForEvent: async () => undefined,
}));
let finishReadOrdinary!: () => void;
let reading!: () => void;
const started = new Promise<void>((resolve) => { reading = resolve; });
const pendingRead = withBrowserPage(workHubKey, 'snapshot', async () => {
reading();
return new Promise<never>(() => {});
return new Promise<void>((resolve) => { finishReadOrdinary = resolve; });
});
const revoked = assert.rejects(pendingRead, BrowserActionRevokedError);
await started;
workHubVisible = false;
browserIpc.refreshVisibility();
await revoked;
finishReadOrdinary();
await pendingRead;
for (const kind of ['observe', 'navigate', 'mutate'] as const) {
assert.equal(await browserViewHost().canDrive(workHubKey, kind), false);
assert.equal(await browserViewHost().canDrive(workHubKey, kind), true);
}
assert.equal(await browserViewHost().canDrive(mainKey, 'observe'), true);
workHubVisible = true;
assert.equal(await browserViewHost().canDrive(workHubKey, 'observe'), true);
// Reparenting follows the current native window, not the initial dock.
windows.set(workHub, floatingWindow);
floatingWindow.visible = false;
assert.equal(await browserViewHost().canDrive(workHubKey, 'navigate'), false);
assert.equal(await browserViewHost().canDrive(workHubKey, 'navigate'), true);
floatingWindow.visible = true;
floatingWindow.minimized = true;
assert.equal(await browserViewHost().canDrive(workHubKey, 'observe'), false);
assert.equal(await browserViewHost().canDrive(workHubKey, 'observe'), true);
floatingWindow.minimized = false;
assert.equal(await browserViewHost().canDrive(workHubKey, 'navigate'), true);

// The reserved coordination identity alone gets background access, still
// Background access still requires a selected Session and is
// fenced by Host epoch, live selection, and renderer lifetime.
const coordinationKey = desktopSessionResourceKey({ ...scope, sessionId: WORKHUB_COORDINATION_SESSION_ID });
workHubVisible = false;
Expand Down Expand Up @@ -358,8 +359,16 @@ test('browser IPC isolates owned renderer documents and their native parents', a

// Closing Main destroys its WebContents before selection cleanup runs.
// Electron rejects fromWebContents on that destroyed native object.
emit('browser:active-session', main, scope, 'main-session', 'main-document', 5);
emit('browser:setViewport', main, scope, { sessionId: 'main-session', rect: mainRect }, 'main-document', 5);
emit('browser:active-session', main, scope, 'main-session', 'main-document', 6);
emit('browser:setViewport', main, scope, { sessionId: 'main-session', rect: mainRect }, 'main-document', 6);
emit('browser:setViewport', main, scope, { sessionId: 'main-session', rect: null }, 'main-document', 6);
assert.equal(mainController.viewports.at(-1), null);
assert.equal(await browserViewHost().canDrive(mainKey, 'mutate'), true);
const hiddenLease = browserViewHost().beginAction(mainKey);
assert.ok(hiddenLease, 'collapsed ordinary browser gets a background rendering lease');
await hiddenLease.ready;
await hiddenLease.release();
assert.equal(mainController.viewports.at(-1), null, 'automation does not open the panel');
assert.doesNotThrow(() => main.destroy());
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(mainController.disposed, true, 'closing Main still releases its owned browser page');
Expand Down
47 changes: 47 additions & 0 deletions apps/desktop/src/main/__tests__/session-local.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,53 @@ test('a Message dispatched in a released epoch is settled from the Host, never r
assert.equal(store.get('authority', 'message-1')?.result?.disposition, 'turn_started');
});

test('an assistant Inbox receipt survives a released epoch without replaying or inventing a Turn', async (t) => {
const { store, beforeClose } = await database(t);
const submissions: TurnMessageSubmitInput[] = [];
const queried: string[][] = [];
const target: DesktopSessionLocalTarget = {
partition: 'authority',
profileId: 'profile',
scope: { hostId: 'root', targetEpoch: 'target-1' },
client: {
...client('epoch-2'),
async queryMessageExecutions(input) {
queried.push([...input.messageIds]);
return {
resolutions: [
{ messageId: 'message-1', state: 'inbox' },
],
};
},
},
submit: async (input) => {
submissions.push(input);
return accepted;
},
};
const service = new DesktopSessionLocalService(store, {
targets: () => [target],
changed() {},
onError: (error) => assert.fail(String(error)),
});
beforeClose.push(() => service.close());
// Dispatch once in the previous epoch, then lose the answer the way a Host
// restart does: the local copy is left `unknown` with a dead dispatch epoch.
const dispatched = store.enqueue('authority', intent());
store.update({
...dispatched,
state: 'unknown',
intent: { ...dispatched.intent, originHostEpoch: 'epoch-1' },
});
service.wake();
await waitFor(() => store.get('authority', 'message-1')?.state === 'accepted');
assert.deepEqual(queried, [['message-1']]);
// The old epoch's submit is never replayed: that answer can only be
// `outcome_unknown`, which would freeze the copy forever.
assert.deepEqual(submissions, []);
assert.equal(store.get('authority', 'message-1')?.result?.disposition, 'inbox');
});

test('a stale-epoch Message the Host never admitted is retired so later sends proceed', async (t) => {
const { store, beforeClose } = await database(t);
const submissions: string[] = [];
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/browser-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export function registerBrowserIpc(deps: BrowserIpcDeps): BrowserIpcController {
const owner = ownerForSession(sessionId);
if (!owner || !browserParentForRenderer(owner)) return false;
const ref = parseDesktopSessionResourceKey(sessionId);
return isWorkHubCoordinationSessionId(ref.sessionId) && deps.isHostActive(ref);
return deps.isHostActive(ref);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve the ordinary-Session browser visibility gate

Removing the coordination-Session predicate grants every selected ordinary Session a background action lease. A renderer can remain selected while its parent view or native window is hidden/minimized. createBrowserViewHost.canDrive then returns true before checking visibility/viewport, and revokeHiddenActions no longer severs its running action. An agent can therefore read or mutate an authenticated page that the user cannot see, contrary to the explicit security policy in browser/logic.ts:99–107.

Using the registered IPC and actual withBrowserPage path with the existing native/CDP fixture, I observed all three action callbacks execute with the ordinary owner hidden, its window hidden, and its window minimized. Loading only the actual d6ff911 IPC module blocks all nine with BrowserActionBlockedError. Collapsing the panel also newly admits mutation without a viewport. The changed test now expects these hidden actions to succeed and removes the revocation assertion.

Keep background access restricted to WorkHub coordination as before, or explicitly revoke ordinary-Session access when the view/window is hidden, minimized or the panel is collapsed. Retain negative tests for hidden reads, mutations and in-flight revocation; selection/Host identity alone is not an equivalent security boundary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The feature owner has explicitly confirmed that background browser operation includes a hidden or minimized Maka window, as well as a collapsed browser panel. This does not exceed the requested product scope; the changed admission expectations are intentional. We will retain this capability rather than restore visibility-based denial. This does not authorize arbitrary unselected sessions: selected-session ownership, renderer lifetime, active Host and epoch checks still apply. The old visibility-only policy comment needs to be reconciled with this intentional policy change; it should not be treated as evidence that the requested behavior itself is accidental.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Astro-Han Browser operation while the Maka window is hidden or minimized, or the browser panel is collapsed, is intended product behavior. Please re-review the browser finding on that basis, including whether the remaining selected-session ownership, renderer lifetime, active Host and epoch checks are sufficient for this intended capability. This clarification does not claim that the separate Inbox admission race or architecture-check failures have been fixed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: With hidden ordinary Sessions now allowed to act, the previous Session keeps this access until the renderer's next browser:active-session arrives. browser-selection.ts:63-84 sends that only after the new Session's Host identity resolves, and sends nothing to revoke the old selection first. Reproduced: while B's resolution was pending, a hidden and collapsed A still executed browser_click. Revoke the old selection immediately on change, then show the new one when it resolves.

};
const revokeHiddenActions = (): void => {
for (const sessionId of views.sessionIds()) views.get(sessionId)?.refreshRendering();
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/main/browser/automation-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ import type { BrowserViewManager } from './view-manager.js';
* with views owned by conversations, session → endpoint resolution is the
* identity mapping — ensure the conversation's view and attach the CDP bridge to
* it. An action always lands in its OWN session's view, but that view may be
* hidden (the user switched conversations), so canDrive enforces the visible
* lease for ordinary sessions. WorkHub coordination can hold an explicit
* background action lease instead. The
* hidden (the browser panel is collapsed), so every renderer-owned Session
* on a live Host can hold a background action lease. Unselected Sessions
* remain gated by their presentation ownership. The
* endpoint and its secret stay same-process values, never crossing renderer IPC
* or preload.
*
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/main/browser/browser-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ export interface BrowserViewHost {
openOriginLease(sessionId: string, approvedUrl: string, kind: BrowserActionKind): BrowserOriginLease;
/**
* The Host action gate: may `sessionId` run a
* `kind` action right now? Ordinary Sessions require a visible conversation
* (mutation also needs a viewport). The Host-bound WorkHub coordination
* Session alone may operate in the background. Checked before resolveEndpoint so a blocked action
* `kind` action right now? Renderer-owned Sessions on a live Host may operate with a hidden
* browser panel, using a background action lease for rendering and input.
* Session selection and renderer ownership still apply. Checked before resolveEndpoint so a blocked action
* creates no view.
*
* Resolves async for one case: a mutate on the conversation that IS on screen
Expand Down
3 changes: 1 addition & 2 deletions apps/desktop/src/main/browser/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,7 @@ export async function withBrowserPage<T>(
): Promise<T> {
if (opts?.abort?.aborted) throw new BrowserActionCanceledError(label);
const kind: TakeoverMode = opts?.takeover ?? 'observe';
// The Host admits visible ordinary Sessions and the background-authorized
// WorkHub coordination Session. Runs BEFORE acquire, so a vetoed
// The Host admits renderer-owned Sessions even with hidden browser panels. Runs BEFORE acquire, so a vetoed
// background action creates no view and opens no connection. For a mutate whose
// viewport is briefly absent (a permission modal just closed), canDrive waits
// out the renderer's strip restore so the first approved click/type lands;
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/session-local-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,8 @@ export class DesktopSessionLocalService {
},
error: undefined,
});
} else if (resolution?.state === 'inbox') {
this.store.update({ ...current, state: 'accepted', result: { disposition: 'inbox', skillInvocation: EMPTY_SKILL_INVOCATION }, error: undefined });
} else if (resolution?.state === 'pending') {
// The Host durably holds a queued admission for it. Delivery succeeded;
// the Host queue owns the ordering, so this local copy yields.
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1281,7 +1281,7 @@ export interface MakaBridge {
): Promise<
| {
ok: true;
disposition: 'turn_started' | 'steering' | 'followup' | 'locally_saved';
disposition: 'turn_started' | 'steering' | 'followup' | 'locally_saved' | 'inbox';
turnId?: string;
attachments: import('@maka/core/events').AttachmentRef[];
inlineReferences: import('@maka/core/events').InlineReference[];
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1305,6 +1305,7 @@ function AppShellContent({
onOpenWorkHub={openWorkHub} onOpenSession={(sessionId) => { closeSettings(); openSession(sessionId); }} />
<WorkHubDock workbar={selectors} workbarTogglePosition={workbarTogglePosition} visible={workHubActive && sessionsSelected && !shellObscured} />
<ChatSurfaceLayout
className={activeSessionForView?.labels?.includes('personal-assistant') ? 'personal-assistant-chat' : undefined}
data-session-history-surface="true"
// ChatView positions this transcript: switching conversations,
// following the tail and the moves the reader asks for are one
Expand Down Expand Up @@ -1364,6 +1365,9 @@ function AppShellContent({
contextPickEnabled={canStageComposerContext}
executorComposer={{ selection: executor, taskSubmissionHardBlocked, connectionCount: connections.length, onSetup: () => openSettingsSection('external-agents'), onNewTask: openNewTaskSurface }}
activeSession={activeSessionForView}
compactControls={activeSessionForView?.labels?.includes('personal-assistant')}
placeholder={activeSessionForView?.labels?.includes('personal-assistant')
? (uiLocale === 'zh-CN' ? '聊点什么…' : 'Send a message…') : undefined}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The inline locale ternary sends zh-TW to English and grows app-shell.tsx tokens, which the architecture ratchet rejects. Use a catalog entry owned by the feature.

{...{ executorTarget, onExecutorTargetChange }}
usageModel={activeModel}
usageRoute={activeSessionForModelControls}
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/renderer/chat-message-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
useUiLocale,
type LiveTurnProjection,
} from '@maka/ui';
import { PersonalAssistantTyping } from './features/personal-assistant/typing.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: check:architecture rejects this direct feature import (feature imports must use index). Re-export PersonalAssistantTyping from ./features/personal-assistant/index.js and import it from there.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 (check:architecture): Import this through features/personal-assistant/index.js. Even after that change and --write, the strict-base ratchet still rejects the new useAssistantAvatar hook call here (hookCalls 4 -> 5) and the app-shell.tsx token growth (7285 -> 7337). Move that logic behind a feature-owned component.

import { PersonalAssistantAvatar, useAssistantAvatar } from './features/personal-assistant/index.js';
import { OnboardingHero } from './onboarding-hero';
import type { SessionHealthNoticeView, SessionUiReads } from './features/conversation/index.js';
import type { WorkspaceReadinessRecovery } from './workspace-readiness-recovery';
Expand Down Expand Up @@ -124,6 +126,9 @@ export function ChatMessageSurface({
onSkip,
...chatViewRest
}: ChatMessageSurfaceProps) {
const isAssistant = chatViewRest.activeSession?.labels?.includes('personal-assistant') === true;
const avatar = useAssistantAvatar(isAssistant, activeSessionId);
const assistantAvatar = useMemo(() => isAssistant ? <PersonalAssistantAvatar avatar={avatar} /> : undefined, [isAssistant, avatar]);
const locale = useUiLocale();
const copy = getShellCopy(locale).app;
// Configuration notices share the Settings label; identity recovery supplies
Expand Down Expand Up @@ -204,6 +209,8 @@ export function ChatMessageSurface({
{(goalProjection) => (
<StagedQuoteChatView
{...chatViewRest}
assistantAvatar={assistantAvatar}
activityContent={isAssistant && visible && activeSessionId ? <PersonalAssistantTyping key={activeSessionId} sessionId={activeSessionId} avatar={assistantAvatar} /> : undefined}
liveTurns={seededLiveTurns}
// Every branch above reseeds `sessionId` to `activeSessionId`, and a
// render-phase setState re-runs this body before anything commits, so
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ export function createChatActions<Owner extends ComposerSurfaceOwner>(deps: {
id: messageId,
text: input.displayText ?? skillFeedback.skillInvocationDisplayText(input.command.text, result.skillInvocation),
attachments: [...result.attachments],
transientPlacement: result.disposition !== 'turn_started' && placement === 'next_turn' ? 'follow_up' : 'transcript',
transientPlacement: result.disposition !== 'turn_started' && result.disposition !== 'inbox' && placement === 'next_turn' ? 'follow_up' : 'transcript',
...(result.turnId ? { hostTurnId: result.turnId } : {}),
...copiedArray('directoryReferences', directoryReferences),
...copiedArray('quotes', input.quotes ?? []),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export interface ConversationMessageCommand {
export type ConversationMessageSubmission =
| {
readonly ok: true;
readonly disposition: 'turn_started' | 'steering' | 'followup' | 'locally_saved';
readonly disposition: 'turn_started' | 'steering' | 'followup' | 'locally_saved' | 'inbox';
readonly turnId?: string;
readonly attachments: AttachmentRef[];
readonly inlineReferences: InlineReference[];
Expand Down
76 changes: 76 additions & 0 deletions apps/desktop/src/renderer/features/personal-assistant/avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { useEffect, useState } from 'react';
import { DEFAULT_ASSISTANT_AVATAR, assistantAvatarSchema, type AssistantAvatar } from '@maka/runtime-host/protocol';
import { usePersonalAssistantServices } from './services.js';
import { originalHost } from './navigation.js';

type AvatarView = AssistantAvatar & { imageUrl?: string };

export function useAssistantAvatar(enabled: boolean, sessionId?: string) {
const services = usePersonalAssistantServices();
const [state, setState] = useState<{ scope?: string; avatar: AvatarView }>({ avatar: DEFAULT_ASSISTANT_AVATAR });
useEffect(() => {
if (!enabled) return;
let live = true;
let cachedImage: { id: string; url: string } | undefined;
let timer: ReturnType<typeof setTimeout>;
const refresh = async () => {
try {
const host = sessionId
? originalHost(sessionId, (await services.hostSnapshot()).entries).host
: await services.defaultHost();
const result = await services.call('assistant.avatar', {}, host);
const avatar: AvatarView = assistantAvatarSchema.parse(result.avatar);
if (avatar.image) {
if (cachedImage?.id !== avatar.image.id) {
let data = '';
while (live && data.length < avatar.image.length) {
const chunk = await services.call('assistant.avatar-image', { id: avatar.image.id, offset: data.length }, host);
if (chunk.id !== avatar.image.id || chunk.offset !== data.length || typeof chunk.data !== 'string'
|| !chunk.data.length || data.length + chunk.data.length > avatar.image.length) throw Error('Invalid avatar image');
data += chunk.data;
}
if (!live) return;
cachedImage = { id: avatar.image.id, url: `data:${avatar.image.mimeType};base64,${data}` };
}
avatar.imageUrl = cachedImage.url;
} else cachedImage = undefined;
if (live) setState((old) => old.scope === sessionId && old.avatar.emoji === avatar.emoji && old.avatar.background === avatar.background && old.avatar.imageUrl === avatar.imageUrl
? old : { scope: sessionId, avatar });
} catch {
// A disconnected Host must not display another Host's identity.
if (live) setState({ scope: sessionId, avatar: DEFAULT_ASSISTANT_AVATAR });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: On Host failure this sets a fresh object every 3 s. That changes the assistantAvatar element and re-renders every memoized TurnView. Dedupe like the success path does.

} finally {
if (live) timer = setTimeout(refresh, 3000);
}
};
void refresh();
return () => { live = false; clearTimeout(timer); };
}, [enabled, sessionId, services]);
return enabled && state.scope === sessionId ? state.avatar : DEFAULT_ASSISTANT_AVATAR;
}

export function PersonalAssistantAvatar({ avatar }: { avatar: AvatarView }) {
return <span className="personal-assistant-avatar" role="img" aria-label="个人助手头像"
style={{ backgroundColor: avatar.background }}>
{avatar.imageUrl ? <img src={avatar.imageUrl} alt="" draggable={false} /> : avatar.emoji}
</span>;
}
Loading
Loading