Some checks failed
CI / test (map[image:macos-latest name:macos], 1, 4) (push) Has been cancelled
CI / test (map[image:macos-latest name:macos], 2, 4) (push) Has been cancelled
CI / test (map[image:macos-latest name:macos], 3, 4) (push) Has been cancelled
CI / test (map[image:macos-latest name:macos], 4, 4) (push) Has been cancelled
CI / test (map[image:windows-latest name:windows], 1, 4) (push) Has been cancelled
CI / test (map[image:windows-latest name:windows], 2, 4) (push) Has been cancelled
CI / test (map[image:windows-latest name:windows], 3, 4) (push) Has been cancelled
CI / test (map[image:windows-latest name:windows], 4, 4) (push) Has been cancelled
CI / merge-reports (push) Has been cancelled
- Added a new integration script to manage custom features related to smart context. - Implemented handlers for smart context operations (get, update, clear, stats) in ipc. - Created a SmartContextStore class to manage context snippets and summaries. - Developed hooks for React to interact with smart context (useSmartContext, useUpdateSmartContext, useClearSmartContext, useSmartContextStats). - Included backup and restore functionality in the integration script. - Validated integration by checking for custom modifications and file existence.
1011 lines
31 KiB
Plaintext
1011 lines
31 KiB
Plaintext
5 commits
|
|
14 files changed
|
|
3 contributors
|
|
Commits on Aug 26, 2025
|
|
Remove rate limit simulation from fake LLM server (#1)
|
|
|
|
@pesachnps
|
|
@cursoragent
|
|
3 people authored on Aug 26
|
|
Add payload size limits and truncation for chat context (#2)
|
|
|
|
@pesachnps
|
|
@cursoragent
|
|
3 people authored on Aug 26
|
|
Update README with detailed installation, build, and development inst…
|
|
|
|
@pesachnps
|
|
@cursoragent
|
|
3 people authored on Aug 26
|
|
Update GitHub repository link in PromoMessage component (#4)
|
|
|
|
@pesachnps
|
|
@cursoragent
|
|
3 people authored on Aug 26
|
|
Add smart context retrieval for enhanced chat memory and context (#5)
|
|
|
|
@pesachnps
|
|
@cursoragent
|
|
3 people authored on Aug 26
|
|
Showing with 666 additions and 66 deletions.
|
|
81 changes: 72 additions & 9 deletions81
|
|
README.md
|
|
Original file line number Diff line number Diff line change
|
|
@@ -2,28 +2,91 @@
|
|
|
|
Dyad is a local, open-source AI app builder. It's fast, private, and fully under your control — like Lovable, v0, or Bolt, but running right on your machine.
|
|
|
|
[](http://dyad.sh/)
|
|
|
|
More info at: [http://dyad.sh/](http://dyad.sh/)
|
|

|
|
|
|
## 🚀 Features
|
|
|
|
- ⚡️ **Local**: Fast, private and no lock-in.
|
|
- 🛠 **Bring your own keys**: Use your own AI API keys — no vendor lock-in.
|
|
- 🖥️ **Cross-platform**: Easy to run on Mac or Windows.
|
|
|
|
## 📦 Download
|
|
## 🧰 Prerequisites
|
|
|
|
- Node.js >= 20
|
|
- npm (comes with Node.js)
|
|
- Git
|
|
|
|
You can verify your versions:
|
|
|
|
```bash
|
|
node -v
|
|
npm -v
|
|
```
|
|
|
|
## 🏗️ Install (from source)
|
|
|
|
```bash
|
|
git clone https://github.com/dyad-sh/dyad.git
|
|
cd dyad
|
|
npm install
|
|
```
|
|
|
|
## ▶️ Run locally (development)
|
|
|
|
- Start the app with the default configuration:
|
|
|
|
```bash
|
|
npm start
|
|
```
|
|
|
|
- Optionally, point the app to a locally running engine (on http://localhost:8080/v1):
|
|
|
|
```bash
|
|
npm run dev:engine
|
|
```
|
|
|
|
No sign-up required. Just download and go.
|
|
### Environment variables (optional)
|
|
|
|
### [👉 Download for your platform](https://www.dyad.sh/#download)
|
|
- `DYAD_ENGINE_URL`: URL of the Dyad engine (defaults to built-in configuration).
|
|
- `DYAD_GATEWAY_URL`: URL of a compatible gateway if you prefer to route requests.
|
|
|
|
Example:
|
|
|
|
```bash
|
|
DYAD_ENGINE_URL=http://localhost:8080/v1 npm start
|
|
```
|
|
|
|
## 📦 Build installers (make)
|
|
|
|
Create platform-specific distributables:
|
|
|
|
```bash
|
|
npm run make
|
|
```
|
|
|
|
Outputs are written to the `out/` directory.
|
|
|
|
## 🧪 Tests and linting
|
|
|
|
```bash
|
|
# Unit tests
|
|
npm test
|
|
|
|
# Lint
|
|
npm run lint
|
|
|
|
# Prettier check
|
|
npm run prettier:check
|
|
```
|
|
|
|
## 🤝 Community
|
|
|
|
Join our growing community of AI app builders on **Reddit**: [r/dyadbuilders](https://www.reddit.com/r/dyadbuilders/) - share your projects and get help from the community!
|
|
Join our growing community of AI app builders on **Reddit**: [r/dyadbuilders](https://www.reddit.com/r/dyadbuilders/) — share your projects and get help from the community!
|
|
|
|
## 🛠️ Contributing
|
|
|
|
**Dyad** is open-source (Apache 2.0 licensed).
|
|
If you're interested in contributing to Dyad, please read our [contributing](./CONTRIBUTING.md) doc.
|
|
|
|
## 📄 License
|
|
|
|
If you're interested in contributing to dyad, please read our [contributing](./CONTRIBUTING.md) doc.
|
|
MIT License — see [LICENSE](./LICENSE).
|
|
10 changes: 5 additions & 5 deletions10
|
|
package-lock.json
|
|
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
|
|
|
|
40 changes: 36 additions & 4 deletions40
|
|
src/components/HelpDialog.tsx
|
|
Original file line number Diff line number Diff line change
|
|
@@ -155,11 +155,43 @@ ${debugInfo.logs.slice(-3_500) || "No logs available"}
|
|
|
|
setIsUploading(true);
|
|
try {
|
|
// Prepare data for upload
|
|
// Prepare data for upload with pruning to avoid oversized payloads
|
|
const MAX_LOG_CHARS = 150_000;
|
|
const MAX_CODEBASE_CHARS = 200_000;
|
|
const MAX_MESSAGES = 200; // recent messages
|
|
const MAX_MESSAGE_CHARS = 8_000; // per message
|
|
|
|
const logs = chatLogsData.debugInfo?.logs || "";
|
|
const prunedLogs = logs.length > MAX_LOG_CHARS
|
|
? logs.slice(-MAX_LOG_CHARS)
|
|
: logs;
|
|
|
|
const codebase = chatLogsData.codebase || "";
|
|
const prunedCodebase = codebase.length > MAX_CODEBASE_CHARS
|
|
? codebase.slice(0, MAX_CODEBASE_CHARS) +
|
|
`\n\n[Truncated codebase snippet: original length ${codebase.length}]`
|
|
: codebase;
|
|
|
|
const allMessages = chatLogsData.chat?.messages || [];
|
|
const recentMessages = allMessages.slice(-MAX_MESSAGES).map((m) => ({
|
|
...m,
|
|
content:
|
|
typeof m.content === "string" && m.content.length > MAX_MESSAGE_CHARS
|
|
? m.content.slice(0, MAX_MESSAGE_CHARS) +
|
|
`\n\n[Truncated message: original length ${m.content.length}]`
|
|
: m.content,
|
|
}));
|
|
|
|
const chatLogsJson = {
|
|
systemInfo: chatLogsData.debugInfo,
|
|
chat: chatLogsData.chat,
|
|
codebaseSnippet: chatLogsData.codebase,
|
|
systemInfo: {
|
|
...chatLogsData.debugInfo,
|
|
logs: prunedLogs,
|
|
},
|
|
chat: {
|
|
...chatLogsData.chat,
|
|
messages: recentMessages,
|
|
},
|
|
codebaseSnippet: prunedCodebase,
|
|
};
|
|
|
|
// Get signed URL
|
|
2 changes: 1 addition & 1 deletion2
|
|
src/components/chat/PromoMessage.tsx
|
|
Original file line number Diff line number Diff line change
|
|
@@ -192,7 +192,7 @@ export const GITHUB_TIP: MessageConfig = {
|
|
{
|
|
type: "link",
|
|
content: "GitHub",
|
|
url: "https://github.com/dyad-sh/dyad",
|
|
url: "https://github.com/pesachnps/dyad-without-the-limits",
|
|
},
|
|
],
|
|
};
|
|
61 changes: 61 additions & 0 deletions61
|
|
src/hooks/useSmartContext.ts
|
|
Original file line number Diff line number Diff line change
|
|
@@ -0,0 +1,61 @@
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { IpcClient } from "@/ipc/ipc_client";
|
|
import type {
|
|
SmartContextMeta,
|
|
SmartContextSnippet,
|
|
SmartContextRetrieveResult,
|
|
} from "@/ipc/ipc_types";
|
|
|
|
export function useSmartContextMeta(chatId: number) {
|
|
return useQuery<SmartContextMeta, Error>({
|
|
queryKey: ["smart-context", chatId, "meta"],
|
|
queryFn: async () => {
|
|
const ipc = IpcClient.getInstance();
|
|
return ipc.getSmartContextMeta(chatId);
|
|
},
|
|
enabled: !!chatId,
|
|
});
|
|
}
|
|
|
|
export function useRetrieveSmartContext(
|
|
chatId: number,
|
|
query: string,
|
|
budgetTokens: number,
|
|
) {
|
|
return useQuery<SmartContextRetrieveResult, Error>({
|
|
queryKey: ["smart-context", chatId, "retrieve", query, budgetTokens],
|
|
queryFn: async () => {
|
|
const ipc = IpcClient.getInstance();
|
|
return ipc.retrieveSmartContext({ chatId, query, budgetTokens });
|
|
},
|
|
enabled: !!chatId && !!query && budgetTokens > 0,
|
|
meta: { showErrorToast: true },
|
|
});
|
|
}
|
|
|
|
export function useUpsertSmartContextSnippets(chatId: number) {
|
|
const qc = useQueryClient();
|
|
return useMutation<number, Error, Array<Pick<SmartContextSnippet, "text" | "source">>>({
|
|
mutationFn: async (snippets) => {
|
|
const ipc = IpcClient.getInstance();
|
|
return ipc.upsertSmartContextSnippets(chatId, snippets);
|
|
},
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["smart-context", chatId] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUpdateRollingSummary(chatId: number) {
|
|
const qc = useQueryClient();
|
|
return useMutation<SmartContextMeta, Error, { summary: string }>({
|
|
mutationFn: async ({ summary }) => {
|
|
const ipc = IpcClient.getInstance();
|
|
return ipc.updateSmartContextRollingSummary(chatId, summary);
|
|
},
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["smart-context", chatId, "meta"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
137 changes: 125 additions & 12 deletions137
|
|
src/ipc/handlers/chat_stream_handlers.ts
|
|
Original file line number Diff line number Diff line change
|
|
@@ -38,7 +38,7 @@ import * as path from "path";
|
|
import * as os from "os";
|
|
import * as crypto from "crypto";
|
|
import { readFile, writeFile, unlink } from "fs/promises";
|
|
import { getMaxTokens, getTemperature } from "../utils/token_utils";
|
|
import { getMaxTokens, getTemperature, getContextWindow } from "../utils/token_utils";
|
|
import { MAX_CHAT_TURNS_IN_CONTEXT } from "@/constants/settings_constants";
|
|
import { validateChatContext } from "../utils/context_paths_utils";
|
|
import { GoogleGenerativeAIProviderOptions } from "@ai-sdk/google";
|
|
@@ -64,11 +64,31 @@ import { parseAppMentions } from "@/shared/parse_mention_apps";
|
|
import { prompts as promptsTable } from "../../db/schema";
|
|
import { inArray } from "drizzle-orm";
|
|
import { replacePromptReference } from "../utils/replacePromptReference";
|
|
import {
|
|
appendSnippets,
|
|
retrieveContext as retrieveSmartContext,
|
|
updateRollingSummary as updateSmartContextRollingSummary,
|
|
} from "../utils/smart_context_store";
|
|
|
|
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
|
|
|
|
const logger = log.scope("chat_stream_handlers");
|
|
|
|
// Request size safeguards
|
|
const MAX_CODEBASE_CHARS = 200_000; // ~200k chars for codebase context
|
|
const MAX_OTHER_APPS_CODEBASE_CHARS = 200_000; // cap referenced apps section
|
|
const MAX_TEXT_ATTACHMENT_CHARS = 50_000; // per text attachment
|
|
const MAX_IMAGE_BYTES = 3 * 1024 * 1024; // 3MB per image
|
|
|
|
function truncateWithNotice(input: string, limit: number, label: string): string {
|
|
if (input.length <= limit) return input;
|
|
const head = input.slice(0, limit);
|
|
return (
|
|
head +
|
|
`\n\n[Truncated ${label}: original length ${input.length.toLocaleString()} > limit ${limit.toLocaleString()}]`
|
|
);
|
|
}
|
|
|
|
// Track active streams for cancellation
|
|
const activeStreams = new Map<number, AbortController>();
|
|
|
|
@@ -437,6 +457,12 @@ ${componentSnippet}
|
|
);
|
|
}
|
|
|
|
const cappedOtherAppsCodebaseInfo = truncateWithNotice(
|
|
otherAppsCodebaseInfo,
|
|
MAX_OTHER_APPS_CODEBASE_CHARS,
|
|
"referenced apps codebase context",
|
|
);
|
|
|
|
logger.log(`Extracted codebase information from ${appPath}`);
|
|
logger.log(
|
|
"codebaseInfo: length",
|
|
@@ -450,6 +476,13 @@ ${componentSnippet}
|
|
files,
|
|
);
|
|
|
|
// Apply size caps to codebase snippets prior to prompt construction
|
|
const cappedCodebaseInfo = truncateWithNotice(
|
|
codebaseInfo,
|
|
MAX_CODEBASE_CHARS,
|
|
"codebase context",
|
|
);
|
|
|
|
// Prepare message history for the AI
|
|
const messageHistory = updatedChat.messages.map((message) => ({
|
|
role: message.role as "user" | "assistant" | "system",
|
|
@@ -576,36 +609,72 @@ This conversation includes one or more image attachments. When the user uploads
|
|
`;
|
|
}
|
|
|
|
const codebasePrefix = isEngineEnabled
|
|
? // No codebase prefix if engine is set, we will take of it there.
|
|
[]
|
|
const codebasePrefix = settings.enableProSmartFilesContextMode
|
|
? ([] as const)
|
|
: ([
|
|
{
|
|
role: "user",
|
|
content: createCodebasePrompt(codebaseInfo),
|
|
content: createCodebasePrompt(cappedCodebaseInfo),
|
|
},
|
|
{
|
|
role: "assistant",
|
|
content: "OK, got it. I'm ready to help",
|
|
content:
|
|
"Thanks for the codebase. I will use it to answer your questions.",
|
|
},
|
|
] as const);
|
|
|
|
const otherCodebasePrefix = otherAppsCodebaseInfo
|
|
const otherCodebasePrefix = cappedOtherAppsCodebaseInfo
|
|
? ([
|
|
{
|
|
role: "user",
|
|
content: createOtherAppsCodebasePrompt(otherAppsCodebaseInfo),
|
|
content: createOtherAppsCodebasePrompt(cappedOtherAppsCodebaseInfo),
|
|
},
|
|
{
|
|
role: "assistant",
|
|
content: "OK.",
|
|
content:
|
|
"Got it. I will use the referenced apps' codebases for read-only context.",
|
|
},
|
|
] as const)
|
|
: ([] as const);
|
|
|
|
// Smart Context: retrieve rolling summary + top snippets within a budget
|
|
const contextWindow = await getContextWindow();
|
|
// Reserve budget: 20% system/instructions, 20% output, 15% summary, rest for snippets
|
|
const availableForContext = Math.floor(contextWindow * 0.45);
|
|
const retrieval = await retrieveSmartContext(
|
|
req.chatId,
|
|
req.prompt,
|
|
Math.max(0, availableForContext),
|
|
);
|
|
|
|
const summaryPrefix: ModelMessage[] = retrieval.rollingSummary
|
|
? ([
|
|
{
|
|
role: "user",
|
|
content: `Rolling summary:\n${retrieval.rollingSummary}`,
|
|
},
|
|
{ role: "assistant", content: "Okay." },
|
|
] as const)
|
|
: [];
|
|
: ([] as const);
|
|
|
|
const snippetsText = retrieval.snippets
|
|
.map((s) => `- ${s.text}`)
|
|
.join("\n\n");
|
|
const snippetsPrefix: ModelMessage[] = snippetsText
|
|
? ([
|
|
{
|
|
role: "user",
|
|
content: `Relevant context snippets (compressed):\n${snippetsText}`,
|
|
},
|
|
{ role: "assistant", content: "Noted." },
|
|
] as const)
|
|
: ([] as const);
|
|
|
|
let chatMessages: ModelMessage[] = [
|
|
...codebasePrefix,
|
|
...otherCodebasePrefix,
|
|
...summaryPrefix,
|
|
...snippetsPrefix,
|
|
...limitedMessageHistory.map((msg) => ({
|
|
role: msg.role as "user" | "assistant" | "system",
|
|
// Why remove thinking tags?
|
|
@@ -868,6 +937,12 @@ ${problemReport.problems
|
|
chatContext,
|
|
virtualFileSystem,
|
|
});
|
|
|
|
const cappedCodebaseInfoForContinuation = truncateWithNotice(
|
|
codebaseInfo,
|
|
MAX_CODEBASE_CHARS,
|
|
"codebase context",
|
|
);
|
|
const { modelClient } = await getModelClient(
|
|
settings.selectedModel,
|
|
settings,
|
|
@@ -886,7 +961,7 @@ ${problemReport.problems
|
|
) {
|
|
return {
|
|
role: "user",
|
|
content: createCodebasePrompt(codebaseInfo),
|
|
content: createCodebasePrompt(cappedCodebaseInfoForContinuation),
|
|
} as const;
|
|
}
|
|
return msg;
|
|
@@ -1055,6 +1130,21 @@ ${problemReport.problems
|
|
}
|
|
}
|
|
|
|
// Append Smart Context snippets from this turn and update rolling summary title if present
|
|
try {
|
|
// Add user prompt and assistant response as snippets for future retrieval
|
|
await appendSnippets(req.chatId, [
|
|
{ text: userPrompt, source: { type: "message" } },
|
|
{ text: fullResponse, source: { type: "message" } },
|
|
]);
|
|
const summaryMatch = fullResponse.match(/<dyad-chat-summary>(.*?)<\/dyad-chat-summary>/);
|
|
if (summaryMatch && summaryMatch[1]) {
|
|
await updateSmartContextRollingSummary(req.chatId, summaryMatch[1]);
|
|
}
|
|
} catch (e) {
|
|
logger.warn("smart-context post-write failed", e);
|
|
}
|
|
|
|
// Return the chat ID for backwards compatibility
|
|
return req.chatId;
|
|
} catch (error) {
|
|
@@ -1135,6 +1225,12 @@ async function replaceTextAttachmentWithContent(
|
|
// Read the full content
|
|
const fullContent = await readFile(filePath, "utf-8");
|
|
|
|
const contentToInclude = truncateWithNotice(
|
|
fullContent,
|
|
MAX_TEXT_ATTACHMENT_CHARS,
|
|
`attachment ${fileName}`,
|
|
);
|
|
|
|
// Replace the placeholder tag with the full content
|
|
const escapedPath = filePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const tagPattern = new RegExp(
|
|
@@ -1144,7 +1240,7 @@ async function replaceTextAttachmentWithContent(
|
|
|
|
const replacedText = text.replace(
|
|
tagPattern,
|
|
`Full content of ${fileName}:\n\`\`\`\n${fullContent}\n\`\`\``,
|
|
`Full content of ${fileName}:\n\`\`\`\n${contentToInclude}\n\`\`\``,
|
|
);
|
|
|
|
logger.log(
|
|
@@ -1193,10 +1289,17 @@ async function prepareMessageWithAttachments(
|
|
});
|
|
|
|
// Add image parts for any image attachments
|
|
const omittedImages: string[] = [];
|
|
for (const filePath of attachmentPaths) {
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(ext)) {
|
|
try {
|
|
// Check size before reading
|
|
const stats = await fs.promises.stat(filePath);
|
|
if (stats.size > MAX_IMAGE_BYTES) {
|
|
omittedImages.push(path.basename(filePath));
|
|
continue;
|
|
}
|
|
// Read the file as a buffer
|
|
const imageBuffer = await readFile(filePath);
|
|
|
|
@@ -1213,6 +1316,16 @@ async function prepareMessageWithAttachments(
|
|
}
|
|
}
|
|
|
|
if (omittedImages.length > 0) {
|
|
contentParts.push({
|
|
type: "text",
|
|
text:
|
|
`Note: ${omittedImages.length} image(s) omitted due to size limits (>${Math.floor(
|
|
MAX_IMAGE_BYTES / (1024 * 1024),
|
|
)}MB): ` + omittedImages.join(", "),
|
|
});
|
|
}
|
|
|
|
// Return the message with the content array
|
|
return {
|
|
role: "user",
|
|
66 changes: 66 additions & 0 deletions66
|
|
src/ipc/handlers/smart_context_handlers.ts
|
|
Original file line number Diff line number Diff line change
|
|
@@ -0,0 +1,66 @@
|
|
import log from "electron-log";
|
|
import { createLoggedHandler } from "./safe_handle";
|
|
import {
|
|
appendSnippets,
|
|
readMeta,
|
|
retrieveContext,
|
|
updateRollingSummary,
|
|
rebuildIndex,
|
|
type SmartContextSnippet,
|
|
type SmartContextMeta,
|
|
} from "../utils/smart_context_store";
|
|
|
|
const logger = log.scope("smart_context_handlers");
|
|
const handle = createLoggedHandler(logger);
|
|
|
|
export interface UpsertSnippetsParams {
|
|
chatId: number;
|
|
snippets: Array<{
|
|
text: string;
|
|
source:
|
|
| { type: "message"; messageIndex?: number }
|
|
| { type: "code"; filePath: string }
|
|
| { type: "attachment"; name: string; mime?: string }
|
|
| { type: "other"; label?: string };
|
|
}>;
|
|
}
|
|
|
|
export interface RetrieveContextParams {
|
|
chatId: number;
|
|
query: string;
|
|
budgetTokens: number;
|
|
}
|
|
|
|
export function registerSmartContextHandlers() {
|
|
handle("sc:get-meta", async (_event, chatId: number): Promise<SmartContextMeta> => {
|
|
return readMeta(chatId);
|
|
});
|
|
|
|
handle(
|
|
"sc:upsert-snippets",
|
|
async (_event, params: UpsertSnippetsParams): Promise<number> => {
|
|
const count = await appendSnippets(params.chatId, params.snippets);
|
|
return count;
|
|
},
|
|
);
|
|
|
|
handle(
|
|
"sc:update-rolling-summary",
|
|
async (_event, params: { chatId: number; summary: string }): Promise<SmartContextMeta> => {
|
|
return updateRollingSummary(params.chatId, params.summary);
|
|
},
|
|
);
|
|
|
|
handle(
|
|
"sc:retrieve-context",
|
|
async (_event, params: RetrieveContextParams) => {
|
|
return retrieveContext(params.chatId, params.query, params.budgetTokens);
|
|
},
|
|
);
|
|
|
|
handle("sc:rebuild-index", async (_event, chatId: number) => {
|
|
await rebuildIndex(chatId);
|
|
return { ok: true } as const;
|
|
});
|
|
}
|
|
|
|
38 changes: 38 additions & 0 deletions38
|
|
src/ipc/ipc_client.ts
|
|
Original file line number Diff line number Diff line change
|
|
@@ -65,6 +65,12 @@ import type {
|
|
import type { Template } from "../shared/templates";
|
|
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
|
|
import { showError } from "@/lib/toast";
|
|
import type {
|
|
SmartContextMeta,
|
|
SmartContextSnippet,
|
|
SmartContextRetrieveResult,
|
|
RetrieveSmartContextParams,
|
|
} from "./ipc_types";
|
|
|
|
export interface ChatStreamCallbacks {
|
|
onUpdate: (messages: Message[]) => void;
|
|
@@ -240,6 +246,38 @@ export class IpcClient {
|
|
return IpcClient.instance;
|
|
}
|
|
|
|
// --- Smart Context ---
|
|
public async getSmartContextMeta(chatId: number): Promise<SmartContextMeta> {
|
|
return this.ipcRenderer.invoke("sc:get-meta", chatId);
|
|
}
|
|
|
|
public async upsertSmartContextSnippets(
|
|
chatId: number,
|
|
snippets: Array<Pick<SmartContextSnippet, "text" | "source">>,
|
|
): Promise<number> {
|
|
return this.ipcRenderer.invoke("sc:upsert-snippets", { chatId, snippets });
|
|
}
|
|
|
|
public async updateSmartContextRollingSummary(
|
|
chatId: number,
|
|
summary: string,
|
|
): Promise<SmartContextMeta> {
|
|
return this.ipcRenderer.invoke("sc:update-rolling-summary", {
|
|
chatId,
|
|
summary,
|
|
});
|
|
}
|
|
|
|
public async retrieveSmartContext(
|
|
params: RetrieveSmartContextParams,
|
|
): Promise<SmartContextRetrieveResult> {
|
|
return this.ipcRenderer.invoke("sc:retrieve-context", params);
|
|
}
|
|
|
|
public async rebuildSmartContextIndex(chatId: number): Promise<void> {
|
|
await this.ipcRenderer.invoke("sc:rebuild-index", chatId);
|
|
}
|
|
|
|
public async restartDyad(): Promise<void> {
|
|
await this.ipcRenderer.invoke("restart-dyad");
|
|
}
|
|
2 changes: 2 additions & 0 deletions2
|
|
src/ipc/ipc_host.ts
|
|
Original file line number Diff line number Diff line change
|
|
@@ -30,6 +30,7 @@ import { registerTemplateHandlers } from "./handlers/template_handlers";
|
|
import { registerPortalHandlers } from "./handlers/portal_handlers";
|
|
import { registerPromptHandlers } from "./handlers/prompt_handlers";
|
|
import { registerHelpBotHandlers } from "./handlers/help_bot_handlers";
|
|
import { registerSmartContextHandlers } from "./handlers/smart_context_handlers";
|
|
|
|
export function registerIpcHandlers() {
|
|
// Register all IPC handlers by category
|
|
@@ -65,4 +66,5 @@ export function registerIpcHandlers() {
|
|
registerPortalHandlers();
|
|
registerPromptHandlers();
|
|
registerHelpBotHandlers();
|
|
registerSmartContextHandlers();
|
|
}
|
|
40 changes: 40 additions & 0 deletions40
|
|
src/ipc/ipc_types.ts
|
|
Original file line number Diff line number Diff line change
|
|
@@ -354,6 +354,46 @@ export interface UploadFileToCodebaseResult {
|
|
filePath: string;
|
|
}
|
|
|
|
// --- Smart Context Types ---
|
|
export type SmartContextSource =
|
|
| { type: "message"; messageIndex?: number }
|
|
| { type: "code"; filePath: string }
|
|
| { type: "attachment"; name: string; mime?: string }
|
|
| { type: "other"; label?: string };
|
|
|
|
export interface SmartContextSnippet {
|
|
id: string;
|
|
text: string;
|
|
score?: number;
|
|
source: SmartContextSource;
|
|
ts: number;
|
|
tokens?: number;
|
|
}
|
|
|
|
export interface SmartContextMetaConfig {
|
|
maxSnippets?: number;
|
|
}
|
|
|
|
export interface SmartContextMeta {
|
|
entityId: string;
|
|
updatedAt: number;
|
|
rollingSummary?: string;
|
|
summaryTokens?: number;
|
|
config?: SmartContextMetaConfig;
|
|
}
|
|
|
|
export interface RetrieveSmartContextParams {
|
|
chatId: number;
|
|
query: string;
|
|
budgetTokens: number;
|
|
}
|
|
|
|
export interface SmartContextRetrieveResult {
|
|
rollingSummary?: string;
|
|
usedTokens: number;
|
|
snippets: SmartContextSnippet[];
|
|
}
|
|
|
|
// --- Prompts ---
|
|
export interface PromptDto {
|
|
id: number;
|
|
213 changes: 213 additions & 0 deletions213
|
|
src/ipc/utils/smart_context_store.ts
|
|
Original file line number Diff line number Diff line change
|
|
@@ -0,0 +1,213 @@
|
|
import path from "node:path";
|
|
import { promises as fs } from "node:fs";
|
|
import { randomUUID } from "node:crypto";
|
|
import { getUserDataPath } from "../../paths/paths";
|
|
import { estimateTokens } from "./token_utils";
|
|
|
|
export type SmartContextSource =
|
|
| { type: "message"; messageIndex?: number }
|
|
| { type: "code"; filePath: string }
|
|
| { type: "attachment"; name: string; mime?: string }
|
|
| { type: "other"; label?: string };
|
|
|
|
export interface SmartContextSnippet {
|
|
id: string;
|
|
text: string;
|
|
score?: number;
|
|
source: SmartContextSource;
|
|
ts: number; // epoch ms
|
|
tokens?: number;
|
|
}
|
|
|
|
export interface SmartContextMetaConfig {
|
|
maxSnippets?: number;
|
|
}
|
|
|
|
export interface SmartContextMeta {
|
|
entityId: string; // e.g., chatId as string
|
|
updatedAt: number;
|
|
rollingSummary?: string;
|
|
summaryTokens?: number;
|
|
config?: SmartContextMetaConfig;
|
|
}
|
|
|
|
function getThreadDir(chatId: number): string {
|
|
const base = path.join(getUserDataPath(), "smart-context", "threads");
|
|
return path.join(base, String(chatId));
|
|
}
|
|
|
|
function getMetaPath(chatId: number): string {
|
|
return path.join(getThreadDir(chatId), "meta.json");
|
|
}
|
|
|
|
function getSnippetsPath(chatId: number): string {
|
|
return path.join(getThreadDir(chatId), "snippets.jsonl");
|
|
}
|
|
|
|
async function ensureDir(dir: string): Promise<void> {
|
|
await fs.mkdir(dir, { recursive: true });
|
|
}
|
|
|
|
export async function readMeta(chatId: number): Promise<SmartContextMeta> {
|
|
const dir = getThreadDir(chatId);
|
|
await ensureDir(dir);
|
|
const metaPath = getMetaPath(chatId);
|
|
try {
|
|
const raw = await fs.readFile(metaPath, "utf8");
|
|
const meta = JSON.parse(raw) as SmartContextMeta;
|
|
return meta;
|
|
} catch {
|
|
const fresh: SmartContextMeta = {
|
|
entityId: String(chatId),
|
|
updatedAt: Date.now(),
|
|
rollingSummary: "",
|
|
summaryTokens: 0,
|
|
config: { maxSnippets: 400 },
|
|
};
|
|
await fs.writeFile(metaPath, JSON.stringify(fresh, null, 2), "utf8");
|
|
return fresh;
|
|
}
|
|
}
|
|
|
|
export async function writeMeta(
|
|
chatId: number,
|
|
meta: SmartContextMeta,
|
|
): Promise<void> {
|
|
const dir = getThreadDir(chatId);
|
|
await ensureDir(dir);
|
|
const metaPath = getMetaPath(chatId);
|
|
const updated: SmartContextMeta = {
|
|
...meta,
|
|
entityId: String(chatId),
|
|
updatedAt: Date.now(),
|
|
};
|
|
await fs.writeFile(metaPath, JSON.stringify(updated, null, 2), "utf8");
|
|
}
|
|
|
|
export async function updateRollingSummary(
|
|
chatId: number,
|
|
summary: string,
|
|
): Promise<SmartContextMeta> {
|
|
const meta = await readMeta(chatId);
|
|
const summaryTokens = estimateTokens(summary || "");
|
|
const next: SmartContextMeta = {
|
|
...meta,
|
|
rollingSummary: summary,
|
|
summaryTokens,
|
|
};
|
|
await writeMeta(chatId, next);
|
|
return next;
|
|
}
|
|
|
|
export async function appendSnippets(
|
|
chatId: number,
|
|
snippets: Omit<SmartContextSnippet, "id" | "ts" | "tokens">[],
|
|
): Promise<number> {
|
|
const dir = getThreadDir(chatId);
|
|
await ensureDir(dir);
|
|
const snippetsPath = getSnippetsPath(chatId);
|
|
const withDefaults: SmartContextSnippet[] = snippets.map((s) => ({
|
|
id: randomUUID(),
|
|
ts: Date.now(),
|
|
tokens: estimateTokens(s.text),
|
|
...s,
|
|
}));
|
|
const lines = withDefaults.map((obj) => JSON.stringify(obj)).join("\n");
|
|
await fs.appendFile(snippetsPath, (lines ? lines + "\n" : ""), "utf8");
|
|
|
|
// prune if exceeded max
|
|
const meta = await readMeta(chatId);
|
|
const maxSnippets = meta.config?.maxSnippets ?? 400;
|
|
try {
|
|
const file = await fs.readFile(snippetsPath, "utf8");
|
|
const allLines = file.split("\n").filter(Boolean);
|
|
if (allLines.length > maxSnippets) {
|
|
const toKeep = allLines.slice(allLines.length - maxSnippets);
|
|
await fs.writeFile(snippetsPath, toKeep.join("\n") + "\n", "utf8");
|
|
return toKeep.length;
|
|
}
|
|
return allLines.length;
|
|
} catch {
|
|
return withDefaults.length;
|
|
}
|
|
}
|
|
|
|
export async function readAllSnippets(chatId: number): Promise<SmartContextSnippet[]> {
|
|
try {
|
|
const raw = await fs.readFile(getSnippetsPath(chatId), "utf8");
|
|
return raw
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line) as SmartContextSnippet);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function normalize(value: number, min: number, max: number): number {
|
|
if (max === min) return 0;
|
|
return (value - min) / (max - min);
|
|
}
|
|
|
|
function keywordScore(text: string, query: string): number {
|
|
const toTokens = (s: string) =>
|
|
s
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9_\- ]+/g, " ")
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
const qTokens = new Set(toTokens(query));
|
|
const tTokens = toTokens(text);
|
|
if (qTokens.size === 0 || tTokens.length === 0) return 0;
|
|
let hits = 0;
|
|
for (const tok of tTokens) if (qTokens.has(tok)) hits++;
|
|
return hits / tTokens.length; // simple overlap ratio
|
|
}
|
|
|
|
export interface RetrieveContextResult {
|
|
rollingSummary?: string;
|
|
usedTokens: number;
|
|
snippets: SmartContextSnippet[];
|
|
}
|
|
|
|
export async function retrieveContext(
|
|
chatId: number,
|
|
query: string,
|
|
budgetTokens: number,
|
|
): Promise<RetrieveContextResult> {
|
|
const meta = await readMeta(chatId);
|
|
const snippets = await readAllSnippets(chatId);
|
|
const now = Date.now();
|
|
let minTs = now;
|
|
let maxTs = 0;
|
|
for (const s of snippets) {
|
|
if (s.ts < minTs) minTs = s.ts;
|
|
if (s.ts > maxTs) maxTs = s.ts;
|
|
}
|
|
const scored = snippets.map((s) => {
|
|
const recency = normalize(s.ts, minTs, maxTs);
|
|
const kw = keywordScore(s.text, query);
|
|
const base = 0.6 * kw + 0.4 * recency;
|
|
const score = base;
|
|
return { ...s, score } as SmartContextSnippet;
|
|
});
|
|
scored.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
|
|
const picked: SmartContextSnippet[] = [];
|
|
let usedTokens = 0;
|
|
for (const s of scored) {
|
|
const t = s.tokens ?? estimateTokens(s.text);
|
|
if (usedTokens + t > budgetTokens) break;
|
|
picked.push(s);
|
|
usedTokens += t;
|
|
}
|
|
|
|
const rollingSummary = meta.rollingSummary || "";
|
|
return { rollingSummary, usedTokens, snippets: picked };
|
|
}
|
|
|
|
export async function rebuildIndex(_chatId: number): Promise<void> {
|
|
// Placeholder for future embedding/vector index rebuild.
|
|
return;
|
|
}
|
|
|
|
6 changes: 6 additions & 0 deletions6
|
|
src/preload.ts
|
|
Original file line number Diff line number Diff line change
|
|
@@ -106,6 +106,12 @@ const validInvokeChannels = [
|
|
"restart-dyad",
|
|
"get-templates",
|
|
"portal:migrate-create",
|
|
// Smart Context
|
|
"sc:get-meta",
|
|
"sc:upsert-snippets",
|
|
"sc:update-rolling-summary",
|
|
"sc:retrieve-context",
|
|
"sc:rebuild-index",
|
|
// Help bot
|
|
"help:chat:start",
|
|
"help:chat:cancel",
|
|
24 changes: 0 additions & 24 deletions24
|
|
testing/fake-llm-server/README.md
|
|
Original file line number Diff line number Diff line change
|
|
@@ -7,7 +7,6 @@ A simple server that mimics the OpenAI streaming chat completions API for testin
|
|
- Implements a basic version of the OpenAI chat completions API
|
|
- Supports both streaming and non-streaming responses
|
|
- Always responds with "hello world" message
|
|
- Simulates a 429 rate limit error when the last message is "[429]"
|
|
- Configurable through environment variables
|
|
|
|
## Installation
|
|
@@ -83,29 +82,6 @@ For non-streaming requests, you'll get a standard JSON response:
|
|
|
|
For streaming requests, you'll receive a series of server-sent events (SSE), each containing a chunk of the response.
|
|
|
|
### Simulating Rate Limit Errors
|
|
|
|
To test how your application handles rate limiting, send a message with content exactly equal to `[429]`:
|
|
|
|
```json
|
|
{
|
|
"messages": [{ "role": "user", "content": "[429]" }],
|
|
"model": "any-model"
|
|
}
|
|
```
|
|
|
|
This will return a 429 status code with the following response:
|
|
|
|
```json
|
|
{
|
|
"error": {
|
|
"message": "Too many requests. Please try again later.",
|
|
"type": "rate_limit_error",
|
|
"param": null,
|
|
"code": "rate_limit_exceeded"
|
|
}
|
|
}
|
|
```
|
|
|
|
## Configuration
|
|
|
|
12 changes: 1 addition & 11 deletions12
|
|
testing/fake-llm-server/chatCompletionHandler.ts
|
|
Original file line number Diff line number Diff line change
|
|
@@ -10,18 +10,8 @@ export const createChatCompletionHandler =
|
|
const { stream = false, messages = [] } = req.body;
|
|
console.log("* Received messages", messages);
|
|
|
|
// Check if the last message contains "[429]" to simulate rate limiting
|
|
// Disabled rate limit simulation
|
|
const lastMessage = messages[messages.length - 1];
|
|
if (lastMessage && lastMessage.content === "[429]") {
|
|
return res.status(429).json({
|
|
error: {
|
|
message: "Too many requests. Please try again later.",
|
|
type: "rate_limit_error",
|
|
param: null,
|
|
code: "rate_limit_exceeded",
|
|
},
|
|
});
|
|
}
|
|
|
|
let messageContent = CANNED_MESSAGE;
|
|
|
|
Footer
|