Repository navigation
feat(assistant): coordinate work through a durable inbox bot #6124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
aae6420
712d531
b4ce59d
f1a5fcb
d6ff911
619d936
01546d7
5bb4956
bac660d
788bbb1
e781f3f
af03f0d
9cba5c2
db11126
f471bce
c59ff5a
df549e5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| } 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>; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,7 +24,8 @@ import { | |
| useShowAssistantBackgroundSessions, | ||
| } from '../../application/contracts/session-catalog/assistant-session-visibility.js'; | ||
| import { SideNavItem } from '@astryxdesign/core/SideNav'; | ||
| import { Sparkles } from '@maka/ui/icons'; | ||
| import { PersonalAssistantAvatar, useAssistantAvatar } from './avatar.js'; | ||
| export { PersonalAssistantAvatar, useAssistantAvatar } from './avatar.js'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2 (CI): Knip (apps/desktop) fails on this head because |
||
| import { Switch } from '@maka/ui'; | ||
| import { usePersonalAssistantServices, type AssistantHost as DesktopRuntimeHostRef } from './services.js'; | ||
| export { createPersonalAssistantServices, PersonalAssistantServicesProvider, type PersonalAssistantPort } from './services.js'; | ||
|
|
@@ -86,6 +87,7 @@ export function PersonalAssistantEntry({ | |
| const enablement = useAssistantEnablement(); | ||
| const [bound, setBound] = useState<string>(); | ||
| const [error, setError] = useState(''); | ||
| const avatar = useAssistantAvatar(enablement.enabled, bound); | ||
| useEffect(() => { | ||
| if (!enablement.enabled) { | ||
| setBound(undefined); | ||
|
|
@@ -117,7 +119,7 @@ export function PersonalAssistantEntry({ | |
| return ( | ||
| <SideNavItem | ||
| label="个人助手" | ||
| icon={Sparkles} | ||
| icon={<PersonalAssistantAvatar avatar={avatar} />} | ||
| size="md" | ||
| isSelected={!!bound && bound === activeSessionId} | ||
| aria-label={error ? '个人助手:' + error : '个人助手'} | ||
|
|
@@ -297,6 +299,7 @@ function PersonalAssistantStatusSettings({ | |
| return ( | ||
| <div className="personal-assistant-settings"> | ||
| {error && <p role="alert">{error}</p>} | ||
| {data.bot?.error && <p role="alert">助手消息处理暂时停止:{data.bot.error}。发送新消息可重试,Inbox 中的消息会保留。</p>} | ||
| {!state ? ( | ||
| <p>请先从侧栏打开个人助手,创建专属会话。</p> | ||
| ) : ( | ||
|
|
||
There was a problem hiding this comment.
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.