feat(ai-chat): enhance date mention functionality and localization

- Introduced new date mention capabilities, allowing for relative date mentions such as "today," "yesterday," and various ranges (e.g., "last 3 days").
- Updated localization files to include translations for new date mention keys in English, Japanese, and Simplified Chinese.
- Refactored mention handling to improve the display of date mentions, ensuring they are formatted correctly based on user language settings.
- Added support for dynamic display names based on date ranges, enhancing user experience in the AI chat interface.

These changes aim to provide a more intuitive and localized experience when users interact with date mentions in the AI chat.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-09-24 22:20:26 +08:00
parent f2f70ecd47
commit 091a4740ba
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
15 changed files with 547 additions and 578 deletions

View File

@ -5,6 +5,8 @@ const { t } = useTranslation()
const { t: settingsT } = useTranslation("settings")
// eslint-disable-next-line react-hooks/rules-of-hooks, unused-imports/no-unused-vars
const { t: shortcutsT } = useTranslation("shortcuts")
// eslint-disable-next-line react-hooks/rules-of-hooks, unused-imports/no-unused-vars
const { t: aiT } = useTranslation("ai")
declare global {
// BIZ ID
export type Id = string
@ -31,6 +33,7 @@ declare global {
export type I18nKeys = OmitStringType<Parameters<typeof t>[0]>
export type I18nKeysForSettings = OmitStringType<Parameters<typeof settingsT>[0]>
export type I18nKeysForShortcuts = OmitStringType<Parameters<typeof shortcutsT>[0]>
export type I18nKeysForAi = OmitStringType<Parameters<typeof aiT>[0]>
// MACROS

View File

@ -1,3 +1,4 @@
import i18next from "i18next"
import type {
DOMConversionMap,
DOMConversionOutput,
@ -13,6 +14,8 @@ import { $applyNodeReplacement, DecoratorNode } from "lexical"
import * as React from "react"
import { MentionComponent } from "./components/MentionComponent"
import { RANGE_WITH_LABEL_KEY } from "./hooks/dateMentionConfig"
import { getDateMentionDisplayName } from "./hooks/dateMentionUtils"
import type { MentionData } from "./types"
export type SerializedMentionNode = Spread<
@ -76,7 +79,7 @@ export class MentionNode extends DecoratorNode<React.JSX.Element> {
element.dataset.lexicalMention = "true"
element.dataset.mentionType = this.__mentionData.type
element.dataset.mentionId = this.__mentionData.id
element.textContent = `@${this.__mentionData.name}`
element.textContent = `@${resolveMentionDisplayName(this.__mentionData)}`
element.className = "mention-node"
return { element }
}
@ -164,3 +167,14 @@ export function $createMentionNode(mentionData: MentionData): MentionNode {
export function $isMentionNode(node: LexicalNode | null | undefined): node is MentionNode {
return node instanceof MentionNode
}
const resolveMentionDisplayName = (mentionData: MentionData): string => {
if (mentionData.type !== "date") {
return mentionData.name
}
const language = i18next.language || i18next.resolvedLanguage || i18next.options?.lng || "en"
const translate = i18next.getFixedT(language, "ai")
return getDateMentionDisplayName(mentionData, translate, language, RANGE_WITH_LABEL_KEY)
}

View File

@ -7,7 +7,10 @@ import {
} from "@follow/components/ui/tooltip/index.js"
import { cn } from "@follow/utils"
import * as React from "react"
import { useTranslation } from "react-i18next"
import { RANGE_WITH_LABEL_KEY } from "../hooks/dateMentionConfig"
import { getDateMentionDisplayName } from "../hooks/dateMentionUtils"
import type { MentionData } from "../types"
import { MentionTypeIcon } from "./shared/MentionTypeIcon"
@ -16,24 +19,25 @@ interface MentionComponentProps {
className?: string
}
const MentionTooltipContent = ({ mentionData }: { mentionData: MentionData }) => (
<div className="flex items-center gap-2">
<div className="bg-fill border-fill-secondary flex size-6 flex-shrink-0 items-center justify-center rounded-full border">
<MentionTypeIcon type={mentionData.type} />
</div>
<div className="flex flex-col gap-1">
<p className="text-text text-sm font-medium">@{mentionData.name}</p>
<span
className={cn(
"rounded px-1.5 py-0.5 text-xs font-medium",
mentionData.type === "entry" && "bg-blue text-black",
mentionData.type === "feed" && "bg-orange text-black",
mentionData.type === "date" && "bg-purple text-black",
)}
>
{mentionData.type}
</span>
const MentionTooltipContent = ({
mentionData,
displayName,
}: {
mentionData: MentionData
displayName: string
}) => (
<div className="flex items-center gap-2 p-1">
<div
className={cn(
"flex size-5 items-center justify-center rounded text-white",
mentionData.type === "entry" && "bg-blue",
mentionData.type === "feed" && "bg-orange",
mentionData.type === "date" && "bg-purple",
)}
>
<MentionTypeIcon type={mentionData.type} className="size-3" />
</div>
<span className="text-text text-sm">{displayName}</span>
</div>
)
@ -68,6 +72,16 @@ const getMentionStyles = (type: MentionData["type"]) => {
}
}
export const MentionComponent: React.FC<MentionComponentProps> = ({ mentionData, className }) => {
const { t, i18n } = useTranslation("ai")
const language = i18n.language || i18n.resolvedLanguage || "en"
const displayName = React.useMemo(() => {
if (mentionData.type === "date") {
return getDateMentionDisplayName(mentionData, t, language, RANGE_WITH_LABEL_KEY)
}
return mentionData.name
}, [mentionData, t, language])
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
// Handle mention click - could navigate to user profile, topic page, etc.
@ -80,12 +94,12 @@ export const MentionComponent: React.FC<MentionComponentProps> = ({ mentionData,
<TooltipTrigger asChild>
<span className={cn(getMentionStyles(mentionData.type), className)} onClick={handleClick}>
<MentionTypeIcon type={mentionData.type} />
<span>@{mentionData.name}</span>
<span>@{displayName}</span>
</span>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="top" className="max-w-[300px]">
<MentionTooltipContent mentionData={mentionData} />
<MentionTooltipContent mentionData={mentionData} displayName={displayName} />
</TooltipContent>
</TooltipPortal>
</TooltipRoot>

View File

@ -13,8 +13,11 @@ import { cn, thenable } from "@follow/utils"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import * as React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { MENTION_TRIGGER_PATTERN } from "../constants"
import { RANGE_WITH_LABEL_KEY } from "../hooks/dateMentionConfig"
import { getDateMentionDisplayName } from "../hooks/dateMentionUtils"
import type { MentionData } from "../types"
import { calculateDropdownPosition } from "../utils/positioning"
import { MentionTypeIcon } from "./shared/MentionTypeIcon"
@ -42,21 +45,38 @@ const MentionSuggestionItem = React.memo(
onClick: (mention: MentionData) => void
query: string
}) => {
const { t, i18n } = useTranslation("ai")
const language = i18n.language || i18n.resolvedLanguage || "en"
const displayName = React.useMemo(() => {
if (mention.type === "date") {
return getDateMentionDisplayName(mention, t, language, RANGE_WITH_LABEL_KEY)
}
return mention.name
}, [mention, t, language])
const handleClick = useCallback(() => {
onClick(mention)
}, [mention, onClick])
// Highlight matching text
const highlightText = (text: string, query: string) => {
const cleanQuery = query.replace(MENTION_TRIGGER_PATTERN, "").toLowerCase()
const highlightText = (text: string, rawQuery: string) => {
const cleanQuery = rawQuery.replace(MENTION_TRIGGER_PATTERN, "").toLowerCase()
if (!cleanQuery) return text
const parts = text.split(new RegExp(`(${cleanQuery})`, "gi"))
return parts.map((part) => {
return parts.map((part, index) => {
const isMatch = part.toLowerCase() === cleanQuery
if (!part) {
return null
}
return (
<span key={mention.id} className={isMatch ? "text-text-vibrant font-semibold" : ""}>
<span
key={`${mention.id}-${index}`}
className={isMatch ? "text-text-vibrant font-semibold" : ""}
>
{part}
</span>
)
@ -89,7 +109,7 @@ const MentionSuggestionItem = React.memo(
</span>
{/* Content */}
<span className="flex-1 truncate">{highlightText(mention.name, query)}</span>
<span className="flex-1 truncate">{highlightText(displayName, query)}</span>
{/* Selection Indicator */}
{isSelected && (

View File

@ -2,254 +2,92 @@ import type { Dayjs } from "dayjs"
import { clampRangeToPastMonth } from "./dateMentionUtils"
export const DATE_FORMATS = [
"YYYY-MM-DD",
"YYYY/MM/DD",
"YYYY.MM.DD",
"YYYYMMDD",
"YYYY-M-D",
"YYYY/M/D",
"YYYY.M.D",
"MM/DD/YYYY",
"M/D/YYYY",
"DD/MM/YYYY",
"D/M/YYYY",
"MM-DD-YYYY",
"M-D-YYYY",
"DD-MM-YYYY",
"D-M-YYYY",
"MMMM D, YYYY",
"MMM D, YYYY",
"D MMM YYYY",
"D MMMM YYYY",
"MMMM D YYYY",
"MMM D YYYY",
"YYYY年M月D日",
]
export const MAX_INLINE_DATE_SUGGESTIONS = 3
export interface WeekdayInfo {
index: number
english: string
short: string
chineseRoots: string[]
}
export type DateRangeFactory = (today: Dayjs) => ReturnType<typeof clampRangeToPastMonth>
export const WEEKDAY_INFOS: readonly WeekdayInfo[] = [
{
index: 0,
english: "Sunday",
short: "Sun",
chineseRoots: ["周日", "周天", "星期日", "星期天", "礼拜日", "礼拜天"],
},
{ index: 1, english: "Monday", short: "Mon", chineseRoots: ["周一", "星期一", "礼拜一"] },
{ index: 2, english: "Tuesday", short: "Tue", chineseRoots: ["周二", "星期二", "礼拜二"] },
{ index: 3, english: "Wednesday", short: "Wed", chineseRoots: ["周三", "星期三", "礼拜三"] },
{ index: 4, english: "Thursday", short: "Thu", chineseRoots: ["周四", "星期四", "礼拜四"] },
{ index: 5, english: "Friday", short: "Fri", chineseRoots: ["周五", "星期五", "礼拜五"] },
{ index: 6, english: "Saturday", short: "Sat", chineseRoots: ["周六", "星期六", "礼拜六"] },
]
export const ENGLISH_THIS_WEEK_PREFIXES = new Set(["this", "this week", "current", "current week"])
export const ENGLISH_LAST_WEEK_PREFIXES = new Set([
"last",
"last week",
"previous",
"previous week",
])
export const CHINESE_THIS_WEEK_PREFIXES = new Set([
"这周",
"本周",
"这星期",
"本星期",
"这礼拜",
"本礼拜",
])
export const CHINESE_LAST_WEEK_PREFIXES = new Set([
"上周",
"上星期",
"上一周",
"上个星期",
"上礼拜",
"上个礼拜",
])
export const ENGLISH_WEEKDAY_MAP: Record<string, number> = Object.fromEntries(
WEEKDAY_INFOS.flatMap((info) => [
[info.english.toLowerCase(), info.index],
[info.short.toLowerCase(), info.index],
]),
)
export const CHINESE_WEEKDAY_MAP: Record<string, number> = Object.fromEntries(
WEEKDAY_INFOS.flatMap((info) => info.chineseRoots.map((root) => [root, info.index])),
)
export interface WeekdayAutoCompleteConfig {
export interface RelativeDateDefinition {
id: string
dayIndex: number
prefix: "this" | "last"
displayName: string
keywords: string[]
labelKey: I18nKeysForAi
searchKeys: I18nKeysForAi[]
range: DateRangeFactory
}
export const WEEKDAY_AUTOCOMPLETE_CONFIGS: WeekdayAutoCompleteConfig[] = WEEKDAY_INFOS.flatMap(
({ index, english, short, chineseRoots }) => {
const englishLower = english.toLowerCase()
const shortLower = short.toLowerCase()
const baseEnglishKeywords = [englishLower, shortLower]
const thisEnglishKeywords = [
`this ${englishLower}`,
`this ${shortLower}`,
`current ${englishLower}`,
`current ${shortLower}`,
...baseEnglishKeywords,
]
const lastEnglishKeywords = [
`last ${englishLower}`,
`last ${shortLower}`,
`previous ${englishLower}`,
`previous ${shortLower}`,
...baseEnglishKeywords,
]
const chineseThisKeywords = chineseRoots.flatMap((root) => [root, `${root}`, `${root}`])
const chineseLastKeywords = chineseRoots.flatMap((root) => [`${root}`, `上个${root}`])
return [
{
id: `date:this-${englishLower}`,
dayIndex: index,
prefix: "this" as const,
displayName: `This ${english}`,
keywords: [...thisEnglishKeywords, ...chineseThisKeywords],
},
{
id: `date:last-${englishLower}`,
dayIndex: index,
prefix: "last" as const,
displayName: `Last ${english}`,
keywords: [...lastEnglishKeywords, ...chineseLastKeywords],
},
]
},
)
export interface RelativeDateConfig {
id: string
displayName: string
keywordSeeds: string[]
range: (today: Dayjs) => ReturnType<typeof clampRangeToPastMonth>
}
export const RELATIVE_DATE_CONFIGS: RelativeDateConfig[] = [
export const RELATIVE_DATE_DEFINITIONS: readonly RelativeDateDefinition[] = [
{
id: "date:today",
displayName: "Today",
keywordSeeds: ["today", "tod", "今天", "今日"],
id: "date:relative:today",
labelKey: "mentions.date.relative.today.label",
searchKeys: ["mentions.date.relative.today.search"],
range: (today) => clampRangeToPastMonth({ start: today, end: today }),
},
{
id: "date:yesterday",
displayName: "Yesterday",
keywordSeeds: ["yesterday", "yday", "昨天", "昨日"],
id: "date:relative:yesterday",
labelKey: "mentions.date.relative.yesterday.label",
searchKeys: ["mentions.date.relative.yesterday.search"],
range: (today) => {
const yesterday = today.subtract(1, "day")
return clampRangeToPastMonth({ start: yesterday, end: yesterday })
const target = today.subtract(1, "day")
return clampRangeToPastMonth({ start: target, end: target })
},
},
{
id: "date:last-3-days",
displayName: "Last 3 days",
keywordSeeds: ["last 3 days", "past 3 days", "3d", "最近3天", "最近三天", "近3天", "近三天"],
id: "date:relative:last-3-days",
labelKey: "mentions.date.relative.last_3_days.label",
searchKeys: ["mentions.date.relative.last_3_days.search"],
range: (today) => clampRangeToPastMonth({ start: today.subtract(2, "day"), end: today }),
},
{
id: "date:last-7-days",
displayName: "Last 7 days",
keywordSeeds: [
"last 7 days",
"past 7 days",
"past week",
"7d",
"最近7天",
"最近七天",
"近7天",
"近七天",
"近一周",
"最近一周",
],
id: "date:relative:last-7-days",
labelKey: "mentions.date.relative.last_7_days.label",
searchKeys: ["mentions.date.relative.last_7_days.search"],
range: (today) => clampRangeToPastMonth({ start: today.subtract(6, "day"), end: today }),
},
{
id: "date:last-30-days",
displayName: "Last 30 days",
keywordSeeds: [
"last 30 days",
"past 30 days",
"30d",
"month",
"最近30天",
"最近三十天",
"近30天",
"近三十天",
"近一个月",
"最近一个月",
],
id: "date:relative:last-30-days",
labelKey: "mentions.date.relative.last_30_days.label",
searchKeys: ["mentions.date.relative.last_30_days.search"],
range: (today) => clampRangeToPastMonth({ start: today.subtract(29, "day"), end: today }),
},
{
id: "date:this-week",
displayName: "This week",
keywordSeeds: [
"this week",
"current week",
"week",
"这周",
"本周",
"这星期",
"本星期",
"这礼拜",
"本礼拜",
],
id: "date:relative:this-week",
labelKey: "mentions.date.relative.this_week.label",
searchKeys: ["mentions.date.relative.this_week.search"],
range: (today) => clampRangeToPastMonth({ start: today.startOf("week"), end: today }),
},
{
id: "date:last-week",
displayName: "Last week",
keywordSeeds: [
"last week",
"previous week",
"lw",
"上周",
"上一周",
"上星期",
"上个星期",
"上礼拜",
"上个礼拜",
],
id: "date:relative:last-week",
labelKey: "mentions.date.relative.last_week.label",
searchKeys: ["mentions.date.relative.last_week.search"],
range: (today) => {
const lastWeekStart = today.subtract(1, "week").startOf("week")
const lastWeekEnd = lastWeekStart.add(6, "day")
return clampRangeToPastMonth({ start: lastWeekStart, end: lastWeekEnd })
const start = today.subtract(1, "week").startOf("week")
const end = start.add(6, "day")
return clampRangeToPastMonth({ start, end })
},
},
{
id: "date:this-month",
displayName: "This month",
keywordSeeds: ["this month", "current month", "month", "这个月", "本月", "这月"],
id: "date:relative:this-month",
labelKey: "mentions.date.relative.this_month.label",
searchKeys: ["mentions.date.relative.this_month.search"],
range: (today) => clampRangeToPastMonth({ start: today.startOf("month"), end: today }),
},
{
id: "date:last-month",
displayName: "Last month",
keywordSeeds: ["last month", "previous month", "上个月", "上月", "上一个月"],
id: "date:relative:last-month",
labelKey: "mentions.date.relative.last_month.label",
searchKeys: ["mentions.date.relative.last_month.search"],
range: (today) => {
const lastMonthStart = today.subtract(1, "month").startOf("month")
const lastMonthEnd = lastMonthStart.endOf("month")
return clampRangeToPastMonth({ start: lastMonthStart, end: lastMonthEnd })
const start = today.subtract(1, "month").startOf("month")
const end = start.endOf("month")
return clampRangeToPastMonth({ start, end })
},
},
]
export type WeekdayPrefix = "auto" | "this" | "last"
export interface WeekdayTranslationDescriptor {
id: string
index: number
labelKey: string
searchKey: string
}
export const RANGE_WITH_LABEL_KEY = "mentions.date.display.with_range"

View File

@ -1,342 +1,152 @@
import dayjs from "dayjs"
import type { IFuseOptions } from "fuse.js"
import Fuse from "fuse.js"
import type { TFunction } from "i18next"
import type { MentionData } from "../types"
import type { MentionData, MentionLabelDescriptor } from "../types"
import type { RelativeDateDefinition } from "./dateMentionConfig"
import { RANGE_WITH_LABEL_KEY, RELATIVE_DATE_DEFINITIONS } from "./dateMentionConfig"
import type { DateRange } from "./dateMentionUtils"
import {
CHINESE_LAST_WEEK_PREFIXES,
CHINESE_THIS_WEEK_PREFIXES,
CHINESE_WEEKDAY_MAP,
DATE_FORMATS,
ENGLISH_LAST_WEEK_PREFIXES,
ENGLISH_THIS_WEEK_PREFIXES,
ENGLISH_WEEKDAY_MAP,
RELATIVE_DATE_CONFIGS,
WEEKDAY_AUTOCOMPLETE_CONFIGS,
} from "./dateMentionConfig"
import { clampRangeToPastMonth, createMentionFromRange, formatRangeValue } from "./dateMentionUtils"
createDateMentionData,
formatLocalizedRange,
resolveMentionLabel,
} from "./dateMentionUtils"
type MentionParser = (query: string) => MentionData | null
type MentionListBuilder = (query: string) => MentionData[]
type AiTFunction = TFunction<"ai">
export const buildRelativeDateMentions: MentionListBuilder = (query) => {
const normalized = query.trim().toLowerCase()
const today = dayjs().startOf("day")
const mentions: MentionData[] = []
RELATIVE_DATE_CONFIGS.forEach((config) => {
const range = config.range(today)
if (!range) return
const keywordPool = new Set(config.keywordSeeds.map((seed) => seed.toLowerCase()))
keywordPool.add(formatRangeValue(range))
const matches =
!normalized || Array.from(keywordPool).some((keyword) => keyword.includes(normalized))
if (!matches) return
mentions.push(createMentionFromRange(range, config.displayName, config.id))
})
return mentions
interface DateMentionBuilderContext {
t: AiTFunction
language: string
}
const buildWeekdayMention = (
dayIndex: number,
prefix: "auto" | "this" | "last",
displayName: string,
): MentionData | null => {
const today = dayjs().startOf("day")
const minAllowed = today.subtract(1, "month")
let baseWeekStart = today.startOf("week")
if (prefix === "last") {
baseWeekStart = baseWeekStart.subtract(1, "week")
}
let candidate = baseWeekStart.add(dayIndex, "day")
if ((prefix === "auto" || prefix === "this") && candidate.isAfter(today)) {
candidate = candidate.subtract(1, "week")
}
if (candidate.isAfter(today)) {
candidate = today
}
if (candidate.isBefore(minAllowed)) {
return null
}
const clamped = clampRangeToPastMonth({ start: candidate, end: candidate })
if (!clamped) return null
return createMentionFromRange(clamped, displayName)
interface RelativeDateCandidate {
definition: RelativeDateDefinition
label: MentionLabelDescriptor
searchTerms: string[]
}
const parseWeekdayMention: MentionParser = (raw) => {
const trimmed = raw.trim()
if (!trimmed) return null
const normalized = trimmed.toLowerCase()
const englishMatch = normalized.match(
/^(?:(this week|this|current week|current|last week|last|previous week|previous)\s+)?(monday|mon|tuesday|tue|tues|wednesday|wed|weds|thursday|thu|thur|thurs|friday|fri|saturday|sat|sunday|sun)$/,
)
if (englishMatch) {
const prefixRaw = (englishMatch[1] ?? "").trim()
const dayToken = englishMatch[2]
if (!dayToken) return null
const dayIndex = ENGLISH_WEEKDAY_MAP[dayToken]
if (dayIndex === undefined) return null
let prefix: "auto" | "this" | "last" = "auto"
if (prefixRaw) {
if (ENGLISH_THIS_WEEK_PREFIXES.has(prefixRaw)) {
prefix = "this"
} else if (ENGLISH_LAST_WEEK_PREFIXES.has(prefixRaw)) {
prefix = "last"
} else {
return null
}
}
return buildWeekdayMention(dayIndex, prefix, trimmed)
}
const chineseMatch = trimmed.match(
/^(这周|本周|这星期|本星期|这礼拜|本礼拜|上周|上星期|上一周|上个星期|上礼拜|上个礼拜)?(周[一二三四五六日天]|星期[一二三四五六日天]|礼拜[一二三四五六日天])$/,
)
if (chineseMatch) {
const prefixRaw = chineseMatch[1]
const dayToken = chineseMatch[2]
if (!dayToken) return null
const dayIndex = CHINESE_WEEKDAY_MAP[dayToken]
if (dayIndex === undefined) return null
let prefix: "auto" | "this" | "last" = "auto"
if (prefixRaw) {
if (CHINESE_THIS_WEEK_PREFIXES.has(prefixRaw)) {
prefix = "this"
} else if (CHINESE_LAST_WEEK_PREFIXES.has(prefixRaw)) {
prefix = "last"
} else {
return null
}
}
return buildWeekdayMention(dayIndex, prefix, trimmed)
}
return null
const FUSE_OPTIONS: IFuseOptions<RelativeDateCandidate> = {
includeScore: true,
threshold: 0.3,
ignoreLocation: true,
minMatchCharLength: 1,
keys: ["searchTerms"],
}
export const buildWeekdayAutoCompleteMentions: MentionListBuilder = (query) => {
const trimmed = query.trim()
if (!trimmed) return []
const sanitizeTerm = (term: string): string => term.trim()
const normalized = trimmed.toLowerCase()
const addSearchTerm = (set: Set<string>, term: string) => {
const cleaned = sanitizeTerm(term)
if (!cleaned) return
for (const config of WEEKDAY_AUTOCOMPLETE_CONFIGS) {
const matches = config.keywords.some((keyword) => {
const lower = keyword.toLowerCase()
return lower.includes(normalized) || normalized.includes(lower)
set.add(cleaned)
const lowered = cleaned.toLowerCase()
if (lowered !== cleaned) {
set.add(lowered)
}
}
const extractSearchTerms = (t: AiTFunction, key: string): string[] => {
// Use a relaxed call signature to avoid strict key typing issues
const tUnsafe: (key: string, options?: any) => unknown = (key, options) =>
(t as unknown as (k: string, o?: any) => unknown)(key, options)
const raw = tUnsafe(key, { returnObjects: true }) as unknown
// Backward-compatible: if translations provided an array, keep supporting it
if (Array.isArray(raw)) {
return raw
.map((item) => (typeof item === "string" ? item : String(item)))
.map(sanitizeTerm)
.filter(Boolean)
}
// Preferred: translations provide a single string; support multiple synonyms
// delimited by common separators: |, comma (en/, zh ), Japanese/Chinese lists (、), or newline
const value = tUnsafe(key) as unknown
if (typeof value !== "string") return []
const pieces = value
.split(/[|,,、\n]/g)
.map(sanitizeTerm)
.filter(Boolean)
// If no delimiter found and non-empty, treat as single term
return pieces.length > 0 ? pieces : [sanitizeTerm(value)].filter(Boolean)
}
const buildRelativeCandidates = ({ t }: DateMentionBuilderContext): RelativeDateCandidate[] => {
return RELATIVE_DATE_DEFINITIONS.map<RelativeDateCandidate>((definition) => {
const terms = new Set<string>()
const label: MentionLabelDescriptor = { key: definition.labelKey }
// Use unsafe t signature for label key to avoid namespace mismatch typing
const tUnsafeLabel: (key: string) => string = (key) =>
(t as unknown as (k: string) => string)(key)
addSearchTerm(terms, tUnsafeLabel(definition.labelKey))
definition.searchKeys.forEach((key) => {
extractSearchTerms(t, key).forEach((term) => addSearchTerm(terms, term))
})
if (!matches) continue
const mention = buildWeekdayMention(config.dayIndex, config.prefix, config.displayName)
if (mention) {
return [{ ...mention, id: config.id }]
return {
definition,
label,
searchTerms: Array.from(terms),
}
}
return []
})
}
const parseDateRangeInput: MentionParser = (raw) => {
if (!raw.includes("..")) return null
const buildRangeMention = (
candidate: RelativeDateCandidate,
range: DateRange,
context: DateMentionBuilderContext,
): MentionData => {
const labelText = resolveMentionLabel(
candidate.label,
// Relax i18n typing to a simple translator signature
context.t,
)
const rangeText = formatLocalizedRange(range, context.language)
const appendRange = labelText
? labelText.localeCompare(rangeText, undefined, { sensitivity: "accent" }) !== 0
: true
const [startRaw, endRaw] = raw.split("..", 2)
if (!startRaw || !endRaw) return null
const startDate = parseDateInput(startRaw)
const endDate = parseDateInput(endRaw)
if (!startDate || !endDate) return null
const range = clampRangeToPastMonth({ start: startDate, end: endDate })
return range ? createMentionFromRange(range, null) : null
return createDateMentionData({
id: candidate.definition.id,
range,
label: candidate.label,
labelOptions: appendRange ? { appendRange: true } : undefined,
translate: context.t,
locale: context.language,
withRangeKey: RANGE_WITH_LABEL_KEY,
})
}
const parseDateInput = (raw: string) => {
const trimmed = raw.trim()
if (!trimmed) return null
const normalizeQuery = (query: string): string => {
const trimmed = query.trim()
if (!trimmed) return ""
for (const format of DATE_FORMATS) {
const parsed = dayjs(trimmed, format, true)
if (parsed.isValid()) {
return parsed.startOf("day")
}
}
return parseMonthDayInput(trimmed)
return trimmed.startsWith("@") ? trimmed.slice(1) : trimmed
}
const parseMonthDayInput = (raw: string) => {
const currentDay = dayjs().startOf("day")
const currentYear = currentDay.year()
export const createDateMentionBuilder = (context: DateMentionBuilderContext) => {
const candidates = buildRelativeCandidates(context)
const fuse = new Fuse(candidates, FUSE_OPTIONS)
const numericMatch = raw.match(/^(\d{1,2})[-/.](\d{1,2})$/)
const zhMatch = raw.match(/^(\d{1,2})月(\d{1,2})日$/)
return (query: string): MentionData[] => {
const normalized = normalizeQuery(query)
const today = dayjs().startOf("day")
const bucket = normalized ? fuse.search(normalized).map((result) => result.item) : candidates
const match = numericMatch ?? zhMatch
if (!match) return null
const mentions: MentionData[] = []
const month = Number(match[1])
const day = Number(match[2])
if (Number.isNaN(month) || Number.isNaN(day)) {
return null
bucket.forEach((candidate) => {
const range = candidate.definition.range(today)
if (!range) return
mentions.push(buildRangeMention(candidate, range, context))
})
return mentions
}
let candidate = dayjs(`${currentYear}-${month}-${day}`, "YYYY-M-D", true)
if (!candidate.isValid()) {
return null
}
if (candidate.isAfter(currentDay)) {
candidate = candidate.subtract(1, "year")
}
return candidate.startOf("day")
}
const parseSpecificDate: MentionParser = (raw) => {
const date = parseDateInput(raw)
if (!date) return null
const range = clampRangeToPastMonth({ start: date, end: date })
return range ? createMentionFromRange(range, date.format("MMMM D, YYYY")) : null
}
const parseNumericMonthMention: MentionParser = (raw) => {
const normalized = raw.trim()
if (!normalized) return null
const hyphenMatch = normalized.match(/^(\d{4})[-/](\d{1,2})$/)
if (hyphenMatch) {
const [, year, month] = hyphenMatch
const monthStart = dayjs(`${year}-${month}-01`, "YYYY-M-D", true)
if (monthStart.isValid()) {
const range = clampRangeToPastMonth({
start: monthStart.startOf("month"),
end: monthStart.endOf("month"),
})
return range ? createMentionFromRange(range, monthStart.format("MMMM YYYY")) : null
}
}
if (/^\d{6}$/.test(normalized)) {
const year = normalized.slice(0, 4)
const month = normalized.slice(4)
const monthStart = dayjs(`${year}-${month}-01`, "YYYY-M-D", true)
if (monthStart.isValid()) {
const range = clampRangeToPastMonth({
start: monthStart.startOf("month"),
end: monthStart.endOf("month"),
})
return range ? createMentionFromRange(range, monthStart.format("MMMM YYYY")) : null
}
}
return null
}
const parseNamedMonthMention: MentionParser = (raw) => {
const monthFormats = ["MMMM YYYY", "MMM YYYY", "YYYY MMMM", "YYYY MMM", "YYYY年M月", "YYYY年MM月"]
for (const format of monthFormats) {
const parsed = dayjs(raw, format, true)
if (parsed.isValid()) {
const range = clampRangeToPastMonth({
start: parsed.startOf("month"),
end: parsed.endOf("month"),
})
return range ? createMentionFromRange(range, parsed.format("MMMM YYYY")) : null
}
}
return null
}
const parseYearMention: MentionParser = (raw) => {
const trimmed = raw.trim()
if (!trimmed) return null
const numericMatch = trimmed.match(/^(\d{4})$/)
if (numericMatch) {
const [, year] = numericMatch
const yearStart = dayjs(`${year}-01-01`, "YYYY-MM-DD", true)
if (yearStart.isValid()) {
const range = clampRangeToPastMonth({
start: yearStart.startOf("year"),
end: yearStart.endOf("year"),
})
return range ? createMentionFromRange(range, yearStart.format("YYYY")) : null
}
}
const localizedMatch = trimmed.match(/^(\d{4})年$/)
if (localizedMatch) {
const [, year] = localizedMatch
const yearStart = dayjs(`${year}-01-01`, "YYYY-MM-DD", true)
if (yearStart.isValid()) {
const range = clampRangeToPastMonth({
start: yearStart.startOf("year"),
end: yearStart.endOf("year"),
})
return range ? createMentionFromRange(range, yearStart.format("YYYY")) : null
}
}
return null
}
const singleMentionParsers: MentionParser[] = [
parseDateRangeInput,
parseSpecificDate,
parseWeekdayMention,
parseNumericMonthMention,
parseNamedMonthMention,
parseYearMention,
]
export const buildAbsoluteDateMentions: MentionListBuilder = (query) => {
for (const parser of singleMentionParsers) {
const mention = parser(query)
if (mention) {
return [mention]
}
}
return []
}
export const buildDateMentions = (query: string): MentionData[] => {
const mentions = new Map<string, MentionData>()
const addMention = (mention: MentionData) => {
const key = `${mention.type}:${String(mention.value)}`
if (!mentions.has(key)) {
mentions.set(key, mention)
}
}
buildRelativeDateMentions(query).forEach(addMention)
buildWeekdayAutoCompleteMentions(query).forEach(addMention)
buildAbsoluteDateMentions(query).forEach(addMention)
return Array.from(mentions.values())
}

View File

@ -1,2 +1,2 @@
export { MAX_INLINE_DATE_SUGGESTIONS } from "./dateMentionConfig"
export { buildDateMentions } from "./dateMentionParsers"
export { createDateMentionBuilder } from "./dateMentionParsers"

View File

@ -1,9 +1,12 @@
import type { Dayjs } from "dayjs"
import dayjs from "dayjs"
import type { TFunction } from "i18next"
import { MENTION_DATE_VALUE_FORMAT } from "~/modules/ai-chat/utils/mentionDate"
import type { MentionData } from "../types"
import type { MentionData, MentionLabelDescriptor, MentionLabelValue } from "../types"
import type { RelativeDateDefinition } from "./dateMentionConfig"
import { RELATIVE_DATE_DEFINITIONS } from "./dateMentionConfig"
export interface DateRange {
start: Dayjs
@ -42,40 +45,166 @@ export const formatRangeValue = (range: DateRange): string => {
return `${startIso}..${endIso}`
}
export const createMentionFromRange = (
const DEFAULT_DATE_FORMAT: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
}
export const formatLocalizedDate = (
date: Dayjs,
locale: string,
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_FORMAT,
): string => {
return new Intl.DateTimeFormat(locale, options).format(date.toDate())
}
export const formatLocalizedRange = (
range: DateRange,
displayName: string | null,
id?: string,
): MentionData => {
locale: string,
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_FORMAT,
): string => {
const startFormatted = formatLocalizedDate(range.start, locale, options)
const endFormatted = formatLocalizedDate(range.end, locale, options)
if (startFormatted === endFormatted) {
return startFormatted
}
return `${startFormatted} ${endFormatted}`
}
export type LabelTranslator = TFunction<"ai", undefined>
const isLabelDescriptor = (value: MentionLabelValue): value is MentionLabelDescriptor => {
return typeof value === "object" && value !== null && "key" in value
}
const resolveLabelValue = (
value: MentionLabelValue,
translate: LabelTranslator,
): string | number | boolean => {
if (isLabelDescriptor(value)) {
return resolveMentionLabel(value, translate) ?? ""
}
return value
}
export const resolveMentionLabel = (
label: MentionLabelDescriptor | undefined,
translate: LabelTranslator,
): string | undefined => {
if (!label) {
return undefined
}
const resolvedValues = label.values
? Object.fromEntries(
Object.entries(label.values).map(([key, value]) => [
key,
resolveLabelValue(value, translate),
]),
)
: undefined
return translate(label.key, resolvedValues)
}
export const createDateMentionData = ({
id,
range,
label,
labelOptions,
translate,
locale,
withRangeKey,
displayName,
}: {
id?: string
range: DateRange
label?: MentionLabelDescriptor
labelOptions?: MentionData["labelOptions"]
translate: LabelTranslator
locale: string
withRangeKey: I18nKeysForAi
displayName?: string
}): MentionData => {
const value = formatRangeValue(range)
const baseLabel = displayName ?? resolveMentionLabel(label, translate) ?? value
let resolvedName = baseLabel
if (labelOptions?.appendRange) {
resolvedName = translate(withRangeKey, {
label: baseLabel,
range: formatLocalizedRange(range, locale),
})
}
return {
id: id ?? `date:${value}`,
name: formatDisplayLabel(displayName, range),
name: resolvedName,
type: "date",
value,
label,
labelOptions,
}
}
export const formatDisplayLabel = (displayName: string | null, range: DateRange): string => {
const rangeText = formatRangeText(range)
export const parseRangeValue = (value: string): DateRange | null => {
const [startIso, endIsoExclusive] = value.split("..", 2)
if (!startIso || !endIsoExclusive) return null
if (!displayName) {
return rangeText
const start = dayjs(startIso, MENTION_DATE_VALUE_FORMAT, true)
const endExclusive = dayjs(endIsoExclusive, MENTION_DATE_VALUE_FORMAT, true)
if (!start.isValid() || !endExclusive.isValid()) return null
const end = endExclusive.subtract(1, "day")
return {
start: start.startOf("day"),
end: end.startOf("day"),
}
return displayName.toLowerCase() === rangeText.toLowerCase()
? rangeText
: `${displayName} (${rangeText})`
}
export const formatRangeText = (range: DateRange): string => {
if (range.start.isSame(range.end, "day")) {
return range.start.format("MMM D, YYYY")
export const getDateMentionDisplayName = (
mention: Pick<MentionData, "label" | "labelOptions" | "value" | "name">,
translate: LabelTranslator,
locale: string,
withRangeKey: I18nKeysForAi,
): string => {
// Only rely on value range to determine the display name
if (typeof mention.value !== "string") {
return mention.name
}
if (range.start.year() === range.end.year()) {
return `${range.start.format("MMM D")} ${range.end.format("MMM D, YYYY")}`
const range = parseRangeValue(mention.value)
if (!range) {
return mention.name
}
return `${range.start.format("MMM D, YYYY")} ${range.end.format("MMM D, YYYY")}`
const today = dayjs().startOf("day")
const isSameDay = (a: Dayjs, b: Dayjs) => a.isSame(b, "day")
const matchRelative = (): RelativeDateDefinition | null => {
for (const def of RELATIVE_DATE_DEFINITIONS) {
const defRange = def.range(today)
if (!defRange) continue
if (isSameDay(defRange.start, range.start) && isSameDay(defRange.end, range.end)) {
return def
}
}
return null
}
const matched = matchRelative()
if (matched) {
const label = translate(matched.labelKey)
return translate(withRangeKey, {
label,
range: formatLocalizedRange(range, locale),
})
}
// Fallback: show the localized date range only
return formatLocalizedRange(range, locale)
}

View File

@ -7,7 +7,7 @@ import { useChatBlockActions } from "~/modules/ai-chat/store/hooks"
import type { AIChatContextBlock, ValueContextBlock } from "~/modules/ai-chat/store/types"
import { $isMentionNode, MentionNode } from "../MentionNode"
import type { MentionData } from "../types"
import type { MentionData, MentionType } from "../types"
interface MentionBlockReference {
mentionNodeKey: string
@ -38,7 +38,7 @@ const getBlockType = (mentionType: string): ValueContextBlock["type"] => {
* - When a block is removed, corresponding mentions are removed
* - When a mention is removed, corresponding block is removed (if no other mentions reference it)
*/
export const useMentionBlockSync = () => {
export const useMentionBlockSync = (ignoreTypes: MentionType[]) => {
const [editor] = useLexicalComposerContext()
const blockActions = useChatBlockActions()
const blocks = useAIChatStore()((state) => state.blocks)
@ -110,6 +110,10 @@ export const useMentionBlockSync = () => {
// Handle mention insertion - create block and track reference
const handleMentionInsert = useCallback(
(mentionData: MentionData, mentionNodeKey: string) => {
if (ignoreTypes.includes(mentionData.type)) {
return
}
const resourceId = getResourceId(mentionData.type, mentionData.value as string)
// Check if block already exists for this resource
@ -125,6 +129,10 @@ export const useMentionBlockSync = () => {
// Create new block
const blockType = getBlockType(mentionData.type)
if (!blockType) {
return
}
// Generate block ID (mimicking the block slice logic)
const newBlock: Omit<ValueContextBlock, "id"> = {
type: blockType,

View File

@ -10,7 +10,7 @@ import { useMentionSearchService } from "./useMentionSearchService"
*/
export const useMentionIntegration = () => {
const { searchMentions } = useMentionSearchService()
const { handleMentionInsert: syncMentionInsert } = useMentionBlockSync()
const { handleMentionInsert: syncMentionInsert } = useMentionBlockSync(["date"])
// Handle mention insertion with node key tracking
const handleMentionInsert = useCallback(

View File

@ -1,19 +1,24 @@
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { useFeedEntrySearchService } from "~/modules/ai-chat/hooks/useFeedEntrySearchService"
import type { MentionData, MentionType } from "../types"
import { buildDateMentions, MAX_INLINE_DATE_SUGGESTIONS } from "./dateMentionSearch"
import { createDateMentionBuilder, MAX_INLINE_DATE_SUGGESTIONS } from "./dateMentionSearch"
/**
* Hook that provides search functionality for mentions
* Uses the shared feed/entry search service
*/
export const useMentionSearchService = () => {
const { t, i18n } = useTranslation("ai")
const language = i18n.language || i18n.resolvedLanguage || "en"
const { search } = useFeedEntrySearchService({
maxRecentEntries: 50,
})
const buildDateMentions = useMemo(() => createDateMentionBuilder({ t, language }), [t, language])
const searchMentions = useMemo(() => {
return async (query: string, type?: MentionType): Promise<MentionData[]> => {
const trimmedQuery = query.trim()
@ -66,7 +71,7 @@ export const useMentionSearchService = () => {
return results
}
}, [search])
}, [buildDateMentions, search])
return { searchMentions }
}

View File

@ -1,10 +1,21 @@
export type MentionType = "entry" | "feed" | "date"
export type MentionLabelValue = string | number | boolean | MentionLabelDescriptor
export interface MentionLabelDescriptor {
key: I18nKeysForAi
values?: Record<string, MentionLabelValue>
}
export interface MentionData {
id: string
name: string
type: MentionType
value: unknown
label?: MentionLabelDescriptor
labelOptions?: {
appendRange?: boolean
}
}
export interface MentionMatch {

View File

@ -79,6 +79,45 @@
"integration.mcp.services.empty.title": "No MCP Services Connected",
"integration.mcp.services.title": "MCP Services",
"integration.title": "Integration",
"mentions.date.display.with_range": "{{label}} ({{range}})",
"mentions.date.relative.last_30_days.label": "Last 30 days",
"mentions.date.relative.last_30_days.search": "last 30 days|past 30 days",
"mentions.date.relative.last_3_days.label": "Last 3 days",
"mentions.date.relative.last_3_days.search": "last 3 days|past 3 days",
"mentions.date.relative.last_7_days.label": "Last 7 days",
"mentions.date.relative.last_7_days.search": "last 7 days|past 7 days",
"mentions.date.relative.last_month.label": "Last month",
"mentions.date.relative.last_month.search": "last month|previous month",
"mentions.date.relative.last_week.label": "Last week",
"mentions.date.relative.last_week.search": "last week|previous week",
"mentions.date.relative.this_month.label": "This month",
"mentions.date.relative.this_month.search": "this month|current month",
"mentions.date.relative.this_week.label": "This week",
"mentions.date.relative.this_week.search": "this week|current week",
"mentions.date.relative.today.label": "Today",
"mentions.date.relative.today.search": "today",
"mentions.date.relative.yesterday.label": "Yesterday",
"mentions.date.relative.yesterday.search": "yesterday",
"mentions.date.weekday.auto.label": "{{weekday}}",
"mentions.date.weekday.day.friday.label": "Friday",
"mentions.date.weekday.day.friday.search": "friday|fri",
"mentions.date.weekday.day.monday.label": "Monday",
"mentions.date.weekday.day.monday.search": "monday|mon",
"mentions.date.weekday.day.saturday.label": "Saturday",
"mentions.date.weekday.day.saturday.search": "saturday|sat",
"mentions.date.weekday.day.sunday.label": "Sunday",
"mentions.date.weekday.day.sunday.search": "sunday|sun",
"mentions.date.weekday.day.thursday.label": "Thursday",
"mentions.date.weekday.day.thursday.search": "thursday|thu",
"mentions.date.weekday.day.tuesday.label": "Tuesday",
"mentions.date.weekday.day.tuesday.search": "tuesday|tue",
"mentions.date.weekday.day.wednesday.label": "Wednesday",
"mentions.date.weekday.day.wednesday.search": "wednesday|wed",
"mentions.date.weekday.last.label": "Last {{weekday}}",
"mentions.date.weekday.prefix.auto.search": "",
"mentions.date.weekday.prefix.last.search": "last|last week",
"mentions.date.weekday.prefix.this.search": "this|this week",
"mentions.date.weekday.this.label": "This {{weekday}}",
"personalize.description": "Tell me about yourself to get personalized AI responses.",
"personalize.prompt.help": "This helps AI provide personalized responses based on your preferences.",
"personalize.prompt.label": "Personal Prompt",

View File

@ -73,6 +73,45 @@
"integration.mcp.services.empty.title": "MCPサービスが接続されていません",
"integration.mcp.services.title": "MCPサービス",
"integration.title": "統合",
"mentions.date.display.with_range": "{{label}}{{range}}",
"mentions.date.relative.last_30_days.label": "過去30日間",
"mentions.date.relative.last_30_days.search": "過去30日間|直近30日間",
"mentions.date.relative.last_3_days.label": "過去3日間",
"mentions.date.relative.last_3_days.search": "過去3日間|直近3日間",
"mentions.date.relative.last_7_days.label": "過去7日間",
"mentions.date.relative.last_7_days.search": "過去7日間|直近7日間",
"mentions.date.relative.last_month.label": "先月",
"mentions.date.relative.last_month.search": "先月|せんげつ",
"mentions.date.relative.last_week.label": "先週",
"mentions.date.relative.last_week.search": "先週|せんしゅう",
"mentions.date.relative.this_month.label": "今月",
"mentions.date.relative.this_month.search": "今月|こんげつ",
"mentions.date.relative.this_week.label": "今週",
"mentions.date.relative.this_week.search": "今週|こんしゅう",
"mentions.date.relative.today.label": "今日",
"mentions.date.relative.today.search": "今日|きょう",
"mentions.date.relative.yesterday.label": "昨日",
"mentions.date.relative.yesterday.search": "昨日|きのう",
"mentions.date.weekday.auto.label": "{{weekday}}",
"mentions.date.weekday.day.friday.label": "金曜日",
"mentions.date.weekday.day.friday.search": "金曜日|金曜|きんようび",
"mentions.date.weekday.day.monday.label": "月曜日",
"mentions.date.weekday.day.monday.search": "月曜日|月曜|げつようび",
"mentions.date.weekday.day.saturday.label": "土曜日",
"mentions.date.weekday.day.saturday.search": "土曜日|土曜|どようび",
"mentions.date.weekday.day.sunday.label": "日曜日",
"mentions.date.weekday.day.sunday.search": "日曜日|日曜|にちようび",
"mentions.date.weekday.day.thursday.label": "木曜日",
"mentions.date.weekday.day.thursday.search": "木曜日|木曜|もくようび",
"mentions.date.weekday.day.tuesday.label": "火曜日",
"mentions.date.weekday.day.tuesday.search": "火曜日|火曜|かようび",
"mentions.date.weekday.day.wednesday.label": "水曜日",
"mentions.date.weekday.day.wednesday.search": "水曜日|水曜|すいようび",
"mentions.date.weekday.last.label": "先週{{weekday}}",
"mentions.date.weekday.prefix.auto.search": "",
"mentions.date.weekday.prefix.last.search": "先週|せんしゅう",
"mentions.date.weekday.prefix.this.search": "今週|こんしゅう",
"mentions.date.weekday.this.label": "今週{{weekday}}",
"personalize.description": "あなた自身について教えてください。そうすれば、パーソナライズされたAIの応答が得られます。",
"personalize.prompt.help": "これにより、AIはあなたの好みに基づいてパーソナライズされた応答を提供できます。",
"personalize.prompt.label": "個人用プロンプト",

View File

@ -75,6 +75,45 @@
"integration.mcp.services.empty.title": "未连接MCP服务",
"integration.mcp.services.title": "MCP服务",
"integration.title": "集成",
"mentions.date.display.with_range": "{{label}}{{range}}",
"mentions.date.relative.last_30_days.label": "最近30天",
"mentions.date.relative.last_30_days.search": "最近30天|近一个月",
"mentions.date.relative.last_3_days.label": "最近3天",
"mentions.date.relative.last_3_days.search": "最近3天|近三天",
"mentions.date.relative.last_7_days.label": "最近7天",
"mentions.date.relative.last_7_days.search": "最近7天|近一周",
"mentions.date.relative.last_month.label": "上个月",
"mentions.date.relative.last_month.search": "上个月|上月|上一个月",
"mentions.date.relative.last_week.label": "上周",
"mentions.date.relative.last_week.search": "上周|上一周|上星期|上个星期|上礼拜|上个礼拜",
"mentions.date.relative.this_month.label": "这个月",
"mentions.date.relative.this_month.search": "这个月|本月|这月",
"mentions.date.relative.this_week.label": "这周",
"mentions.date.relative.this_week.search": "这周|本周|这星期|本星期|这礼拜|本礼拜",
"mentions.date.relative.today.label": "今天",
"mentions.date.relative.today.search": "今天|今日",
"mentions.date.relative.yesterday.label": "昨天",
"mentions.date.relative.yesterday.search": "昨天|昨日",
"mentions.date.weekday.auto.label": "{{weekday}}",
"mentions.date.weekday.day.friday.label": "周五",
"mentions.date.weekday.day.friday.search": "周五|星期五|礼拜五",
"mentions.date.weekday.day.monday.label": "周一",
"mentions.date.weekday.day.monday.search": "周一|星期一|礼拜一",
"mentions.date.weekday.day.saturday.label": "周六",
"mentions.date.weekday.day.saturday.search": "周六|星期六|礼拜六",
"mentions.date.weekday.day.sunday.label": "周日",
"mentions.date.weekday.day.sunday.search": "周日|星期日|礼拜日|周天|星期天|礼拜天",
"mentions.date.weekday.day.thursday.label": "周四",
"mentions.date.weekday.day.thursday.search": "周四|星期四|礼拜四",
"mentions.date.weekday.day.tuesday.label": "周二",
"mentions.date.weekday.day.tuesday.search": "周二|星期二|礼拜二",
"mentions.date.weekday.day.wednesday.label": "周三",
"mentions.date.weekday.day.wednesday.search": "周三|星期三|礼拜三",
"mentions.date.weekday.last.label": "上周{{weekday}}",
"mentions.date.weekday.prefix.auto.search": "",
"mentions.date.weekday.prefix.last.search": "上周|上一周|上星期|上个星期|上礼拜|上个礼拜",
"mentions.date.weekday.prefix.this.search": "这周|本周|这星期|本星期|这礼拜|本礼拜",
"mentions.date.weekday.this.label": "本周{{weekday}}",
"personalize.description": "介绍一下您自己以获得个性化的AI响应。",
"personalize.prompt.help": "这有助于AI根据您的偏好提供个性化响应。",
"personalize.prompt.label": "个人提示",