-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Context aware chat #2876
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Context aware chat #2876
Changes from 4 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| 'use client' | ||
|
|
||
| import { useContextTracking } from '@/app/project/[id]/_hooks/use-context-tracking'; | ||
| import { Tooltip, TooltipContent, TooltipTrigger } from '@onlook/ui/tooltip'; | ||
| import { cn } from '@onlook/ui/utils'; | ||
| import { observer } from 'mobx-react-lite'; | ||
| import type { ChatMessage } from '@onlook/models'; | ||
|
|
||
| interface ContextIndicatorProps { | ||
| messages: ChatMessage[]; | ||
| modelId?: string; | ||
| } | ||
|
|
||
| function formatTokens(tokens: number): string { | ||
| if (tokens >= 1000000) { | ||
| return `${(tokens / 1000000).toFixed(1)}M`; | ||
| } else if (tokens >= 1000) { | ||
| return `${(tokens / 1000).toFixed(1)}K`; | ||
| } | ||
| return tokens.toString(); | ||
| } | ||
|
|
||
| export const ContextIndicator = observer(({ messages, modelId = 'openai:gpt-4' }: ContextIndicatorProps) => { | ||
| const contextTracking = useContextTracking(messages, modelId); | ||
|
|
||
| if (contextTracking.usage.totalTokens === 0) { | ||
| return null; | ||
| } | ||
|
|
||
| const percentage = Math.min(contextTracking.percentage, 100); | ||
|
|
||
| // Color logic based on percentage thresholds | ||
| let colors = { stroke: '#6b7280', text: 'text-gray-400', bg: '' }; | ||
|
|
||
| if (percentage >= 90) { | ||
| colors = { stroke: '#ef4444', text: 'text-red-500', bg: 'bg-red-900' }; | ||
| } else if (percentage >= 85) { | ||
| colors = { stroke: '#f97316', text: 'text-orange-500', bg: 'bg-orange-900' }; | ||
| } else if (percentage >= 75) { | ||
| colors = { stroke: '#fbbf24', text: 'text-amber-200', bg: 'bg-amber-900' }; | ||
| } else if (percentage >= 50) { | ||
| colors = { stroke: '#6b7280', text: 'text-gray-400', bg: '' }; | ||
| } | ||
|
|
||
| // Format percentage display | ||
| const displayPercentage = percentage < 50 | ||
| ? Math.round(percentage).toString() + '%' | ||
| : percentage.toFixed(1) + '%'; | ||
|
|
||
| return ( | ||
| <Tooltip> | ||
| <TooltipTrigger asChild> | ||
| <div className={cn( | ||
| "flex items-center gap-1.5 px-1.5 py-1 rounded-md hover:bg-muted/30 transition-colors cursor-pointer shadow-md hover:shadow-lg transition-shadow", | ||
| colors.bg | ||
| )}> | ||
| <div className="relative"> | ||
| <svg width="16" height="16" className="transform -rotate-90"> | ||
| <circle | ||
| cx="8" | ||
| cy="8" | ||
| r="6" | ||
| stroke="currentColor" | ||
| strokeWidth="1.5" | ||
| fill="none" | ||
| className="text-muted-foreground/20" | ||
| /> | ||
| <circle | ||
| cx="8" | ||
| cy="8" | ||
| r="6" | ||
| stroke={colors.stroke} | ||
| strokeWidth="1.5" | ||
| fill="none" | ||
| strokeDasharray={2 * Math.PI * 6} | ||
| strokeDashoffset={2 * Math.PI * 6 - (percentage / 100) * 2 * Math.PI * 6} | ||
| strokeLinecap="round" | ||
| className="transition-all duration-300" | ||
| /> | ||
| </svg> | ||
| </div> | ||
| <span className={cn("text-xs font-medium tabular-nums", colors.text)}> | ||
| {displayPercentage} | ||
| </span> | ||
| </div> | ||
| </TooltipTrigger> | ||
| <TooltipContent side="top" className="hideArrow"> | ||
| {formatTokens(contextTracking.usage.totalTokens)}/{formatTokens(contextTracking.limits.contextWindow)} context used | ||
| </TooltipContent> | ||
| </Tooltip> | ||
| ); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| 'use client' | ||
|
|
||
| import { getContextUsage, getModelLimits } from '@onlook/ai/src/tokens'; | ||
| import type { TokenUsage, ModelLimits } from '@onlook/ai/src/tokens'; | ||
| import type { ChatMessage } from '@onlook/models'; | ||
| import { useEffect, useState } from 'react'; | ||
|
|
||
| interface ContextTrackingState { | ||
| usage: TokenUsage; | ||
| limits: ModelLimits; | ||
| percentage: number; | ||
| } | ||
|
|
||
| export const useContextTracking = (messages: ChatMessage[], modelId: string = 'openai:gpt-4') => { | ||
| const [contextState, setContextState] = useState<ContextTrackingState>({ | ||
| usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, | ||
| limits: getModelLimits(modelId), | ||
| percentage: 0 | ||
| }); | ||
|
|
||
| useEffect(() => { | ||
| const updateContextUsage = async () => { | ||
| const contextUsage = await getContextUsage(messages, modelId); | ||
| setContextState(contextUsage); | ||
| }; | ||
|
|
||
| updateContextUsage(); | ||
| }, [messages, modelId]); | ||
|
|
||
| return { | ||
| ...contextState | ||
| }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -418,6 +418,7 @@ | |
| "name": "@onlook/ui", | ||
| "version": "0.0.0", | ||
| "dependencies": { | ||
| "@ai-sdk/ui-utils": "^1.2.11", | ||
| "@emotion/react": "^11.13.3", | ||
| "@emotion/styled": "^11.13.0", | ||
| "@hookform/resolvers": "^5.0.1", | ||
|
|
@@ -531,6 +532,8 @@ | |
|
|
||
| "@ai-sdk/react": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider-utils": "3.0.7", "ai": "5.0.25", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.25.76 || ^4" }, "optionalPeers": ["zod"] }, "sha512-y7yhQbiImAvOcNm75uf4RnRqJvKB/gj790m5Dct+W63seOjEDNYnQhfwxvF0Py3snlcFO+q3SzvUSH+1yG+jlQ=="], | ||
|
|
||
| "@ai-sdk/ui-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="], | ||
|
|
||
| "@alloc/quick-lru": ["@alloc/[email protected]", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], | ||
|
|
||
| "@aws-crypto/crc32": ["@aws-crypto/[email protected]", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], | ||
|
|
@@ -4057,6 +4060,10 @@ | |
|
|
||
| "@ai-sdk/react/ai": ["[email protected]", "", { "dependencies": { "@ai-sdk/gateway": "1.0.14", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.7", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-c7WeAeGnWk+aKUCMgUal2Qa7POcC1/75SrrYHPhlYLWE+DJ2G0gde5TftwCEMwG+pQ6BhaTQHIAQoBR2GGNyAw=="], | ||
|
|
||
| "@ai-sdk/ui-utils/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], | ||
|
|
||
| "@ai-sdk/ui-utils/zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], | ||
|
|
||
| "@aws-crypto/util/@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], | ||
|
|
||
| "@babel/core/convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], | ||
|
|
@@ -4737,6 +4744,10 @@ | |
|
|
||
| "@ai-sdk/react/ai/@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], | ||
|
|
||
| "@ai-sdk/ui-utils/@ai-sdk/provider-utils/nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], | ||
|
|
||
| "@ai-sdk/ui-utils/@ai-sdk/provider-utils/secure-json-parse": ["[email protected]", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], | ||
|
|
||
| "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], | ||
|
|
||
| "@babel/helper-compilation-targets/lru-cache/yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,17 +2,97 @@ import { type ChatMessage } from '@onlook/models'; | |||||||||
| import type { TextUIPart, ToolUIPart } from 'ai'; | ||||||||||
| import { encode } from 'gpt-tokenizer'; | ||||||||||
|
|
||||||||||
| export interface ModelLimits { | ||||||||||
| maxTokens: number; | ||||||||||
| contextWindow: number; | ||||||||||
| inputLimit: number; | ||||||||||
| outputLimit: number; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export const MODEL_LIMITS: Record<string, ModelLimits> = { | ||||||||||
| 'claude-sonnet-4-20250514': { | ||||||||||
| maxTokens: 1000000, | ||||||||||
| contextWindow: 1000000, | ||||||||||
| inputLimit: 800000, | ||||||||||
| outputLimit: 200000, | ||||||||||
| }, | ||||||||||
| 'claude-3-5-haiku-20241022': { | ||||||||||
| maxTokens: 200000, | ||||||||||
| contextWindow: 200000, | ||||||||||
| inputLimit: 180000, | ||||||||||
| outputLimit: 200000, | ||||||||||
| }, | ||||||||||
| 'anthropic/claude-sonnet-4': { | ||||||||||
| maxTokens: 1000000, | ||||||||||
| contextWindow: 1000000, | ||||||||||
| inputLimit: 800000, | ||||||||||
| outputLimit: 200000, | ||||||||||
| }, | ||||||||||
| 'anthropic/claude-3.5-haiku': { | ||||||||||
| maxTokens: 200000, | ||||||||||
| contextWindow: 200000, | ||||||||||
| inputLimit: 180000, | ||||||||||
| outputLimit: 200000, | ||||||||||
| }, | ||||||||||
| 'openai/gpt-5': { | ||||||||||
| maxTokens: 400000, | ||||||||||
| contextWindow: 400000, | ||||||||||
| inputLimit: 272000, | ||||||||||
| outputLimit: 400000, | ||||||||||
| }, | ||||||||||
| 'openai/gpt-5-mini': { | ||||||||||
| maxTokens: 400000, | ||||||||||
| contextWindow: 400000, | ||||||||||
| inputLimit: 272000, | ||||||||||
| outputLimit: 400000, | ||||||||||
| }, | ||||||||||
| 'openai/gpt-5-nano': { | ||||||||||
| maxTokens: 400000, | ||||||||||
| contextWindow: 400000, | ||||||||||
| inputLimit: 272000, | ||||||||||
| outputLimit: 400000, | ||||||||||
| }, | ||||||||||
| 'gpt-5': { | ||||||||||
| maxTokens: 400000, | ||||||||||
| contextWindow: 400000, | ||||||||||
| inputLimit: 272000, | ||||||||||
| outputLimit: 128000, | ||||||||||
| }, | ||||||||||
| 'claude-sonnet-4': { | ||||||||||
| maxTokens: 1000000, | ||||||||||
| contextWindow: 1000000, | ||||||||||
| inputLimit: 800000, | ||||||||||
| outputLimit: 200000, | ||||||||||
| }, | ||||||||||
| 'claude-3-5-haiku': { | ||||||||||
| maxTokens: 200000, | ||||||||||
| contextWindow: 200000, | ||||||||||
| inputLimit: 180000, | ||||||||||
| outputLimit: 8000, | ||||||||||
| }, | ||||||||||
| default: { | ||||||||||
| maxTokens: 128000, | ||||||||||
| contextWindow: 128000, | ||||||||||
| inputLimit: 100000, | ||||||||||
| outputLimit: 4000, | ||||||||||
| }, | ||||||||||
| }; | ||||||||||
|
Comment on lines
+12
to
+79
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chainVerify model limit accuracy and consider maintenance. The model limits contain specific values for various AI models. These may become outdated as providers update their offerings. Please verify:
🌐 Web query: 💡 Result:
Fix token-limit inaccuracies and add provenance packages/ai/src/tokens/index.ts (lines 12–79):
🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| export function getModelLimits(modelId: string): ModelLimits { | ||||||||||
| return MODEL_LIMITS[modelId] || MODEL_LIMITS['default']!; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export async function countTokensWithRoles(messages: ChatMessage[]): Promise<number> { | ||||||||||
| const perMessageExtra = 4; // ~role + metadata tokens (OpenAI chat format) | ||||||||||
| const perReplyExtra = 2; // for assistant reply priming | ||||||||||
| const perMessageExtra = 4; | ||||||||||
| const perReplyExtra = 2; | ||||||||||
| let total = 0; | ||||||||||
| for (const m of messages) { | ||||||||||
| const content = m.parts | ||||||||||
| .map((p) => { | ||||||||||
| if (p.type === 'text') { | ||||||||||
| return (p as TextUIPart).text; | ||||||||||
| } else if (p.type.startsWith('tool-')) { | ||||||||||
| return JSON.stringify((p as ToolUIPart).input); // TODO: check if this is correct | ||||||||||
| return JSON.stringify((p as ToolUIPart).input); | ||||||||||
| } | ||||||||||
| return ''; | ||||||||||
| }) | ||||||||||
|
|
@@ -21,3 +101,41 @@ export async function countTokensWithRoles(messages: ChatMessage[]): Promise<num | |||||||||
| } | ||||||||||
| return total + perReplyExtra; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export interface TokenUsage { | ||||||||||
| inputTokens: number; | ||||||||||
| outputTokens: number; | ||||||||||
| totalTokens: number; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export function countTokensInString(text: string): number { | ||||||||||
| return encode(text).length; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export async function getContextUsage( | ||||||||||
| messages: ChatMessage[], | ||||||||||
| modelId: string = 'openai:gpt-4', | ||||||||||
| ): Promise<{ | ||||||||||
| usage: TokenUsage; | ||||||||||
| limits: ModelLimits; | ||||||||||
| percentage: number; | ||||||||||
| }> { | ||||||||||
| const totalTokens = await countTokensWithRoles(messages); | ||||||||||
| const limits = getModelLimits(modelId); | ||||||||||
| const inputTokens = Math.floor(totalTokens * 0.8); | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The fixed 80/20 split for input/output tokens may not accurately reflect actual token usage. Consider clarifying or revisiting this logic. |
||||||||||
| const outputTokens = totalTokens - inputTokens; | ||||||||||
|
Comment on lines
+125
to
+126
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Logic error in token calculation: The code arbitrarily assigns 80% of total tokens as input tokens and 20% as output tokens (lines 125-126). This is incorrect because it's calculating token distribution from existing messages, not predicting future usage. For existing messages, all tokens should be considered input tokens since they're already part of the conversation context. The output tokens should represent available space for the model's response, not a portion of existing messages.
Suggested change
Spotted by Diamond |
||||||||||
|
|
||||||||||
| const usage: TokenUsage = { | ||||||||||
| inputTokens, | ||||||||||
| outputTokens, | ||||||||||
| totalTokens, | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| const percentage = (totalTokens / limits.contextWindow) * 100; | ||||||||||
|
|
||||||||||
| return { | ||||||||||
| usage, | ||||||||||
| limits, | ||||||||||
| percentage, | ||||||||||
| }; | ||||||||||
| } | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Async setup race condition: The useEffect calls an async function updateContextUsage() but doesn't await it or handle the Promise properly. This can cause the component to render with stale state while the async operation is still pending. The async function should be properly awaited or the Promise should be handled to prevent race conditions between state updates and renders.
Spotted by Diamond

Is this helpful? React 👍 or 👎 to let us know.