mirror of
https://github.com/langgenius/dify.git
synced 2026-01-11 17:01:58 +00:00
Compare commits
6 Commits
refactor/w
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b8ccb6344 | ||
|
|
ea85b1fec4 | ||
|
|
a2e03b811e | ||
|
|
1e10bf525c | ||
|
|
8b1af36d94 | ||
|
|
d9af65e84b |
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from typing import Union, cast
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -41,6 +42,7 @@ from core.tools.tool_manager import ToolManager
|
||||
from core.tools.utils.dataset_retriever_tool import DatasetRetrieverTool
|
||||
from extensions.ext_database import db
|
||||
from factories import file_factory
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import Conversation, Message, MessageAgentThought, MessageFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -289,6 +291,7 @@ class BaseAgentRunner(AppRunner):
|
||||
thought = MessageAgentThought(
|
||||
message_id=message_id,
|
||||
message_chain_id=None,
|
||||
tool_process_data=None,
|
||||
thought="",
|
||||
tool=tool_name,
|
||||
tool_labels_str="{}",
|
||||
@@ -296,20 +299,20 @@ class BaseAgentRunner(AppRunner):
|
||||
tool_input=tool_input,
|
||||
message=message,
|
||||
message_token=0,
|
||||
message_unit_price=0,
|
||||
message_price_unit=0,
|
||||
message_unit_price=Decimal(0),
|
||||
message_price_unit=Decimal("0.001"),
|
||||
message_files=json.dumps(messages_ids) if messages_ids else "",
|
||||
answer="",
|
||||
observation="",
|
||||
answer_token=0,
|
||||
answer_unit_price=0,
|
||||
answer_price_unit=0,
|
||||
answer_unit_price=Decimal(0),
|
||||
answer_price_unit=Decimal("0.001"),
|
||||
tokens=0,
|
||||
total_price=0,
|
||||
total_price=Decimal(0),
|
||||
position=self.agent_thought_count + 1,
|
||||
currency="USD",
|
||||
latency=0,
|
||||
created_by_role="account",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=self.user_id,
|
||||
)
|
||||
|
||||
@@ -342,7 +345,8 @@ class BaseAgentRunner(AppRunner):
|
||||
raise ValueError("agent thought not found")
|
||||
|
||||
if thought:
|
||||
agent_thought.thought += thought
|
||||
existing_thought = agent_thought.thought or ""
|
||||
agent_thought.thought = f"{existing_thought}{thought}"
|
||||
|
||||
if tool_name:
|
||||
agent_thought.tool = tool_name
|
||||
@@ -440,21 +444,30 @@ class BaseAgentRunner(AppRunner):
|
||||
agent_thoughts: list[MessageAgentThought] = message.agent_thoughts
|
||||
if agent_thoughts:
|
||||
for agent_thought in agent_thoughts:
|
||||
tools = agent_thought.tool
|
||||
if tools:
|
||||
tools = tools.split(";")
|
||||
tool_names_raw = agent_thought.tool
|
||||
if tool_names_raw:
|
||||
tool_names = tool_names_raw.split(";")
|
||||
tool_calls: list[AssistantPromptMessage.ToolCall] = []
|
||||
tool_call_response: list[ToolPromptMessage] = []
|
||||
try:
|
||||
tool_inputs = json.loads(agent_thought.tool_input)
|
||||
except Exception:
|
||||
tool_inputs = {tool: {} for tool in tools}
|
||||
try:
|
||||
tool_responses = json.loads(agent_thought.observation)
|
||||
except Exception:
|
||||
tool_responses = dict.fromkeys(tools, agent_thought.observation)
|
||||
tool_input_payload = agent_thought.tool_input
|
||||
if tool_input_payload:
|
||||
try:
|
||||
tool_inputs = json.loads(tool_input_payload)
|
||||
except Exception:
|
||||
tool_inputs = {tool: {} for tool in tool_names}
|
||||
else:
|
||||
tool_inputs = {tool: {} for tool in tool_names}
|
||||
|
||||
for tool in tools:
|
||||
observation_payload = agent_thought.observation
|
||||
if observation_payload:
|
||||
try:
|
||||
tool_responses = json.loads(observation_payload)
|
||||
except Exception:
|
||||
tool_responses = dict.fromkeys(tool_names, observation_payload)
|
||||
else:
|
||||
tool_responses = dict.fromkeys(tool_names, observation_payload)
|
||||
|
||||
for tool in tool_names:
|
||||
# generate a uuid for tool call
|
||||
tool_call_id = str(uuid.uuid4())
|
||||
tool_calls.append(
|
||||
@@ -484,7 +497,7 @@ class BaseAgentRunner(AppRunner):
|
||||
*tool_call_response,
|
||||
]
|
||||
)
|
||||
if not tools:
|
||||
if not tool_names_raw:
|
||||
result.append(AssistantPromptMessage(content=agent_thought.thought))
|
||||
else:
|
||||
if message.answer:
|
||||
|
||||
@@ -1843,7 +1843,7 @@ class MessageChain(TypeBase):
|
||||
)
|
||||
|
||||
|
||||
class MessageAgentThought(Base):
|
||||
class MessageAgentThought(TypeBase):
|
||||
__tablename__ = "message_agent_thoughts"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="message_agent_thought_pkey"),
|
||||
@@ -1851,34 +1851,42 @@ class MessageAgentThought(Base):
|
||||
sa.Index("message_agent_thought_message_chain_id_idx", "message_chain_id"),
|
||||
)
|
||||
|
||||
id = mapped_column(StringUUID, default=lambda: str(uuid4()))
|
||||
message_id = mapped_column(StringUUID, nullable=False)
|
||||
message_chain_id = mapped_column(StringUUID, nullable=True)
|
||||
id: Mapped[str] = mapped_column(
|
||||
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
|
||||
)
|
||||
message_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
position: Mapped[int] = mapped_column(sa.Integer, nullable=False)
|
||||
thought = mapped_column(LongText, nullable=True)
|
||||
tool = mapped_column(LongText, nullable=True)
|
||||
tool_labels_str = mapped_column(LongText, nullable=False, default=sa.text("'{}'"))
|
||||
tool_meta_str = mapped_column(LongText, nullable=False, default=sa.text("'{}'"))
|
||||
tool_input = mapped_column(LongText, nullable=True)
|
||||
observation = mapped_column(LongText, nullable=True)
|
||||
created_by_role: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
message_chain_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
|
||||
thought: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
|
||||
tool: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
|
||||
tool_labels_str: Mapped[str] = mapped_column(LongText, nullable=False, default=sa.text("'{}'"))
|
||||
tool_meta_str: Mapped[str] = mapped_column(LongText, nullable=False, default=sa.text("'{}'"))
|
||||
tool_input: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
|
||||
observation: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
|
||||
# plugin_id = mapped_column(StringUUID, nullable=True) ## for future design
|
||||
tool_process_data = mapped_column(LongText, nullable=True)
|
||||
message = mapped_column(LongText, nullable=True)
|
||||
message_token: Mapped[int | None] = mapped_column(sa.Integer, nullable=True)
|
||||
message_unit_price = mapped_column(sa.Numeric, nullable=True)
|
||||
message_price_unit = mapped_column(sa.Numeric(10, 7), nullable=False, server_default=sa.text("0.001"))
|
||||
message_files = mapped_column(LongText, nullable=True)
|
||||
answer = mapped_column(LongText, nullable=True)
|
||||
answer_token: Mapped[int | None] = mapped_column(sa.Integer, nullable=True)
|
||||
answer_unit_price = mapped_column(sa.Numeric, nullable=True)
|
||||
answer_price_unit = mapped_column(sa.Numeric(10, 7), nullable=False, server_default=sa.text("0.001"))
|
||||
tokens: Mapped[int | None] = mapped_column(sa.Integer, nullable=True)
|
||||
total_price = mapped_column(sa.Numeric, nullable=True)
|
||||
currency = mapped_column(String(255), nullable=True)
|
||||
latency: Mapped[float | None] = mapped_column(sa.Float, nullable=True)
|
||||
created_by_role = mapped_column(String(255), nullable=False)
|
||||
created_by = mapped_column(StringUUID, nullable=False)
|
||||
created_at = mapped_column(sa.DateTime, nullable=False, server_default=sa.func.current_timestamp())
|
||||
tool_process_data: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
|
||||
message: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
|
||||
message_token: Mapped[int | None] = mapped_column(sa.Integer, nullable=True, default=None)
|
||||
message_unit_price: Mapped[Decimal | None] = mapped_column(sa.Numeric, nullable=True, default=None)
|
||||
message_price_unit: Mapped[Decimal] = mapped_column(
|
||||
sa.Numeric(10, 7), nullable=False, default=Decimal("0.001"), server_default=sa.text("0.001")
|
||||
)
|
||||
message_files: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
|
||||
answer: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
|
||||
answer_token: Mapped[int | None] = mapped_column(sa.Integer, nullable=True, default=None)
|
||||
answer_unit_price: Mapped[Decimal | None] = mapped_column(sa.Numeric, nullable=True, default=None)
|
||||
answer_price_unit: Mapped[Decimal] = mapped_column(
|
||||
sa.Numeric(10, 7), nullable=False, default=Decimal("0.001"), server_default=sa.text("0.001")
|
||||
)
|
||||
tokens: Mapped[int | None] = mapped_column(sa.Integer, nullable=True, default=None)
|
||||
total_price: Mapped[Decimal | None] = mapped_column(sa.Numeric, nullable=True, default=None)
|
||||
currency: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
|
||||
latency: Mapped[float | None] = mapped_column(sa.Float, nullable=True, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime, nullable=False, init=False, server_default=sa.func.current_timestamp()
|
||||
)
|
||||
|
||||
@property
|
||||
def files(self) -> list[Any]:
|
||||
|
||||
@@ -230,7 +230,6 @@ class TestAgentService:
|
||||
|
||||
# Create first agent thought
|
||||
thought1 = MessageAgentThought(
|
||||
id=fake.uuid4(),
|
||||
message_id=message.id,
|
||||
position=1,
|
||||
thought="I need to analyze the user's request",
|
||||
@@ -257,7 +256,6 @@ class TestAgentService:
|
||||
|
||||
# Create second agent thought
|
||||
thought2 = MessageAgentThought(
|
||||
id=fake.uuid4(),
|
||||
message_id=message.id,
|
||||
position=2,
|
||||
thought="Based on the analysis, I can provide a response",
|
||||
@@ -545,7 +543,6 @@ class TestAgentService:
|
||||
|
||||
# Create agent thought with tool error
|
||||
thought_with_error = MessageAgentThought(
|
||||
id=fake.uuid4(),
|
||||
message_id=message.id,
|
||||
position=1,
|
||||
thought="I need to analyze the user's request",
|
||||
@@ -759,7 +756,6 @@ class TestAgentService:
|
||||
|
||||
# Create agent thought with multiple tools
|
||||
complex_thought = MessageAgentThought(
|
||||
id=fake.uuid4(),
|
||||
message_id=message.id,
|
||||
position=1,
|
||||
thought="I need to use multiple tools to complete this task",
|
||||
@@ -877,7 +873,6 @@ class TestAgentService:
|
||||
|
||||
# Create agent thought with files
|
||||
thought_with_files = MessageAgentThought(
|
||||
id=fake.uuid4(),
|
||||
message_id=message.id,
|
||||
position=1,
|
||||
thought="I need to process some files",
|
||||
@@ -957,7 +952,6 @@ class TestAgentService:
|
||||
|
||||
# Create agent thought with empty tool data
|
||||
empty_thought = MessageAgentThought(
|
||||
id=fake.uuid4(),
|
||||
message_id=message.id,
|
||||
position=1,
|
||||
thought="I need to analyze the user's request",
|
||||
@@ -999,7 +993,6 @@ class TestAgentService:
|
||||
|
||||
# Create agent thought with malformed JSON
|
||||
malformed_thought = MessageAgentThought(
|
||||
id=fake.uuid4(),
|
||||
message_id=message.id,
|
||||
position=1,
|
||||
thought="I need to analyze the user's request",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Preview } from '@storybook/react'
|
||||
import type { Resource } from 'i18next'
|
||||
import { withThemeByDataAttribute } from '@storybook/addon-themes'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ToastProvider } from '../app/components/base/toast'
|
||||
import I18N from '../app/components/i18n'
|
||||
import { I18nClientProvider as I18N } from '../app/components/provider/i18n'
|
||||
import commonEnUS from '../i18n/en-US/common.json'
|
||||
|
||||
import '../app/styles/globals.css'
|
||||
import '../app/styles/markdown.scss'
|
||||
@@ -16,6 +18,14 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
})
|
||||
|
||||
const storyResources: Resource = {
|
||||
'en-US': {
|
||||
// Preload the most common namespace to avoid missing keys during initial render;
|
||||
// other namespaces will be loaded on demand via resourcesToBackend.
|
||||
common: commonEnUS as unknown as Record<string, unknown>,
|
||||
},
|
||||
}
|
||||
|
||||
export const decorators = [
|
||||
withThemeByDataAttribute({
|
||||
themes: {
|
||||
@@ -28,7 +38,7 @@ export const decorators = [
|
||||
(Story) => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18N locale="en-US">
|
||||
<I18N locale="en-US" resource={storyResources}>
|
||||
<ToastProvider>
|
||||
<Story />
|
||||
</ToastProvider>
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
|
||||
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
|
||||
} from '@/app/education-apply/constants'
|
||||
import { fetchSetupStatus } from '@/service/common'
|
||||
import { sendGAEvent } from '@/utils/gtag'
|
||||
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
|
||||
import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect'
|
||||
import { trackEvent } from './base/amplitude'
|
||||
|
||||
@@ -33,15 +33,8 @@ export const AppInitializer = ({
|
||||
|
||||
const isSetupFinished = useCallback(async () => {
|
||||
try {
|
||||
if (localStorage.getItem('setup_status') === 'finished')
|
||||
return true
|
||||
const setUpStatus = await fetchSetupStatus()
|
||||
if (setUpStatus.step !== 'finished') {
|
||||
localStorage.removeItem('setup_status')
|
||||
return false
|
||||
}
|
||||
localStorage.setItem('setup_status', 'finished')
|
||||
return true
|
||||
const setUpStatus = await fetchSetupStatusWithCache()
|
||||
return setUpStatus.step === 'finished'
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
|
||||
3
web/app/components/provider/serwist.tsx
Normal file
3
web/app/components/provider/serwist.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
'use client'
|
||||
|
||||
export { SerwistProvider } from '@serwist/turbopack/react'
|
||||
@@ -12,6 +12,7 @@ import { ToastProvider } from './components/base/toast'
|
||||
import BrowserInitializer from './components/browser-initializer'
|
||||
import { ReactScanLoader } from './components/devtools/react-scan/loader'
|
||||
import { I18nServerProvider } from './components/provider/i18n-server'
|
||||
import { SerwistProvider } from './components/provider/serwist'
|
||||
import SentryInitializer from './components/sentry-initializer'
|
||||
import RoutePrefixHandle from './routePrefixHandle'
|
||||
import './styles/globals.css'
|
||||
@@ -39,6 +40,9 @@ const LocaleLayout = async ({
|
||||
}) => {
|
||||
const locale = await getLocaleOnServer()
|
||||
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''
|
||||
const swUrl = `${basePath}/serwist/sw.js`
|
||||
|
||||
const datasetMap: Record<DatasetAttr, string | undefined> = {
|
||||
[DatasetAttr.DATA_API_PREFIX]: process.env.NEXT_PUBLIC_API_PREFIX,
|
||||
[DatasetAttr.DATA_PUBLIC_API_PREFIX]: process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX,
|
||||
@@ -92,33 +96,35 @@ const LocaleLayout = async ({
|
||||
className="color-scheme h-full select-auto"
|
||||
{...datasetMap}
|
||||
>
|
||||
<ReactScanLoader />
|
||||
<JotaiProvider>
|
||||
<ThemeProvider
|
||||
attribute="data-theme"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
enableColorScheme={false}
|
||||
>
|
||||
<NuqsAdapter>
|
||||
<BrowserInitializer>
|
||||
<SentryInitializer>
|
||||
<TanstackQueryInitializer>
|
||||
<I18nServerProvider>
|
||||
<ToastProvider>
|
||||
<GlobalPublicStoreProvider>
|
||||
{children}
|
||||
</GlobalPublicStoreProvider>
|
||||
</ToastProvider>
|
||||
</I18nServerProvider>
|
||||
</TanstackQueryInitializer>
|
||||
</SentryInitializer>
|
||||
</BrowserInitializer>
|
||||
</NuqsAdapter>
|
||||
</ThemeProvider>
|
||||
</JotaiProvider>
|
||||
<RoutePrefixHandle />
|
||||
<SerwistProvider swUrl={swUrl}>
|
||||
<ReactScanLoader />
|
||||
<JotaiProvider>
|
||||
<ThemeProvider
|
||||
attribute="data-theme"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
enableColorScheme={false}
|
||||
>
|
||||
<NuqsAdapter>
|
||||
<BrowserInitializer>
|
||||
<SentryInitializer>
|
||||
<TanstackQueryInitializer>
|
||||
<I18nServerProvider>
|
||||
<ToastProvider>
|
||||
<GlobalPublicStoreProvider>
|
||||
{children}
|
||||
</GlobalPublicStoreProvider>
|
||||
</ToastProvider>
|
||||
</I18nServerProvider>
|
||||
</TanstackQueryInitializer>
|
||||
</SentryInitializer>
|
||||
</BrowserInitializer>
|
||||
</NuqsAdapter>
|
||||
</ThemeProvider>
|
||||
</JotaiProvider>
|
||||
<RoutePrefixHandle />
|
||||
</SerwistProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
14
web/app/serwist/[path]/route.ts
Normal file
14
web/app/serwist/[path]/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createSerwistRoute } from '@serwist/turbopack'
|
||||
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''
|
||||
const revision = spawnSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf-8' }).stdout?.trim() || randomUUID()
|
||||
|
||||
export const { dynamic, dynamicParams, revalidate, generateStaticParams, GET } = createSerwistRoute({
|
||||
additionalPrecacheEntries: [{ url: `${basePath}/_offline.html`, revision }],
|
||||
swSrc: 'app/sw.ts',
|
||||
nextConfig: {
|
||||
basePath,
|
||||
},
|
||||
})
|
||||
104
web/app/sw.ts
Normal file
104
web/app/sw.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/// <reference no-default-lib="true" />
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist'
|
||||
import { CacheableResponsePlugin, CacheFirst, ExpirationPlugin, NetworkFirst, Serwist, StaleWhileRevalidate } from 'serwist'
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line ts/consistent-type-definitions
|
||||
interface WorkerGlobalScope extends SerwistGlobalConfig {
|
||||
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined
|
||||
}
|
||||
}
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope
|
||||
|
||||
const scopePathname = new URL(self.registration.scope).pathname
|
||||
const basePath = scopePathname.replace(/\/serwist\/$/, '').replace(/\/$/, '')
|
||||
const offlineUrl = `${basePath}/_offline.html`
|
||||
|
||||
const serwist = new Serwist({
|
||||
precacheEntries: self.__SW_MANIFEST,
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
navigationPreload: true,
|
||||
runtimeCaching: [
|
||||
{
|
||||
matcher: ({ url }) => url.origin === 'https://fonts.googleapis.com',
|
||||
handler: new CacheFirst({
|
||||
cacheName: 'google-fonts',
|
||||
plugins: [
|
||||
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 4,
|
||||
maxAgeSeconds: 365 * 24 * 60 * 60,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
matcher: ({ url }) => url.origin === 'https://fonts.gstatic.com',
|
||||
handler: new CacheFirst({
|
||||
cacheName: 'google-fonts-webfonts',
|
||||
plugins: [
|
||||
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 4,
|
||||
maxAgeSeconds: 365 * 24 * 60 * 60,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
matcher: ({ request }) => request.destination === 'image',
|
||||
handler: new CacheFirst({
|
||||
cacheName: 'images',
|
||||
plugins: [
|
||||
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 64,
|
||||
maxAgeSeconds: 30 * 24 * 60 * 60,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
matcher: ({ request }) => request.destination === 'script' || request.destination === 'style',
|
||||
handler: new StaleWhileRevalidate({
|
||||
cacheName: 'static-resources',
|
||||
plugins: [
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 32,
|
||||
maxAgeSeconds: 24 * 60 * 60,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
matcher: ({ url, sameOrigin }) => sameOrigin && url.pathname.startsWith('/api/'),
|
||||
handler: new NetworkFirst({
|
||||
cacheName: 'api-cache',
|
||||
networkTimeoutSeconds: 10,
|
||||
plugins: [
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 16,
|
||||
maxAgeSeconds: 60 * 60,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
fallbacks: {
|
||||
entries: [
|
||||
{
|
||||
url: offlineUrl,
|
||||
matcher({ request }) {
|
||||
return request.destination === 'document'
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
serwist.addEventListeners()
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { create } from 'zustand'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { getSystemFeatures } from '@/service/common'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
|
||||
|
||||
type GlobalPublicStore = {
|
||||
isGlobalPending: boolean
|
||||
@@ -25,21 +26,36 @@ export const useGlobalPublicStore = create<GlobalPublicStore>(set => ({
|
||||
const GlobalPublicStoreProvider: FC<PropsWithChildren> = ({
|
||||
children,
|
||||
}) => {
|
||||
const { isPending, data } = useQuery({
|
||||
queryKey: ['systemFeatures'],
|
||||
queryFn: getSystemFeatures,
|
||||
// Fetch systemFeatures and setupStatus in parallel to reduce waterfall.
|
||||
// setupStatus is prefetched here and cached in localStorage for AppInitializer.
|
||||
// We only destructure featuresQuery since setupStatus result is not used directly.
|
||||
const [featuresQuery] = useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['systemFeatures'],
|
||||
queryFn: getSystemFeatures,
|
||||
},
|
||||
{
|
||||
queryKey: ['setupStatus'],
|
||||
queryFn: fetchSetupStatusWithCache,
|
||||
staleTime: Infinity, // Once fetched, no need to refetch
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { setSystemFeatures, setIsGlobalPending: setIsPending } = useGlobalPublicStore()
|
||||
useEffect(() => {
|
||||
if (data)
|
||||
setSystemFeatures({ ...defaultSystemFeatures, ...data })
|
||||
}, [data, setSystemFeatures])
|
||||
|
||||
useEffect(() => {
|
||||
setIsPending(isPending)
|
||||
}, [isPending, setIsPending])
|
||||
if (featuresQuery.data)
|
||||
setSystemFeatures({ ...defaultSystemFeatures, ...featuresQuery.data })
|
||||
}, [featuresQuery.data, setSystemFeatures])
|
||||
|
||||
if (isPending)
|
||||
useEffect(() => {
|
||||
setIsPending(featuresQuery.isPending)
|
||||
}, [featuresQuery.isPending, setIsPending])
|
||||
|
||||
// Only block on systemFeatures, setupStatus is prefetched for AppInitializer
|
||||
if (featuresQuery.isPending)
|
||||
return <div className="flex h-screen w-screen items-center justify-center"><Loading /></div>
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
@@ -15,10 +15,7 @@ const config: KnipConfig = {
|
||||
ignoreBinaries: [
|
||||
'only-allow',
|
||||
],
|
||||
ignoreDependencies: [
|
||||
// required by next-pwa
|
||||
'babel-loader',
|
||||
],
|
||||
ignoreDependencies: [],
|
||||
rules: {
|
||||
files: 'warn',
|
||||
dependencies: 'warn',
|
||||
|
||||
@@ -1,77 +1,8 @@
|
||||
import withBundleAnalyzerInit from '@next/bundle-analyzer'
|
||||
import createMDX from '@next/mdx'
|
||||
import { codeInspectorPlugin } from 'code-inspector-plugin'
|
||||
import withPWAInit from 'next-pwa'
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
|
||||
const withPWA = withPWAInit({
|
||||
dest: 'public',
|
||||
register: true,
|
||||
skipWaiting: true,
|
||||
disable: process.env.NODE_ENV === 'development',
|
||||
fallbacks: {
|
||||
document: '/_offline.html',
|
||||
},
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'google-fonts',
|
||||
expiration: {
|
||||
maxEntries: 4,
|
||||
maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'google-fonts-webfonts',
|
||||
expiration: {
|
||||
maxEntries: 4,
|
||||
maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|avif)$/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'images',
|
||||
expiration: {
|
||||
maxEntries: 64,
|
||||
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:js|css)$/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'static-resources',
|
||||
expiration: {
|
||||
maxEntries: 32,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 1 day
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /^\/api\/.*/i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
networkTimeoutSeconds: 10,
|
||||
expiration: {
|
||||
maxEntries: 16,
|
||||
maxAgeSeconds: 60 * 60, // 1 hour
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const withMDX = createMDX({
|
||||
extension: /\.mdx?$/,
|
||||
options: {
|
||||
@@ -97,6 +28,7 @@ const remoteImageURLs = [hasSetWebPrefix ? new URL(`${process.env.NEXT_PUBLIC_WE
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
basePath: process.env.NEXT_PUBLIC_BASE_PATH || '',
|
||||
serverExternalPackages: ['esbuild-wasm'],
|
||||
transpilePackages: ['echarts', 'zrender'],
|
||||
turbopack: {
|
||||
rules: codeInspectorPlugin({
|
||||
@@ -148,4 +80,4 @@ const nextConfig = {
|
||||
},
|
||||
}
|
||||
|
||||
export default withPWA(withBundleAnalyzer(withMDX(nextConfig)))
|
||||
export default withBundleAnalyzer(withMDX(nextConfig))
|
||||
|
||||
@@ -111,7 +111,6 @@
|
||||
"mitt": "^3.0.1",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "~15.5.9",
|
||||
"next-pwa": "^5.6.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.8.6",
|
||||
"pinyin-pro": "^3.27.0",
|
||||
@@ -153,7 +152,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^6.7.3",
|
||||
"@babel/core": "^7.28.4",
|
||||
"@chromatic-com/storybook": "^4.1.1",
|
||||
"@eslint-react/eslint-plugin": "^2.3.13",
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
@@ -162,6 +160,7 @@
|
||||
"@next/eslint-plugin-next": "15.5.9",
|
||||
"@next/mdx": "15.5.9",
|
||||
"@rgrove/parse-xml": "^4.2.0",
|
||||
"@serwist/turbopack": "^9.5.0",
|
||||
"@storybook/addon-docs": "9.1.13",
|
||||
"@storybook/addon-links": "9.1.13",
|
||||
"@storybook/addon-onboarding": "9.1.13",
|
||||
@@ -194,9 +193,9 @@
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@vitest/coverage-v8": "4.0.16",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"babel-loader": "^10.0.0",
|
||||
"code-inspector-plugin": "1.2.9",
|
||||
"cross-env": "^10.1.0",
|
||||
"esbuild-wasm": "^0.27.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
@@ -212,6 +211,7 @@
|
||||
"postcss": "^8.5.6",
|
||||
"react-scan": "^0.4.3",
|
||||
"sass": "^1.93.2",
|
||||
"serwist": "^9.5.0",
|
||||
"storybook": "9.1.17",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"tsx": "^4.21.0",
|
||||
|
||||
1124
web/pnpm-lock.yaml
generated
1124
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
139
web/utils/setup-status.spec.ts
Normal file
139
web/utils/setup-status.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { SetupStatusResponse } from '@/models/common'
|
||||
|
||||
import { fetchSetupStatus } from '@/service/common'
|
||||
|
||||
import { fetchSetupStatusWithCache } from './setup-status'
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
fetchSetupStatus: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockFetchSetupStatus = vi.mocked(fetchSetupStatus)
|
||||
|
||||
describe('setup-status utilities', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('fetchSetupStatusWithCache', () => {
|
||||
describe('when cache exists', () => {
|
||||
it('should return cached finished status without API call', async () => {
|
||||
localStorage.setItem('setup_status', 'finished')
|
||||
|
||||
const result = await fetchSetupStatusWithCache()
|
||||
|
||||
expect(result).toEqual({ step: 'finished' })
|
||||
expect(mockFetchSetupStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not modify localStorage when returning cached value', async () => {
|
||||
localStorage.setItem('setup_status', 'finished')
|
||||
|
||||
await fetchSetupStatusWithCache()
|
||||
|
||||
expect(localStorage.getItem('setup_status')).toBe('finished')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when cache does not exist', () => {
|
||||
it('should call API and cache finished status', async () => {
|
||||
const apiResponse: SetupStatusResponse = { step: 'finished' }
|
||||
mockFetchSetupStatus.mockResolvedValue(apiResponse)
|
||||
|
||||
const result = await fetchSetupStatusWithCache()
|
||||
|
||||
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual(apiResponse)
|
||||
expect(localStorage.getItem('setup_status')).toBe('finished')
|
||||
})
|
||||
|
||||
it('should call API and remove cache when not finished', async () => {
|
||||
const apiResponse: SetupStatusResponse = { step: 'not_started' }
|
||||
mockFetchSetupStatus.mockResolvedValue(apiResponse)
|
||||
|
||||
const result = await fetchSetupStatusWithCache()
|
||||
|
||||
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual(apiResponse)
|
||||
expect(localStorage.getItem('setup_status')).toBeNull()
|
||||
})
|
||||
|
||||
it('should clear stale cache when API returns not_started', async () => {
|
||||
localStorage.setItem('setup_status', 'some_invalid_value')
|
||||
const apiResponse: SetupStatusResponse = { step: 'not_started' }
|
||||
mockFetchSetupStatus.mockResolvedValue(apiResponse)
|
||||
|
||||
const result = await fetchSetupStatusWithCache()
|
||||
|
||||
expect(result).toEqual(apiResponse)
|
||||
expect(localStorage.getItem('setup_status')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cache edge cases', () => {
|
||||
it('should call API when cache value is empty string', async () => {
|
||||
localStorage.setItem('setup_status', '')
|
||||
const apiResponse: SetupStatusResponse = { step: 'finished' }
|
||||
mockFetchSetupStatus.mockResolvedValue(apiResponse)
|
||||
|
||||
const result = await fetchSetupStatusWithCache()
|
||||
|
||||
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual(apiResponse)
|
||||
})
|
||||
|
||||
it('should call API when cache value is not "finished"', async () => {
|
||||
localStorage.setItem('setup_status', 'not_started')
|
||||
const apiResponse: SetupStatusResponse = { step: 'finished' }
|
||||
mockFetchSetupStatus.mockResolvedValue(apiResponse)
|
||||
|
||||
const result = await fetchSetupStatusWithCache()
|
||||
|
||||
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual(apiResponse)
|
||||
})
|
||||
|
||||
it('should call API when localStorage key does not exist', async () => {
|
||||
const apiResponse: SetupStatusResponse = { step: 'finished' }
|
||||
mockFetchSetupStatus.mockResolvedValue(apiResponse)
|
||||
|
||||
const result = await fetchSetupStatusWithCache()
|
||||
|
||||
expect(mockFetchSetupStatus).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual(apiResponse)
|
||||
})
|
||||
})
|
||||
|
||||
describe('API response handling', () => {
|
||||
it('should preserve setup_at from API response', async () => {
|
||||
const setupDate = new Date('2024-01-01')
|
||||
const apiResponse: SetupStatusResponse = {
|
||||
step: 'finished',
|
||||
setup_at: setupDate,
|
||||
}
|
||||
mockFetchSetupStatus.mockResolvedValue(apiResponse)
|
||||
|
||||
const result = await fetchSetupStatusWithCache()
|
||||
|
||||
expect(result).toEqual(apiResponse)
|
||||
expect(result.setup_at).toEqual(setupDate)
|
||||
})
|
||||
|
||||
it('should propagate API errors', async () => {
|
||||
const apiError = new Error('Network error')
|
||||
mockFetchSetupStatus.mockRejectedValue(apiError)
|
||||
|
||||
await expect(fetchSetupStatusWithCache()).rejects.toThrow('Network error')
|
||||
})
|
||||
|
||||
it('should not update cache when API call fails', async () => {
|
||||
mockFetchSetupStatus.mockRejectedValue(new Error('API error'))
|
||||
|
||||
await expect(fetchSetupStatusWithCache()).rejects.toThrow()
|
||||
|
||||
expect(localStorage.getItem('setup_status')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
21
web/utils/setup-status.ts
Normal file
21
web/utils/setup-status.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { SetupStatusResponse } from '@/models/common'
|
||||
import { fetchSetupStatus } from '@/service/common'
|
||||
|
||||
const SETUP_STATUS_KEY = 'setup_status'
|
||||
|
||||
const isSetupStatusCached = (): boolean =>
|
||||
localStorage.getItem(SETUP_STATUS_KEY) === 'finished'
|
||||
|
||||
export const fetchSetupStatusWithCache = async (): Promise<SetupStatusResponse> => {
|
||||
if (isSetupStatusCached())
|
||||
return { step: 'finished' }
|
||||
|
||||
const status = await fetchSetupStatus()
|
||||
|
||||
if (status.step === 'finished')
|
||||
localStorage.setItem(SETUP_STATUS_KEY, 'finished')
|
||||
else
|
||||
localStorage.removeItem(SETUP_STATUS_KEY)
|
||||
|
||||
return status
|
||||
}
|
||||
Reference in New Issue
Block a user