feat(core): update chat error style (#9885)
[BS-2487](https://linear.app/affine-design/issue/BS-2487/报错样式更新)
This commit is contained in:
@@ -2,39 +2,40 @@ import { type EditorHost } from '@blocksuite/affine/block-std';
|
|||||||
import {
|
import {
|
||||||
type AIError,
|
type AIError,
|
||||||
PaymentRequiredError,
|
PaymentRequiredError,
|
||||||
|
scrollbarStyle,
|
||||||
UnauthorizedError,
|
UnauthorizedError,
|
||||||
|
unsafeCSSVarV2,
|
||||||
} from '@blocksuite/affine/blocks';
|
} from '@blocksuite/affine/blocks';
|
||||||
import { WithDisposable } from '@blocksuite/affine/global/utils';
|
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/utils';
|
||||||
import { html, LitElement, nothing, type TemplateResult } from 'lit';
|
import { ToggleDownIcon } from '@blocksuite/icons/lit';
|
||||||
|
import { signal } from '@preact/signals-core';
|
||||||
|
import { baseTheme } from '@toeverything/theme';
|
||||||
|
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
|
||||||
import { property } from 'lit/decorators.js';
|
import { property } from 'lit/decorators.js';
|
||||||
import { styleMap } from 'lit/directives/style-map.js';
|
|
||||||
|
|
||||||
import { ErrorTipIcon } from '../_common/icons';
|
import { ErrorTipIcon } from '../_common/icons';
|
||||||
import { AIProvider } from '../provider';
|
import { AIProvider } from '../provider';
|
||||||
|
|
||||||
export class AIErrorWrapper extends WithDisposable(LitElement) {
|
export class AIErrorWrapper extends SignalWatcher(WithDisposable(LitElement)) {
|
||||||
@property({ attribute: false })
|
static override styles = css`
|
||||||
accessor text!: TemplateResult<1>;
|
.error-wrapper {
|
||||||
|
|
||||||
protected override render() {
|
|
||||||
return html` <style>
|
|
||||||
.answer-tip {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 4px;
|
gap: 12px;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 8px;
|
padding: 8px 8px 12px 8px;
|
||||||
background-color: var(--affine-background-error-color);
|
background-color: ${unsafeCSSVarV2('layer/background/error')};
|
||||||
|
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||||
|
|
||||||
.bottom {
|
.content {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
color: var(--affine-error-color, #eb4335);
|
color: ${unsafeCSSVarV2('status/error')};
|
||||||
font-feature-settings:
|
font-feature-settings:
|
||||||
'clig' off,
|
'clig' off,
|
||||||
'liga' off;
|
'liga' off;
|
||||||
@@ -43,128 +44,202 @@ export class AIErrorWrapper extends WithDisposable(LitElement) {
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 22px; /* 157.143% */
|
line-height: 22px; /* 157.143% */
|
||||||
margin-bottom: 4px;
|
|
||||||
|
|
||||||
a {
|
.icon svg {
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
div svg {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
top: 3px;
|
top: 3px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<div class="answer-tip">
|
|
||||||
<div class="bottom">
|
|
||||||
<div>${ErrorTipIcon}</div>
|
|
||||||
<div>${this.text}</div>
|
|
||||||
</div>
|
|
||||||
<slot></slot>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PaymentRequiredErrorRenderer = (host: EditorHost) => html`
|
.text-container {
|
||||||
<style>
|
|
||||||
.upgrade {
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 4px 12px;
|
flex-direction: column;
|
||||||
justify-content: center;
|
gap: 8px;
|
||||||
align-items: center;
|
}
|
||||||
|
|
||||||
|
.detail-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
border-radius: 8px;
|
width: 100%;
|
||||||
margin-left: auto;
|
}
|
||||||
border: 1px solid var(--affine-border-color, #e3e2e4);
|
.detail-title {
|
||||||
background: var(--affine-primary-color);
|
|
||||||
.content {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 0px 4px;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--affine-pure-white);
|
}
|
||||||
/* light/xsMedium */
|
.detail-title:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.detail-content {
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: ${unsafeCSSVarV2('layer/background/translucentUI')};
|
||||||
|
height: 66px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
${scrollbarStyle('.detail-content')}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.toggle.up svg {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.action-button {
|
||||||
|
cursor: pointer;
|
||||||
|
color: ${unsafeCSSVarV2('text/primary')};
|
||||||
|
background: ${unsafeCSSVarV2('button/secondary')};
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid ${unsafeCSSVarV2('button/innerBlackBorder')};
|
||||||
|
padding: 4px 12px;
|
||||||
font-size: var(--affine-font-xs);
|
font-size: var(--affine-font-xs);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 20px; /* 166.667% */
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
.action-button:hover {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
rgba(0, 0, 0, 0.04),
|
||||||
|
rgba(0, 0, 0, 0.04)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
`;
|
||||||
<ai-error-wrapper
|
|
||||||
.text=${html`You've reached the current usage cap for AFFiNE AI. You can
|
private readonly _showDetailContent = signal(false);
|
||||||
subscribe to AFFiNE AI to continue the AI experience!`}
|
|
||||||
>
|
protected override render() {
|
||||||
|
return html` <div class="error-wrapper">
|
||||||
|
<div class="content">
|
||||||
|
<div class="icon">${ErrorTipIcon}</div>
|
||||||
|
<div class="text-container">
|
||||||
|
<div>${this.text}</div>
|
||||||
|
${this.showDetailPanel
|
||||||
|
? html`<div class="detail-container">
|
||||||
<div
|
<div
|
||||||
@click=${() => AIProvider.slots.requestUpgradePlan.emit({ host: host })}
|
class="detail-title"
|
||||||
class="upgrade"
|
@click=${() =>
|
||||||
|
(this._showDetailContent.value =
|
||||||
|
!this._showDetailContent.value)}
|
||||||
>
|
>
|
||||||
<div class="content">Upgrade</div>
|
<span>Show detail</span>
|
||||||
</div></ai-error-wrapper
|
<span
|
||||||
|
class="toggle ${this._showDetailContent.value
|
||||||
|
? 'down'
|
||||||
|
: 'up'}"
|
||||||
>
|
>
|
||||||
|
${ToggleDownIcon({ width: '16px', height: '16px' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
${this._showDetailContent.value
|
||||||
|
? html`<div class="detail-content">${this.errorMessage}</div>`
|
||||||
|
: nothing}
|
||||||
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action">
|
||||||
|
<span
|
||||||
|
class="action-button"
|
||||||
|
@click=${this.onClick}
|
||||||
|
data-testid="ai-error-action-button"
|
||||||
|
>
|
||||||
|
${this.actionText}
|
||||||
|
${this.actionTooltip
|
||||||
|
? html`<affine-tooltip tip-position="top"
|
||||||
|
>${this.actionTooltip}</affine-tooltip
|
||||||
|
>`
|
||||||
|
: nothing}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor text: string = '';
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor onClick: () => void = () => {};
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor errorMessage: string = '';
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor actionText: string = 'Contact us';
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor actionTooltip: string = '';
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor showDetailPanel: boolean = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaymentRequiredErrorRenderer = (host: EditorHost) => html`
|
||||||
|
<ai-error-wrapper
|
||||||
|
.text=${"You've reached the current usage cap for AFFiNE AI. You can subscribe to AFFiNE AI to continue the AI experience!"}
|
||||||
|
.actionText=${'Upgrade'}
|
||||||
|
.onClick=${() => AIProvider.slots.requestUpgradePlan.emit({ host })}
|
||||||
|
></ai-error-wrapper>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LoginRequiredErrorRenderer = (host: EditorHost) => html`
|
||||||
|
<ai-error-wrapper
|
||||||
|
.text=${'You need to login to AFFiNE Cloud to continue using AFFiNE AI.'}
|
||||||
|
.actionText=${'Login'}
|
||||||
|
.onClick=${() => AIProvider.slots.requestLogin.emit({ host })}
|
||||||
|
></ai-error-wrapper>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type ErrorProps = {
|
type ErrorProps = {
|
||||||
text?: TemplateResult<1>;
|
text?: string;
|
||||||
template?: TemplateResult<1>;
|
errorMessage?: string;
|
||||||
error?: TemplateResult<1>;
|
actionText?: string;
|
||||||
|
actionTooltip?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateText = (error?: TemplateResult<1>) =>
|
const generalErrorText =
|
||||||
html`${error || 'An error occurred'}, If this issue persists please let us
|
'An error occurred, If this issue persists please let us know.';
|
||||||
know.<a href="mailto:support@toeverything.info">
|
|
||||||
support@toeverything.info
|
|
||||||
</a>`;
|
|
||||||
|
|
||||||
const nope = html`${nothing}`;
|
|
||||||
const GeneralErrorRenderer = (props: ErrorProps = {}) => {
|
const GeneralErrorRenderer = (props: ErrorProps = {}) => {
|
||||||
const { text = generateText(props.error), template = nope } = props;
|
const onClick = () => {
|
||||||
return html`<ai-error-wrapper .text=${text}>${template}</ai-error-wrapper>`;
|
window.open('mailto:support@toeverything.info', '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
return html`<ai-error-wrapper
|
||||||
|
.text=${props.text ?? generalErrorText}
|
||||||
|
.errorMessage=${props.errorMessage ?? ''}
|
||||||
|
.showDetailPanel=${!!props.errorMessage}
|
||||||
|
.actionText=${props.actionText ?? 'Contact us'}
|
||||||
|
.actionTooltip=${props.actionTooltip ?? 'support@toeverything.info'}
|
||||||
|
.onClick=${onClick}
|
||||||
|
></ai-error-wrapper>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function AIChatErrorRenderer(host: EditorHost, error: AIError) {
|
||||||
|
if (error instanceof PaymentRequiredError) {
|
||||||
|
return PaymentRequiredErrorRenderer(host);
|
||||||
|
} else if (error instanceof UnauthorizedError) {
|
||||||
|
return LoginRequiredErrorRenderer(host);
|
||||||
|
} else {
|
||||||
|
return GeneralErrorRenderer({
|
||||||
|
errorMessage: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
'ai-error-wrapper': AIErrorWrapper;
|
'ai-error-wrapper': AIErrorWrapper;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AIChatErrorRenderer(host: EditorHost, error: AIError) {
|
|
||||||
console.error(error);
|
|
||||||
if (error instanceof PaymentRequiredError) {
|
|
||||||
return PaymentRequiredErrorRenderer(host);
|
|
||||||
} else if (error instanceof UnauthorizedError) {
|
|
||||||
return GeneralErrorRenderer({
|
|
||||||
text: html`You need to login to AFFiNE Cloud to continue using AFFiNE AI.`,
|
|
||||||
template: html`<div
|
|
||||||
style=${styleMap({
|
|
||||||
padding: '4px 12px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid var(--affine-border-color)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
backgroundColor: 'var(--affine-hover-color)',
|
|
||||||
})}
|
|
||||||
@click=${() => AIProvider.slots.requestLogin.emit({ host })}
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</div>`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const tip = error.message;
|
|
||||||
return GeneralErrorRenderer({
|
|
||||||
error: html` <style>
|
|
||||||
.tip {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<span class="tip"
|
|
||||||
>An error occurred<affine-tooltip
|
|
||||||
tip-position="bottom-start"
|
|
||||||
.arrow=${false}
|
|
||||||
>${tip}</affine-tooltip
|
|
||||||
></span
|
|
||||||
>`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -164,8 +164,8 @@ test('can trigger login at chat side panel', async ({ page }) => {
|
|||||||
await waitForEditorLoad(page);
|
await waitForEditorLoad(page);
|
||||||
await clickNewPageButton(page);
|
await clickNewPageButton(page);
|
||||||
await makeChat(page, 'hello');
|
await makeChat(page, 'hello');
|
||||||
const loginTips = await page.waitForSelector('ai-error-wrapper');
|
const loginButton = await page.getByTestId('ai-error-action-button');
|
||||||
expect(await loginTips.innerText()).toBe('Login');
|
expect(await loginButton.innerText()).toBe('Login');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can chat after login at chat side panel', async ({ page }) => {
|
test('can chat after login at chat side panel', async ({ page }) => {
|
||||||
@@ -173,8 +173,8 @@ test('can chat after login at chat side panel', async ({ page }) => {
|
|||||||
await waitForEditorLoad(page);
|
await waitForEditorLoad(page);
|
||||||
await clickNewPageButton(page);
|
await clickNewPageButton(page);
|
||||||
await makeChat(page, 'hello');
|
await makeChat(page, 'hello');
|
||||||
const loginTips = await page.waitForSelector('ai-error-wrapper');
|
const loginButton = await page.getByTestId('ai-error-action-button');
|
||||||
(await loginTips.$('div'))!.click();
|
await loginButton.click();
|
||||||
// login
|
// login
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
await loginUserDirectly(page, user);
|
await loginUserDirectly(page, user);
|
||||||
|
|||||||
Reference in New Issue
Block a user