Skip to content

Commit 5841ddf

Browse files
committed
Add web AI client time context
1 parent 3e96d14 commit 5841ddf

3 files changed

Lines changed: 146 additions & 7 deletions

File tree

server/utils/ai/agent/clientRuntime.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ChatToolCall } from '../client';
44
import { getNativeRoteTools } from './tools';
55
import {
66
DEFAULT_AGENT_POLICY,
7+
type RoteAgentClientContext,
78
type RoteAgentClientState,
89
type RoteAgentContext,
910
type RoteAgentRequest,
@@ -14,6 +15,42 @@ function sourceKey(source: SemanticSearchResult): string {
1415
return `${source.sourceType}:${source.sourceId}`;
1516
}
1617

18+
function asRecord(value: unknown): Record<string, unknown> {
19+
return value && typeof value === 'object' && !Array.isArray(value)
20+
? (value as Record<string, unknown>)
21+
: {};
22+
}
23+
24+
function sanitizeString(value: unknown, maxLength: number): string | undefined {
25+
if (typeof value !== 'string') return undefined;
26+
const trimmed = value.trim();
27+
return trimmed ? trimmed.slice(0, maxLength) : undefined;
28+
}
29+
30+
function sanitizeUtcOffsetMinutes(value: unknown): number | undefined {
31+
const numeric = Number(value);
32+
if (!Number.isFinite(numeric)) return undefined;
33+
const minutes = Math.trunc(numeric);
34+
return minutes >= -14 * 60 && minutes <= 14 * 60 ? minutes : undefined;
35+
}
36+
37+
function sanitizeClientContext(value: unknown): RoteAgentClientContext | null {
38+
const raw = asRecord(value);
39+
if (!Object.keys(raw).length) return null;
40+
41+
const context: RoteAgentClientContext = {
42+
nowIso: sanitizeString(raw.nowIso, 64),
43+
localDate: sanitizeString(raw.localDate, 32),
44+
localDateTime: sanitizeString(raw.localDateTime, 64),
45+
timeZone: sanitizeString(raw.timeZone, 80),
46+
utcOffsetMinutes: sanitizeUtcOffsetMinutes(raw.utcOffsetMinutes),
47+
locale: sanitizeString(raw.locale, 32),
48+
calendar: sanitizeString(raw.calendar, 32),
49+
};
50+
51+
return Object.values(context).some((item) => item !== undefined) ? context : null;
52+
}
53+
1754
class ClientSourceCollector {
1855
private sourceKeys: string[];
1956
private sourcesByKey = new Map<string, SemanticSearchResult>();
@@ -85,6 +122,7 @@ function sanitizeRequest(value: unknown): RoteAgentRequest {
85122
pendingPlan: request.pendingPlan,
86123
clarificationAnswer:
87124
typeof request.clarificationAnswer === 'string' ? request.clarificationAnswer : undefined,
125+
clientContext: sanitizeClientContext(request.clientContext),
88126
};
89127
}
90128

@@ -100,6 +138,7 @@ function sanitizeState(value: unknown): RoteAgentClientState {
100138
.slice(0, 500)
101139
: [],
102140
selectedContext: state.selectedContext || null,
141+
clientContext: sanitizeClientContext(state.clientContext),
103142
stateVersion: Number.isFinite(state.stateVersion) ? Number(state.stateVersion) : 1,
104143
};
105144
}
@@ -121,6 +160,9 @@ export async function executeClientRoteTool(params: {
121160

122161
const request = sanitizeRequest(params.request);
123162
const state = sanitizeState(params.state);
163+
if (!state.clientContext && request.clientContext) {
164+
state.clientContext = request.clientContext;
165+
}
124166
const collector = new ClientSourceCollector(params.sourceKeys);
125167
const call: ChatToolCall = {
126168
id: `client_${Date.now()}_${Math.random().toString(16).slice(2)}`,

web/src/utils/aiApi.ts

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ export interface AiAgentClientState {
172172
selectedSourceIds?: string[];
173173
selectedTags?: string[];
174174
} | null;
175+
clientContext?: AiClientRequestContext | null;
175176
stateVersion?: number;
176177
}
177178

@@ -243,10 +244,87 @@ export type AiChatPayload = {
243244
excludeIds?: string[];
244245
history?: { role: 'user' | 'assistant'; content: string }[];
245246
state?: AiAgentClientState | null;
247+
clientContext?: AiClientRequestContext | null;
246248
debug?: boolean;
247249
enableThinking?: boolean;
248250
};
249251

252+
export interface AiClientRequestContext {
253+
nowIso: string;
254+
localDate: string;
255+
localDateTime: string;
256+
timeZone?: string;
257+
utcOffsetMinutes: number;
258+
locale?: string;
259+
calendar?: string;
260+
}
261+
262+
function padDatePart(value: number): string {
263+
return String(value).padStart(2, '0');
264+
}
265+
266+
function formatUtcOffset(minutes: number): string {
267+
const sign = minutes >= 0 ? '+' : '-';
268+
const absolute = Math.abs(minutes);
269+
return `${sign}${padDatePart(Math.floor(absolute / 60))}:${padDatePart(absolute % 60)}`;
270+
}
271+
272+
export function createAiClientRequestContext(now = new Date()): AiClientRequestContext {
273+
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
274+
const utcOffsetMinutes = -now.getTimezoneOffset();
275+
const localDate = [
276+
now.getFullYear(),
277+
padDatePart(now.getMonth() + 1),
278+
padDatePart(now.getDate()),
279+
].join('-');
280+
const localTime = [
281+
padDatePart(now.getHours()),
282+
padDatePart(now.getMinutes()),
283+
padDatePart(now.getSeconds()),
284+
].join(':');
285+
286+
return {
287+
nowIso: now.toISOString(),
288+
localDate,
289+
localDateTime: `${localDate}T${localTime}${formatUtcOffset(utcOffsetMinutes)}`,
290+
timeZone: resolvedOptions.timeZone,
291+
utcOffsetMinutes,
292+
locale: typeof navigator !== 'undefined' ? navigator.language : resolvedOptions.locale,
293+
calendar: resolvedOptions.calendar,
294+
};
295+
}
296+
297+
export function buildAiClientTimeContextMessage(context: AiClientRequestContext): string {
298+
return [
299+
'Use the current request time context for relative date phrases.',
300+
`Client now (UTC): ${context.nowIso}`,
301+
`Client local date: ${context.localDate}`,
302+
`Client local date/time: ${context.localDateTime}`,
303+
context.timeZone ? `Client time zone: ${context.timeZone}` : null,
304+
`Client UTC offset minutes: ${context.utcOffsetMinutes}`,
305+
context.locale ? `Client locale: ${context.locale}` : null,
306+
context.calendar ? `Client calendar: ${context.calendar}` : null,
307+
'Resolve relative date phrases such as today, yesterday, this month, last month, 最近, 本月, and 上月 using this context.',
308+
'For Rote search tools, prefer structured timeRange preset/rolling/relative_between or pass the original phrase as timeExpression. Use from/to only for explicit absolute dates.',
309+
]
310+
.filter((line): line is string => Boolean(line))
311+
.join('\n');
312+
}
313+
314+
export function withAiClientRequestContext(payload: AiChatPayload): AiChatPayload {
315+
const clientContext = payload.clientContext || createAiClientRequestContext();
316+
return {
317+
...payload,
318+
clientContext,
319+
state: payload.state
320+
? {
321+
...payload.state,
322+
clientContext: payload.state.clientContext || clientContext,
323+
}
324+
: payload.state,
325+
};
326+
}
327+
250328
export const getClientAgentBootstrap = () =>
251329
get('/ai/client-agent/bootstrap').then((res) => res.data as ClientAgentBootstrap);
252330

@@ -256,14 +334,26 @@ export const executeClientAgentTool = (payload: {
256334
request: AiChatPayload;
257335
state?: AiAgentClientState | null;
258336
sourceKeys?: string[];
259-
}) =>
260-
post('/ai/client-agent/tools/execute', payload).then((res) => res.data as ClientAgentToolResult);
337+
}) => {
338+
const request = withAiClientRequestContext(payload.request);
339+
return post('/ai/client-agent/tools/execute', {
340+
...payload,
341+
request,
342+
state: payload.state
343+
? {
344+
...payload.state,
345+
clientContext: payload.state.clientContext || request.clientContext,
346+
}
347+
: payload.state,
348+
}).then((res) => res.data as ClientAgentToolResult);
349+
};
261350

262351
async function createAiStreamRequest(
263352
endpoint: '/ai/chat/stream' | '/ai/agent/stream',
264353
payload: AiChatPayload,
265354
signal?: AbortSignal
266355
) {
356+
const requestPayload = withAiClientRequestContext(payload);
267357
let token = authService.getAccessToken();
268358
const request = () =>
269359
fetch(`${getApiUrl()}${endpoint}`, {
@@ -272,7 +362,7 @@ async function createAiStreamRequest(
272362
'Content-Type': 'application/json',
273363
...(token ? { Authorization: `Bearer ${token}` } : {}),
274364
},
275-
body: JSON.stringify(payload),
365+
body: JSON.stringify(requestPayload),
276366
signal,
277367
});
278368

web/src/utils/localAiAgent.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { PersonalAiProviderConfig } from '@/state/localAi';
22
import {
3+
buildAiClientTimeContextMessage,
34
executeClientAgentTool,
45
getClientAgentBootstrap,
6+
withAiClientRequestContext,
57
type AiAgentClientState,
68
type AiChatPayload,
79
type AiChatStreamHandlers,
@@ -40,18 +42,23 @@ export async function localAiAgentStream(params: {
4042
enableThinking: boolean;
4143
signal?: AbortSignal;
4244
}) {
45+
const payload = withAiClientRequestContext(params.payload);
46+
const clientContext = payload.clientContext;
4347
const bootstrap = params.toolsAvailable ? await getClientAgentBootstrap() : null;
4448
const messages: LocalChatMessage[] = [
4549
{
4650
role: 'system',
4751
content: bootstrap?.systemPrompt || PERSONAL_ASSISTANT_PROMPT,
4852
},
49-
...(params.payload.history || []).slice(-8),
50-
{ role: 'user', content: params.payload.message },
53+
...(clientContext
54+
? [{ role: 'system' as const, content: buildAiClientTimeContextMessage(clientContext) }]
55+
: []),
56+
...(payload.history || []).slice(-8),
57+
{ role: 'user', content: payload.message },
5158
];
5259
const sourceMap = new Map<string, AiSemanticResult>();
5360
let sourceKeys: string[] = [];
54-
let state: AiAgentClientState = params.payload.state || { stateVersion: 1, seenSourceIds: [] };
61+
let state: AiAgentClientState = payload.state || { stateVersion: 1, seenSourceIds: [] };
5562
let toolCallCount = 0;
5663
let hasAnswer = false;
5764
const availableToolNames = new Set(bootstrap?.tools.map((tool) => tool.function.name) || []);
@@ -142,7 +149,7 @@ export async function localAiAgentStream(params: {
142149
const result = await executeClientAgentTool({
143150
toolName: call.function.name,
144151
arguments: args,
145-
request: params.payload,
152+
request: payload,
146153
state,
147154
sourceKeys,
148155
});

0 commit comments

Comments
 (0)