mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-28 19:05:31 +02:00
Merge branch 'rowboatlabs:main' into main
This commit is contained in:
commit
0d68f30033
70 changed files with 5551 additions and 2583 deletions
18
.github/workflows/electron-build.yml
vendored
18
.github/workflows/electron-build.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
|
|
@ -21,7 +21,7 @@ jobs:
|
||||||
version: 9
|
version: 9
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
@ -107,7 +107,7 @@ jobs:
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload workflow artifacts
|
- name: Upload workflow artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: distributables
|
name: distributables
|
||||||
path: apps/x/apps/main/out/make/*
|
path: apps/x/apps/main/out/make/*
|
||||||
|
|
@ -118,7 +118,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
|
|
@ -126,7 +126,7 @@ jobs:
|
||||||
version: 9
|
version: 9
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
@ -171,7 +171,7 @@ jobs:
|
||||||
working-directory: apps/x/apps/main
|
working-directory: apps/x/apps/main
|
||||||
|
|
||||||
- name: Upload workflow artifacts
|
- name: Upload workflow artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: distributables-linux
|
name: distributables-linux
|
||||||
path: apps/x/apps/main/out/make/*
|
path: apps/x/apps/main/out/make/*
|
||||||
|
|
@ -182,7 +182,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
|
|
@ -190,7 +190,7 @@ jobs:
|
||||||
version: 9
|
version: 9
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
@ -237,7 +237,7 @@ jobs:
|
||||||
working-directory: apps/x/apps/main
|
working-directory: apps/x/apps/main
|
||||||
|
|
||||||
- name: Upload workflow artifacts
|
- name: Upload workflow artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: distributables-windows
|
name: distributables-windows
|
||||||
path: apps/x/apps/main/out/make/*
|
path: apps/x/apps/main/out/make/*
|
||||||
|
|
|
||||||
8
.github/workflows/rowboat-build.yml
vendored
8
.github/workflows/rowboat-build.yml
vendored
|
|
@ -8,10 +8,10 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
cache-dependency-path: 'apps/rowboat/package-lock.json'
|
cache-dependency-path: 'apps/rowboat/package-lock.json'
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
|
|
@ -29,10 +29,10 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
cache-dependency-path: 'apps/rowboat/package-lock.json'
|
cache-dependency-path: 'apps/rowboat/package-lock.json'
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
|
|
|
||||||
4
.github/workflows/x-publish.yml
vendored
4
.github/workflows/x-publish.yml
vendored
|
|
@ -12,10 +12,10 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
registry-url: https://registry.npmjs.org/
|
registry-url: https://registry.npmjs.org/
|
||||||
|
|
|
||||||
|
|
@ -141,11 +141,6 @@ Examples: Exa (web search), Twitter/X, ElevenLabs (voice), Slack, Linear/Jira, G
|
||||||
- No proprietary formats or hosted lock-in
|
- No proprietary formats or hosted lock-in
|
||||||
- You can inspect, edit, back up, or delete everything at any time
|
- You can inspect, edit, back up, or delete everything at any time
|
||||||
|
|
||||||
|
|
||||||
## Looking for Rowboat Web Studio?
|
|
||||||
|
|
||||||
If you’re looking for Rowboat web Studio, start [here](https://docs.rowboatlabs.com/).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { skillCatalog } from "./skills/index.js";
|
import { skillCatalog } from "./skills/index.js";
|
||||||
import { WorkDir as BASE_DIR } from "../../config/config.js";
|
import { WorkDir as BASE_DIR } from "../../config/config.js";
|
||||||
|
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
|
||||||
|
|
||||||
|
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
||||||
|
|
||||||
export const CopilotInstructions = `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. You can also help the user with general tasks.
|
export const CopilotInstructions = `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. You can also help the user with general tasks.
|
||||||
|
|
||||||
|
|
@ -39,6 +42,8 @@ When a user asks for ANY task that might require external capabilities (web sear
|
||||||
- Use relative paths (no \${BASE_DIR} prefixes) when running commands or referencing files.
|
- Use relative paths (no \${BASE_DIR} prefixes) when running commands or referencing files.
|
||||||
- Keep user data safe—double-check before editing or deleting important resources.
|
- Keep user data safe—double-check before editing or deleting important resources.
|
||||||
|
|
||||||
|
${runtimeContextPrompt}
|
||||||
|
|
||||||
## Workspace access & scope
|
## Workspace access & scope
|
||||||
- You have full read/write access inside \`${BASE_DIR}\` (this resolves to the user's \`~/.rowboat\` directory). Create folders, files, and agents there using builtin tools or allowed shell commands—don't wait for the user to do it manually.
|
- You have full read/write access inside \`${BASE_DIR}\` (this resolves to the user's \`~/.rowboat\` directory). Create folders, files, and agents there using builtin tools or allowed shell commands—don't wait for the user to do it manually.
|
||||||
- If a user mentions a different root (e.g., \`~/.rowboatx\` or another path), clarify whether they meant the Rowboat workspace and propose the equivalent path you can act on. Only refuse if they explicitly insist on an inaccessible location.
|
- If a user mentions a different root (e.g., \`~/.rowboatx\` or another path), clarify whether they meant the Rowboat workspace and propose the equivalent path you can act on. Only refuse if they explicitly insist on an inaccessible location.
|
||||||
|
|
|
||||||
69
apps/cli/src/application/assistant/runtime-context.ts
Normal file
69
apps/cli/src/application/assistant/runtime-context.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
export type RuntimeShellDialect = 'windows-cmd' | 'posix-sh';
|
||||||
|
export type RuntimeOsName = 'Windows' | 'macOS' | 'Linux' | 'Unknown';
|
||||||
|
|
||||||
|
export interface RuntimeContext {
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
osName: RuntimeOsName;
|
||||||
|
shellDialect: RuntimeShellDialect;
|
||||||
|
shellExecutable: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExecutionShell(platform: NodeJS.Platform = process.platform): string {
|
||||||
|
return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext {
|
||||||
|
if (platform === 'win32') {
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
osName: 'Windows',
|
||||||
|
shellDialect: 'windows-cmd',
|
||||||
|
shellExecutable: getExecutionShell(platform),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
osName: 'macOS',
|
||||||
|
shellDialect: 'posix-sh',
|
||||||
|
shellExecutable: getExecutionShell(platform),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'linux') {
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
osName: 'Linux',
|
||||||
|
shellDialect: 'posix-sh',
|
||||||
|
shellExecutable: getExecutionShell(platform),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
osName: 'Unknown',
|
||||||
|
shellDialect: 'posix-sh',
|
||||||
|
shellExecutable: getExecutionShell(platform),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuntimeContextPrompt(runtime: RuntimeContext): string {
|
||||||
|
if (runtime.shellDialect === 'windows-cmd') {
|
||||||
|
return `## Runtime Platform (CRITICAL)
|
||||||
|
- Detected platform: **${runtime.platform}**
|
||||||
|
- Detected OS: **${runtime.osName}**
|
||||||
|
- Shell used by executeCommand: **${runtime.shellExecutable}** (Windows Command Prompt / cmd syntax)
|
||||||
|
- Use Windows command syntax for executeCommand (for example: \`dir\`, \`type\`, \`copy\`, \`move\`, \`del\`, \`rmdir\`).
|
||||||
|
- Use Windows-style absolute paths when outside workspace (for example: \`C:\\Users\\...\`).
|
||||||
|
- Do not assume macOS/Linux command syntax when the runtime is Windows.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `## Runtime Platform (CRITICAL)
|
||||||
|
- Detected platform: **${runtime.platform}**
|
||||||
|
- Detected OS: **${runtime.osName}**
|
||||||
|
- Shell used by executeCommand: **${runtime.shellExecutable}** (POSIX sh syntax)
|
||||||
|
- Use POSIX command syntax for executeCommand (for example: \`ls\`, \`cat\`, \`cp\`, \`mv\`, \`rm\`).
|
||||||
|
- Use POSIX paths when outside workspace (for example: \`~/Desktop\`, \`/Users/.../\` on macOS, \`/home/.../\` on Linux).
|
||||||
|
- Do not assume Windows command syntax when the runtime is POSIX.`;
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { exec, execSync } from 'child_process';
|
import { exec, execSync } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../../config/security.js';
|
import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../../config/security.js';
|
||||||
|
import { getExecutionShell } from '../assistant/runtime-context.js';
|
||||||
|
|
||||||
const execPromise = promisify(exec);
|
const execPromise = promisify(exec);
|
||||||
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/;
|
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/;
|
||||||
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
|
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
|
||||||
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
|
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
|
||||||
|
const EXECUTION_SHELL = getExecutionShell();
|
||||||
|
|
||||||
function sanitizeToken(token: string): string {
|
function sanitizeToken(token: string): string {
|
||||||
return token.trim().replace(/^['"]+|['"]+$/g, '');
|
return token.trim().replace(/^['"]+|['"]+$/g, '');
|
||||||
|
|
@ -91,7 +93,7 @@ export async function executeCommand(
|
||||||
cwd: options?.cwd,
|
cwd: options?.cwd,
|
||||||
timeout: options?.timeout,
|
timeout: options?.timeout,
|
||||||
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
|
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
|
||||||
shell: '/bin/sh', // use sh for cross-platform compatibility
|
shell: EXECUTION_SHELL,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -125,7 +127,7 @@ export function executeCommandSync(
|
||||||
cwd: options?.cwd,
|
cwd: options?.cwd,
|
||||||
timeout: options?.timeout,
|
timeout: options?.timeout,
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
shell: '/bin/sh',
|
shell: EXECUTION_SHELL,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -18,36 +18,12 @@
|
||||||
"group": "Getting Started",
|
"group": "Getting Started",
|
||||||
"pages": [
|
"pages": [
|
||||||
"docs/getting-started/introduction",
|
"docs/getting-started/introduction",
|
||||||
"docs/getting-started/quickstart",
|
"docs/getting-started/quickstart"
|
||||||
"docs/getting-started/license"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"group": "Using Rowboat",
|
|
||||||
"pages": [
|
|
||||||
"docs/using-rowboat/rowboat-studio",
|
|
||||||
"docs/using-rowboat/agents",
|
|
||||||
"docs/using-rowboat/tools",
|
|
||||||
"docs/using-rowboat/rag",
|
|
||||||
"docs/using-rowboat/triggers",
|
|
||||||
"docs/using-rowboat/jobs",
|
|
||||||
"docs/using-rowboat/conversations",
|
|
||||||
{
|
|
||||||
"group": "Customise",
|
|
||||||
"icon": "sliders",
|
|
||||||
"pages": [
|
|
||||||
"docs/using-rowboat/customise/custom-llms"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"group": "API & SDK",
|
|
||||||
"pages": ["docs/api-sdk/using_the_api", "docs/api-sdk/using_the_sdk"]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"group": "Development",
|
"group": "Development",
|
||||||
"pages": ["docs/development/contribution-guide", "docs/development/roadmap"]
|
"pages": ["docs/development/contribution-guide", "docs/getting-started/license"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
---
|
|
||||||
title: "Using the API"
|
|
||||||
description: "This is a guide on using the HTTP API to power conversations with the assistant created in Studio."
|
|
||||||
icon: "code"
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
## Deploy your assistant to production on Studio
|
|
||||||
<Frame>
|
|
||||||
<img src="/docs/img/prod-deploy.png" alt="Prod Deploy" />
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
## Obtain API key and Project ID
|
|
||||||
|
|
||||||
Generate API keys via the developer configs in your project. Copy the Project ID from the same page.
|
|
||||||
<Frame>
|
|
||||||
<img src="/docs/img/dev-config.png" alt="Developer Configs" />
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
## API Endpoint
|
|
||||||
|
|
||||||
```
|
|
||||||
POST <HOST>/api/v1/<PROJECT_ID>/chat
|
|
||||||
```
|
|
||||||
|
|
||||||
Where:
|
|
||||||
|
|
||||||
- For self-hosted: `<HOST>` is `http://localhost:3000`
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
Include your API key in the Authorization header:
|
|
||||||
|
|
||||||
```
|
|
||||||
Authorization: Bearer <API_KEY>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### First Turn
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl --location '<HOST>/api/v1/<PROJECT_ID>/chat' \
|
|
||||||
--header 'Content-Type: application/json' \
|
|
||||||
--header 'Authorization: Bearer <API_KEY>' \
|
|
||||||
--data '{
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "Hello, can you help me?"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"state": null
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Response:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "Hello! Yes, I'd be happy to help you. What can I assist you with today?",
|
|
||||||
"agenticResponseType": "external"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"state": {
|
|
||||||
"last_agent_name": "MainAgent"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Subsequent Turn
|
|
||||||
|
|
||||||
Notice how we include both the previous messages and the state from the last response:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl --location '<HOST>/api/v1/<PROJECT_ID>/chat' \
|
|
||||||
--header 'Content-Type: application/json' \
|
|
||||||
--header 'Authorization: Bearer <API_KEY>' \
|
|
||||||
--data '{
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "Hello, can you help me?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "Hello! Yes, I'd be happy to help you. What can I assist you with today?",
|
|
||||||
"agenticResponseType": "external"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "What services do you offer?"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"state": {
|
|
||||||
"last_agent_name": "MainAgent"
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Specification
|
|
||||||
|
|
||||||
### Request Schema
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
// Required fields
|
|
||||||
messages: Message[]; // Array of message objects representing the conversation history
|
|
||||||
state: any; // State object from previous response, or null for first message
|
|
||||||
|
|
||||||
// Optional fields
|
|
||||||
workflowId?: string; // Specific workflow ID to use (defaults to production workflow)
|
|
||||||
testProfileId?: string; // Test profile ID for simulation
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Message Types
|
|
||||||
|
|
||||||
Messages can be one of the following types:
|
|
||||||
|
|
||||||
1. System Message
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
role: "system";
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. User Message
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
role: "user";
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Assistant Message
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
role: "assistant";
|
|
||||||
content: string;
|
|
||||||
agenticResponseType: "internal" | "external";
|
|
||||||
agenticSender?: string | null;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Response Schema
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
messages: Message[]; // Array of new messages from this turn
|
|
||||||
state: any; // State object to pass in the next request
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
1. Always pass the complete conversation history in the `messages` array
|
|
||||||
2. Always include the `state` from the previous response in your next request
|
|
||||||
3. The last message in the response's `messages` array will be a user-facing assistant message (`agenticResponseType: "external"`)
|
|
||||||
|
|
||||||
## Rate Limiting
|
|
||||||
|
|
||||||
The API has rate limits per project. If exceeded, you'll receive a 429 status code.
|
|
||||||
|
|
||||||
## Error Responses
|
|
||||||
|
|
||||||
- 400: Invalid request body or missing/invalid Authorization header
|
|
||||||
- 403: Invalid API key
|
|
||||||
- 404: Project or workflow not found
|
|
||||||
- 429: Rate limit exceeded
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
---
|
|
||||||
title: "Using the SDK"
|
|
||||||
description: "This is a guide on using the RowBoat Python SDK as an alternative to the [RowBoat HTTP API](/using_the_api) to power conversations with the assistant created in Studio."
|
|
||||||
icon: "toolbox"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
- ``` pip install rowboat ```
|
|
||||||
- [Deploy your assistant to production](/using_the_api/#deploy-your-assistant-to-production-on-studio)
|
|
||||||
- [Obtain your `<API_KEY>` and `<PROJECT_ID>`](/using_the_api/#obtain-api-key-and-project-id)
|
|
||||||
|
|
||||||
### API Host
|
|
||||||
- For the open source installation, the `<HOST>` is [http://localhost:3000](http://localhost:3000)
|
|
||||||
- When using the hosted app, the `<HOST>` is [https://app.rowboatlabs.com](https://app.rowboatlabs.com)
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Basic Usage
|
|
||||||
|
|
||||||
The main way to interact with Rowboat is using the `Client` class, which provides a stateless chat API. You can manage conversation state using the `conversationId` returned in each response.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from rowboat.client import Client
|
|
||||||
from rowboat.schema import UserMessage
|
|
||||||
|
|
||||||
# Initialize the client
|
|
||||||
client = Client(
|
|
||||||
host="<HOST>",
|
|
||||||
projectId="<PROJECT_ID>",
|
|
||||||
apiKey="<API_KEY>"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start a new conversation
|
|
||||||
result = client.run_turn(
|
|
||||||
messages=[
|
|
||||||
UserMessage(role='user', content="What is the capital of France?")
|
|
||||||
]
|
|
||||||
)
|
|
||||||
print(result.turn.output[-1].content)
|
|
||||||
# The capital of France is Paris.
|
|
||||||
|
|
||||||
print("Conversation ID:", result.conversationId)
|
|
||||||
|
|
||||||
# Continue the conversation by passing the conversationId
|
|
||||||
result = client.run_turn(
|
|
||||||
messages=[
|
|
||||||
UserMessage(role='user', content="What other major cities are in that country?")
|
|
||||||
],
|
|
||||||
conversationId=result.conversationId
|
|
||||||
)
|
|
||||||
print(result.turn.output[-1].content)
|
|
||||||
# Other major cities in France include Lyon, Marseille, Toulouse, and Nice.
|
|
||||||
|
|
||||||
result = client.run_turn(
|
|
||||||
messages=[
|
|
||||||
UserMessage(role='user', content="What's the population of the first city you mentioned?")
|
|
||||||
],
|
|
||||||
conversationId=result.conversationId
|
|
||||||
)
|
|
||||||
print(result.turn.output[-1].content)
|
|
||||||
# Lyon has a population of approximately 513,000 in the city proper.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using Tool Overrides (Mock Tools)
|
|
||||||
|
|
||||||
You can provide tool override instructions to test a specific configuration using the `mockTools` argument:
|
|
||||||
|
|
||||||
```python
|
|
||||||
result = client.run_turn(
|
|
||||||
messages=[
|
|
||||||
UserMessage(role='user', content="What's the weather?")
|
|
||||||
],
|
|
||||||
mockTools={
|
|
||||||
"weather_lookup": "The weather in any city is sunny and 25°C.",
|
|
||||||
"calculator": "The result of any calculation is 42."
|
|
||||||
}
|
|
||||||
)
|
|
||||||
print(result.turn.output[-1].content)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Message Types
|
|
||||||
|
|
||||||
You can use different message types as defined in `rowboat.schema`, such as `UserMessage`, `SystemMessage`, `AssistantMessage`, etc. See `schema.py` for all available message types.
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
If the API returns a non-200 status code, a `ValueError` will be raised with the error details.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
For more advanced usage, see the docstrings in `client.py` and the message schemas in `schema.py`.
|
|
||||||
|
|
@ -1,59 +1,55 @@
|
||||||
---
|
---
|
||||||
title: "Contribution Guide"
|
title: "Contribution Guide"
|
||||||
description: "Learn how to contribute to the Rowboat project and help improve our platform."
|
description: "How to contribute to Rowboat — from bug reports to pull requests."
|
||||||
icon: "github"
|
icon: "github"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Join the Rowboat Voyage
|
# Contributing to Rowboat
|
||||||
|
|
||||||
We're building Rowboat as an open-source, community-powered platform — and we'd love for you to hop aboard! Whether you're fixing typos, suggesting a new tool integration, or designing your dream multi-agent workflow, your contributions are valuable and welcome.
|
Rowboat is open-source and we welcome contributions of all kinds — bug reports, feature ideas, code, and docs improvements.
|
||||||
|
|
||||||
<Frame>
|
**Quick links:**
|
||||||
<img src="/docs/img/contribution-guide-hero.png" className="w-full max-w-[600px] rounded-xl" alt="Contribution guide hero image showing community collaboration" />
|
- [GitHub Repository](https://github.com/rowboatlabs/rowboat)
|
||||||
</Frame>
|
- [Discord Community](https://discord.gg/wajrgmJQ6b)
|
||||||
|
- [Open Issues](https://github.com/rowboatlabs/rowboat/issues)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How You Can Contribute
|
## Ways to Contribute
|
||||||
|
|
||||||
|
**Report bugs or suggest improvements** — If something feels off or could be better, [open an issue](https://github.com/rowboatlabs/rowboat/issues/new). Include steps to reproduce for bugs, or a clear description of the improvement you have in mind.
|
||||||
|
|
||||||
- **Tackle Open Issues**
|
**Fix an existing issue** — Browse [open issues](https://github.com/rowboatlabs/rowboat/issues) and comment on one to let us know you're working on it.
|
||||||
Browse our [GitHub Issues](https://github.com/rowboatlabs/rowboat/issues) for tags like `good first issue`, `help wanted`, or `bug` to find a spot that fits your skillset.
|
|
||||||
|
|
||||||
- **Join the Community**
|
**Propose a new feature or integration** — Open an issue first so we can discuss the approach before you invest time building it.
|
||||||
Our [Discord](https://discord.gg/rxB8pzHxaS) is the go-to hub for brainstorming, feedback, and finding contributors for bigger efforts.
|
|
||||||
|
|
||||||
- **Propose Something New**
|
|
||||||
Have a new tool integration idea or found a bug? Open an issue and let’s discuss it!
|
|
||||||
|
|
||||||
|
**Improve documentation** — Typos, unclear explanations, missing examples — all fair game.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contribution Workflow & Best Practices
|
## Contribution Workflow
|
||||||
|
|
||||||
Whether it's your first contribution or your fiftieth, here's what a great contribution looks like:
|
|
||||||
|
|
||||||
| Step / Tip | Description |
|
|
||||||
|-------------------------------|-----------------------------------------------------------------------------------------------|
|
|
||||||
| **1. Fork the Repository** | Create a personal copy of [rowboatlabs/rowboat](https://github.com/rowboatlabs/rowboat) to start contributing. |
|
|
||||||
| **2. Create a New Branch** | Use a descriptive name like `fix-tool-crash` or `feature-new-mcp`. Avoid committing to `main`. |
|
|
||||||
| **3. Make Your Changes** | Focus your PR on a single issue or feature to keep things clean and reviewable. |
|
|
||||||
| **4. Write Tests if Needed** | If you change logic, add relevant tests so your contribution is future-proof. |
|
|
||||||
| **5. Run Tests & Lint Locally**| Make sure your branch passes all tests and code quality checks before pushing. |
|
|
||||||
| **6. Document or Demo It** | Add helpful context: screenshots, example scripts, or a short video/gif to demonstrate your changes. |
|
|
||||||
| **7. Submit a Pull Request** | Open a PR with a clear description of your changes and any context reviewers should know. |
|
|
||||||
| **8. Collaborate on Feedback** | Our maintainers may leave comments — it’s all part of the process. Let’s improve it together. |
|
|
||||||
| **9. Don’t Be Shy to Follow Up** | Feel free to ping the PR/issue if you're waiting on feedback. We appreciate polite nudges. |
|
|
||||||
| **10. Celebrate the Merge!** | You just made Rowboat better. Thanks for contributing 🚀 |
|
|
||||||
|
|
||||||
<Tip>If you're fixing typos, spacing, or small tweaks — try bundling those into a related PR instead of sending them standalone. It helps keep reviews focused. </Tip>
|
|
||||||
|
|
||||||
|
1. **Fork** [rowboatlabs/rowboat](https://github.com/rowboatlabs/rowboat) and clone it locally.
|
||||||
|
2. **Create a branch from `dev`** with a descriptive name (`fix-tool-crash`, `feature-mcp-discovery`).
|
||||||
|
3. **Make your changes.** Keep PRs focused on a single issue or feature.
|
||||||
|
4. **Test your changes** locally before submitting.
|
||||||
|
5. **Open a pull request against `dev`** (not `main`) with a clear description of what you changed and why. Screenshots or short demos are appreciated for UI changes.
|
||||||
|
6. **Respond to feedback** — maintainers may request changes. This is normal and collaborative.
|
||||||
|
7. **Merge!** Once approved, we'll merge your PR.
|
||||||
|
|
||||||
|
<Tip>For small fixes like typos or formatting, try bundling related changes into a single PR rather than submitting them individually.</Tip>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Come Build With Us
|
## Guidelines
|
||||||
|
|
||||||
We believe great ideas come from the community — and that means **you**. Whether you're an engineer, designer, AI tinkerer, or curious beginner, there’s room on this boat for everyone.
|
- **One PR, one concern.** Don't mix unrelated changes in the same pull request.
|
||||||
|
- **Write clear commit messages.** A reviewer should understand what changed from the message alone.
|
||||||
|
- **Follow existing code style.** Match the patterns you see in the codebase.
|
||||||
|
- **Be patient and respectful.** We review PRs as quickly as we can. A polite ping on Discord is always welcome if things go quiet.
|
||||||
|
|
||||||
Let’s build the future of AI workflows — together. 🫶
|
---
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
If you're stuck or unsure about anything, drop a message in our [Discord](https://discord.gg/wajrgmJQ6b). We're happy to help you get unblocked.
|
||||||
|
|
@ -1,60 +1,96 @@
|
||||||
---
|
---
|
||||||
title: "Introduction"
|
title: "Introduction"
|
||||||
description: "Welcome to the official Rowboat documentation! Rowboat is a low-code AI IDE to build tool connected multi-agent assistants. Rowboat copilot builds the agents for you based on your requirements with the option do everything manually as well."
|
description: "Welcome to the official Rowboat documentation! Rowboat is an open-source AI coworker that turns work into a knowledge graph and acts on it."
|
||||||
icon: "book-open"
|
icon: "book-open"
|
||||||
---
|
---
|
||||||
|
[](https://www.youtube.com/watch?v=5AWoGo-L16I)
|
||||||
<Frame>
|
|
||||||
<img src="../videos/Intro-Video.gif" alt="Intro Video" />
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
## What is RowBoat?
|
|
||||||
**RowBoat is a state-of-art platform to build multi-agent AI systems in a visual interface, with the help of a copilot.**
|
|
||||||
|
|
||||||
RowBoat enables you to build, manage and deploy user-facing assistants. An assistant is made up of multiple agents, each having access to a set of tools and working together to interact with the user as a single assistant. You can connect any tool to the agents.
|
|
||||||
|
|
||||||
For example, you can build a *meeting prep assistant* that helps you prepare for upcoming meetings. One agent can access your Google Calendar to see your scheduled meetings, another agent can research the meeting attendees (such as finding their LinkedIn profiles or recent news), and a third agent can compile this research and send it to your email before the meeting. This way, you get automated, personalized meeting prep without manual effort.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How RowBoat works
|
## What is Rowboat?
|
||||||
|
Rowboat is a local-first AI coworker, with work memory. Rowboat connects to your email and meeting notes, builds a long-lived knowledge graph, and uses that context to help you get work done - privately, on your machine.
|
||||||
|
|
||||||
### RowBoat Studio
|
You can do things like:
|
||||||
RowBoat Studio lets you create AI agents in minutes, using a visual interface and plain language.
|
- `Build me a deck about our next quarter roadmap` → generates a PDF using context from your knowledge graph
|
||||||
There are key components that you will work with:
|
- `Prep me for my meeting with Alex` → pulls past decisions, open questions, and relevant threads into a crisp brief (or a voice note)
|
||||||
- Agents
|
- Visualize, edit, and update your knowledge graph anytime (it’s just Markdown)
|
||||||
- Playground
|
- Record voice memos that automatically capture and update key takeaways in the graph
|
||||||
- Copilot
|
|
||||||
|
|
||||||
|
|
||||||
<Card title="Using Rowboat" icon="puzzle-piece" horizontal href="/docs/using-rowboat/rowboat-studio">
|
|
||||||
Learn about Rowboat Studio and key concepts used in building assistants
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
|
|
||||||
### RowBoat Chat API & SDK
|
|
||||||
- [RowBoat Chat API](/docs/api-sdk/using_the_api) is a stateless HTTP API to interface with the assistant created on RowBoat Studio. You can use the API to drive end-user facing conversations in your app or website.
|
|
||||||
- [RowBoat Chat SDK](/docs/api-sdk/using_the_sdk) is a simple Python SDK which wraps the HTTP API under the hood. It provides a clean interface for managing conversations using conversation IDs for state management.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Why RowBoat?
|
## What it does
|
||||||
Rowboat is the fastest way to build and deploy multi-agent assistants.
|
|
||||||
|
|
||||||
<Steps>
|
Rowboat is a **local-first AI coworker** that can:
|
||||||
<Step title="Build complex assistants">
|
- **Remember** the important context you don’t want to re-explain (people, projects, decisions, commitments)
|
||||||
Use plain language and a powerful visual interface to design and orchestrate multi-agent assistants with ease.
|
- **Understand** what’s relevant right now (before a meeting, while replying to an email, when writing a doc)
|
||||||
</Step>
|
- **Help you act** by drafting, summarizing, planning, and producing real artifacts (briefs, emails, docs, PDF slides)
|
||||||
|
|
||||||
<Step title="Integrate tools and MCP servers">
|
Under the hood, Rowboat maintains an **Obsidian-compatible vault** of plain Markdown notes with backlinks — a transparent “working memory” you can inspect and edit.
|
||||||
Add tools and connect to MCP servers in just minutes — no complex setup required.
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Expedite your AI roadmap">
|
## Integrations
|
||||||
Accelerate development with battle-tested tooling tailored for building production-ready, multi-agent AI systems.
|
|
||||||
</Step>
|
|
||||||
</Steps>
|
|
||||||
|
|
||||||
|
Rowboat builds memory from the work you already do, including:
|
||||||
|
- **Gmail** (email)
|
||||||
|
- **Granola** (meeting notes)
|
||||||
|
- **Fireflies** (meeting notes)
|
||||||
|
|
||||||
|
## How it’s different
|
||||||
|
|
||||||
|
Most AI tools reconstruct context on demand by searching transcripts or documents.
|
||||||
|
|
||||||
|
Rowboat maintains **long-lived knowledge** instead:
|
||||||
|
- context accumulates over time
|
||||||
|
- relationships are explicit and inspectable
|
||||||
|
- notes are editable by you, not hidden inside a model
|
||||||
|
- everything lives on your machine as plain Markdown
|
||||||
|
|
||||||
|
The result is memory that compounds, rather than retrieval that starts cold every time.
|
||||||
|
|
||||||
|
## What you can do with it
|
||||||
|
|
||||||
|
- **Meeting prep** from prior decisions, threads, and open questions
|
||||||
|
- **Email drafting** grounded in history and commitments
|
||||||
|
- **Docs & decks** generated from your ongoing context (including PDF slides)
|
||||||
|
- **Follow-ups**: capture decisions, action items, and owners so nothing gets dropped
|
||||||
|
- **On-your-machine help**: create files, summarize into notes, and run workflows using local tools (with explicit, reviewable actions)
|
||||||
|
|
||||||
|
## Background agents
|
||||||
|
|
||||||
|
Rowboat can spin up **background agents** to do repeatable work automatically - so routine tasks happen without you having to ask every time.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Draft email replies in the background (grounded in your past context and commitments)
|
||||||
|
- Generate a daily voice note each morning (agenda, priorities, upcoming meetings)
|
||||||
|
- Create recurring project updates from the latest emails/notes
|
||||||
|
- Keep your knowledge graph up to date as new information comes in
|
||||||
|
|
||||||
|
You control what runs, when it runs, and what gets written back into your local Markdown vault.
|
||||||
|
|
||||||
|
## Bring your own model
|
||||||
|
|
||||||
|
Rowboat works with the model setup you prefer:
|
||||||
|
- **Local models** via Ollama or LM Studio
|
||||||
|
- **Hosted models** (bring your own API key/provider)
|
||||||
|
- Swap models anytime — your data stays in your local Markdown vault
|
||||||
|
|
||||||
|
## Extend Rowboat with tools (MCP)
|
||||||
|
|
||||||
|
Rowboat can connect to external tools and services via **Model Context Protocol (MCP)**.
|
||||||
|
That means you can plug in (for example) search, databases, CRMs, support tools, and automations - or your own internal tools.
|
||||||
|
|
||||||
|
Examples: Exa (web search), Twitter/X, ElevenLabs (voice), Slack, Linear/Jira, GitHub, and more.
|
||||||
|
|
||||||
|
## Local-first by design
|
||||||
|
|
||||||
|
- All data is stored locally as plain Markdown
|
||||||
|
- No proprietary formats or hosted lock-in
|
||||||
|
- You can inspect, edit, back up, or delete everything at any time
|
||||||
|
|
||||||
|
---
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[Discord](https://discord.gg/wajrgmJQ6b) · [Twitter](https://x.com/intent/user?screen_name=rowboatlabshq)
|
||||||
|
</div>
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
@ -72,7 +108,7 @@ Need help using Rowboat? Join our community!
|
||||||
<Card
|
<Card
|
||||||
title="Discord"
|
title="Discord"
|
||||||
icon="discord"
|
icon="discord"
|
||||||
horizontal href="https://discord.gg/rxB8pzHxaS"
|
horizontal href="https://discord.gg/wajrgmJQ6b"
|
||||||
>
|
>
|
||||||
Join our growing discord community and interact with hundreds of developer using Rowboat!
|
Join our growing discord community and interact with hundreds of developer using Rowboat!
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -3,92 +3,25 @@ title: "Quickstart"
|
||||||
description: "guide to getting started with rowboat"
|
description: "guide to getting started with rowboat"
|
||||||
icon: "rocket"
|
icon: "rocket"
|
||||||
---
|
---
|
||||||
---
|
**Download latest for Mac/Windows/Linux:** [Download](https://www.rowboatlabs.com/downloads)
|
||||||
# Cloud Setup
|
|
||||||
|
|
||||||
Using the open-source version of Rowboat requires more technical skill to set up and navigate. For the smoothest experience, we recommend using our [hosted version](https://dev.rowboatlabs.com/)
|
**All release files:** https://github.com/rowboatlabs/rowboat/releases/latest
|
||||||
|
|
||||||
---
|
## Google setup (optional)
|
||||||
|
To connect Gmail, Calendar, and Drive, follow [Google setup](https://github.com/rowboatlabs/rowboat/blob/main/google-setup.md).
|
||||||
|
|
||||||
# Local Setup
|
## Voice notes (optional)
|
||||||
|
To enable voice notes, add a Deepgram API key in `~/.rowboat/config/deepgram.json`:
|
||||||
|
|
||||||
<Note>Pre-requisite: Ensure Docker is installed on your machine. You'll also need an OpenAI account and API key to use the Copilot and agents.</Note>
|
```json
|
||||||
|
{
|
||||||
|
"apiKey": "<key>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Web search (optional)
|
||||||
|
To use Brave web search, add the Brave API key in `~/.rowboat/config/brave-search.json`.
|
||||||
|
|
||||||
<Steps>
|
To use Exa research search, add the Exa API key in `~/.rowboat/config/exa-search.json`.
|
||||||
<Step title="Set your OpenAI key">
|
|
||||||
Export your OpenAI API key in your terminal:
|
|
||||||
|
|
||||||
```bash
|
(Use the same JSON format as above.)
|
||||||
export OPENAI_API_KEY=your-openai-api-key
|
|
||||||
```
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Set up Composio for tools and triggers (optional)">
|
|
||||||
To use external tools and triggers, export your Composio API key:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export COMPOSIO_API_KEY=your-composio-api-key
|
|
||||||
export COMPOSIO_TRIGGERS_WEBHOOK_SECRET=your-webhook-secret
|
|
||||||
```
|
|
||||||
|
|
||||||
<Note>For more detailed setup instructions, see the [Triggers](/docs/using-rowboat/triggers#local-setup) page.</Note>
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Clone the repository and start Rowboat Docker">
|
|
||||||
Clone the Rowboat repository and start the app using Docker:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone git@github.com:rowboatlabs/rowboat.git
|
|
||||||
cd rowboat
|
|
||||||
./start.sh
|
|
||||||
```
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Access the app">
|
|
||||||
Once Docker is running, open your browser and go to:
|
|
||||||
|
|
||||||
[http://localhost:3000](http://localhost:3000)
|
|
||||||
</Step>
|
|
||||||
</Steps>
|
|
||||||
|
|
||||||
|
|
||||||
<Info>See the [Using custom LLM providers](#using-custom-llm-providers) section below for using custom providers like OpenRouter and LiteLLM. </Info>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Demo
|
|
||||||
{/* (would be better to change this to a Getiing Started Tutorial) */}
|
|
||||||
|
|
||||||
#### Create a multi-agent assistant with MCP tools by chatting with Rowboat
|
|
||||||
[](https://youtu.be/YRTCw9UHRbU)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Integrate with Rowboat agents
|
|
||||||
|
|
||||||
There are 2 ways to integrate with the agents you create in Rowboat
|
|
||||||
|
|
||||||
<Columns cols={2}>
|
|
||||||
|
|
||||||
<Card title="Using the API" icon="code" horizontal href="/docs/api-sdk/using_the_api">
|
|
||||||
Guide on using the HTTP API
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Using the SDK" icon="toolbox" horizontal href="/docs/api-sdk/using_the_sdk">
|
|
||||||
Guide on using the Python SDK
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
</Columns>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Using custom LLM providers
|
|
||||||
By default, Rowboat uses OpenAI LLMs (gpt-4o, gpt-4.1, etc.) for both agents and copilot, when you export your OPENAI_API_KEY.
|
|
||||||
|
|
||||||
However, you can also configure custom LLM providers (e.g. LiteLLM, OpenRouter) to use any of the hundreds of available LLMs beyond OpenAI, such as Claude, DeepSeek, Ollama LLMs and so on.
|
|
||||||
|
|
||||||
Check out our page on customising
|
|
||||||
<Card title="Customise" icon="sliders" horizontal href="/docs/using-rowboat/customise">
|
|
||||||
Learn more about customising your Rowboat experience here
|
|
||||||
</Card>
|
|
||||||
|
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
---
|
|
||||||
title: "Agents"
|
|
||||||
description: "Learn about creating and configuring individual agents within your multi-agent system"
|
|
||||||
icon: "robot"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Agents are the core building blocks of Rowboat's multi-agent system. Each agent carries out a specific part of a conversation, handles tasks via tools, and can collaborate with other agents to orchestrate complex workflows.
|
|
||||||
|
|
||||||
They are powered by LLMs and can:
|
|
||||||
- Respond to user input
|
|
||||||
- Trigger tools or APIs
|
|
||||||
- Pass control to other agents using @mentions
|
|
||||||
- Fetch or process internal data
|
|
||||||
- Execute RAG (Retrieval-Augmented Generation) queries
|
|
||||||
- Participate in sequential pipeline workflows
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agent Types
|
|
||||||
|
|
||||||
Rowboat supports several types of agents, each designed for specific use cases:
|
|
||||||
|
|
||||||
| Name | Purpose | Characteristics |
|
|
||||||
|------|---------|-----------------|
|
|
||||||
| **Conversational Agents** (`conversation`) | Primary user-facing agents that interact directly with users and orchestrate workflows. | • Can respond to users and orchestrate workflows<br />• Typically serve as the start agent (Hub Agent)|
|
|
||||||
| **Task Agents** (`internal`) | Specialized agents that perform specific tasks without direct user interaction. | • Focused on specific functions<br />• Return results to parent agents|
|
|
||||||
| **Pipeline Agents** (`pipeline`) | Sequential workflow execution agents that process data in a chain. | • Execute in sequence within a pipeline<br />• Cannot transfer to other agents directly|
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agent Configuration
|
|
||||||
|
|
||||||
Agents are configured through two main tabs in the Rowboat Studio interface:
|
|
||||||
|
|
||||||
### **Instructions Tab**
|
|
||||||
|
|
||||||
|
|
||||||
#### Description
|
|
||||||
A clear description of the agent's role and responsibilities
|
|
||||||
|
|
||||||
#### Instructions
|
|
||||||
Instructions are the backbone of the agent's behavior. Use the Copilot's structured format for consistency:
|
|
||||||
|
|
||||||
**Recommended Structure:**
|
|
||||||
```
|
|
||||||
## 🧑💼 Role:
|
|
||||||
[Clear description of the agent's role]
|
|
||||||
|
|
||||||
## ⚙️ Steps to Follow:
|
|
||||||
1. [Step 1]
|
|
||||||
2. [Step 2]
|
|
||||||
3. [Step 3]
|
|
||||||
|
|
||||||
## 🎯 Scope:
|
|
||||||
✅ In Scope:
|
|
||||||
- [What the agent should handle]
|
|
||||||
|
|
||||||
❌ Out of Scope:
|
|
||||||
- [What the agent should NOT handle]
|
|
||||||
|
|
||||||
## 📋 Guidelines:
|
|
||||||
✔️ Dos:
|
|
||||||
- [Positive behaviors]
|
|
||||||
|
|
||||||
🚫 Don'ts:
|
|
||||||
- [Negative behaviors]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Examples
|
|
||||||
These help agents behave correctly in specific situations. Each example can include:
|
|
||||||
- A sample user message
|
|
||||||
- The expected agent response
|
|
||||||
- Any tool calls (if applicable)
|
|
||||||
|
|
||||||
### **Configurations Tab**
|
|
||||||
|
|
||||||
#### Name
|
|
||||||
Name of the agent
|
|
||||||
|
|
||||||
|
|
||||||
#### Behaviour
|
|
||||||
- **Agent Type**: Choose from `conversation`, `internal`, or `pipeline`
|
|
||||||
- **Model**: Select the LLM model (GPT-4.1, GPT-4o, google/gemini-2.5-flash, etc.)
|
|
||||||
|
|
||||||
#### RAG
|
|
||||||
- **Add Source**: Connect data sources to enable RAG capabilities for the agent
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Creating Your Initial Set of Agents
|
|
||||||
|
|
||||||
Let Copilot bootstrap your agent graph.
|
|
||||||
|
|
||||||
### Instruct Copilot
|
|
||||||
|
|
||||||
Start by telling Copilot what your assistant is meant to do — it'll generate an initial set of agents with best-practice instructions, role definitions, and connected agents.
|
|
||||||
|
|
||||||
<Frame>
|
|
||||||
<img src="/docs/img/create-agents-delivery.png" className="w-full max-w-[400px] rounded-xl" alt="Creating agents with Copilot" />
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
### Inspect the Output
|
|
||||||
|
|
||||||
After applying the suggested agents, take a close look at each one's:
|
|
||||||
- **Instructions**: Define how the agent behaves
|
|
||||||
- **Examples**: Guide agent responses and tool use
|
|
||||||
|
|
||||||
<Frame>
|
|
||||||
<img src="/docs/img/agent-instruction.png" alt="Inspect agent instructions" />
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Updating Agent Behavior
|
|
||||||
|
|
||||||
There are three ways to update an agent:
|
|
||||||
|
|
||||||
### 1. With Copilot
|
|
||||||
|
|
||||||
Copilot understands the current chat context and can help rewrite or improve an agent's behavior based on how it performed.
|
|
||||||
|
|
||||||
<Frame>
|
|
||||||
<img src="/docs/img/update-agent-copilot.png" className="w-full max-w-[400px] rounded-xl" alt="Update agent using Copilot" />
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 2. Manual Edits
|
|
||||||
|
|
||||||
You can always manually edit the agent's instructions.
|
|
||||||
|
|
||||||
<Frame>
|
|
||||||
<img src="/docs/img/update-agent-manual.png" alt="Manually edit agent" />
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
---
|
|
||||||
title: "Conversations"
|
|
||||||
description: "View and manage all conversations with your Rowboat agents"
|
|
||||||
icon: "list-check"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Conversations page in Rowboat shows you all the interactions between users and your agents. Here you can monitor conversations, view detailed message exchanges, and understand how your agents are performing.
|
|
||||||
|
|
||||||
<Frame>
|
|
||||||
<img src="/docs/img/conversations-ui.png" className="w-full max-w-[800px] rounded-xl" alt="Conversations page UI showing list of conversations" />
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
## What You'll See
|
|
||||||
|
|
||||||
The Conversations page displays a list of all conversations organized by time:
|
|
||||||
|
|
||||||
- **Today**: Recent conversations from today
|
|
||||||
- **This week**: Conversations from the current week
|
|
||||||
- **This month**: Conversations from the current month
|
|
||||||
- **Older**: Conversations from previous months
|
|
||||||
|
|
||||||
Each conversation shows:
|
|
||||||
- **Conversation ID**: Unique identifier for the conversation
|
|
||||||
- **Created time**: When the conversation started
|
|
||||||
- **Reason**: What triggered the conversation (chat or job)
|
|
||||||
|
|
||||||
## Viewing Conversation Details
|
|
||||||
Click on any conversation to see the detailed view with all the message exchanges:
|
|
||||||
|
|
||||||
<Frame>
|
|
||||||
<img src="/docs/img/conversations-inside-run.png" className="w-full max-w-[800px] rounded-xl" alt="Conversation details showing expanded view of a conversation with turns and messages" />
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
**Conversation Metadata**: Shows the Conversation ID, creation time, and last update time.
|
|
||||||
|
|
||||||
**Workflow**: Shows the workflow JSON
|
|
||||||
|
|
||||||
**Turns**: Each conversation is made up of turns, where:
|
|
||||||
- **Turn #1, #2, etc.**: Numbered sequence of interactions
|
|
||||||
- **Reason badge**: Shows why each turn happened (chat, API, job, etc.)
|
|
||||||
- **Timestamp**: When each turn occurred
|
|
||||||
- **Input messages**: What was sent to your agents
|
|
||||||
- **Output messages**: What your agents responded with
|
|
||||||
|
|
||||||
### Turn Details
|
|
||||||
|
|
||||||
Each turn displays:
|
|
||||||
- **Input**: The messages sent to your agents (user messages, system messages)
|
|
||||||
- **Output**: The responses from your agents
|
|
||||||
- **Error information**: Any issues that occurred during processing
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
---
|
|
||||||
title: "Custom LLMs"
|
|
||||||
description: "How to use and configure custom LLMs in Rowboat."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<Note> This is currently only possible in the self hosted version of Rowboat</Note>
|
|
||||||
|
|
||||||
## Using custom LLM providers
|
|
||||||
|
|
||||||
By default, Rowboat uses OpenAI LLMs (gpt-4o, gpt-4.1, etc.) for both agents and copilot, when you export your OPENAI_API_KEY.
|
|
||||||
|
|
||||||
However, you can also configure custom LLM providers (e.g. LiteLLM, OpenRouter) to use any of the hundreds of available LLMs beyond OpenAI, such as Claude, DeepSeek, Ollama LLMs and so on.
|
|
||||||
|
|
||||||
<Steps>
|
|
||||||
<Step title="Set up your LLM provider">
|
|
||||||
Configure your environment variables to point to your preferred LLM backend. Example using LiteLLM:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export PROVIDER_BASE_URL=http://host.docker.internal:4000/
|
|
||||||
export PROVIDER_API_KEY=sk-1234
|
|
||||||
```
|
|
||||||
|
|
||||||
Rowboat uses <code>gpt-4.1</code> as the default model for agents and copilot. You can override these:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export PROVIDER_DEFAULT_MODEL=claude-3-7-sonnet-latest
|
|
||||||
export PROVIDER_COPILOT_MODEL=gpt-4.1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Notes:**
|
|
||||||
- Copilot is optimized for <code>gpt-4o</code>/<code>gpt-4.1</code>. We strongly recommend using these models for best results.
|
|
||||||
- You can use different models for the copilot and each agent, but all must be from the same provider (e.g., LiteLLM).
|
|
||||||
- Rowboat is provider-agnostic — any backend implementing the OpenAI messages format should work.
|
|
||||||
- OpenAI-specific tools (like <code>web_search</code>) will not function with non-OpenAI providers. Remove such tools to avoid errors.
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Clone the repository and start Rowboat Docker">
|
|
||||||
Clone the Rowboat repo and spin it up locally:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone git@github.com:rowboatlabs/rowboat.git
|
|
||||||
cd rowboat
|
|
||||||
docker-compose up --build
|
|
||||||
```
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Access the app">
|
|
||||||
Once Docker is running, navigate to:
|
|
||||||
|
|
||||||
[http://localhost:3000](http://localhost:3000)
|
|
||||||
</Step>
|
|
||||||
</Steps>
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
---
|
|
||||||
title: "Jobs"
|
|
||||||
description: "Monitor and inspect all your trigger executions and job runs"
|
|
||||||
icon: "message"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Jobs page in Rowboat provides a comprehensive view of all your automated job executions. Here you can monitor the status of your triggers, inspect what happened during each run, and troubleshoot any issues that may have occurred.
|
|
||||||
|
|
||||||
<Frame>
|
|
||||||
<img src="/docs/img/jobs-ui.png" className="w-full max-w-[800px] rounded-xl" alt="Jobs page showing list of all job runs with status indicators" />
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
## What You'll See
|
|
||||||
|
|
||||||
The Jobs page displays a list of all job runs from your triggers, including:
|
|
||||||
|
|
||||||
- **External trigger executions** from webhook events
|
|
||||||
- **One-time trigger runs** from scheduled jobs
|
|
||||||
- **Recurring trigger executions** from cron-based schedules
|
|
||||||
|
|
||||||
Each job run displays the following key information:
|
|
||||||
- **Job ID**: Unique identifier for the job run
|
|
||||||
- **Status**: Indicates if the job succeeded, failed, or is in progress
|
|
||||||
- **Reason**: The trigger or cause for the job (e.g., external trigger, scheduled, cron)
|
|
||||||
- **Created Time**: When the job was executed
|
|
||||||
|
|
||||||
## Viewing Job Details
|
|
||||||
|
|
||||||
### Expand a Job Run
|
|
||||||
|
|
||||||
Click on any job run to expand it and see detailed information about what happened during execution:
|
|
||||||
|
|
||||||
<Frame>
|
|
||||||
<img src="/docs/img/jobs-inside-run.png" className="w-full max-w-[800px] rounded-xl" alt="Job run details showing expanded information for a specific job" />
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
**Basic job details**: Job ID, Status, creation time, Updated time, Conversation ID and Turn ID. By clicking on the Conversation ID, you can view more in-depth details about the run.
|
|
||||||
|
|
||||||
**Job Reason**: Why the job triggered - either external trigger, scheduled, or cron.
|
|
||||||
|
|
||||||
**Job Input**: The input data sent to your assistant.
|
|
||||||
|
|
||||||
**Job Output**: The final output produced by your agents.
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
---
|
|
||||||
title: "RAG (Data)"
|
|
||||||
description: "How to use our inbuilt RAG"
|
|
||||||
icon: "database"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Using RAG in Rowboat
|
|
||||||
|
|
||||||
Rowboat provides multiple ways to enhance your agents' context with Retrieval-Augmented Generation (RAG). This guide will help you set up and use each RAG feature.
|
|
||||||
|
|
||||||
<Info>RAG is called "Data" on the build view in the Rowboat UI. </Info>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Types of RAG
|
|
||||||
|
|
||||||
| RAG Type | Description | Configuration Required |
|
|
||||||
|----------|-------------|------------------------|
|
|
||||||
| **Text RAG** | Process and reason over text content directly | No configuration needed |
|
|
||||||
| **File Uploads** | Upload PDF files directly from your device | No configuration needed |
|
|
||||||
| **URL Scraping** | Scrape content from web URLs using Firecrawl | Requires API key setup |
|
|
||||||
|
|
||||||
<Note> URL Scraping does not require any setup in the managed version of Rowboat. </Note>
|
|
||||||
|
|
||||||
<Frame>
|
|
||||||
<img src="/docs/img/rag-adding-data.png" className="w-full max-w-[800px] rounded-xl" alt="Adding data sources for RAG in Rowboat" />
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
|
|
||||||
## RAG Features
|
|
||||||
|
|
||||||
### 1. Text RAG
|
|
||||||
|
|
||||||
Process and reason over text content directly
|
|
||||||
|
|
||||||
|
|
||||||
### 2. File Uploads
|
|
||||||
|
|
||||||
- Upload PDF files directly from your device
|
|
||||||
- **Open Source Version**: Files are stored locally on your machine
|
|
||||||
- **Managed Version**: Files are stored in cloud S3 storage
|
|
||||||
- Files are parsed using OpenAI by default
|
|
||||||
|
|
||||||
<Note> You can also use Google's Gemini model for parsing as it is better at parsing larger files. </Note>
|
|
||||||
|
|
||||||
#### 2.1 Using Gemini for File Parsing
|
|
||||||
|
|
||||||
To use Google's Gemini model for parsing uploaded PDFs, set the following variables:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enable Gemini for file parsing
|
|
||||||
export USE_GEMINI_FILE_PARSING=true
|
|
||||||
export GOOGLE_API_KEY=your_google_api_key
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
### 3. URL Scraping
|
|
||||||
|
|
||||||
Rowboat uses Firecrawl for URL scraping. You can have a maximum of 100 URLs.
|
|
||||||
|
|
||||||
**Open Source Version**: To enable URL scraping, set the following variables:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export USE_RAG_SCRAPING=true
|
|
||||||
export FIRECRAWL_API_KEY=your_firecrawl_api_key
|
|
||||||
```
|
|
||||||
|
|
||||||
**Managed Version**: No configuration required - URL scraping is handled automatically.
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
---
|
|
||||||
title: "Rowboat Studio"
|
|
||||||
description: "Visual interface to build, test, and deploy multi-agent AI assistants using plain language"
|
|
||||||
icon: "puzzle-piece"
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
**Rowboat Studio** is your visual interface for building AI assistants — powered by agents, tools, and workflows — using plain language and minimal setup. It brings the process of creating multi-agent systems down to just a few clicks.
|
|
||||||
|
|
||||||
Workflows created within Rowboat are known as **assistants**, and each assistant is composed of:
|
|
||||||
- One or more **agents**
|
|
||||||
- Attached **tools** and **MCP servers**
|
|
||||||
|
|
||||||
Once built, assistants can be tested live in the **playground** and deployed in real-world products using the [API](/docs/api-sdk/using_the_api) or [SDK](/docs/api-sdk//using_the_sdk).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Components
|
|
||||||
|
|
||||||
Here’s what you’ll interact with in Studio:
|
|
||||||
|
|
||||||
| Component | Description | Highlights |
|
|
||||||
|------------|-------------|------------|
|
|
||||||
| **Agent** | Core building blocks of your assistant.<br />Each agent handles a specific part of the conversation and performs tasks using tools and instructions. | • Define behavior in plain language<br />• Connect agents into a graph<br />• Attach tools and RAG sources |
|
|
||||||
| **Playground** | Interactive testbed for conversations.<br />Lets you simulate end-user chats with your assistant and inspect agent behavior in real time. | • Real-time feedback and debugging<br />• See tool calls and agent handoffs<br />• Test individual agents or the whole system |
|
|
||||||
| **Copilot** | Your AI assistant for building assistants.<br />Copilot creates and updates agents, tools, and instructions based on your plain-English prompts. | • Understands full system context<br />• Improves agents based on playground chat<br />• Builds workflows intelligently and fast |
|
|
||||||
|
|
||||||
> **Agents are the heart of every assistant.** Learn more about how they work in the Agents page.
|
|
||||||
|
|
||||||
<Card title="Agents" icon="robot" horizontal href="/docs/using-rowboat/agents">
|
|
||||||
Learn about creating and configuring individual agents within your multi-agent system
|
|
||||||
</Card>
|
|
||||||
---
|
|
||||||
|
|
||||||
## Building in Rowboat
|
|
||||||
|
|
||||||
<Steps>
|
|
||||||
<Step title="Describe your assistant to Copilot">
|
|
||||||
Use plain language to tell Copilot what you want your assistant to do. Copilot will auto-generate the agents, instructions, and tools that form the base of your assistant.
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Review and apply Copilot suggestions">
|
|
||||||
Inspect the created agents — especially their instructions and examples — and refine or approve them before moving forward.
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Connect MCP servers and tools">
|
|
||||||
Integrate external services, tools, and backend logic into your agents using Rowboat's modular system. Tools are tied to agents and triggered through instructions.
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Test in the Playground">
|
|
||||||
Use the chat playground to simulate real-world conversations. You’ll see which agent takes control, what tools are triggered, and how your assistant flows.
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Deploy via API or SDK">
|
|
||||||
Assistants can be deployed into production using the **Rowboat Chat API** or the **Rowboat Chat SDK**. Both support stateless and stateful conversation flows.
|
|
||||||
</Step>
|
|
||||||
</Steps>
|
|
||||||
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
---
|
|
||||||
title: "Tools"
|
|
||||||
description: "Add and configure tools for your agents to interact with external services"
|
|
||||||
icon: "wrench"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Tools page in Rowboat lets you add and configure tools that your agents can use to interact with external services, APIs, and systems. Tools enable your agents to perform real-world actions like sending emails, managing calendars, or processing payments.
|
|
||||||
|
|
||||||
<Frame>
|
|
||||||
<img src="/docs/img/tools-ui.png" alt="Screenshot of the Tools UI in Rowboat" className="w-full max-w-[800px] rounded-xl shadow" />
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
## Tool Types
|
|
||||||
|
|
||||||
| Tool Type | Description | Use Case | Availability |
|
|
||||||
|-----------|-------------|----------|--------------|
|
|
||||||
| **Library Tools** | Pre-built integrations with popular services | Quick setup, no configuration needed | Managed and open source |
|
|
||||||
| **MCP Tools** | Custom tools from MCP servers | Custom functionality, specialized APIs | Managed and open source |
|
|
||||||
| **Webhook Tools** | HTTP endpoints for custom integrations | Your own systems, custom workflows | Open source only |
|
|
||||||
|
|
||||||
|
|
||||||
## Library (Composio Tools)
|
|
||||||
|
|
||||||
- Browse a library of 500+ toolkits from popular services
|
|
||||||
- With 3000+ tools to choose from!
|
|
||||||
- Click on a service to see available tools and add them to your workflow
|
|
||||||
- Users must create a [Composio](https://composio.dev/) account and add their API key
|
|
||||||
- Tools require authorization to work properly
|
|
||||||
|
|
||||||
### Setting up Composio API Key
|
|
||||||
|
|
||||||
To use Composio tools, get a Composio key and export it as an environment variable:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export COMPOSIO_API_KEY=your_api_key_here
|
|
||||||
```
|
|
||||||
|
|
||||||
<Note>Users can visit [Composio's toolkit documentation](https://docs.composio.dev/toolkits/introduction) for a deep dive into all the tools available.</Note>
|
|
||||||
|
|
||||||
## Custom MCP Servers
|
|
||||||
|
|
||||||
- Add your own MCP (Model Context Protocol) servers
|
|
||||||
- Connect to custom tools and APIs you've built
|
|
||||||
- Configure server URLs and authentication
|
|
||||||
- Import tools from your MCP servers
|
|
||||||
|
|
||||||
## Webhook
|
|
||||||
|
|
||||||
<Note>Webhook tools are only available in the open source (local) version of Rowboat.</Note>
|
|
||||||
|
|
||||||
- Create custom webhook tools
|
|
||||||
- Configure HTTP endpoints for your agents to call
|
|
||||||
- Set up custom authentication and parameters
|
|
||||||
- Build integrations with your own systems
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
---
|
|
||||||
title: "Triggers"
|
|
||||||
description: "Learn about setting up automated triggers for your Rowboat agents"
|
|
||||||
icon: "bolt"
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Triggers in Rowboat are automated mechanisms that activate your agents when specific events occur or conditions are met. They form the foundation of your automated workflow system, enabling your agents to respond to external events, scheduled times, and system conditions without manual intervention.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Trigger Types
|
|
||||||
|
|
||||||
Rowboat supports three main categories of triggers, each designed for different automation scenarios:
|
|
||||||
|
|
||||||
| Trigger Type | Purpose | Execution | Use Cases |
|
|
||||||
|--------------|---------|-----------|-----------|
|
|
||||||
| **External Triggers** | Connect to external services and events | Real-time via webhooks | Slack messages, GitHub events, email processing |
|
|
||||||
| **One-Time Triggers** | Execute at specific predetermined times | Single execution at set time | Delayed responses, time-sensitive actions |
|
|
||||||
| **Recurring Triggers** | Execute on repeating schedules | Continuous via cron expressions | Daily reports, periodic maintenance, regular syncs |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## External Triggers (Composio Integration)
|
|
||||||
|
|
||||||
External triggers are powered by **Composio** and allow users to use triggers from across 30+ services including Slack, GitHub, Gmail, Notion, Google Calendar, and more.
|
|
||||||
|
|
||||||
<Frame>
|
|
||||||
<img src="/docs/img/triggers-external-ui.png" className="w-full max-w-[800px] rounded-xl" alt="External Triggers UI showing available toolkits and services" />
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
### Creating External Triggers
|
|
||||||
|
|
||||||
1. **Click New External Trigger**: Start the trigger creation process
|
|
||||||
2. **Select a Toolkit**: Browse available toolkits or search for specific services
|
|
||||||
3. **Choose Trigger Type**: Select the specific trigger from available options, click configure
|
|
||||||
4. **Authenticate**: Complete OAuth2 flow or enter API keys for the selected service (your preferred method)
|
|
||||||
5. **Configure**: Set up event filters and data mapping if required
|
|
||||||
6. **Deploy**: Activate the trigger to start listening for events
|
|
||||||
|
|
||||||
### Local Setup
|
|
||||||
|
|
||||||
If you're running the open source version of Rowboat, you'll need to set up external triggers manually. In the managed version, this is all handled automatically for you.
|
|
||||||
|
|
||||||
<Steps>
|
|
||||||
<Step title="Create a Composio project">
|
|
||||||
Sign into [Composio](https://composio.dev/) and create a new project for your Rowboat instance.
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Get your Composio API key">
|
|
||||||
Go to your project settings and copy the project API key. Export it in your Rowboat environment:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export COMPOSIO_API_KEY=your-composio-api-key
|
|
||||||
```
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Expose your Rowboat port">
|
|
||||||
Use ngrok to expose your local Rowboat instance:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ngrok http 3000
|
|
||||||
```
|
|
||||||
|
|
||||||
Copy the generated ngrok URL (e.g., `https://a5fe8c0d45b8.ngrok-free.app`).
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Configure webhook URL">
|
|
||||||
In Composio, go to Events & Triggers section and set the Trigger Webhook URL to:
|
|
||||||
|
|
||||||
```
|
|
||||||
{ngrok_url}/api/composio/webhook
|
|
||||||
```
|
|
||||||
|
|
||||||
Example: `https://a5fe8c0d45b8.ngrok-free.app/api/composio/webhook`
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Set webhook secret">
|
|
||||||
Copy the Webhook Secret from Composio and export it in Rowboat:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export COMPOSIO_TRIGGERS_WEBHOOK_SECRET=your-webhook-secret
|
|
||||||
```
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Restart Rowboat">
|
|
||||||
Restart your Rowboat instance to load the new environment variables. You're now ready to use external triggers!
|
|
||||||
</Step>
|
|
||||||
</Steps>
|
|
||||||
|
|
||||||
<Note>Make sure your Rowboat assistant is deployed before receiving trigger calls</Note>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## One-Time Triggers (Scheduled Jobs)
|
|
||||||
|
|
||||||
One-time triggers execute your agents at a specific, predetermined time. They're useful for delayed responses, batch processing, time-sensitive actions, or coordinating with external schedules.
|
|
||||||
|
|
||||||
<Frame>
|
|
||||||
<img src="/docs/img/triggers-onetime-ui.png" className="w-full max-w-[800px] rounded-xl" alt="One-Time Triggers UI showing scheduled job configuration" />
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
### Creating One-Time Triggers
|
|
||||||
1. Set the exact execution time (date and time)
|
|
||||||
2. Configure the input messages for your agents
|
|
||||||
3. Deploy to schedule the execution
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recurring Triggers (Cron-based Jobs)
|
|
||||||
|
|
||||||
Recurring triggers execute your agents on a repeating schedule using cron expressions. They're ideal for daily reports, periodic maintenance, regular data syncs, and continuous monitoring tasks.
|
|
||||||
|
|
||||||
<Frame>
|
|
||||||
<img src="/docs/img/triggers-recurring-ui.png" className="w-full max-w-[800px] rounded-xl" alt="Recurring Triggers UI showing cron-based job configuration" />
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
### Creating Recurring Triggers
|
|
||||||
1. Define the cron expression (e.g., `0 9 * * *` for daily at 9 AM)
|
|
||||||
2. Configure the recurring message structure
|
|
||||||
3. Enable the trigger to start the recurring schedule
|
|
||||||
|
|
||||||
### Common Cron Patterns
|
|
||||||
```cron
|
|
||||||
0 9 * * * # Daily at 9:00 AM
|
|
||||||
0 8 * * 1 # Every Monday at 8:00 AM
|
|
||||||
*/15 * * * * # Every 15 minutes
|
|
||||||
0 0 1 * * # First day of month at midnight
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "npx next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ import * as composioHandler from './composio-handler.js';
|
||||||
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
||||||
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
|
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
|
||||||
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
||||||
|
import { search } from '@x/core/dist/search/search.js';
|
||||||
|
import { versionHistory } from '@x/core';
|
||||||
|
|
||||||
type InvokeChannels = ipc.InvokeChannels;
|
type InvokeChannels = ipc.InvokeChannels;
|
||||||
type IPCChannels = ipc.IPCChannels;
|
type IPCChannels = ipc.IPCChannels;
|
||||||
|
|
@ -104,6 +106,18 @@ let watcher: FSWatcher | null = null;
|
||||||
const changeQueue = new Set<string>();
|
const changeQueue = new Set<string>();
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit knowledge commit event to all renderer windows
|
||||||
|
*/
|
||||||
|
function emitKnowledgeCommitEvent(): void {
|
||||||
|
const windows = BrowserWindow.getAllWindows();
|
||||||
|
for (const win of windows) {
|
||||||
|
if (!win.isDestroyed() && win.webContents) {
|
||||||
|
win.webContents.send('knowledge:didCommit', {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emit workspace change event to all renderer windows
|
* Emit workspace change event to all renderer windows
|
||||||
*/
|
*/
|
||||||
|
|
@ -282,6 +296,9 @@ export function stopServicesWatcher(): void {
|
||||||
* Add new handlers here as you add channels to IPCChannels
|
* Add new handlers here as you add channels to IPCChannels
|
||||||
*/
|
*/
|
||||||
export function setupIpcHandlers() {
|
export function setupIpcHandlers() {
|
||||||
|
// Forward knowledge commit events to renderer for panel refresh
|
||||||
|
versionHistory.onCommit(() => emitKnowledgeCommitEvent());
|
||||||
|
|
||||||
registerIpcHandlers({
|
registerIpcHandlers({
|
||||||
'app:getVersions': async () => {
|
'app:getVersions': async () => {
|
||||||
// args is null for this channel (no request payload)
|
// args is null for this channel (no request payload)
|
||||||
|
|
@ -497,5 +514,22 @@ export function setupIpcHandlers() {
|
||||||
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
||||||
return { data: buffer.toString('base64'), mimeType, size: stat.size };
|
return { data: buffer.toString('base64'), mimeType, size: stat.size };
|
||||||
},
|
},
|
||||||
|
// Knowledge version history handlers
|
||||||
|
'knowledge:history': async (_event, args) => {
|
||||||
|
const commits = await versionHistory.getFileHistory(args.path);
|
||||||
|
return { commits };
|
||||||
|
},
|
||||||
|
'knowledge:fileAtCommit': async (_event, args) => {
|
||||||
|
const content = await versionHistory.getFileAtCommit(args.path, args.oid);
|
||||||
|
return { content };
|
||||||
|
},
|
||||||
|
'knowledge:restore': async (_event, args) => {
|
||||||
|
await versionHistory.restoreFile(args.path, args.oid);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
// Search handler
|
||||||
|
'search:query': async (_event, args) => {
|
||||||
|
return search(args.query, args.limit, args.types);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.j
|
||||||
import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js";
|
import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js";
|
||||||
import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js";
|
import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js";
|
||||||
import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js";
|
import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js";
|
||||||
import { init as initPreBuiltRunner } from "@x/core/dist/pre_built/runner.js";
|
|
||||||
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
||||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||||
import started from "electron-squirrel-startup";
|
import started from "electron-squirrel-startup";
|
||||||
|
|
@ -171,9 +170,6 @@ app.whenReady().then(async () => {
|
||||||
// start knowledge graph builder
|
// start knowledge graph builder
|
||||||
initGraphBuilder();
|
initGraphBuilder();
|
||||||
|
|
||||||
// start pre-built agent runner
|
|
||||||
initPreBuiltRunner();
|
|
||||||
|
|
||||||
// start background agent runner (scheduled agents)
|
// start background agent runner (scheduled agents)
|
||||||
initAgentRunner();
|
initAgentRunner();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ async function getProviderConfiguration(provider: string, clientIdOverride?: str
|
||||||
return clientIdOverride;
|
return clientIdOverride;
|
||||||
}
|
}
|
||||||
const oauthRepo = getOAuthRepo();
|
const oauthRepo = getOAuthRepo();
|
||||||
const clientId = await oauthRepo.getClientId(provider);
|
const { clientId } = await oauthRepo.read(provider);
|
||||||
if (clientId) {
|
if (clientId) {
|
||||||
return clientId;
|
return clientId;
|
||||||
}
|
}
|
||||||
|
|
@ -179,9 +179,9 @@ export async function connectProvider(provider: string, clientId?: string): Prom
|
||||||
|
|
||||||
// Build authorization URL
|
// Build authorization URL
|
||||||
const authUrl = oauthClient.buildAuthorizationUrl(config, {
|
const authUrl = oauthClient.buildAuthorizationUrl(config, {
|
||||||
redirectUri: REDIRECT_URI,
|
redirect_uri: REDIRECT_URI,
|
||||||
scope: scopes.join(' '),
|
scope: scopes.join(' '),
|
||||||
codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
state,
|
state,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -212,11 +212,11 @@ export async function connectProvider(provider: string, clientId?: string): Prom
|
||||||
|
|
||||||
// Save tokens
|
// Save tokens
|
||||||
console.log(`[OAuth] Token exchange successful for ${provider}`);
|
console.log(`[OAuth] Token exchange successful for ${provider}`);
|
||||||
await oauthRepo.saveTokens(provider, tokens);
|
await oauthRepo.upsert(provider, { tokens });
|
||||||
if (provider === 'google' && clientId) {
|
if (provider === 'google' && clientId) {
|
||||||
await oauthRepo.setClientId(provider, clientId);
|
await oauthRepo.upsert(provider, { clientId });
|
||||||
}
|
}
|
||||||
await oauthRepo.clearError(provider);
|
await oauthRepo.upsert(provider, { error: null });
|
||||||
|
|
||||||
// Trigger immediate sync for relevant providers
|
// Trigger immediate sync for relevant providers
|
||||||
if (provider === 'google') {
|
if (provider === 'google') {
|
||||||
|
|
@ -281,7 +281,7 @@ export async function connectProvider(provider: string, clientId?: string): Prom
|
||||||
export async function disconnectProvider(provider: string): Promise<{ success: boolean }> {
|
export async function disconnectProvider(provider: string): Promise<{ success: boolean }> {
|
||||||
try {
|
try {
|
||||||
const oauthRepo = getOAuthRepo();
|
const oauthRepo = getOAuthRepo();
|
||||||
await oauthRepo.clearTokens(provider);
|
await oauthRepo.delete(provider);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('OAuth disconnect failed:', error);
|
console.error('OAuth disconnect failed:', error);
|
||||||
|
|
@ -297,7 +297,7 @@ export async function getAccessToken(provider: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const oauthRepo = getOAuthRepo();
|
const oauthRepo = getOAuthRepo();
|
||||||
|
|
||||||
let tokens = await oauthRepo.getTokens(provider);
|
const { tokens } = await oauthRepo.read(provider);
|
||||||
if (!tokens) {
|
if (!tokens) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -306,7 +306,7 @@ export async function getAccessToken(provider: string): Promise<string | null> {
|
||||||
if (oauthClient.isTokenExpired(tokens)) {
|
if (oauthClient.isTokenExpired(tokens)) {
|
||||||
if (!tokens.refresh_token) {
|
if (!tokens.refresh_token) {
|
||||||
// No refresh token, need to reconnect
|
// No refresh token, need to reconnect
|
||||||
await oauthRepo.setError(provider, 'Missing refresh token. Please reconnect.');
|
await oauthRepo.upsert(provider, { error: 'Missing refresh token. Please reconnect.' });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,11 +316,11 @@ export async function getAccessToken(provider: string): Promise<string | null> {
|
||||||
|
|
||||||
// Refresh token, preserving existing scopes
|
// Refresh token, preserving existing scopes
|
||||||
const existingScopes = tokens.scopes;
|
const existingScopes = tokens.scopes;
|
||||||
tokens = await oauthClient.refreshTokens(config, tokens.refresh_token, existingScopes);
|
const refreshedTokens = await oauthClient.refreshTokens(config, tokens.refresh_token, existingScopes);
|
||||||
await oauthRepo.saveTokens(provider, tokens);
|
await oauthRepo.upsert(provider, { tokens });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Token refresh failed';
|
const message = error instanceof Error ? error.message : 'Token refresh failed';
|
||||||
await oauthRepo.setError(provider, message);
|
await oauthRepo.upsert(provider, { error: message });
|
||||||
console.error('Token refresh failed:', error);
|
console.error('Token refresh failed:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -102,7 +102,7 @@ export const Conversation = ({ className, children, ...props }: ConversationProp
|
||||||
* Must be used inside Conversation component.
|
* Must be used inside Conversation component.
|
||||||
*/
|
*/
|
||||||
export const ScrollPositionPreserver = () => {
|
export const ScrollPositionPreserver = () => {
|
||||||
const { isAtBottom } = useStickToBottomContext();
|
const { isAtBottom, scrollRef } = useStickToBottomContext();
|
||||||
const preservationContext = useContext(ScrollPreservationContext);
|
const preservationContext = useContext(ScrollPreservationContext);
|
||||||
const containerFoundRef = useRef(false);
|
const containerFoundRef = useRef(false);
|
||||||
|
|
||||||
|
|
@ -110,29 +110,13 @@ export const ScrollPositionPreserver = () => {
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (containerFoundRef.current || !preservationContext) return;
|
if (containerFoundRef.current || !preservationContext) return;
|
||||||
|
|
||||||
// Find the scroll container (StickToBottom creates one)
|
// Use the local StickToBottom scroll container for this conversation instance.
|
||||||
// It's the first parent with overflow-y scroll/auto
|
const container = scrollRef.current;
|
||||||
const findScrollContainer = (): HTMLElement | null => {
|
|
||||||
const candidates = document.querySelectorAll('[role="log"]');
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
// The scroll container is a direct child of the role="log" element
|
|
||||||
const children = candidate.children;
|
|
||||||
for (const child of children) {
|
|
||||||
const style = window.getComputedStyle(child);
|
|
||||||
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
|
|
||||||
return child as HTMLElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const container = findScrollContainer();
|
|
||||||
if (container) {
|
if (container) {
|
||||||
preservationContext.registerScrollContainer(container);
|
preservationContext.registerScrollContainer(container);
|
||||||
containerFoundRef.current = true;
|
containerFoundRef.current = true;
|
||||||
}
|
}
|
||||||
}, [preservationContext]);
|
}, [preservationContext, scrollRef]);
|
||||||
|
|
||||||
// Track engagement based on scroll position
|
// Track engagement based on scroll position
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,14 @@
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, XCircleIcon, XIcon } from "lucide-react";
|
import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
import { ToolCallPart } from "@x/shared/dist/message.js";
|
import { ToolCallPart } from "@x/shared/dist/message.js";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
@ -11,6 +17,8 @@ import z from "zod";
|
||||||
export type PermissionRequestProps = ComponentProps<"div"> & {
|
export type PermissionRequestProps = ComponentProps<"div"> & {
|
||||||
toolCall: z.infer<typeof ToolCallPart>;
|
toolCall: z.infer<typeof ToolCallPart>;
|
||||||
onApprove?: () => void;
|
onApprove?: () => void;
|
||||||
|
onApproveSession?: () => void;
|
||||||
|
onApproveAlways?: () => void;
|
||||||
onDeny?: () => void;
|
onDeny?: () => void;
|
||||||
isProcessing?: boolean;
|
isProcessing?: boolean;
|
||||||
response?: 'approve' | 'deny' | null;
|
response?: 'approve' | 'deny' | null;
|
||||||
|
|
@ -20,6 +28,8 @@ export const PermissionRequest = ({
|
||||||
className,
|
className,
|
||||||
toolCall,
|
toolCall,
|
||||||
onApprove,
|
onApprove,
|
||||||
|
onApproveSession,
|
||||||
|
onApproveAlways,
|
||||||
onDeny,
|
onDeny,
|
||||||
isProcessing = false,
|
isProcessing = false,
|
||||||
response = null,
|
response = null,
|
||||||
|
|
@ -117,16 +127,40 @@ export const PermissionRequest = ({
|
||||||
</div>
|
</div>
|
||||||
{!isResponded && (
|
{!isResponded && (
|
||||||
<div className="flex items-center gap-2 pt-2">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
<Button
|
<div className="flex flex-1 items-center">
|
||||||
variant="default"
|
<Button
|
||||||
size="sm"
|
variant="default"
|
||||||
onClick={onApprove}
|
size="sm"
|
||||||
disabled={isProcessing}
|
onClick={onApprove}
|
||||||
className="flex-1"
|
disabled={isProcessing}
|
||||||
>
|
className={cn("flex-1", command && "rounded-r-none")}
|
||||||
<CheckIcon className="size-4" />
|
>
|
||||||
Approve
|
<CheckIcon className="size-4" />
|
||||||
</Button>
|
Approve
|
||||||
|
</Button>
|
||||||
|
{command && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="rounded-l-none border-l border-l-primary-foreground/20 px-1.5"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={onApproveSession}>
|
||||||
|
Allow for Session
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onApproveAlways}>
|
||||||
|
Always Allow
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -931,7 +931,13 @@ export const PromptInputTextarea = ({
|
||||||
if (autoFocus || focusTrigger !== undefined) {
|
if (autoFocus || focusTrigger !== undefined) {
|
||||||
// Small delay to ensure the element is fully mounted and visible
|
// Small delay to ensure the element is fully mounted and visible
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
textareaRef.current?.focus();
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
try {
|
||||||
|
textarea.focus({ preventScroll: true });
|
||||||
|
} catch {
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
360
apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx
Normal file
360
apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
ArrowUp,
|
||||||
|
AudioLines,
|
||||||
|
FileArchive,
|
||||||
|
FileCode2,
|
||||||
|
FileIcon,
|
||||||
|
FileSpreadsheet,
|
||||||
|
FileText,
|
||||||
|
FileVideo,
|
||||||
|
LoaderIcon,
|
||||||
|
Plus,
|
||||||
|
Square,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
type AttachmentIconKind,
|
||||||
|
getAttachmentDisplayName,
|
||||||
|
getAttachmentIconKind,
|
||||||
|
getAttachmentToneClass,
|
||||||
|
getAttachmentTypeLabel,
|
||||||
|
} from '@/lib/attachment-presentation'
|
||||||
|
import { getExtension, getFileDisplayName, getMimeFromExtension, isImageMime } from '@/lib/file-utils'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
type FileMention,
|
||||||
|
type PromptInputMessage,
|
||||||
|
PromptInputProvider,
|
||||||
|
PromptInputTextarea,
|
||||||
|
usePromptInputController,
|
||||||
|
} from '@/components/ai-elements/prompt-input'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
export type StagedAttachment = {
|
||||||
|
id: string
|
||||||
|
path: string
|
||||||
|
filename: string
|
||||||
|
mimeType: string
|
||||||
|
isImage: boolean
|
||||||
|
size: number
|
||||||
|
thumbnailUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB
|
||||||
|
|
||||||
|
function getAttachmentIcon(kind: AttachmentIconKind) {
|
||||||
|
switch (kind) {
|
||||||
|
case 'audio':
|
||||||
|
return AudioLines
|
||||||
|
case 'video':
|
||||||
|
return FileVideo
|
||||||
|
case 'spreadsheet':
|
||||||
|
return FileSpreadsheet
|
||||||
|
case 'archive':
|
||||||
|
return FileArchive
|
||||||
|
case 'code':
|
||||||
|
return FileCode2
|
||||||
|
case 'text':
|
||||||
|
return FileText
|
||||||
|
default:
|
||||||
|
return FileIcon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatInputInnerProps {
|
||||||
|
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
||||||
|
onStop?: () => void
|
||||||
|
isProcessing: boolean
|
||||||
|
isStopping?: boolean
|
||||||
|
isActive: boolean
|
||||||
|
presetMessage?: string
|
||||||
|
onPresetMessageConsumed?: () => void
|
||||||
|
runId?: string | null
|
||||||
|
initialDraft?: string
|
||||||
|
onDraftChange?: (text: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatInputInner({
|
||||||
|
onSubmit,
|
||||||
|
onStop,
|
||||||
|
isProcessing,
|
||||||
|
isStopping,
|
||||||
|
isActive,
|
||||||
|
presetMessage,
|
||||||
|
onPresetMessageConsumed,
|
||||||
|
runId,
|
||||||
|
initialDraft,
|
||||||
|
onDraftChange,
|
||||||
|
}: ChatInputInnerProps) {
|
||||||
|
const controller = usePromptInputController()
|
||||||
|
const message = controller.textInput.value
|
||||||
|
const [attachments, setAttachments] = useState<StagedAttachment[]>([])
|
||||||
|
const [focusNonce, setFocusNonce] = useState(0)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing
|
||||||
|
|
||||||
|
// Restore the tab draft when this input mounts.
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialDraft) {
|
||||||
|
controller.textInput.setInput(initialDraft)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDraftChange?.(message)
|
||||||
|
}, [message, onDraftChange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (presetMessage) {
|
||||||
|
controller.textInput.setInput(presetMessage)
|
||||||
|
onPresetMessageConsumed?.()
|
||||||
|
}
|
||||||
|
}, [presetMessage, controller.textInput, onPresetMessageConsumed])
|
||||||
|
|
||||||
|
const addFiles = useCallback(async (paths: string[]) => {
|
||||||
|
const newAttachments: StagedAttachment[] = []
|
||||||
|
for (const filePath of paths) {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('shell:readFileBase64', { path: filePath })
|
||||||
|
if (result.size > MAX_ATTACHMENT_SIZE) {
|
||||||
|
toast.error(`File too large: ${getFileDisplayName(filePath)} (max 10MB)`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const mime = result.mimeType || getMimeFromExtension(getExtension(filePath))
|
||||||
|
const image = isImageMime(mime)
|
||||||
|
newAttachments.push({
|
||||||
|
id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
path: filePath,
|
||||||
|
filename: getFileDisplayName(filePath),
|
||||||
|
mimeType: mime,
|
||||||
|
isImage: image,
|
||||||
|
size: result.size,
|
||||||
|
thumbnailUrl: image ? `data:${mime};base64,${result.data}` : undefined,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to read file:', filePath, err)
|
||||||
|
toast.error(`Failed to read: ${getFileDisplayName(filePath)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newAttachments.length > 0) {
|
||||||
|
setAttachments((prev) => [...prev, ...newAttachments])
|
||||||
|
setFocusNonce((value) => value + 1)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removeAttachment = useCallback((id: string) => {
|
||||||
|
setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (!canSubmit) return
|
||||||
|
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments)
|
||||||
|
controller.textInput.clear()
|
||||||
|
controller.mentions.clearMentions()
|
||||||
|
setAttachments([])
|
||||||
|
}, [attachments, canSubmit, controller, message, onSubmit])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}, [handleSubmit])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) return
|
||||||
|
const onDragOver = (e: DragEvent) => {
|
||||||
|
if (e.dataTransfer?.types?.includes('Files')) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDrop = (e: DragEvent) => {
|
||||||
|
if (e.dataTransfer?.types?.includes('Files')) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
||||||
|
const paths = Array.from(e.dataTransfer.files)
|
||||||
|
.map((file) => window.electronUtils?.getPathForFile(file))
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
if (paths.length > 0) {
|
||||||
|
void addFiles(paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('dragover', onDragOver)
|
||||||
|
document.addEventListener('drop', onDrop)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('dragover', onDragOver)
|
||||||
|
document.removeEventListener('drop', onDrop)
|
||||||
|
}
|
||||||
|
}, [addFiles, isActive])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-background shadow-none">
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
|
||||||
|
{attachments.map((attachment) => {
|
||||||
|
const attachmentType = getAttachmentTypeLabel(attachment)
|
||||||
|
const attachmentName = getAttachmentDisplayName(attachment)
|
||||||
|
const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={attachment.id}
|
||||||
|
className="group relative inline-flex min-w-[230px] max-w-[320px] items-center gap-2 rounded-xl border border-border/50 bg-muted/80 px-2.5 py-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg',
|
||||||
|
attachment.isImage && attachment.thumbnailUrl
|
||||||
|
? 'bg-muted'
|
||||||
|
: getAttachmentToneClass(attachmentType)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{attachment.isImage && attachment.thumbnailUrl ? (
|
||||||
|
<img src={attachment.thumbnailUrl} alt="" className="size-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<Icon className="size-5" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm leading-tight font-medium">{attachmentName}</span>
|
||||||
|
<span className="block pt-0.5 text-xs leading-tight text-muted-foreground">{attachmentType}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeAttachment(attachment.id)}
|
||||||
|
className="absolute right-1 top-1 flex size-5 items-center justify-center rounded-full border border-border/70 bg-background/70 text-muted-foreground opacity-0 transition-[opacity,color] duration-150 hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-4">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const files = e.target.files
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
const paths = Array.from(files)
|
||||||
|
.map((file) => window.electronUtils?.getPathForFile(file))
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
if (paths.length > 0) {
|
||||||
|
void addFiles(paths)
|
||||||
|
}
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
aria-label="Attach files"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<PromptInputTextarea
|
||||||
|
placeholder="Type your message..."
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
autoFocus={isActive}
|
||||||
|
focusTrigger={isActive ? `${runId ?? 'new'}:${focusNonce}` : undefined}
|
||||||
|
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
{isProcessing ? (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
onClick={onStop}
|
||||||
|
title={isStopping ? 'Click again to force stop' : 'Stop generation'}
|
||||||
|
className={cn(
|
||||||
|
'h-7 w-7 shrink-0 rounded-full transition-all',
|
||||||
|
isStopping
|
||||||
|
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||||
|
: 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isStopping ? (
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Square className="h-3 w-3 fill-current" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className={cn(
|
||||||
|
'h-7 w-7 shrink-0 rounded-full transition-all',
|
||||||
|
canSubmit
|
||||||
|
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatInputWithMentionsProps {
|
||||||
|
knowledgeFiles: string[]
|
||||||
|
recentFiles: string[]
|
||||||
|
visibleFiles: string[]
|
||||||
|
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
||||||
|
onStop?: () => void
|
||||||
|
isProcessing: boolean
|
||||||
|
isStopping?: boolean
|
||||||
|
isActive?: boolean
|
||||||
|
presetMessage?: string
|
||||||
|
onPresetMessageConsumed?: () => void
|
||||||
|
runId?: string | null
|
||||||
|
initialDraft?: string
|
||||||
|
onDraftChange?: (text: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatInputWithMentions({
|
||||||
|
knowledgeFiles,
|
||||||
|
recentFiles,
|
||||||
|
visibleFiles,
|
||||||
|
onSubmit,
|
||||||
|
onStop,
|
||||||
|
isProcessing,
|
||||||
|
isStopping,
|
||||||
|
isActive = true,
|
||||||
|
presetMessage,
|
||||||
|
onPresetMessageConsumed,
|
||||||
|
runId,
|
||||||
|
initialDraft,
|
||||||
|
onDraftChange,
|
||||||
|
}: ChatInputWithMentionsProps) {
|
||||||
|
return (
|
||||||
|
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
|
||||||
|
<ChatInputInner
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onStop={onStop}
|
||||||
|
isProcessing={isProcessing}
|
||||||
|
isStopping={isStopping}
|
||||||
|
isActive={isActive}
|
||||||
|
presetMessage={presetMessage}
|
||||||
|
onPresetMessageConsumed={onPresetMessageConsumed}
|
||||||
|
runId={runId}
|
||||||
|
initialDraft={initialDraft}
|
||||||
|
onDraftChange={onDraftChange}
|
||||||
|
/>
|
||||||
|
</PromptInputProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
137
apps/x/apps/renderer/src/components/chat-message-attachments.tsx
Normal file
137
apps/x/apps/renderer/src/components/chat-message-attachments.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import {
|
||||||
|
AudioLines,
|
||||||
|
FileArchive,
|
||||||
|
FileCode2,
|
||||||
|
FileIcon,
|
||||||
|
FileSpreadsheet,
|
||||||
|
FileText,
|
||||||
|
FileVideo,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import type { MessageAttachment } from '@/lib/chat-conversation'
|
||||||
|
import {
|
||||||
|
type AttachmentIconKind,
|
||||||
|
getAttachmentDisplayName,
|
||||||
|
getAttachmentIconKind,
|
||||||
|
getAttachmentToneClass,
|
||||||
|
getAttachmentTypeLabel,
|
||||||
|
} from '@/lib/attachment-presentation'
|
||||||
|
import { isImageMime, toFileUrl } from '@/lib/file-utils'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function getAttachmentIcon(kind: AttachmentIconKind) {
|
||||||
|
switch (kind) {
|
||||||
|
case 'audio':
|
||||||
|
return AudioLines
|
||||||
|
case 'video':
|
||||||
|
return FileVideo
|
||||||
|
case 'spreadsheet':
|
||||||
|
return FileSpreadsheet
|
||||||
|
case 'archive':
|
||||||
|
return FileArchive
|
||||||
|
case 'code':
|
||||||
|
return FileCode2
|
||||||
|
case 'text':
|
||||||
|
return FileText
|
||||||
|
default:
|
||||||
|
return FileIcon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageAttachmentPreview({ attachment }: { attachment: MessageAttachment }) {
|
||||||
|
const fallbackFileUrl = useMemo(() => toFileUrl(attachment.path), [attachment.path])
|
||||||
|
const [src, setSrc] = useState(attachment.thumbnailUrl || fallbackFileUrl)
|
||||||
|
const [triedBase64, setTriedBase64] = useState(Boolean(attachment.thumbnailUrl))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextSrc = attachment.thumbnailUrl || fallbackFileUrl
|
||||||
|
setSrc(nextSrc)
|
||||||
|
setTriedBase64(Boolean(attachment.thumbnailUrl))
|
||||||
|
}, [attachment.thumbnailUrl, fallbackFileUrl])
|
||||||
|
|
||||||
|
const loadBase64 = useMemo(
|
||||||
|
() => async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('shell:readFileBase64', { path: attachment.path })
|
||||||
|
const mimeType = result.mimeType || attachment.mimeType || 'image/*'
|
||||||
|
setSrc(`data:${mimeType};base64,${result.data}`)
|
||||||
|
} catch {
|
||||||
|
// Keep current src; fallback rendering (broken image icon) is better than crashing.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[attachment.mimeType, attachment.path]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (attachment.thumbnailUrl || triedBase64) return
|
||||||
|
setTriedBase64(true)
|
||||||
|
void loadBase64()
|
||||||
|
}, [attachment.thumbnailUrl, loadBase64, triedBase64])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt="Image attachment"
|
||||||
|
className="h-44 w-auto max-w-[300px] rounded-2xl border border-border/70 bg-muted object-cover"
|
||||||
|
onError={() => {
|
||||||
|
if (triedBase64) return
|
||||||
|
setTriedBase64(true)
|
||||||
|
void loadBase64()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatMessageAttachmentsProps {
|
||||||
|
attachments: MessageAttachment[]
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessageAttachments({ attachments, className }: ChatMessageAttachmentsProps) {
|
||||||
|
if (attachments.length === 0) return null
|
||||||
|
|
||||||
|
const imageAttachments = attachments.filter((attachment) => isImageMime(attachment.mimeType))
|
||||||
|
const fileAttachments = attachments.filter((attachment) => !isImageMime(attachment.mimeType))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col items-end gap-2', className)}>
|
||||||
|
{imageAttachments.length > 0 && (
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
{imageAttachments.map((attachment, index) => (
|
||||||
|
<ImageAttachmentPreview key={`${attachment.path}-${index}`} attachment={attachment} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fileAttachments.length > 0 && (
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
{fileAttachments.map((attachment, index) => {
|
||||||
|
const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
|
||||||
|
const attachmentName = getAttachmentDisplayName(attachment)
|
||||||
|
const attachmentType = getAttachmentTypeLabel(attachment)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`${attachment.path}-${index}`}
|
||||||
|
className="inline-flex min-w-[240px] max-w-[440px] items-center gap-3 rounded-2xl border border-border/50 bg-muted/75 px-3 py-2.5 text-sm text-foreground"
|
||||||
|
title={attachmentName}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex size-12 shrink-0 items-center justify-center rounded-xl',
|
||||||
|
getAttachmentToneClass(attachmentType)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-6 shrink-0" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm leading-tight font-medium">{attachmentName}</span>
|
||||||
|
<span className="block pt-0.5 text-xs leading-tight text-muted-foreground">{attachmentType}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -52,7 +52,16 @@ function getMarkdownWithBlankLines(editor: Editor): string {
|
||||||
const blocks: string[] = []
|
const blocks: string[] = []
|
||||||
|
|
||||||
// Helper to convert a node to markdown text
|
// Helper to convert a node to markdown text
|
||||||
const nodeToText = (node: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }): string => {
|
const nodeToText = (node: {
|
||||||
|
type?: string
|
||||||
|
content?: Array<{
|
||||||
|
type?: string
|
||||||
|
text?: string
|
||||||
|
marks?: Array<{ type: string; attrs?: Record<string, unknown> }>
|
||||||
|
attrs?: Record<string, unknown>
|
||||||
|
}>
|
||||||
|
attrs?: Record<string, unknown>
|
||||||
|
}): string => {
|
||||||
if (!node.content) return ''
|
if (!node.content) return ''
|
||||||
return node.content.map(child => {
|
return node.content.map(child => {
|
||||||
if (child.type === 'text') {
|
if (child.type === 'text') {
|
||||||
|
|
@ -67,6 +76,9 @@ function getMarkdownWithBlankLines(editor: Editor): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return text
|
return text
|
||||||
|
} else if (child.type === 'wikiLink') {
|
||||||
|
const path = (child.attrs?.path as string) || ''
|
||||||
|
return path ? `[[${path}]]` : ''
|
||||||
} else if (child.type === 'hardBreak') {
|
} else if (child.type === 'hardBreak') {
|
||||||
return '\n'
|
return '\n'
|
||||||
}
|
}
|
||||||
|
|
@ -183,6 +195,9 @@ interface MarkdownEditorProps {
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
wikiLinks?: WikiLinkConfig
|
wikiLinks?: WikiLinkConfig
|
||||||
onImageUpload?: (file: File) => Promise<string | null>
|
onImageUpload?: (file: File) => Promise<string | null>
|
||||||
|
editorSessionKey?: number
|
||||||
|
onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void
|
||||||
|
editable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type WikiLinkMatch = {
|
type WikiLinkMatch = {
|
||||||
|
|
@ -266,6 +281,9 @@ export function MarkdownEditor({
|
||||||
placeholder = 'Start writing...',
|
placeholder = 'Start writing...',
|
||||||
wikiLinks,
|
wikiLinks,
|
||||||
onImageUpload,
|
onImageUpload,
|
||||||
|
editorSessionKey = 0,
|
||||||
|
onHistoryHandlersChange,
|
||||||
|
editable = true,
|
||||||
}: MarkdownEditorProps) {
|
}: MarkdownEditorProps) {
|
||||||
const isInternalUpdate = useRef(false)
|
const isInternalUpdate = useRef(false)
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
@ -287,6 +305,7 @@ export function MarkdownEditor({
|
||||||
)
|
)
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
|
editable,
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
heading: {
|
heading: {
|
||||||
|
|
@ -388,7 +407,7 @@ export function MarkdownEditor({
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}, [editorSessionKey])
|
||||||
|
|
||||||
const orderedFiles = useMemo(() => {
|
const orderedFiles = useMemo(() => {
|
||||||
if (!wikiLinks) return []
|
if (!wikiLinks) return []
|
||||||
|
|
@ -477,12 +496,37 @@ export function MarkdownEditor({
|
||||||
isInternalUpdate.current = true
|
isInternalUpdate.current = true
|
||||||
// Pre-process to preserve blank lines
|
// Pre-process to preserve blank lines
|
||||||
const preprocessed = preprocessMarkdown(content)
|
const preprocessed = preprocessMarkdown(content)
|
||||||
editor.commands.setContent(preprocessed)
|
// Treat tab-open content as baseline: do not add hydration to undo history.
|
||||||
|
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
|
||||||
isInternalUpdate.current = false
|
isInternalUpdate.current = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [editor, content])
|
}, [editor, content])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onHistoryHandlersChange) return
|
||||||
|
if (!editor) {
|
||||||
|
onHistoryHandlersChange(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onHistoryHandlersChange({
|
||||||
|
undo: () => editor.chain().focus().undo().run(),
|
||||||
|
redo: () => editor.chain().focus().redo().run(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
onHistoryHandlersChange(null)
|
||||||
|
}
|
||||||
|
}, [editor, onHistoryHandlersChange])
|
||||||
|
|
||||||
|
// Update editable state when prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor) {
|
||||||
|
editor.setEditable(editable)
|
||||||
|
}
|
||||||
|
}, [editor, editable])
|
||||||
|
|
||||||
// Force re-render decorations when selection highlight changes
|
// Force re-render decorations when selection highlight changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editor) {
|
if (editor) {
|
||||||
|
|
|
||||||
|
|
@ -57,14 +57,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||||
const [modelsLoading, setModelsLoading] = useState(false)
|
const [modelsLoading, setModelsLoading] = useState(false)
|
||||||
const [modelsError, setModelsError] = useState<string | null>(null)
|
const [modelsError, setModelsError] = useState<string | null>(null)
|
||||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string }>>({
|
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
|
||||||
openai: { apiKey: "", baseURL: "", model: "" },
|
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
anthropic: { apiKey: "", baseURL: "", model: "" },
|
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
google: { apiKey: "", baseURL: "", model: "" },
|
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
openrouter: { apiKey: "", baseURL: "", model: "" },
|
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
aigateway: { apiKey: "", baseURL: "", model: "" },
|
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" },
|
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
|
||||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" },
|
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
|
||||||
})
|
})
|
||||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
||||||
status: "idle",
|
status: "idle",
|
||||||
|
|
@ -87,7 +87,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
const [slackConnecting, setSlackConnecting] = useState(false)
|
const [slackConnecting, setSlackConnecting] = useState(false)
|
||||||
|
|
||||||
const updateProviderConfig = useCallback(
|
const updateProviderConfig = useCallback(
|
||||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => {
|
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
|
||||||
setProviderConfigs(prev => ({
|
setProviderConfigs(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[provider]: { ...prev[provider], ...updates },
|
[provider]: { ...prev[provider], ...updates },
|
||||||
|
|
@ -287,6 +287,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
const apiKey = activeConfig.apiKey.trim() || undefined
|
const apiKey = activeConfig.apiKey.trim() || undefined
|
||||||
const baseURL = activeConfig.baseURL.trim() || undefined
|
const baseURL = activeConfig.baseURL.trim() || undefined
|
||||||
const model = activeConfig.model.trim()
|
const model = activeConfig.model.trim()
|
||||||
|
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
|
||||||
const providerConfig = {
|
const providerConfig = {
|
||||||
provider: {
|
provider: {
|
||||||
flavor: llmProvider,
|
flavor: llmProvider,
|
||||||
|
|
@ -294,6 +295,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
baseURL,
|
baseURL,
|
||||||
},
|
},
|
||||||
model,
|
model,
|
||||||
|
knowledgeGraphModel,
|
||||||
}
|
}
|
||||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|
@ -657,39 +659,74 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span>
|
<div className="space-y-2">
|
||||||
{modelsLoading ? (
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Assistant model</span>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
{modelsLoading ? (
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
Loading models...
|
<Loader2 className="size-4 animate-spin" />
|
||||||
</div>
|
Loading...
|
||||||
) : showModelInput ? (
|
</div>
|
||||||
<Input
|
) : showModelInput ? (
|
||||||
value={activeConfig.model}
|
<Input
|
||||||
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
|
value={activeConfig.model}
|
||||||
placeholder="Enter model"
|
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
|
||||||
/>
|
placeholder="Enter model"
|
||||||
) : (
|
/>
|
||||||
<Select
|
) : (
|
||||||
value={activeConfig.model}
|
<Select
|
||||||
onValueChange={(value) => updateProviderConfig(llmProvider, { model: value })}
|
value={activeConfig.model}
|
||||||
>
|
onValueChange={(value) => updateProviderConfig(llmProvider, { model: value })}
|
||||||
<SelectTrigger>
|
>
|
||||||
<SelectValue placeholder="Select a model" />
|
<SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectValue placeholder="Select a model" />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
{modelsForProvider.map((model) => (
|
<SelectContent>
|
||||||
<SelectItem key={model.id} value={model.id}>
|
{modelsForProvider.map((model) => (
|
||||||
{model.name || model.id}
|
<SelectItem key={model.id} value={model.id}>
|
||||||
</SelectItem>
|
{model.name || model.id}
|
||||||
))}
|
</SelectItem>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</SelectContent>
|
||||||
)}
|
</Select>
|
||||||
{modelsError && (
|
)}
|
||||||
<div className="text-xs text-destructive">{modelsError}</div>
|
{modelsError && (
|
||||||
)}
|
<div className="text-xs text-destructive">{modelsError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Knowledge graph model</span>
|
||||||
|
{modelsLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : showModelInput ? (
|
||||||
|
<Input
|
||||||
|
value={activeConfig.knowledgeGraphModel}
|
||||||
|
onChange={(e) => updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })}
|
||||||
|
placeholder={activeConfig.model || "Enter model"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={activeConfig.knowledgeGraphModel || "__same__"}
|
||||||
|
onValueChange={(value) => updateProviderConfig(llmProvider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a model" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||||
|
{modelsForProvider.map((model) => (
|
||||||
|
<SelectItem key={model.id} value={model.id}>
|
||||||
|
{model.name || model.id}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showApiKey && (
|
{showApiKey && (
|
||||||
|
|
|
||||||
208
apps/x/apps/renderer/src/components/search-dialog.tsx
Normal file
208
apps/x/apps/renderer/src/components/search-dialog.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
} from '@/components/ui/command'
|
||||||
|
import { useDebounce } from '@/hooks/use-debounce'
|
||||||
|
import { useSidebarSection, type ActiveSection } from '@/contexts/sidebar-context'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
type: 'knowledge' | 'chat'
|
||||||
|
title: string
|
||||||
|
preview: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchType = 'knowledge' | 'chat'
|
||||||
|
|
||||||
|
function activeTabToTypes(section: ActiveSection): SearchType[] {
|
||||||
|
if (section === 'knowledge') return ['knowledge']
|
||||||
|
return ['chat'] // "tasks" tab maps to chat
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSelectFile: (path: string) => void
|
||||||
|
onSelectRun: (runId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: SearchDialogProps) {
|
||||||
|
const { activeSection } = useSidebarSection()
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([])
|
||||||
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
|
const [activeTypes, setActiveTypes] = useState<Set<SearchType>>(
|
||||||
|
() => new Set(activeTabToTypes(activeSection))
|
||||||
|
)
|
||||||
|
const debouncedQuery = useDebounce(query, 250)
|
||||||
|
|
||||||
|
// Sync filter preselection when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setActiveTypes(new Set(activeTabToTypes(activeSection)))
|
||||||
|
}
|
||||||
|
}, [open, activeSection])
|
||||||
|
|
||||||
|
const toggleType = useCallback((type: SearchType) => {
|
||||||
|
setActiveTypes(new Set([type]))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!debouncedQuery.trim()) {
|
||||||
|
setResults([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
setIsSearching(true)
|
||||||
|
|
||||||
|
const types = Array.from(activeTypes) as ('knowledge' | 'chat')[]
|
||||||
|
window.ipc.invoke('search:query', { query: debouncedQuery, limit: 20, types })
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setResults(res.results)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Search failed:', err)
|
||||||
|
if (!cancelled) {
|
||||||
|
setResults([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsSearching(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [debouncedQuery, activeTypes])
|
||||||
|
|
||||||
|
// Reset state when dialog closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setQuery('')
|
||||||
|
setResults([])
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const handleSelect = useCallback((result: SearchResult) => {
|
||||||
|
onOpenChange(false)
|
||||||
|
if (result.type === 'knowledge') {
|
||||||
|
onSelectFile(result.path)
|
||||||
|
} else {
|
||||||
|
onSelectRun(result.path)
|
||||||
|
}
|
||||||
|
}, [onOpenChange, onSelectFile, onSelectRun])
|
||||||
|
|
||||||
|
const knowledgeResults = results.filter(r => r.type === 'knowledge')
|
||||||
|
const chatResults = results.filter(r => r.type === 'chat')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title="Search"
|
||||||
|
description="Search across knowledge and chats"
|
||||||
|
showCloseButton={false}
|
||||||
|
className="top-[20%] translate-y-0"
|
||||||
|
>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search..."
|
||||||
|
value={query}
|
||||||
|
onValueChange={setQuery}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
|
||||||
|
<FilterToggle
|
||||||
|
active={activeTypes.has('knowledge')}
|
||||||
|
onClick={() => toggleType('knowledge')}
|
||||||
|
icon={<FileTextIcon className="size-3" />}
|
||||||
|
label="Knowledge"
|
||||||
|
/>
|
||||||
|
<FilterToggle
|
||||||
|
active={activeTypes.has('chat')}
|
||||||
|
onClick={() => toggleType('chat')}
|
||||||
|
icon={<MessageSquareIcon className="size-3" />}
|
||||||
|
label="Chats"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CommandList>
|
||||||
|
{!query.trim() && (
|
||||||
|
<CommandEmpty>Type to search...</CommandEmpty>
|
||||||
|
)}
|
||||||
|
{query.trim() && !isSearching && results.length === 0 && (
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
)}
|
||||||
|
{knowledgeResults.length > 0 && (
|
||||||
|
<CommandGroup heading="Knowledge">
|
||||||
|
{knowledgeResults.map((result) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`knowledge-${result.path}`}
|
||||||
|
value={`knowledge-${result.title}-${result.path}`}
|
||||||
|
onSelect={() => handleSelect(result)}
|
||||||
|
>
|
||||||
|
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex flex-col gap-0.5 min-w-0">
|
||||||
|
<span className="truncate font-medium">{result.title}</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
{chatResults.length > 0 && (
|
||||||
|
<CommandGroup heading="Chats">
|
||||||
|
{chatResults.map((result) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`chat-${result.path}`}
|
||||||
|
value={`chat-${result.title}-${result.path}`}
|
||||||
|
onSelect={() => handleSelect(result)}
|
||||||
|
>
|
||||||
|
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex flex-col gap-0.5 min-w-0">
|
||||||
|
<span className="truncate font-medium">{result.title}</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterToggle({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -167,14 +167,14 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
|
||||||
|
|
||||||
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
||||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string }>>({
|
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
|
||||||
openai: { apiKey: "", baseURL: "", model: "" },
|
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
anthropic: { apiKey: "", baseURL: "", model: "" },
|
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
google: { apiKey: "", baseURL: "", model: "" },
|
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
openrouter: { apiKey: "", baseURL: "", model: "" },
|
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
aigateway: { apiKey: "", baseURL: "", model: "" },
|
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" },
|
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
|
||||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" },
|
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
|
||||||
})
|
})
|
||||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||||
const [modelsLoading, setModelsLoading] = useState(false)
|
const [modelsLoading, setModelsLoading] = useState(false)
|
||||||
|
|
@ -199,7 +199,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
||||||
|
|
||||||
const updateConfig = useCallback(
|
const updateConfig = useCallback(
|
||||||
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => {
|
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
|
||||||
setProviderConfigs(prev => ({
|
setProviderConfigs(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[prov]: { ...prev[prov], ...updates },
|
[prov]: { ...prev[prov], ...updates },
|
||||||
|
|
@ -229,6 +229,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
apiKey: parsed.provider.apiKey || "",
|
apiKey: parsed.provider.apiKey || "",
|
||||||
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
|
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
|
||||||
model: parsed.model,
|
model: parsed.model,
|
||||||
|
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -296,6 +297,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
baseURL: activeConfig.baseURL.trim() || undefined,
|
baseURL: activeConfig.baseURL.trim() || undefined,
|
||||||
},
|
},
|
||||||
model: activeConfig.model.trim(),
|
model: activeConfig.model.trim(),
|
||||||
|
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
|
||||||
}
|
}
|
||||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|
@ -362,40 +364,75 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model selection */}
|
{/* Model selection - side by side */}
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span>
|
<div className="space-y-2">
|
||||||
{modelsLoading ? (
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Assistant model</span>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
{modelsLoading ? (
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
Loading models...
|
<Loader2 className="size-4 animate-spin" />
|
||||||
</div>
|
Loading...
|
||||||
) : showModelInput ? (
|
</div>
|
||||||
<Input
|
) : showModelInput ? (
|
||||||
value={activeConfig.model}
|
<Input
|
||||||
onChange={(e) => updateConfig(provider, { model: e.target.value })}
|
value={activeConfig.model}
|
||||||
placeholder="Enter model"
|
onChange={(e) => updateConfig(provider, { model: e.target.value })}
|
||||||
/>
|
placeholder="Enter model"
|
||||||
) : (
|
/>
|
||||||
<Select
|
) : (
|
||||||
value={activeConfig.model}
|
<Select
|
||||||
onValueChange={(value) => updateConfig(provider, { model: value })}
|
value={activeConfig.model}
|
||||||
>
|
onValueChange={(value) => updateConfig(provider, { model: value })}
|
||||||
<SelectTrigger>
|
>
|
||||||
<SelectValue placeholder="Select a model" />
|
<SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectValue placeholder="Select a model" />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
{modelsForProvider.map((model) => (
|
<SelectContent>
|
||||||
<SelectItem key={model.id} value={model.id}>
|
{modelsForProvider.map((model) => (
|
||||||
{model.name || model.id}
|
<SelectItem key={model.id} value={model.id}>
|
||||||
</SelectItem>
|
{model.name || model.id}
|
||||||
))}
|
</SelectItem>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</SelectContent>
|
||||||
)}
|
</Select>
|
||||||
{modelsError && (
|
)}
|
||||||
<div className="text-xs text-destructive">{modelsError}</div>
|
{modelsError && (
|
||||||
)}
|
<div className="text-xs text-destructive">{modelsError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Knowledge graph model</span>
|
||||||
|
{modelsLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : showModelInput ? (
|
||||||
|
<Input
|
||||||
|
value={activeConfig.knowledgeGraphModel}
|
||||||
|
onChange={(e) => updateConfig(provider, { knowledgeGraphModel: e.target.value })}
|
||||||
|
placeholder={activeConfig.model || "Enter model"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={activeConfig.knowledgeGraphModel || "__same__"}
|
||||||
|
onValueChange={(value) => updateConfig(provider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a model" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||||
|
{modelsForProvider.map((model) => (
|
||||||
|
<SelectItem key={model.id} value={model.id}>
|
||||||
|
{model.name || model.id}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Key */}
|
{/* API Key */}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
ChevronsDownUp,
|
ChevronsDownUp,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Copy,
|
Copy,
|
||||||
|
ExternalLink,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
|
@ -105,6 +106,7 @@ type KnowledgeActions = {
|
||||||
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
||||||
remove: (path: string) => Promise<void>
|
remove: (path: string) => Promise<void>
|
||||||
copyPath: (path: string) => void
|
copyPath: (path: string) => void
|
||||||
|
onOpenInNewTab?: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type RunListItem = {
|
type RunListItem = {
|
||||||
|
|
@ -149,6 +151,7 @@ type TasksActions = {
|
||||||
onNewChat: () => void
|
onNewChat: () => void
|
||||||
onSelectRun: (runId: string) => void
|
onSelectRun: (runId: string) => void
|
||||||
onDeleteRun: (runId: string) => void
|
onDeleteRun: (runId: string) => void
|
||||||
|
onOpenInNewTab?: (runId: string) => void
|
||||||
onSelectBackgroundTask?: (taskName: string) => void
|
onSelectBackgroundTask?: (taskName: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -817,6 +820,36 @@ function KnowledgeSection({
|
||||||
onVoiceNoteCreated?: (path: string) => void
|
onVoiceNoteCreated?: (path: string) => void
|
||||||
}) {
|
}) {
|
||||||
const isExpanded = expandedPaths.size > 0
|
const isExpanded = expandedPaths.size > 0
|
||||||
|
const treeContainerRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedPath) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
let rafId: number | null = null
|
||||||
|
let attempts = 0
|
||||||
|
const maxAttempts = 20
|
||||||
|
|
||||||
|
const revealActiveFile = () => {
|
||||||
|
if (cancelled) return
|
||||||
|
const container = treeContainerRef.current
|
||||||
|
if (!container) return
|
||||||
|
const activeRow = container.querySelector<HTMLElement>('[data-knowledge-active="true"]')
|
||||||
|
if (activeRow) {
|
||||||
|
activeRow.scrollIntoView({ block: "nearest", inline: "nearest" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (attempts >= maxAttempts) return
|
||||||
|
attempts += 1
|
||||||
|
rafId = requestAnimationFrame(revealActiveFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(revealActiveFile)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (rafId !== null) cancelAnimationFrame(rafId)
|
||||||
|
}
|
||||||
|
}, [selectedPath, expandedPaths, tree])
|
||||||
|
|
||||||
const quickActions = [
|
const quickActions = [
|
||||||
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() },
|
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() },
|
||||||
|
|
@ -862,18 +895,20 @@ function KnowledgeSection({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<SidebarGroupContent className="flex-1 overflow-y-auto">
|
<SidebarGroupContent className="flex-1 overflow-y-auto">
|
||||||
<SidebarMenu>
|
<div ref={treeContainerRef}>
|
||||||
{tree.map((item, index) => (
|
<SidebarMenu>
|
||||||
<Tree
|
{tree.map((item, index) => (
|
||||||
key={index}
|
<Tree
|
||||||
item={item}
|
key={index}
|
||||||
selectedPath={selectedPath}
|
item={item}
|
||||||
expandedPaths={expandedPaths}
|
selectedPath={selectedPath}
|
||||||
onSelect={onSelectFile}
|
expandedPaths={expandedPaths}
|
||||||
actions={actions}
|
onSelect={onSelectFile}
|
||||||
/>
|
actions={actions}
|
||||||
))}
|
/>
|
||||||
</SidebarMenu>
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</div>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
|
|
@ -932,7 +967,7 @@ function Tree({
|
||||||
try {
|
try {
|
||||||
await actions.rename(item.path, trimmedName, isDir)
|
await actions.rename(item.path, trimmedName, isDir)
|
||||||
toast('Renamed successfully', 'success')
|
toast('Renamed successfully', 'success')
|
||||||
} catch (err) {
|
} catch {
|
||||||
toast('Failed to rename', 'error')
|
toast('Failed to rename', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -947,7 +982,7 @@ function Tree({
|
||||||
try {
|
try {
|
||||||
await actions.remove(item.path)
|
await actions.remove(item.path)
|
||||||
toast('Moved to trash', 'success')
|
toast('Moved to trash', 'success')
|
||||||
} catch (err) {
|
} catch {
|
||||||
toast('Failed to delete', 'error')
|
toast('Failed to delete', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -981,6 +1016,15 @@ function Tree({
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{!isDir && actions.onOpenInNewTab && (
|
||||||
|
<>
|
||||||
|
<ContextMenuItem onClick={() => actions.onOpenInNewTab!(item.path)}>
|
||||||
|
<ExternalLink className="mr-2 size-4" />
|
||||||
|
Open in new tab
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<ContextMenuItem onClick={handleCopyPath}>
|
<ContextMenuItem onClick={handleCopyPath}>
|
||||||
<Copy className="mr-2 size-4" />
|
<Copy className="mr-2 size-4" />
|
||||||
Copy Path
|
Copy Path
|
||||||
|
|
@ -1033,12 +1077,24 @@ function Tree({
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem
|
||||||
|
className="group/file-item"
|
||||||
|
data-knowledge-file-path={item.path}
|
||||||
|
data-knowledge-active={isSelected ? "true" : "false"}
|
||||||
|
>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={isSelected}
|
isActive={isSelected}
|
||||||
onClick={() => onSelect(item.path, item.kind)}
|
onClick={(e) => {
|
||||||
|
if (e.metaKey && actions.onOpenInNewTab) {
|
||||||
|
actions.onOpenInNewTab(item.path)
|
||||||
|
} else {
|
||||||
|
onSelect(item.path, item.kind)
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span>{item.name}</span>
|
<div className="flex w-full items-center gap-1 min-w-0">
|
||||||
|
<span className="min-w-0 flex-1 truncate">{item.name}</span>
|
||||||
|
</div>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
|
|
@ -1162,37 +1218,54 @@ function TasksSection({
|
||||||
</div>
|
</div>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{runs.map((run) => (
|
{runs.map((run) => (
|
||||||
<SidebarMenuItem key={run.id} className="group/chat-item">
|
<ContextMenu key={run.id}>
|
||||||
<SidebarMenuButton
|
<ContextMenuTrigger asChild>
|
||||||
isActive={currentRunId === run.id}
|
<SidebarMenuItem className="group/chat-item">
|
||||||
onClick={() => actions?.onSelectRun(run.id)}
|
<SidebarMenuButton
|
||||||
>
|
isActive={currentRunId === run.id}
|
||||||
<div className="flex w-full items-center gap-2 min-w-0">
|
onClick={(e) => {
|
||||||
{processingRunIds?.has(run.id) ? (
|
if (e.metaKey && actions?.onOpenInNewTab) {
|
||||||
<span className="size-2 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
|
actions.onOpenInNewTab(run.id)
|
||||||
) : null}
|
} else {
|
||||||
<span className="min-w-0 flex-1 truncate text-sm">{run.title || '(Untitled chat)'}</span>
|
actions?.onSelectRun(run.id)
|
||||||
{run.createdAt ? (
|
}
|
||||||
<span className={`shrink-0 text-[10px] text-muted-foreground${processingRunIds?.has(run.id) ? '' : ' group-hover/chat-item:hidden'}`}>
|
}}
|
||||||
{formatRunTime(run.createdAt)}
|
>
|
||||||
</span>
|
<div className="flex w-full items-center gap-2 min-w-0">
|
||||||
) : null}
|
{processingRunIds?.has(run.id) ? (
|
||||||
{!processingRunIds?.has(run.id) && (
|
<span className="size-2 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
<button
|
) : null}
|
||||||
type="button"
|
<span className="min-w-0 flex-1 truncate text-sm">{run.title || '(Untitled chat)'}</span>
|
||||||
className="shrink-0 hidden group-hover/chat-item:flex items-center justify-center text-muted-foreground hover:text-destructive transition-colors"
|
{run.createdAt ? (
|
||||||
onClick={(e) => {
|
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||||
e.stopPropagation()
|
{formatRunTime(run.createdAt)}
|
||||||
setPendingDeleteRunId(run.id)
|
</span>
|
||||||
}}
|
) : null}
|
||||||
aria-label="Delete chat"
|
</div>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="w-48">
|
||||||
|
{actions?.onOpenInNewTab && (
|
||||||
|
<ContextMenuItem onClick={() => actions.onOpenInNewTab!(run.id)}>
|
||||||
|
<ExternalLink className="mr-2 size-4" />
|
||||||
|
Open in new tab
|
||||||
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
{!processingRunIds?.has(run.id) && (
|
||||||
|
<>
|
||||||
|
{actions?.onOpenInNewTab && <ContextMenuSeparator />}
|
||||||
|
<ContextMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setPendingDeleteRunId(run.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="mr-2 size-4" />
|
||||||
</button>
|
Delete
|
||||||
)}
|
</ContextMenuItem>
|
||||||
</div>
|
</>
|
||||||
</SidebarMenuButton>
|
)}
|
||||||
</SidebarMenuItem>
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
))}
|
))}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
97
apps/x/apps/renderer/src/components/tab-bar.tsx
Normal file
97
apps/x/apps/renderer/src/components/tab-bar.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export type ChatTab = {
|
||||||
|
id: string
|
||||||
|
runId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileTab = {
|
||||||
|
id: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabBarProps<T> {
|
||||||
|
tabs: T[]
|
||||||
|
activeTabId: string
|
||||||
|
getTabTitle: (tab: T) => string
|
||||||
|
getTabId: (tab: T) => string
|
||||||
|
isProcessing?: (tab: T) => boolean
|
||||||
|
onSwitchTab: (tabId: string) => void
|
||||||
|
onCloseTab: (tabId: string) => void
|
||||||
|
layout?: 'fill' | 'scroll'
|
||||||
|
allowSingleTabClose?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabBar<T>({
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
getTabTitle,
|
||||||
|
getTabId,
|
||||||
|
isProcessing,
|
||||||
|
onSwitchTab,
|
||||||
|
onCloseTab,
|
||||||
|
layout = 'fill',
|
||||||
|
allowSingleTabClose = false,
|
||||||
|
}: TabBarProps<T>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 self-stretch min-w-0',
|
||||||
|
layout === 'scroll'
|
||||||
|
? 'overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||||
|
: 'overflow-hidden'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tabs.map((tab, index) => {
|
||||||
|
const tabId = getTabId(tab)
|
||||||
|
const isActive = tabId === activeTabId
|
||||||
|
const processing = isProcessing?.(tab) ?? false
|
||||||
|
const title = getTabTitle(tab)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={tabId}>
|
||||||
|
{index > 0 && (
|
||||||
|
<div className="self-stretch w-px bg-border/70 shrink-0" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSwitchTab(tabId)}
|
||||||
|
className={cn(
|
||||||
|
'titlebar-no-drag group/tab relative flex items-center gap-1.5 px-3 self-stretch text-xs transition-colors',
|
||||||
|
layout === 'scroll' ? 'min-w-[140px] max-w-[240px]' : 'min-w-0 max-w-[220px]',
|
||||||
|
isActive
|
||||||
|
? 'bg-background text-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
||||||
|
)}
|
||||||
|
style={layout === 'scroll' ? { flex: '0 0 auto' } : { flex: '1 1 0px' }}
|
||||||
|
>
|
||||||
|
{processing && (
|
||||||
|
<span className="size-1.5 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
)}
|
||||||
|
<span className="truncate flex-1 text-left">{title}</span>
|
||||||
|
{(allowSingleTabClose || tabs.length > 1) && (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
className="shrink-0 flex items-center justify-center rounded-sm p-0.5 opacity-0 group-hover/tab:opacity-60 hover:opacity-100! hover:bg-foreground/10 transition-all"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onCloseTab(tabId)
|
||||||
|
}}
|
||||||
|
aria-label="Close tab"
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{/* Right edge divider after last tab to close off the section */}
|
||||||
|
{index === tabs.length - 1 && (
|
||||||
|
<div className="self-stretch w-px bg-border/70 shrink-0" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
177
apps/x/apps/renderer/src/components/version-history-panel.tsx
Normal file
177
apps/x/apps/renderer/src/components/version-history-panel.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { X, Clock } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
interface CommitInfo {
|
||||||
|
oid: string
|
||||||
|
message: string
|
||||||
|
timestamp: number
|
||||||
|
author: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionHistoryPanelProps {
|
||||||
|
path: string // knowledge-relative file path (e.g. "knowledge/People/John.md")
|
||||||
|
onClose: () => void
|
||||||
|
onSelectVersion: (oid: string | null, content: string) => void // null = current
|
||||||
|
onRestore: (oid: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(unixSeconds: number): { date: string; time: string } {
|
||||||
|
const d = new Date(unixSeconds * 1000)
|
||||||
|
const date = d.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })
|
||||||
|
const time = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
|
||||||
|
return { date, time }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VersionHistoryPanel({
|
||||||
|
path,
|
||||||
|
onClose,
|
||||||
|
onSelectVersion,
|
||||||
|
onRestore,
|
||||||
|
}: VersionHistoryPanelProps) {
|
||||||
|
const [commits, setCommits] = useState<CommitInfo[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selectedOid, setSelectedOid] = useState<string | null>(null) // null = current/latest
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Strip "knowledge/" prefix for IPC calls
|
||||||
|
const relPath = path.startsWith('knowledge/') ? path.slice('knowledge/'.length) : path
|
||||||
|
|
||||||
|
const loadHistory = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('knowledge:history', { path: relPath })
|
||||||
|
setCommits(result.commits)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load version history:', err)
|
||||||
|
setError('Failed to load history')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [relPath])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadHistory()
|
||||||
|
}, [loadHistory])
|
||||||
|
|
||||||
|
// Refresh when new commits land
|
||||||
|
useEffect(() => {
|
||||||
|
return window.ipc.on('knowledge:didCommit', () => {
|
||||||
|
loadHistory()
|
||||||
|
})
|
||||||
|
}, [loadHistory])
|
||||||
|
|
||||||
|
const handleSelectCommit = useCallback(async (oid: string, isLatest: boolean) => {
|
||||||
|
if (isLatest) {
|
||||||
|
setSelectedOid(null)
|
||||||
|
// Read current file content
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('workspace:readFile', { path })
|
||||||
|
onSelectVersion(null, result.data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to read current file:', err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedOid(oid)
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('knowledge:fileAtCommit', { path: relPath, oid })
|
||||||
|
onSelectVersion(oid, result.content)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load file at commit:', err)
|
||||||
|
}
|
||||||
|
}, [path, relPath, onSelectVersion])
|
||||||
|
|
||||||
|
const handleRestore = useCallback(() => {
|
||||||
|
if (selectedOid) {
|
||||||
|
onRestore(selectedOid)
|
||||||
|
}
|
||||||
|
}, [selectedOid, onRestore])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-[280px] shrink-0 border-l border-border bg-background">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
|
||||||
|
<span className="text-sm font-medium text-foreground">Version history</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
aria-label="Close version history"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Commit list */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : commits.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
No history available
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-1">
|
||||||
|
{commits.map((commit, index) => {
|
||||||
|
const isLatest = index === 0
|
||||||
|
const isSelected = isLatest ? selectedOid === null : selectedOid === commit.oid
|
||||||
|
const { date, time } = formatTimestamp(commit.timestamp)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={commit.oid}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectCommit(commit.oid, isLatest)}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-3 py-2 transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'bg-accent'
|
||||||
|
: 'hover:bg-accent/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{!isLatest && (
|
||||||
|
<Clock className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
{date} · {time}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isLatest && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Current version
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{selectedOid && (
|
||||||
|
<div className="shrink-0 border-t border-border p-3">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleRestore}
|
||||||
|
>
|
||||||
|
Restore this version
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
apps/x/apps/renderer/src/lib/attachment-presentation.ts
Normal file
107
apps/x/apps/renderer/src/lib/attachment-presentation.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { getExtension } from '@/lib/file-utils'
|
||||||
|
|
||||||
|
export type AttachmentLike = {
|
||||||
|
filename?: string
|
||||||
|
path: string
|
||||||
|
mimeType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AttachmentIconKind =
|
||||||
|
| 'audio'
|
||||||
|
| 'video'
|
||||||
|
| 'spreadsheet'
|
||||||
|
| 'archive'
|
||||||
|
| 'code'
|
||||||
|
| 'text'
|
||||||
|
| 'file'
|
||||||
|
|
||||||
|
const ARCHIVE_EXTENSIONS = new Set([
|
||||||
|
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz',
|
||||||
|
])
|
||||||
|
|
||||||
|
const SPREADSHEET_EXTENSIONS = new Set([
|
||||||
|
'csv', 'tsv', 'xls', 'xlsx',
|
||||||
|
])
|
||||||
|
|
||||||
|
const CODE_EXTENSIONS = new Set([
|
||||||
|
'js', 'jsx', 'ts', 'tsx', 'json', 'yaml', 'yml', 'toml', 'xml',
|
||||||
|
'py', 'rb', 'go', 'rs', 'java', 'kt', 'c', 'cpp', 'h', 'hpp',
|
||||||
|
'cs', 'php', 'swift', 'sh', 'sql', 'html', 'css', 'scss', 'md',
|
||||||
|
])
|
||||||
|
|
||||||
|
export function getAttachmentDisplayName(attachment: AttachmentLike): string {
|
||||||
|
if (attachment.filename) return attachment.filename
|
||||||
|
const fromPath = attachment.path.split(/[\\/]/).pop()
|
||||||
|
return fromPath || attachment.path
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAttachmentTypeLabel(attachment: AttachmentLike): string {
|
||||||
|
const ext = getExtension(getAttachmentDisplayName(attachment))
|
||||||
|
if (ext) return ext.toUpperCase()
|
||||||
|
|
||||||
|
const mediaType = attachment.mimeType.toLowerCase()
|
||||||
|
if (mediaType.startsWith('audio/')) return 'AUDIO'
|
||||||
|
if (mediaType.startsWith('video/')) return 'VIDEO'
|
||||||
|
if (mediaType.startsWith('text/')) return 'TEXT'
|
||||||
|
if (mediaType.startsWith('image/')) return 'IMAGE'
|
||||||
|
|
||||||
|
const [, subtypeRaw = 'file'] = mediaType.split('/')
|
||||||
|
const subtype = subtypeRaw.split(';')[0].split('+').pop() || 'file'
|
||||||
|
const cleaned = subtype.replace(/[^a-z0-9]/gi, '')
|
||||||
|
return cleaned ? cleaned.toUpperCase() : 'FILE'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAttachmentIconKind(attachment: AttachmentLike): AttachmentIconKind {
|
||||||
|
const mediaType = attachment.mimeType.toLowerCase()
|
||||||
|
const ext = getExtension(attachment.filename || attachment.path)
|
||||||
|
|
||||||
|
if (mediaType.startsWith('audio/')) return 'audio'
|
||||||
|
if (mediaType.startsWith('video/')) return 'video'
|
||||||
|
if (mediaType.includes('spreadsheet') || SPREADSHEET_EXTENSIONS.has(ext)) return 'spreadsheet'
|
||||||
|
if (mediaType.includes('zip') || mediaType.includes('compressed') || ARCHIVE_EXTENSIONS.has(ext)) return 'archive'
|
||||||
|
if (
|
||||||
|
mediaType.includes('json')
|
||||||
|
|| mediaType.includes('javascript')
|
||||||
|
|| mediaType.includes('typescript')
|
||||||
|
|| mediaType.includes('xml')
|
||||||
|
|| CODE_EXTENSIONS.has(ext)
|
||||||
|
) {
|
||||||
|
return 'code'
|
||||||
|
}
|
||||||
|
if (mediaType.startsWith('text/') || mediaType.includes('pdf') || mediaType.includes('document')) {
|
||||||
|
return 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'file'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAttachmentToneClass(typeLabel: string): string {
|
||||||
|
switch (typeLabel) {
|
||||||
|
case 'PDF':
|
||||||
|
return 'bg-red-500 text-white'
|
||||||
|
case 'CSV':
|
||||||
|
case 'XLS':
|
||||||
|
case 'XLSX':
|
||||||
|
case 'TSV':
|
||||||
|
return 'bg-emerald-500 text-white'
|
||||||
|
case 'ZIP':
|
||||||
|
case 'RAR':
|
||||||
|
case '7Z':
|
||||||
|
case 'TAR':
|
||||||
|
case 'GZ':
|
||||||
|
return 'bg-amber-500 text-white'
|
||||||
|
case 'MP3':
|
||||||
|
case 'WAV':
|
||||||
|
case 'M4A':
|
||||||
|
case 'FLAC':
|
||||||
|
case 'AAC':
|
||||||
|
return 'bg-fuchsia-500 text-white'
|
||||||
|
case 'MP4':
|
||||||
|
case 'MOV':
|
||||||
|
case 'AVI':
|
||||||
|
case 'WEBM':
|
||||||
|
return 'bg-violet-500 text-white'
|
||||||
|
default:
|
||||||
|
return 'bg-primary/85 text-primary-foreground'
|
||||||
|
}
|
||||||
|
}
|
||||||
186
apps/x/apps/renderer/src/lib/chat-conversation.ts
Normal file
186
apps/x/apps/renderer/src/lib/chat-conversation.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
import type { ToolUIPart } from 'ai'
|
||||||
|
import z from 'zod'
|
||||||
|
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
|
||||||
|
|
||||||
|
export interface MessageAttachment {
|
||||||
|
path: string
|
||||||
|
filename: string
|
||||||
|
mimeType: string
|
||||||
|
size?: number
|
||||||
|
thumbnailUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
attachments?: MessageAttachment[]
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCall {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
input: ToolUIPart['input']
|
||||||
|
result?: ToolUIPart['output']
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'error'
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorMessage {
|
||||||
|
id: string
|
||||||
|
kind: 'error'
|
||||||
|
message: string
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConversationItem = ChatMessage | ToolCall | ErrorMessage
|
||||||
|
export type PermissionResponse = 'approve' | 'deny'
|
||||||
|
|
||||||
|
export type ChatTabViewState = {
|
||||||
|
runId: string | null
|
||||||
|
conversation: ConversationItem[]
|
||||||
|
currentAssistantMessage: string
|
||||||
|
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
|
||||||
|
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
|
||||||
|
permissionResponses: Map<string, PermissionResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createEmptyChatTabViewState = (): ChatTabViewState => ({
|
||||||
|
runId: null,
|
||||||
|
conversation: [],
|
||||||
|
currentAssistantMessage: '',
|
||||||
|
pendingAskHumanRequests: new Map(),
|
||||||
|
allPermissionRequests: new Map(),
|
||||||
|
permissionResponses: new Map(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'
|
||||||
|
|
||||||
|
export const isChatMessage = (item: ConversationItem): item is ChatMessage => 'role' in item
|
||||||
|
export const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item
|
||||||
|
export const isErrorMessage = (item: ConversationItem): item is ErrorMessage =>
|
||||||
|
'kind' in item && item.kind === 'error'
|
||||||
|
|
||||||
|
export const toToolState = (status: ToolCall['status']): ToolState => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return 'input-streaming'
|
||||||
|
case 'running':
|
||||||
|
return 'input-available'
|
||||||
|
case 'completed':
|
||||||
|
return 'output-available'
|
||||||
|
case 'error':
|
||||||
|
return 'output-error'
|
||||||
|
default:
|
||||||
|
return 'input-available'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeToolInput = (
|
||||||
|
input: ToolCall['input'] | string | undefined
|
||||||
|
): ToolCall['input'] => {
|
||||||
|
if (input === undefined || input === null) return {}
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
const trimmed = input.trim()
|
||||||
|
if (!trimmed) return {}
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed)
|
||||||
|
} catch {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeToolOutput = (
|
||||||
|
output: ToolCall['result'] | undefined,
|
||||||
|
status: ToolCall['status']
|
||||||
|
) => {
|
||||||
|
if (output === undefined || output === null) {
|
||||||
|
return status === 'completed' ? 'No output returned.' : null
|
||||||
|
}
|
||||||
|
if (output === '') return '(empty output)'
|
||||||
|
if (typeof output === 'boolean' || typeof output === 'number') return String(output)
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebSearchCardResult = { title: string; url: string; description: string }
|
||||||
|
|
||||||
|
export type WebSearchCardData = {
|
||||||
|
query: string
|
||||||
|
results: WebSearchCardResult[]
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getWebSearchCardData = (tool: ToolCall): WebSearchCardData | null => {
|
||||||
|
if (tool.name === 'web-search') {
|
||||||
|
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
|
||||||
|
const result = tool.result as Record<string, unknown> | undefined
|
||||||
|
return {
|
||||||
|
query: (input?.query as string) || '',
|
||||||
|
results: (result?.results as WebSearchCardResult[]) || [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool.name === 'research-search') {
|
||||||
|
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
|
||||||
|
const result = tool.result as Record<string, unknown> | undefined
|
||||||
|
const rawResults = (result?.results as Array<{
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
highlights?: string[]
|
||||||
|
text?: string
|
||||||
|
}>) || []
|
||||||
|
const mapped = rawResults.map((entry) => ({
|
||||||
|
title: entry.title,
|
||||||
|
url: entry.url,
|
||||||
|
description: entry.highlights?.[0] || (entry.text ? entry.text.slice(0, 200) : ''),
|
||||||
|
}))
|
||||||
|
const category = input?.category as string | undefined
|
||||||
|
return {
|
||||||
|
query: (input?.query as string) || '',
|
||||||
|
results: mapped,
|
||||||
|
title: category
|
||||||
|
? `${category.charAt(0).toUpperCase() + category.slice(1)} search`
|
||||||
|
: 'Researched the web',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse attached files from message content and return clean message + file paths.
|
||||||
|
export const parseAttachedFiles = (content: string): { message: string; files: string[] } => {
|
||||||
|
const attachedFilesRegex = /<attached-files>\s*([\s\S]*?)\s*<\/attached-files>/
|
||||||
|
const match = content.match(attachedFilesRegex)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return { message: content, files: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesXml = match[1]
|
||||||
|
const filePathRegex = /<file path="([^"]+)">/g
|
||||||
|
const files: string[] = []
|
||||||
|
let fileMatch
|
||||||
|
while ((fileMatch = filePathRegex.exec(filesXml)) !== null) {
|
||||||
|
files.push(fileMatch[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleanMessage = content.replace(attachedFilesRegex, '').trim()
|
||||||
|
for (const filePath of files) {
|
||||||
|
const fileName = filePath.split('/').pop()?.replace(/\.md$/i, '') || ''
|
||||||
|
if (!fileName) continue
|
||||||
|
const mentionRegex = new RegExp(`@${fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi')
|
||||||
|
cleanMessage = cleanMessage.replace(mentionRegex, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message: cleanMessage.trim(), files }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inferRunTitleFromMessage = (content: string): string | undefined => {
|
||||||
|
const { message } = parseAttachedFiles(content)
|
||||||
|
const normalized = message.replace(/\s+/g, ' ').trim()
|
||||||
|
if (!normalized) return undefined
|
||||||
|
return normalized.length > 100 ? normalized.substring(0, 100) : normalized
|
||||||
|
}
|
||||||
61
apps/x/apps/renderer/src/lib/file-utils.ts
Normal file
61
apps/x/apps/renderer/src/lib/file-utils.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
const IMAGE_MIMES = new Set([
|
||||||
|
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
|
||||||
|
'image/svg+xml', 'image/bmp', 'image/tiff', 'image/ico', 'image/avif',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const EXTENSION_TO_MIME: Record<string, string> = {
|
||||||
|
// Images
|
||||||
|
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
||||||
|
webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/ico',
|
||||||
|
avif: 'image/avif', tiff: 'image/tiff',
|
||||||
|
// Text / code
|
||||||
|
txt: 'text/plain', md: 'text/markdown', html: 'text/html', css: 'text/css',
|
||||||
|
csv: 'text/csv', xml: 'text/xml',
|
||||||
|
js: 'text/javascript', ts: 'text/typescript', jsx: 'text/javascript',
|
||||||
|
tsx: 'text/typescript', json: 'application/json', yaml: 'text/yaml',
|
||||||
|
yml: 'text/yaml', toml: 'text/toml',
|
||||||
|
py: 'text/x-python', rb: 'text/x-ruby', rs: 'text/x-rust',
|
||||||
|
go: 'text/x-go', java: 'text/x-java', c: 'text/x-c', cpp: 'text/x-c++',
|
||||||
|
h: 'text/x-c', hpp: 'text/x-c++', sh: 'text/x-shellscript',
|
||||||
|
// Documents
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
// Archives
|
||||||
|
zip: 'application/zip',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isImageMime(mimeType: string): boolean {
|
||||||
|
return IMAGE_MIMES.has(mimeType) || mimeType.startsWith('image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMimeFromExtension(ext: string): string {
|
||||||
|
const normalized = ext.toLowerCase().replace(/^\./, '');
|
||||||
|
return EXTENSION_TO_MIME[normalized] || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileDisplayName(filePath: string): string {
|
||||||
|
return filePath.split('/').pop() || filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExtension(filePath: string): string {
|
||||||
|
const name = filePath.split('/').pop() || '';
|
||||||
|
const dotIndex = name.lastIndexOf('.');
|
||||||
|
return dotIndex > 0 ? name.slice(dotIndex + 1).toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toFileUrl(filePath: string): string {
|
||||||
|
if (!filePath) return filePath;
|
||||||
|
if (
|
||||||
|
filePath.startsWith('data:') ||
|
||||||
|
filePath.startsWith('file://') ||
|
||||||
|
filePath.startsWith('http://') ||
|
||||||
|
filePath.startsWith('https://')
|
||||||
|
) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
const normalized = filePath.replace(/\\/g, '/');
|
||||||
|
const encoded = encodeURI(normalized);
|
||||||
|
if (/^[A-Za-z]:\//.test(normalized)) {
|
||||||
|
return `file:///${encoded}`;
|
||||||
|
}
|
||||||
|
return `file://${encoded}`;
|
||||||
|
}
|
||||||
|
|
@ -9,8 +9,7 @@ export const normalizeWikiPath = (input: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ensureMarkdownExtension = (path: string) => {
|
export const ensureMarkdownExtension = (path: string) => {
|
||||||
const lastSegment = path.split('/').pop() ?? path
|
if (path.toLowerCase().endsWith('.md')) return path
|
||||||
if (lastSegment.includes('.')) return path
|
|
||||||
return `${path}.md`
|
return `${path}.md`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,17 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Notion-like base typography */
|
||||||
.tiptap-editor .ProseMirror {
|
.tiptap-editor .ProseMirror {
|
||||||
padding: 1rem;
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
|
"Segoe UI", Helvetica, "Apple Color Emoji", Arial, sans-serif,
|
||||||
|
"Segoe UI Emoji", "Segoe UI Symbol";
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: rgb(55, 53, 47);
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 4rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,47 +36,45 @@
|
||||||
|
|
||||||
/* Placeholder */
|
/* Placeholder */
|
||||||
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||||
color: var(--muted-foreground);
|
color: rgba(55, 53, 47, 0.4);
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
float: left;
|
float: left;
|
||||||
height: 0;
|
height: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Typography */
|
/* Paragraphs */
|
||||||
.tiptap-editor .ProseMirror {
|
.tiptap-editor .ProseMirror p {
|
||||||
font-size: 1rem;
|
margin: 1px 0;
|
||||||
line-height: 1.75;
|
padding: 3px 2px;
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror > * + * {
|
|
||||||
margin-top: 0.75em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Headings */
|
/* Headings */
|
||||||
.tiptap-editor .ProseMirror h1 {
|
.tiptap-editor .ProseMirror h1 {
|
||||||
font-size: 2em;
|
font-size: 1.875em;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
line-height: 1.2;
|
line-height: 1.3;
|
||||||
margin-top: 1.5em;
|
margin-top: 2em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 4px;
|
||||||
|
padding: 3px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror h2 {
|
.tiptap-editor .ProseMirror h2 {
|
||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
margin-top: 1.25em;
|
margin-top: 1.1em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 1px;
|
||||||
|
padding: 3px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror h3 {
|
.tiptap-editor .ProseMirror h3 {
|
||||||
font-size: 1.25em;
|
font-size: 1.25em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.4;
|
line-height: 1.3;
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 1px;
|
||||||
|
padding: 3px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror h1:first-child,
|
.tiptap-editor .ProseMirror h1:first-child,
|
||||||
|
|
@ -76,16 +83,11 @@
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Paragraphs */
|
|
||||||
.tiptap-editor .ProseMirror p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lists */
|
/* Lists */
|
||||||
.tiptap-editor .ProseMirror ul,
|
.tiptap-editor .ProseMirror ul,
|
||||||
.tiptap-editor .ProseMirror ol {
|
.tiptap-editor .ProseMirror ol {
|
||||||
padding-left: 1.5em;
|
padding-left: 1.625em;
|
||||||
margin: 0.5em 0;
|
margin: 1px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror ul {
|
.tiptap-editor .ProseMirror ul {
|
||||||
|
|
@ -97,7 +99,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror li {
|
.tiptap-editor .ProseMirror li {
|
||||||
margin: 0.25em 0;
|
padding: 3px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror li p {
|
.tiptap-editor .ProseMirror li p {
|
||||||
|
|
@ -106,50 +108,56 @@
|
||||||
|
|
||||||
/* Blockquote */
|
/* Blockquote */
|
||||||
.tiptap-editor .ProseMirror blockquote {
|
.tiptap-editor .ProseMirror blockquote {
|
||||||
border-left: 3px solid var(--border);
|
border-left: 3px solid rgb(55, 53, 47);
|
||||||
padding-left: 1em;
|
padding-left: 14px;
|
||||||
|
margin: 4px 0;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Code */
|
/* Code blocks */
|
||||||
.tiptap-editor .ProseMirror code {
|
|
||||||
background-color: var(--muted);
|
|
||||||
border-radius: 0.25em;
|
|
||||||
padding: 0.15em 0.3em;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Code Block */
|
|
||||||
.tiptap-editor .ProseMirror pre {
|
.tiptap-editor .ProseMirror pre {
|
||||||
background-color: var(--muted);
|
background: rgb(247, 246, 243);
|
||||||
border-radius: 0.5em;
|
border-radius: 4px;
|
||||||
padding: 1em;
|
padding: 2rem;
|
||||||
|
font-family: "SFMono-Regular", Menlo, Consolas, "PT Mono",
|
||||||
|
"Liberation Mono", Courier, monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 8px 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin: 0.75em 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror pre code {
|
.tiptap-editor .ProseMirror pre code {
|
||||||
background: none;
|
background: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 0.875em;
|
font-size: inherit;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Horizontal Rule */
|
/* Inline code */
|
||||||
|
.tiptap-editor .ProseMirror code {
|
||||||
|
background: rgba(135, 131, 120, 0.15);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
font-family: "SFMono-Regular", Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #eb5757;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Divider */
|
||||||
.tiptap-editor .ProseMirror hr {
|
.tiptap-editor .ProseMirror hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid rgba(55, 53, 47, 0.16);
|
||||||
margin: 1.5em 0;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Links */
|
/* Links */
|
||||||
.tiptap-editor .ProseMirror a {
|
.tiptap-editor .ProseMirror a {
|
||||||
color: var(--primary);
|
color: inherit;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
text-decoration-color: rgba(55, 53, 47, 0.4);
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
@ -175,14 +183,13 @@
|
||||||
.tiptap-editor .ProseMirror ul[data-type="taskList"] {
|
.tiptap-editor .ProseMirror ul[data-type="taskList"] {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
margin: 0.5em 0;
|
margin: 1px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror ul[data-type="taskList"] li {
|
.tiptap-editor .ProseMirror ul[data-type="taskList"] li {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
margin: 0.25em 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror ul[data-type="taskList"] li > label {
|
.tiptap-editor .ProseMirror ul[data-type="taskList"] li > label {
|
||||||
|
|
@ -238,14 +245,16 @@
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content area centering */
|
/* Keep knowledge text width readable while margins collapse on narrow panes. */
|
||||||
.tiptap-editor .ProseMirror {
|
.tiptap-editor .ProseMirror {
|
||||||
margin-left: 20%;
|
width: 100%;
|
||||||
margin-right: 20%;
|
max-width: min(56rem, calc(100% - clamp(0.5rem, 2.5vw, 2rem)));
|
||||||
padding-left: 1rem;
|
margin-left: auto;
|
||||||
padding-right: 1rem;
|
margin-right: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-left: clamp(0.5rem, 1.5vw, 1rem);
|
||||||
|
padding-right: clamp(0.5rem, 1.5vw, 1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-link-anchor {
|
.wiki-link-anchor {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
height: 0;
|
height: 0;
|
||||||
|
|
@ -327,3 +336,33 @@
|
||||||
background-color: var(--primary);
|
background-color: var(--primary);
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
.dark .tiptap-editor .ProseMirror {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-editor .ProseMirror a {
|
||||||
|
text-decoration-color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-editor .ProseMirror blockquote {
|
||||||
|
border-left-color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-editor .ProseMirror hr {
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-editor .ProseMirror pre {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-editor .ProseMirror code {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #ff7b72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
"glob": "^13.0.0",
|
"glob": "^13.0.0",
|
||||||
"google-auth-library": "^10.5.0",
|
"google-auth-library": "^10.5.0",
|
||||||
|
"isomorphic-git": "^1.29.0",
|
||||||
"googleapis": "^169.0.0",
|
"googleapis": "^169.0.0",
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
"node-html-markdown": "^2.0.0",
|
"node-html-markdown": "^2.0.0",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { execTool } from "../application/lib/exec-tool.js";
|
||||||
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
|
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
|
||||||
import { BuiltinTools } from "../application/lib/builtin-tools.js";
|
import { BuiltinTools } from "../application/lib/builtin-tools.js";
|
||||||
import { CopilotAgent } from "../application/assistant/agent.js";
|
import { CopilotAgent } from "../application/assistant/agent.js";
|
||||||
import { isBlocked } from "../application/lib/command-executor.js";
|
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
|
||||||
import container from "../di/container.js";
|
import container from "../di/container.js";
|
||||||
import { IModelConfigRepo } from "../models/repo.js";
|
import { IModelConfigRepo } from "../models/repo.js";
|
||||||
import { createProvider } from "../models/models.js";
|
import { createProvider } from "../models/models.js";
|
||||||
|
|
@ -226,13 +226,14 @@ export class StreamStepMessageBuilder {
|
||||||
private textBuffer: string = "";
|
private textBuffer: string = "";
|
||||||
private reasoningBuffer: string = "";
|
private reasoningBuffer: string = "";
|
||||||
private providerOptions: z.infer<typeof ProviderOptions> | undefined = undefined;
|
private providerOptions: z.infer<typeof ProviderOptions> | undefined = undefined;
|
||||||
|
private reasoningProviderOptions: z.infer<typeof ProviderOptions> | undefined = undefined;
|
||||||
|
|
||||||
flushBuffers() {
|
flushBuffers() {
|
||||||
// skip reasoning
|
if (this.reasoningBuffer || this.reasoningProviderOptions) {
|
||||||
// if (this.reasoningBuffer) {
|
this.parts.push({ type: "reasoning", text: this.reasoningBuffer, providerOptions: this.reasoningProviderOptions });
|
||||||
// this.parts.push({ type: "reasoning", text: this.reasoningBuffer });
|
this.reasoningBuffer = "";
|
||||||
// this.reasoningBuffer = "";
|
this.reasoningProviderOptions = undefined;
|
||||||
// }
|
}
|
||||||
if (this.textBuffer) {
|
if (this.textBuffer) {
|
||||||
this.parts.push({ type: "text", text: this.textBuffer });
|
this.parts.push({ type: "text", text: this.textBuffer });
|
||||||
this.textBuffer = "";
|
this.textBuffer = "";
|
||||||
|
|
@ -242,7 +243,11 @@ export class StreamStepMessageBuilder {
|
||||||
ingest(event: z.infer<typeof LlmStepStreamEvent>) {
|
ingest(event: z.infer<typeof LlmStepStreamEvent>) {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "reasoning-start":
|
case "reasoning-start":
|
||||||
|
break;
|
||||||
case "reasoning-end":
|
case "reasoning-end":
|
||||||
|
this.reasoningProviderOptions = event.providerOptions;
|
||||||
|
this.flushBuffers();
|
||||||
|
break;
|
||||||
case "text-start":
|
case "text-start":
|
||||||
case "text-end":
|
case "text-end":
|
||||||
this.flushBuffers();
|
this.flushBuffers();
|
||||||
|
|
@ -352,6 +357,12 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
||||||
return await repo.fetch(id);
|
return await repo.fetch(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
|
export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
|
||||||
const result: ModelMessage[] = [];
|
const result: ModelMessage[] = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
|
|
@ -395,11 +406,37 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "user":
|
case "user":
|
||||||
result.push({
|
if (typeof msg.content === 'string') {
|
||||||
role: "user",
|
// Legacy string — pass through unchanged
|
||||||
content: msg.content,
|
result.push({
|
||||||
providerOptions,
|
role: "user",
|
||||||
});
|
content: msg.content,
|
||||||
|
providerOptions,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// New content parts array — collapse to text for LLM
|
||||||
|
const textSegments: string[] = [];
|
||||||
|
const attachmentLines: string[] = [];
|
||||||
|
|
||||||
|
for (const part of msg.content) {
|
||||||
|
if (part.type === "attachment") {
|
||||||
|
const sizeStr = part.size ? `, ${formatBytes(part.size)}` : '';
|
||||||
|
attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}`);
|
||||||
|
} else {
|
||||||
|
textSegments.push(part.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachmentLines.length > 0) {
|
||||||
|
textSegments.unshift("User has attached the following files:", ...attachmentLines, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
role: "user",
|
||||||
|
content: textSegments.join("\n"),
|
||||||
|
providerOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "tool":
|
case "tool":
|
||||||
result.push({
|
result.push({
|
||||||
|
|
@ -457,6 +494,7 @@ export class AgentState {
|
||||||
pendingAskHumanRequests: Record<string, z.infer<typeof AskHumanRequestEvent>> = {};
|
pendingAskHumanRequests: Record<string, z.infer<typeof AskHumanRequestEvent>> = {};
|
||||||
allowedToolCallIds: Record<string, true> = {};
|
allowedToolCallIds: Record<string, true> = {};
|
||||||
deniedToolCallIds: Record<string, true> = {};
|
deniedToolCallIds: Record<string, true> = {};
|
||||||
|
sessionAllowedCommands: Set<string> = new Set();
|
||||||
|
|
||||||
getPendingPermissions(): z.infer<typeof ToolPermissionRequestEvent>[] {
|
getPendingPermissions(): z.infer<typeof ToolPermissionRequestEvent>[] {
|
||||||
const response: z.infer<typeof ToolPermissionRequestEvent>[] = [];
|
const response: z.infer<typeof ToolPermissionRequestEvent>[] = [];
|
||||||
|
|
@ -593,6 +631,16 @@ export class AgentState {
|
||||||
switch (event.response) {
|
switch (event.response) {
|
||||||
case "approve":
|
case "approve":
|
||||||
this.allowedToolCallIds[event.toolCallId] = true;
|
this.allowedToolCallIds[event.toolCallId] = true;
|
||||||
|
// For session scope, extract command names and add to session allowlist
|
||||||
|
if (event.scope === "session") {
|
||||||
|
const toolCall = this.toolCallIdMap[event.toolCallId];
|
||||||
|
if (toolCall && typeof toolCall.arguments === 'object' && toolCall.arguments !== null && 'command' in toolCall.arguments) {
|
||||||
|
const names = extractCommandNames(String(toolCall.arguments.command));
|
||||||
|
for (const name of names) {
|
||||||
|
this.sessionAllowedCommands.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "deny":
|
case "deny":
|
||||||
this.deniedToolCallIds[event.toolCallId] = true;
|
this.deniedToolCallIds[event.toolCallId] = true;
|
||||||
|
|
@ -658,7 +706,12 @@ export async function* streamAgent({
|
||||||
|
|
||||||
// set up provider + model
|
// set up provider + model
|
||||||
const provider = createProvider(modelConfig.provider);
|
const provider = createProvider(modelConfig.provider);
|
||||||
const model = provider.languageModel(modelConfig.model);
|
const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep"];
|
||||||
|
const modelId = (knowledgeGraphAgents.includes(state.agentName!) && modelConfig.knowledgeGraphModel)
|
||||||
|
? modelConfig.knowledgeGraphModel
|
||||||
|
: modelConfig.model;
|
||||||
|
const model = provider.languageModel(modelId);
|
||||||
|
logger.log(`using model: ${modelId}`);
|
||||||
|
|
||||||
let loopCounter = 0;
|
let loopCounter = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|
@ -827,10 +880,6 @@ export async function* streamAgent({
|
||||||
tools,
|
tools,
|
||||||
signal,
|
signal,
|
||||||
)) {
|
)) {
|
||||||
// Only log significant events (not text-delta to reduce noise)
|
|
||||||
if (event.type !== 'text-delta') {
|
|
||||||
loopLogger.log('got llm-stream-event:', event.type);
|
|
||||||
}
|
|
||||||
messageBuilder.ingest(event);
|
messageBuilder.ingest(event);
|
||||||
yield* processEvent({
|
yield* processEvent({
|
||||||
runId,
|
runId,
|
||||||
|
|
@ -881,7 +930,7 @@ export async function* streamAgent({
|
||||||
}
|
}
|
||||||
if (underlyingTool.type === "builtin" && underlyingTool.name === "executeCommand") {
|
if (underlyingTool.type === "builtin" && underlyingTool.name === "executeCommand") {
|
||||||
// if command is blocked, then seek permission
|
// if command is blocked, then seek permission
|
||||||
if (isBlocked(part.arguments.command)) {
|
if (isBlocked(part.arguments.command, state.sessionAllowedCommands)) {
|
||||||
loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId);
|
loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId);
|
||||||
yield* processEvent({
|
yield* processEvent({
|
||||||
runId,
|
runId,
|
||||||
|
|
@ -924,9 +973,11 @@ async function* streamLlm(
|
||||||
tools: ToolSet,
|
tools: ToolSet,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown> {
|
): AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown> {
|
||||||
|
const converted = convertFromMessages(messages);
|
||||||
|
console.log(`! SENDING payload to model: `, JSON.stringify(converted))
|
||||||
const { fullStream } = streamText({
|
const { fullStream } = streamText({
|
||||||
model,
|
model,
|
||||||
messages: convertFromMessages(messages),
|
messages: converted,
|
||||||
system: instructions,
|
system: instructions,
|
||||||
tools,
|
tools,
|
||||||
stopWhen: stepCountIs(1),
|
stopWhen: stepCountIs(1),
|
||||||
|
|
@ -935,7 +986,7 @@ async function* streamLlm(
|
||||||
for await (const event of fullStream) {
|
for await (const event of fullStream) {
|
||||||
// Check abort on every chunk for responsiveness
|
// Check abort on every chunk for responsiveness
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
// console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event));
|
console.log("-> \t\tstream event", JSON.stringify(event));
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "error":
|
case "error":
|
||||||
yield {
|
yield {
|
||||||
|
|
@ -968,6 +1019,12 @@ async function* streamLlm(
|
||||||
providerOptions: event.providerMetadata,
|
providerOptions: event.providerMetadata,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
case "text-end":
|
||||||
|
yield {
|
||||||
|
type: "text-end",
|
||||||
|
providerOptions: event.providerMetadata,
|
||||||
|
};
|
||||||
|
break;
|
||||||
case "text-delta":
|
case "text-delta":
|
||||||
yield {
|
yield {
|
||||||
type: "text-delta",
|
type: "text-delta",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { skillCatalog } from "./skills/index.js";
|
import { skillCatalog } from "./skills/index.js";
|
||||||
import { WorkDir as BASE_DIR } from "../../config/config.js";
|
import { WorkDir as BASE_DIR } from "../../config/config.js";
|
||||||
|
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
|
||||||
|
|
||||||
|
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
||||||
|
|
||||||
export const CopilotInstructions = `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
|
export const CopilotInstructions = `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
|
||||||
|
|
||||||
|
|
@ -125,6 +128,18 @@ Always consult this catalog first so you load the right skills before taking act
|
||||||
- Summarize completed work and suggest logical next steps at the end of a task.
|
- Summarize completed work and suggest logical next steps at the end of a task.
|
||||||
- Always ask for confirmation before taking destructive actions.
|
- Always ask for confirmation before taking destructive actions.
|
||||||
|
|
||||||
|
## Output Formatting
|
||||||
|
- Use **H3** (###) for section headers in longer responses. Never use H1 or H2 — they're too large for chat.
|
||||||
|
- Use **bold** for key terms, names, or concepts the user should notice.
|
||||||
|
- Keep bullet points short (1-2 lines each). Use them for lists of 3+ items, not for general prose.
|
||||||
|
- Use numbered lists only when order matters (steps, rankings).
|
||||||
|
- For short answers (1-3 sentences), just use plain prose. No headers, no bullets.
|
||||||
|
- Use code blocks with language tags (\`\`\`python, \`\`\`json, etc.) for any code or config.
|
||||||
|
- Use inline \`code\` for file names, commands, variable names, or short technical references.
|
||||||
|
- Add a blank line between sections for breathing room.
|
||||||
|
- Never start a response with a heading. Lead with a sentence or two of context first.
|
||||||
|
- Avoid deeply nested bullets. If nesting beyond 2 levels, restructure.
|
||||||
|
|
||||||
## MCP Tool Discovery (CRITICAL)
|
## MCP Tool Discovery (CRITICAL)
|
||||||
|
|
||||||
**ALWAYS check for MCP tools BEFORE saying you can't do something.**
|
**ALWAYS check for MCP tools BEFORE saying you can't do something.**
|
||||||
|
|
@ -138,18 +153,22 @@ When a user asks for ANY task that might require external capabilities (web sear
|
||||||
- Use relative paths (no \`\${BASE_DIR}\` prefixes) when running commands or referencing files.
|
- Use relative paths (no \`\${BASE_DIR}\` prefixes) when running commands or referencing files.
|
||||||
- Keep user data safe—double-check before editing or deleting important resources.
|
- Keep user data safe—double-check before editing or deleting important resources.
|
||||||
|
|
||||||
|
${runtimeContextPrompt}
|
||||||
|
|
||||||
## Workspace Access & Scope
|
## Workspace Access & Scope
|
||||||
- **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
|
- **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
|
||||||
- **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
|
- **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
|
||||||
- **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads").
|
- **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads").
|
||||||
|
|
||||||
**CRITICAL - When the user asks you to work with files outside ~/.rowboat:**
|
**CRITICAL - When the user asks you to work with files outside ~/.rowboat:**
|
||||||
- The user is on **macOS**. Use macOS paths and commands (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` command).
|
- Follow the detected runtime platform above for shell syntax and filesystem path style.
|
||||||
|
- On macOS/Linux, use POSIX-style commands and paths (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` on macOS).
|
||||||
|
- On Windows, use cmd-compatible commands and Windows paths (e.g., \`C:\\Users\\<name>\\Desktop\`).
|
||||||
- You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths.
|
- You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths.
|
||||||
- NEVER say "I can only run commands inside ~/.rowboat" or "I don't have access to your Desktop" - just use \`executeCommand\`.
|
- NEVER say "I can only run commands inside ~/.rowboat" or "I don't have access to your Desktop" - just use \`executeCommand\`.
|
||||||
- NEVER offer commands for the user to run manually - run them yourself with \`executeCommand\`.
|
- NEVER offer commands for the user to run manually - run them yourself with \`executeCommand\`.
|
||||||
- NEVER say "I'll run shell commands equivalent to..." - just describe what you'll do in plain language (e.g., "I'll move 12 screenshots to a new Screenshots folder").
|
- NEVER say "I'll run shell commands equivalent to..." - just describe what you'll do in plain language (e.g., "I'll move 12 screenshots to a new Screenshots folder").
|
||||||
- NEVER ask what OS the user is on - they are on macOS.
|
- NEVER ask what OS the user is on if runtime platform is already available.
|
||||||
- Load the \`organize-files\` skill for guidance on file organization tasks.
|
- Load the \`organize-files\` skill for guidance on file organization tasks.
|
||||||
|
|
||||||
## Builtin Tools vs Shell Commands
|
## Builtin Tools vs Shell Commands
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
export type RuntimeShellDialect = 'windows-cmd' | 'posix-sh';
|
||||||
|
export type RuntimeOsName = 'Windows' | 'macOS' | 'Linux' | 'Unknown';
|
||||||
|
|
||||||
|
export interface RuntimeContext {
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
osName: RuntimeOsName;
|
||||||
|
shellDialect: RuntimeShellDialect;
|
||||||
|
shellExecutable: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExecutionShell(platform: NodeJS.Platform = process.platform): string {
|
||||||
|
return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext {
|
||||||
|
if (platform === 'win32') {
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
osName: 'Windows',
|
||||||
|
shellDialect: 'windows-cmd',
|
||||||
|
shellExecutable: getExecutionShell(platform),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
osName: 'macOS',
|
||||||
|
shellDialect: 'posix-sh',
|
||||||
|
shellExecutable: getExecutionShell(platform),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'linux') {
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
osName: 'Linux',
|
||||||
|
shellDialect: 'posix-sh',
|
||||||
|
shellExecutable: getExecutionShell(platform),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
osName: 'Unknown',
|
||||||
|
shellDialect: 'posix-sh',
|
||||||
|
shellExecutable: getExecutionShell(platform),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuntimeContextPrompt(runtime: RuntimeContext): string {
|
||||||
|
if (runtime.shellDialect === 'windows-cmd') {
|
||||||
|
return `## Runtime Platform (CRITICAL)
|
||||||
|
- Detected platform: **${runtime.platform}**
|
||||||
|
- Detected OS: **${runtime.osName}**
|
||||||
|
- Shell used by executeCommand: **${runtime.shellExecutable}** (Windows Command Prompt / cmd syntax)
|
||||||
|
- Use Windows command syntax for executeCommand (for example: \`dir\`, \`type\`, \`copy\`, \`move\`, \`del\`, \`rmdir\`).
|
||||||
|
- Use Windows-style absolute paths when outside workspace (for example: \`C:\\Users\\...\`).
|
||||||
|
- Do not assume macOS/Linux command syntax when the runtime is Windows.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `## Runtime Platform (CRITICAL)
|
||||||
|
- Detected platform: **${runtime.platform}**
|
||||||
|
- Detected OS: **${runtime.osName}**
|
||||||
|
- Shell used by executeCommand: **${runtime.shellExecutable}** (POSIX sh syntax)
|
||||||
|
- Use POSIX command syntax for executeCommand (for example: \`ls\`, \`cat\`, \`cp\`, \`mv\`, \`rm\`).
|
||||||
|
- Use POSIX paths when outside workspace (for example: \`~/Desktop\`, \`/Users/.../\` on macOS, \`/home/.../\` on Linux).
|
||||||
|
- Do not assume Windows command syntax when the runtime is POSIX.`;
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
import { exec, execSync, spawn, ChildProcess } from 'child_process';
|
import { exec, execSync, spawn, ChildProcess } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { getSecurityAllowList } from '../../config/security.js';
|
import { getSecurityAllowList } from '../../config/security.js';
|
||||||
|
import { getExecutionShell } from '../assistant/runtime-context.js';
|
||||||
|
|
||||||
const execPromise = promisify(exec);
|
const execPromise = promisify(exec);
|
||||||
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/;
|
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/;
|
||||||
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
|
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
|
||||||
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
|
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
|
||||||
|
const EXECUTION_SHELL = getExecutionShell();
|
||||||
|
|
||||||
function sanitizeToken(token: string): string {
|
function sanitizeToken(token: string): string {
|
||||||
return token.trim().replace(/^['"]+|['"]+$/g, '');
|
return token.trim().replace(/^['"()]+|['"()]+$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractCommandNames(command: string): string[] {
|
export function extractCommandNames(command: string): string[] {
|
||||||
const discovered = new Set<string>();
|
const discovered = new Set<string>();
|
||||||
const segments = command.split(COMMAND_SPLIT_REGEX);
|
const segments = command.split(COMMAND_SPLIT_REGEX);
|
||||||
|
|
||||||
|
|
@ -42,27 +44,21 @@ function extractCommandNames(command: string): string[] {
|
||||||
return Array.from(discovered);
|
return Array.from(discovered);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findBlockedCommands(command: string): string[] {
|
function findBlockedCommands(command: string, sessionAllowedCommands?: Set<string>): string[] {
|
||||||
const invoked = extractCommandNames(command);
|
const invoked = extractCommandNames(command);
|
||||||
if (!invoked.length) return [];
|
if (!invoked.length) return [];
|
||||||
|
|
||||||
const allowList = getSecurityAllowList();
|
const allowList = getSecurityAllowList();
|
||||||
if (!allowList.length) return invoked;
|
if (!allowList.length && (!sessionAllowedCommands || sessionAllowedCommands.size === 0)) return invoked;
|
||||||
|
|
||||||
const allowSet = new Set(allowList);
|
const allowSet = new Set(allowList);
|
||||||
if (allowSet.has('*')) return [];
|
if (allowSet.has('*')) return [];
|
||||||
|
|
||||||
return invoked.filter((cmd) => !allowSet.has(cmd));
|
return invoked.filter((cmd) => !allowSet.has(cmd) && !sessionAllowedCommands?.has(cmd));
|
||||||
}
|
}
|
||||||
|
|
||||||
// export const BlockedResult = {
|
export function isBlocked(command: string, sessionAllowedCommands?: Set<string>): boolean {
|
||||||
// stdout: '',
|
const blocked = findBlockedCommands(command, sessionAllowedCommands);
|
||||||
// stderr: `Command blocked by security policy. Update ${SECURITY_CONFIG_PATH} to allow them before retrying.`,
|
|
||||||
// exitCode: 126,
|
|
||||||
// };
|
|
||||||
|
|
||||||
export function isBlocked(command: string): boolean {
|
|
||||||
const blocked = findBlockedCommands(command);
|
|
||||||
return blocked.length > 0;
|
return blocked.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,7 +87,7 @@ export async function executeCommand(
|
||||||
cwd: options?.cwd,
|
cwd: options?.cwd,
|
||||||
timeout: options?.timeout,
|
timeout: options?.timeout,
|
||||||
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
|
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
|
||||||
shell: '/bin/sh', // use sh for cross-platform compatibility
|
shell: EXECUTION_SHELL,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -151,7 +147,7 @@ export function executeCommandAbortable(
|
||||||
// Check if already aborted before spawning
|
// Check if already aborted before spawning
|
||||||
if (options?.signal?.aborted) {
|
if (options?.signal?.aborted) {
|
||||||
// Return a dummy process and a resolved result
|
// Return a dummy process and a resolved result
|
||||||
const dummyProc = spawn('true', { shell: true });
|
const dummyProc = spawn(process.execPath, ['-e', 'process.exit(0)']);
|
||||||
dummyProc.kill();
|
dummyProc.kill();
|
||||||
return {
|
return {
|
||||||
process: dummyProc,
|
process: dummyProc,
|
||||||
|
|
@ -165,7 +161,7 @@ export function executeCommandAbortable(
|
||||||
}
|
}
|
||||||
|
|
||||||
const proc = spawn(command, [], {
|
const proc = spawn(command, [], {
|
||||||
shell: '/bin/sh',
|
shell: EXECUTION_SHELL,
|
||||||
cwd: options?.cwd,
|
cwd: options?.cwd,
|
||||||
detached: process.platform !== 'win32', // Create process group on Unix
|
detached: process.platform !== 'win32', // Create process group on Unix
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
|
@ -279,7 +275,7 @@ export function executeCommandSync(
|
||||||
cwd: options?.cwd,
|
cwd: options?.cwd,
|
||||||
timeout: options?.timeout,
|
timeout: options?.timeout,
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
shell: '/bin/sh',
|
shell: EXECUTION_SHELL,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js";
|
import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js";
|
||||||
|
import { UserMessageContent } from "@x/shared/dist/message.js";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export type UserMessageContentType = z.infer<typeof UserMessageContent>;
|
||||||
|
|
||||||
type EnqueuedMessage = {
|
type EnqueuedMessage = {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
message: string;
|
message: UserMessageContentType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IMessageQueue {
|
export interface IMessageQueue {
|
||||||
enqueue(runId: string, message: string): Promise<string>;
|
enqueue(runId: string, message: UserMessageContentType): Promise<string>;
|
||||||
dequeue(runId: string): Promise<EnqueuedMessage | null>;
|
dequeue(runId: string): Promise<EnqueuedMessage | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,7 +26,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
||||||
this.idGenerator = idGenerator;
|
this.idGenerator = idGenerator;
|
||||||
}
|
}
|
||||||
|
|
||||||
async enqueue(runId: string, message: string): Promise<string> {
|
async enqueue(runId: string, message: UserMessageContentType): Promise<string> {
|
||||||
if (!this.store[runId]) {
|
if (!this.store[runId]) {
|
||||||
this.store[runId] = [];
|
this.store[runId] = [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,19 +152,11 @@ export function generateState(): string {
|
||||||
*/
|
*/
|
||||||
export function buildAuthorizationUrl(
|
export function buildAuthorizationUrl(
|
||||||
config: client.Configuration,
|
config: client.Configuration,
|
||||||
params: {
|
params: Record<string, string>
|
||||||
redirectUri: string;
|
|
||||||
scope: string;
|
|
||||||
codeChallenge: string;
|
|
||||||
state: string;
|
|
||||||
}
|
|
||||||
): URL {
|
): URL {
|
||||||
return client.buildAuthorizationUrl(config, {
|
return client.buildAuthorizationUrl(config, {
|
||||||
redirect_uri: params.redirectUri,
|
|
||||||
scope: params.scope,
|
|
||||||
code_challenge: params.codeChallenge,
|
|
||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
state: params.state,
|
...params,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -208,6 +200,11 @@ export async function refreshTokens(
|
||||||
tokens.scopes = existingScopes;
|
tokens.scopes = existingScopes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preserve existing refresh token if server didn't return it
|
||||||
|
if (!tokens.refresh_token) {
|
||||||
|
tokens.refresh_token = refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[OAuth] Token refresh successful`);
|
console.log(`[OAuth] Token refresh successful`);
|
||||||
return tokens;
|
return tokens;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ import { OAuthTokens } from './types.js';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
const ProviderConnectionSchema = z.object({
|
const ProviderConnectionSchema = z.object({
|
||||||
tokens: OAuthTokens,
|
tokens: OAuthTokens.nullable().optional(),
|
||||||
clientId: z.string().optional(),
|
clientId: z.string().nullable().optional(),
|
||||||
error: z.string().optional(),
|
error: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const OAuthConfigSchema = z.object({
|
const OAuthConfigSchema = z.object({
|
||||||
|
|
@ -17,7 +17,7 @@ const OAuthConfigSchema = z.object({
|
||||||
|
|
||||||
const ClientFacingConfigSchema = z.record(z.string(), z.object({
|
const ClientFacingConfigSchema = z.record(z.string(), z.object({
|
||||||
connected: z.boolean(),
|
connected: z.boolean(),
|
||||||
error: z.string().optional(),
|
error: z.string().nullable().optional(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const LegacyOauthConfigSchema = z.record(z.string(), OAuthTokens);
|
const LegacyOauthConfigSchema = z.record(z.string(), OAuthTokens);
|
||||||
|
|
@ -28,13 +28,9 @@ const DEFAULT_CONFIG: z.infer<typeof OAuthConfigSchema> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IOAuthRepo {
|
export interface IOAuthRepo {
|
||||||
getTokens(provider: string): Promise<OAuthTokens | null>;
|
read(provider: string): Promise<z.infer<typeof ProviderConnectionSchema>>;
|
||||||
saveTokens(provider: string, tokens: OAuthTokens): Promise<void>;
|
upsert(provider: string, connection: Partial<z.infer<typeof ProviderConnectionSchema>>): Promise<void>;
|
||||||
clearTokens(provider: string): Promise<void>;
|
delete(provider: string): Promise<void>;
|
||||||
getClientId(provider: string): Promise<string | null>;
|
|
||||||
setClientId(provider: string, clientId: string): Promise<void>;
|
|
||||||
setError(provider: string, errorMessage: string): Promise<void>;
|
|
||||||
clearError(provider: string): Promise<void>;
|
|
||||||
getClientFacingConfig(): Promise<z.infer<typeof ClientFacingConfigSchema>>;
|
getClientFacingConfig(): Promise<z.infer<typeof ClientFacingConfigSchema>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,71 +88,22 @@ export class FSOAuthRepo implements IOAuthRepo {
|
||||||
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2));
|
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTokens(provider: string): Promise<OAuthTokens | null> {
|
async read(provider: string): Promise<z.infer<typeof ProviderConnectionSchema>> {
|
||||||
const config = await this.readConfig();
|
const config = await this.readConfig();
|
||||||
const tokens = config.providers[provider]?.tokens;
|
return config.providers[provider] ?? {};
|
||||||
return tokens ?? null;
|
|
||||||
}
|
}
|
||||||
|
async upsert(provider: string, connection: Partial<z.infer<typeof ProviderConnectionSchema>>): Promise<void> {
|
||||||
async saveTokens(provider: string, tokens: OAuthTokens): Promise<void> {
|
|
||||||
const config = await this.readConfig();
|
const config = await this.readConfig();
|
||||||
if (config.providers[provider]) {
|
config.providers[provider] = { ...config.providers[provider] ?? {}, ...connection };
|
||||||
delete config.providers[provider];
|
|
||||||
}
|
|
||||||
config.providers[provider] = {
|
|
||||||
tokens,
|
|
||||||
};
|
|
||||||
await this.writeConfig(config);
|
await this.writeConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearTokens(provider: string): Promise<void> {
|
async delete(provider: string): Promise<void> {
|
||||||
const config = await this.readConfig();
|
const config = await this.readConfig();
|
||||||
delete config.providers[provider];
|
delete config.providers[provider];
|
||||||
await this.writeConfig(config);
|
await this.writeConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getClientId(provider: string): Promise<string | null> {
|
|
||||||
const config = await this.readConfig();
|
|
||||||
const clientId = config.providers[provider]?.clientId;
|
|
||||||
return clientId ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async setClientId(provider: string, clientId: string): Promise<void> {
|
|
||||||
const config = await this.readConfig();
|
|
||||||
if (!config.providers[provider]) {
|
|
||||||
throw new Error(`Provider ${provider} not found`);
|
|
||||||
}
|
|
||||||
config.providers[provider].clientId = clientId;
|
|
||||||
await this.writeConfig(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearClientId(provider: string): Promise<void> {
|
|
||||||
const config = await this.readConfig();
|
|
||||||
if (!config.providers[provider]) {
|
|
||||||
throw new Error(`Provider ${provider} not found`);
|
|
||||||
}
|
|
||||||
delete config.providers[provider].clientId;
|
|
||||||
await this.writeConfig(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setError(provider: string, errorMessage: string): Promise<void> {
|
|
||||||
const config = await this.readConfig();
|
|
||||||
if (!config.providers[provider]) {
|
|
||||||
throw new Error(`Provider ${provider} not found`);
|
|
||||||
}
|
|
||||||
config.providers[provider].error = errorMessage;
|
|
||||||
await this.writeConfig(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearError(provider: string): Promise<void> {
|
|
||||||
const config = await this.readConfig();
|
|
||||||
if (!config.providers[provider]) {
|
|
||||||
throw new Error(`Provider ${provider} not found`);
|
|
||||||
}
|
|
||||||
delete config.providers[provider].error;
|
|
||||||
await this.writeConfig(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getClientFacingConfig(): Promise<z.infer<typeof ClientFacingConfigSchema>> {
|
async getClientFacingConfig(): Promise<z.infer<typeof ClientFacingConfigSchema>> {
|
||||||
const config = await this.readConfig();
|
const config = await this.readConfig();
|
||||||
const clientFacingConfig: z.infer<typeof ClientFacingConfigSchema> = {};
|
const clientFacingConfig: z.infer<typeof ClientFacingConfigSchema> = {};
|
||||||
|
|
|
||||||
|
|
@ -343,7 +343,7 @@ export async function executeAction(
|
||||||
try {
|
try {
|
||||||
const client = getComposioClient();
|
const client = getComposioClient();
|
||||||
const result = await client.tools.execute(actionSlug, {
|
const result = await client.tools.execute(actionSlug, {
|
||||||
userId: connectedAccountId,
|
userId: 'rowboat-user',
|
||||||
arguments: input,
|
arguments: input,
|
||||||
connectedAccountId,
|
connectedAccountId,
|
||||||
dangerouslySkipVersionCheck: true,
|
dangerouslySkipVersionCheck: true,
|
||||||
|
|
@ -352,8 +352,8 @@ export async function executeAction(
|
||||||
console.log(`[Composio] Action completed successfully`);
|
console.log(`[Composio] Action completed successfully`);
|
||||||
return { success: true, data: result.data };
|
return { success: true, data: result.data };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Composio] Action execution failed:`, error);
|
console.error(`[Composio] Action execution failed:`, JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}), 2));
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : (typeof error === 'object' ? JSON.stringify(error) : 'Unknown error');
|
||||||
return { success: false, data: null, error: message };
|
return { success: false, data: null, error: message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,4 +91,9 @@ function ensureWelcomeFile() {
|
||||||
|
|
||||||
ensureDirs();
|
ensureDirs();
|
||||||
ensureDefaultConfigs();
|
ensureDefaultConfigs();
|
||||||
ensureWelcomeFile();
|
ensureWelcomeFile();
|
||||||
|
|
||||||
|
// Initialize version history repo (async, fire-and-forget on startup)
|
||||||
|
import('../knowledge/version_history.js').then(m => m.initRepo()).catch(err => {
|
||||||
|
console.error('[VersionHistory] Failed to init repo:', err);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,23 @@ const DEFAULT_ALLOW_LIST = [
|
||||||
let cachedAllowList: string[] | null = null;
|
let cachedAllowList: string[] | null = null;
|
||||||
let cachedMtimeMs: number | null = null;
|
let cachedMtimeMs: number | null = null;
|
||||||
|
|
||||||
|
export async function addToSecurityConfig(commands: string[]): Promise<void> {
|
||||||
|
ensureSecurityConfigSync();
|
||||||
|
const current = readAllowList();
|
||||||
|
const merged = new Set(current);
|
||||||
|
for (const cmd of commands) {
|
||||||
|
const normalized = cmd.trim().toLowerCase();
|
||||||
|
if (normalized) merged.add(normalized);
|
||||||
|
}
|
||||||
|
await fsPromises.writeFile(
|
||||||
|
SECURITY_CONFIG_PATH,
|
||||||
|
JSON.stringify(Array.from(merged).sort(), null, 2) + "\n",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
// Reset cache so next read picks up the new file
|
||||||
|
resetSecurityAllowListCache();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Async function to ensure security config file exists.
|
* Async function to ensure security config file exists.
|
||||||
* Called explicitly at app startup via initConfigs().
|
* Called explicitly at app startup via initConfigs().
|
||||||
|
|
@ -102,11 +119,9 @@ export function getSecurityAllowList(): string[] {
|
||||||
if (cachedAllowList && cachedMtimeMs === stats.mtimeMs) {
|
if (cachedAllowList && cachedMtimeMs === stats.mtimeMs) {
|
||||||
return cachedAllowList;
|
return cachedAllowList;
|
||||||
}
|
}
|
||||||
|
cachedAllowList = readAllowList();
|
||||||
const allowList = readAllowList();
|
|
||||||
cachedAllowList = allowList;
|
|
||||||
cachedMtimeMs = stats.mtimeMs;
|
cachedMtimeMs = stats.mtimeMs;
|
||||||
return allowList;
|
return cachedAllowList;
|
||||||
} catch {
|
} catch {
|
||||||
cachedAllowList = null;
|
cachedAllowList = null;
|
||||||
cachedMtimeMs = null;
|
cachedMtimeMs = null;
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,7 @@ export * as workspace from './workspace/workspace.js';
|
||||||
export * as watcher from './workspace/watcher.js';
|
export * as watcher from './workspace/watcher.js';
|
||||||
|
|
||||||
// Config initialization
|
// Config initialization
|
||||||
export { initConfigs } from './config/initConfigs.js';
|
export { initConfigs } from './config/initConfigs.js';
|
||||||
|
|
||||||
|
// Knowledge version history
|
||||||
|
export * as versionHistory from './knowledge/version_history.js';
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from './graph_state.js';
|
} from './graph_state.js';
|
||||||
import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js';
|
import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js';
|
||||||
import { limitEventItems } from './limit_event_items.js';
|
import { limitEventItems } from './limit_event_items.js';
|
||||||
|
import { commitAll } from './version_history.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build obsidian-style knowledge graph by running topic extraction
|
* Build obsidian-style knowledge graph by running topic extraction
|
||||||
|
|
@ -320,6 +321,13 @@ async function buildGraphWithFiles(
|
||||||
// Save state after each successful batch
|
// Save state after each successful batch
|
||||||
// This ensures partial progress is saved even if later batches fail
|
// This ensures partial progress is saved even if later batches fail
|
||||||
saveState(state);
|
saveState(state);
|
||||||
|
|
||||||
|
// Commit knowledge changes to version history
|
||||||
|
try {
|
||||||
|
await commitAll('Knowledge update', 'Rowboat');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[GraphBuilder] Failed to commit version history:`, err);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hadError = true;
|
hadError = true;
|
||||||
console.error(`Error processing batch ${batchNumber}:`, error);
|
console.error(`Error processing batch ${batchNumber}:`, error);
|
||||||
|
|
@ -467,6 +475,13 @@ async function processVoiceMemosForKnowledge(): Promise<boolean> {
|
||||||
|
|
||||||
// Save state after each batch
|
// Save state after each batch
|
||||||
saveState(state);
|
saveState(state);
|
||||||
|
|
||||||
|
// Commit knowledge changes to version history
|
||||||
|
try {
|
||||||
|
await commitAll('Knowledge update', 'Rowboat');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[GraphBuilder] Failed to commit version history:`, err);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hadError = true;
|
hadError = true;
|
||||||
console.error(`[GraphBuilder] Error processing batch ${batchNumber}:`, error);
|
console.error(`[GraphBuilder] Error processing batch ${batchNumber}:`, error);
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export class FirefliesClientFactory {
|
||||||
*/
|
*/
|
||||||
static async getClient(): Promise<Client | null> {
|
static async getClient(): Promise<Client | null> {
|
||||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||||
const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME);
|
const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
|
||||||
|
|
||||||
if (!tokens) {
|
if (!tokens) {
|
||||||
this.clearCache();
|
this.clearCache();
|
||||||
|
|
@ -49,7 +49,7 @@ export class FirefliesClientFactory {
|
||||||
// Token expired, try to refresh
|
// Token expired, try to refresh
|
||||||
if (!tokens.refresh_token) {
|
if (!tokens.refresh_token) {
|
||||||
console.log("[Fireflies] Token expired and no refresh token available.");
|
console.log("[Fireflies] Token expired and no refresh token available.");
|
||||||
await oauthRepo.setError(this.PROVIDER_NAME, 'Missing refresh token. Please reconnect.');
|
await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Missing refresh token. Please reconnect.' });
|
||||||
this.clearCache();
|
this.clearCache();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +62,7 @@ export class FirefliesClientFactory {
|
||||||
tokens.refresh_token,
|
tokens.refresh_token,
|
||||||
existingScopes
|
existingScopes
|
||||||
);
|
);
|
||||||
await oauthRepo.saveTokens(this.PROVIDER_NAME, refreshedTokens);
|
await oauthRepo.upsert(this.PROVIDER_NAME, { tokens: refreshedTokens });
|
||||||
|
|
||||||
// Update cached tokens and recreate client
|
// Update cached tokens and recreate client
|
||||||
this.cache.tokens = refreshedTokens;
|
this.cache.tokens = refreshedTokens;
|
||||||
|
|
@ -77,7 +77,7 @@ export class FirefliesClientFactory {
|
||||||
return this.cache.client;
|
return this.cache.client;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to refresh token for Fireflies';
|
const message = error instanceof Error ? error.message : 'Failed to refresh token for Fireflies';
|
||||||
await oauthRepo.setError(this.PROVIDER_NAME, message);
|
await oauthRepo.upsert(this.PROVIDER_NAME, { error: message });
|
||||||
console.error("[Fireflies] Failed to refresh token:", error);
|
console.error("[Fireflies] Failed to refresh token:", error);
|
||||||
this.clearCache();
|
this.clearCache();
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -107,7 +107,7 @@ export class FirefliesClientFactory {
|
||||||
*/
|
*/
|
||||||
static async hasValidCredentials(): Promise<boolean> {
|
static async hasValidCredentials(): Promise<boolean> {
|
||||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||||
const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME);
|
const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
|
||||||
return tokens !== null;
|
return tokens !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,9 @@ export class GoogleClientFactory {
|
||||||
|
|
||||||
private static async resolveClientId(): Promise<string> {
|
private static async resolveClientId(): Promise<string> {
|
||||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||||
const clientId = await oauthRepo.getClientId(this.PROVIDER_NAME);
|
const { clientId } = await oauthRepo.read(this.PROVIDER_NAME);
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
await oauthRepo.setError(this.PROVIDER_NAME, 'Google client ID missing. Please reconnect.');
|
await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Google client ID missing. Please reconnect.' });
|
||||||
throw new Error('Google client ID missing. Please reconnect.');
|
throw new Error('Google client ID missing. Please reconnect.');
|
||||||
}
|
}
|
||||||
return clientId;
|
return clientId;
|
||||||
|
|
@ -40,7 +40,7 @@ export class GoogleClientFactory {
|
||||||
*/
|
*/
|
||||||
static async getClient(): Promise<OAuth2Client | null> {
|
static async getClient(): Promise<OAuth2Client | null> {
|
||||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||||
const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME);
|
const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
|
||||||
|
|
||||||
if (!tokens) {
|
if (!tokens) {
|
||||||
this.clearCache();
|
this.clearCache();
|
||||||
|
|
@ -64,7 +64,7 @@ export class GoogleClientFactory {
|
||||||
// Token expired, try to refresh
|
// Token expired, try to refresh
|
||||||
if (!tokens.refresh_token) {
|
if (!tokens.refresh_token) {
|
||||||
console.log("[OAuth] Token expired and no refresh token available for Google.");
|
console.log("[OAuth] Token expired and no refresh token available for Google.");
|
||||||
await oauthRepo.setError(this.PROVIDER_NAME, 'Missing refresh token. Please reconnect.');
|
await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Missing refresh token. Please reconnect.' });
|
||||||
this.clearCache();
|
this.clearCache();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -77,7 +77,7 @@ export class GoogleClientFactory {
|
||||||
tokens.refresh_token,
|
tokens.refresh_token,
|
||||||
existingScopes
|
existingScopes
|
||||||
);
|
);
|
||||||
await oauthRepo.saveTokens(this.PROVIDER_NAME, refreshedTokens);
|
await oauthRepo.upsert(this.PROVIDER_NAME, { tokens: refreshedTokens });
|
||||||
|
|
||||||
// Update cached tokens and recreate client
|
// Update cached tokens and recreate client
|
||||||
this.cache.tokens = refreshedTokens;
|
this.cache.tokens = refreshedTokens;
|
||||||
|
|
@ -89,7 +89,7 @@ export class GoogleClientFactory {
|
||||||
return this.cache.client;
|
return this.cache.client;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to refresh token for Google';
|
const message = error instanceof Error ? error.message : 'Failed to refresh token for Google';
|
||||||
await oauthRepo.setError(this.PROVIDER_NAME, message);
|
await oauthRepo.upsert(this.PROVIDER_NAME, { error: message });
|
||||||
console.error("[OAuth] Failed to refresh token for Google:", error);
|
console.error("[OAuth] Failed to refresh token for Google:", error);
|
||||||
this.clearCache();
|
this.clearCache();
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -116,7 +116,7 @@ export class GoogleClientFactory {
|
||||||
*/
|
*/
|
||||||
static async hasValidCredentials(requiredScopes: string | string[]): Promise<boolean> {
|
static async hasValidCredentials(requiredScopes: string | string[]): Promise<boolean> {
|
||||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||||
const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME);
|
const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
|
||||||
if (!tokens) {
|
if (!tokens) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
243
apps/x/packages/core/src/knowledge/version_history.ts
Normal file
243
apps/x/packages/core/src/knowledge/version_history.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import git from 'isomorphic-git';
|
||||||
|
import { WorkDir } from '../config/config.js';
|
||||||
|
|
||||||
|
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||||
|
|
||||||
|
// Simple promise-based mutex to serialize commits
|
||||||
|
let commitLock: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
// Commit listeners for notifying other layers (e.g. renderer refresh)
|
||||||
|
type CommitListener = () => void;
|
||||||
|
const commitListeners: CommitListener[] = [];
|
||||||
|
|
||||||
|
export function onCommit(listener: CommitListener): () => void {
|
||||||
|
commitListeners.push(listener);
|
||||||
|
return () => {
|
||||||
|
const idx = commitListeners.indexOf(listener);
|
||||||
|
if (idx >= 0) commitListeners.splice(idx, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize a git repo in the knowledge directory if one doesn't exist.
|
||||||
|
* Stages all existing .md files and makes an initial commit.
|
||||||
|
*/
|
||||||
|
export async function initRepo(): Promise<void> {
|
||||||
|
const gitDir = path.join(KNOWLEDGE_DIR, '.git');
|
||||||
|
if (fs.existsSync(gitDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure knowledge dir exists
|
||||||
|
if (!fs.existsSync(KNOWLEDGE_DIR)) {
|
||||||
|
fs.mkdirSync(KNOWLEDGE_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await git.init({ fs, dir: KNOWLEDGE_DIR });
|
||||||
|
|
||||||
|
// Stage all existing .md files
|
||||||
|
const files = getAllMdFiles(KNOWLEDGE_DIR, '');
|
||||||
|
for (const file of files) {
|
||||||
|
await git.add({ fs, dir: KNOWLEDGE_DIR, filepath: file });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
await git.commit({
|
||||||
|
fs,
|
||||||
|
dir: KNOWLEDGE_DIR,
|
||||||
|
message: 'Initial snapshot',
|
||||||
|
author: { name: 'Rowboat', email: 'local' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively find all .md files relative to the knowledge dir.
|
||||||
|
*/
|
||||||
|
function getAllMdFiles(baseDir: string, relDir: string): string[] {
|
||||||
|
const results: string[] = [];
|
||||||
|
const absDir = relDir ? path.join(baseDir, relDir) : baseDir;
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(absDir);
|
||||||
|
} catch {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry === '.git' || entry.startsWith('.')) continue;
|
||||||
|
const fullPath = path.join(absDir, entry);
|
||||||
|
const relPath = relDir ? `${relDir}/${entry}` : entry;
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
results.push(...getAllMdFiles(baseDir, relPath));
|
||||||
|
} else if (entry.endsWith('.md')) {
|
||||||
|
results.push(relPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage all changes to .md files and commit. No-op if nothing changed.
|
||||||
|
* Serialized via a promise lock to prevent concurrent git index corruption.
|
||||||
|
*/
|
||||||
|
export async function commitAll(message: string, authorName: string): Promise<void> {
|
||||||
|
const prev = commitLock;
|
||||||
|
let resolve: () => void;
|
||||||
|
commitLock = new Promise(r => { resolve = r; });
|
||||||
|
|
||||||
|
await prev;
|
||||||
|
try {
|
||||||
|
await commitAllInner(message, authorName);
|
||||||
|
} finally {
|
||||||
|
resolve!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitAllInner(message: string, authorName: string): Promise<void> {
|
||||||
|
const matrix = await git.statusMatrix({ fs, dir: KNOWLEDGE_DIR });
|
||||||
|
|
||||||
|
let hasChanges = false;
|
||||||
|
for (const [filepath, head, workdir, stage] of matrix) {
|
||||||
|
// Skip non-md files
|
||||||
|
if (!filepath.endsWith('.md')) continue;
|
||||||
|
|
||||||
|
// [filepath, HEAD, WORKDIR, STAGE]
|
||||||
|
// Unchanged: [f, 1, 1, 1]
|
||||||
|
if (head === 1 && workdir === 1 && stage === 1) continue;
|
||||||
|
|
||||||
|
hasChanges = true;
|
||||||
|
|
||||||
|
if (workdir === 0) {
|
||||||
|
// File deleted from workdir
|
||||||
|
await git.remove({ fs, dir: KNOWLEDGE_DIR, filepath });
|
||||||
|
} else {
|
||||||
|
// File added or modified
|
||||||
|
await git.add({ fs, dir: KNOWLEDGE_DIR, filepath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasChanges) return;
|
||||||
|
|
||||||
|
await git.commit({
|
||||||
|
fs,
|
||||||
|
dir: KNOWLEDGE_DIR,
|
||||||
|
message,
|
||||||
|
author: { name: authorName, email: 'local' },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const listener of commitListeners) {
|
||||||
|
try { listener(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommitInfo {
|
||||||
|
oid: string;
|
||||||
|
message: string;
|
||||||
|
timestamp: number;
|
||||||
|
author: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_FILE_HISTORY = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get commit history for a specific file.
|
||||||
|
* Returns commits where the file content changed, most recent first.
|
||||||
|
* Capped at MAX_FILE_HISTORY entries.
|
||||||
|
*/
|
||||||
|
export async function getFileHistory(knowledgeRelPath: string): Promise<CommitInfo[]> {
|
||||||
|
// Normalize path separators for git (always forward slashes)
|
||||||
|
const filepath = knowledgeRelPath.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
let commits: Awaited<ReturnType<typeof git.log>>;
|
||||||
|
try {
|
||||||
|
commits = await git.log({ fs, dir: KNOWLEDGE_DIR });
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commits.length === 0) return [];
|
||||||
|
|
||||||
|
const result: CommitInfo[] = [];
|
||||||
|
|
||||||
|
// Walk through commits and check if file changed between consecutive commits
|
||||||
|
for (let i = 0; i < commits.length; i++) {
|
||||||
|
if (result.length >= MAX_FILE_HISTORY) break;
|
||||||
|
|
||||||
|
const commit = commits[i]!;
|
||||||
|
const parentCommit = commits[i + 1]; // undefined for the first (oldest) commit
|
||||||
|
|
||||||
|
const currentOid = await getBlobOidAtCommit(commit.oid, filepath);
|
||||||
|
const parentOid = parentCommit
|
||||||
|
? await getBlobOidAtCommit(parentCommit.oid, filepath)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Include this commit if:
|
||||||
|
// - The file existed and changed from parent
|
||||||
|
// - The file was added (parentOid is null but currentOid exists)
|
||||||
|
// - The file was deleted (currentOid is null but parentOid exists)
|
||||||
|
if (currentOid !== parentOid) {
|
||||||
|
result.push({
|
||||||
|
oid: commit.oid,
|
||||||
|
message: commit.commit.message.trim(),
|
||||||
|
timestamp: commit.commit.author.timestamp,
|
||||||
|
author: commit.commit.author.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the blob OID for a file at a specific commit, or null if not found.
|
||||||
|
*/
|
||||||
|
async function getBlobOidAtCommit(commitOid: string, filepath: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const result = await git.readBlob({
|
||||||
|
fs,
|
||||||
|
dir: KNOWLEDGE_DIR,
|
||||||
|
oid: commitOid,
|
||||||
|
filepath,
|
||||||
|
});
|
||||||
|
// Compute a content hash from the blob to compare
|
||||||
|
return result.oid;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read file content at a specific commit.
|
||||||
|
*/
|
||||||
|
export async function getFileAtCommit(knowledgeRelPath: string, oid: string): Promise<string> {
|
||||||
|
const filepath = knowledgeRelPath.replace(/\\/g, '/');
|
||||||
|
const result = await git.readBlob({
|
||||||
|
fs,
|
||||||
|
dir: KNOWLEDGE_DIR,
|
||||||
|
oid,
|
||||||
|
filepath,
|
||||||
|
});
|
||||||
|
return Buffer.from(result.blob).toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a file to its content at a given commit, then commit the restoration.
|
||||||
|
*/
|
||||||
|
export async function restoreFile(knowledgeRelPath: string, oid: string): Promise<void> {
|
||||||
|
const content = await getFileAtCommit(knowledgeRelPath, oid);
|
||||||
|
const absPath = path.join(KNOWLEDGE_DIR, knowledgeRelPath);
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
const dir = path.dirname(absPath);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(absPath, content, 'utf-8');
|
||||||
|
|
||||||
|
const filename = path.basename(knowledgeRelPath);
|
||||||
|
await commitAll(`Restored ${filename}`, 'You');
|
||||||
|
}
|
||||||
|
|
@ -46,10 +46,18 @@ export class FSRunsRepo implements IRunsRepo {
|
||||||
const messageEvent = event as z.infer<typeof MessageEvent>;
|
const messageEvent = event as z.infer<typeof MessageEvent>;
|
||||||
if (messageEvent.message.role === 'user') {
|
if (messageEvent.message.role === 'user') {
|
||||||
const content = messageEvent.message.content;
|
const content = messageEvent.message.content;
|
||||||
if (typeof content === 'string' && content.trim()) {
|
let textContent: string | undefined;
|
||||||
// Clean attached-files XML and @mentions, then truncate to 100 chars
|
if (typeof content === 'string') {
|
||||||
const cleaned = cleanContentForTitle(content);
|
textContent = content;
|
||||||
if (!cleaned) continue; // Skip if only attached files/mentions
|
} else {
|
||||||
|
textContent = content
|
||||||
|
.filter(p => p.type === 'text')
|
||||||
|
.map(p => p.text)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
if (textContent && textContent.trim()) {
|
||||||
|
const cleaned = cleanContentForTitle(textContent);
|
||||||
|
if (!cleaned) continue;
|
||||||
return cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
|
return cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -90,9 +98,17 @@ export class FSRunsRepo implements IRunsRepo {
|
||||||
if (msg.role === 'user') {
|
if (msg.role === 'user') {
|
||||||
// Found first user message - use as title
|
// Found first user message - use as title
|
||||||
const content = msg.content;
|
const content = msg.content;
|
||||||
if (typeof content === 'string' && content.trim()) {
|
let textContent: string | undefined;
|
||||||
// Clean attached-files XML and @mentions, then truncate
|
if (typeof content === 'string') {
|
||||||
const cleaned = cleanContentForTitle(content);
|
textContent = content;
|
||||||
|
} else {
|
||||||
|
textContent = content
|
||||||
|
.filter(p => p.type === 'text')
|
||||||
|
.map(p => p.text)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
if (textContent && textContent.trim()) {
|
||||||
|
const cleaned = cleanContentForTitle(textContent);
|
||||||
if (cleaned) {
|
if (cleaned) {
|
||||||
title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
|
title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import container from "../di/container.js";
|
import container from "../di/container.js";
|
||||||
import { IMessageQueue } from "../application/lib/message-queue.js";
|
import { IMessageQueue, UserMessageContentType } from "../application/lib/message-queue.js";
|
||||||
import { AskHumanResponseEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
|
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
|
||||||
import { IRunsRepo } from "./repo.js";
|
import { IRunsRepo } from "./repo.js";
|
||||||
import { IAgentRuntime } from "../agents/runtime.js";
|
import { IAgentRuntime } from "../agents/runtime.js";
|
||||||
import { IBus } from "../application/lib/bus.js";
|
import { IBus } from "../application/lib/bus.js";
|
||||||
import { IAbortRegistry } from "./abort-registry.js";
|
import { IAbortRegistry } from "./abort-registry.js";
|
||||||
import { IRunsLock } from "./lock.js";
|
import { IRunsLock } from "./lock.js";
|
||||||
import { forceCloseAllMcpClients } from "../mcp/mcp.js";
|
import { forceCloseAllMcpClients } from "../mcp/mcp.js";
|
||||||
|
import { extractCommandNames } from "../application/lib/command-executor.js";
|
||||||
|
import { addToSecurityConfig } from "../config/security.js";
|
||||||
|
|
||||||
export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> {
|
export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> {
|
||||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||||
|
|
@ -17,7 +19,7 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
|
||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMessage(runId: string, message: string): Promise<string> {
|
export async function createMessage(runId: string, message: UserMessageContentType): Promise<string> {
|
||||||
const queue = container.resolve<IMessageQueue>('messageQueue');
|
const queue = container.resolve<IMessageQueue>('messageQueue');
|
||||||
const id = await queue.enqueue(runId, message);
|
const id = await queue.enqueue(runId, message);
|
||||||
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
||||||
|
|
@ -26,11 +28,32 @@ export async function createMessage(runId: string, message: string): Promise<str
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function authorizePermission(runId: string, ev: z.infer<typeof ToolPermissionAuthorizePayload>): Promise<void> {
|
export async function authorizePermission(runId: string, ev: z.infer<typeof ToolPermissionAuthorizePayload>): Promise<void> {
|
||||||
|
const { scope, ...rest } = ev;
|
||||||
|
|
||||||
|
// For "always" scope, derive command from the run log and persist to security config
|
||||||
|
if (rest.response === "approve" && scope === "always") {
|
||||||
|
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||||
|
const run = await repo.fetch(runId);
|
||||||
|
const permReqEvent = run.log.find(
|
||||||
|
(e): e is z.infer<typeof ToolPermissionRequestEvent> =>
|
||||||
|
e.type === "tool-permission-request"
|
||||||
|
&& e.toolCall.toolCallId === rest.toolCallId
|
||||||
|
&& JSON.stringify(e.subflow) === JSON.stringify(rest.subflow)
|
||||||
|
);
|
||||||
|
if (permReqEvent && typeof permReqEvent.toolCall.arguments === 'object' && permReqEvent.toolCall.arguments !== null && 'command' in permReqEvent.toolCall.arguments) {
|
||||||
|
const commandNames = extractCommandNames(String(permReqEvent.toolCall.arguments.command));
|
||||||
|
if (commandNames.length > 0) {
|
||||||
|
await addToSecurityConfig(commandNames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||||
const event: z.infer<typeof ToolPermissionResponseEvent> = {
|
const event: z.infer<typeof ToolPermissionResponseEvent> = {
|
||||||
...ev,
|
...rest,
|
||||||
runId,
|
runId,
|
||||||
type: "tool-permission-response",
|
type: "tool-permission-response",
|
||||||
|
scope,
|
||||||
};
|
};
|
||||||
await repo.appendEvents(runId, [event]);
|
await repo.appendEvents(runId, [event]);
|
||||||
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
||||||
|
|
|
||||||
375
apps/x/packages/core/src/search/search.ts
Normal file
375
apps/x/packages/core/src/search/search.ts
Normal file
|
|
@ -0,0 +1,375 @@
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import fsp from 'fs/promises';
|
||||||
|
import readline from 'readline';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import { WorkDir } from '../config/config.js';
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
type: 'knowledge' | 'chat';
|
||||||
|
title: string;
|
||||||
|
preview: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||||
|
const RUNS_DIR = path.join(WorkDir, 'runs');
|
||||||
|
|
||||||
|
type SearchType = 'knowledge' | 'chat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search across knowledge files and chat history.
|
||||||
|
* @param types - optional filter to search only specific types (default: both)
|
||||||
|
*/
|
||||||
|
export async function search(query: string, limit = 20, types?: SearchType[]): Promise<{ results: SearchResult[] }> {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { results: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchKnowledgeEnabled = !types || types.includes('knowledge');
|
||||||
|
const searchChatsEnabled = !types || types.includes('chat');
|
||||||
|
|
||||||
|
const [knowledgeResults, chatResults] = await Promise.all([
|
||||||
|
searchKnowledgeEnabled ? searchKnowledge(trimmed, limit) : Promise.resolve([]),
|
||||||
|
searchChatsEnabled ? searchChats(trimmed, limit) : Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const results = [...knowledgeResults, ...chatResults].slice(0, limit);
|
||||||
|
return { results };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search knowledge markdown files by content and filename.
|
||||||
|
*/
|
||||||
|
async function searchKnowledge(query: string, limit: number): Promise<SearchResult[]> {
|
||||||
|
if (!fs.existsSync(KNOWLEDGE_DIR)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
const seenPaths = new Set<string>();
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
// Content search via grep
|
||||||
|
try {
|
||||||
|
const grepMatches = await grepFiles(query, KNOWLEDGE_DIR, '*.md');
|
||||||
|
for (const match of grepMatches) {
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
const relPath = path.relative(WorkDir, match.file);
|
||||||
|
if (seenPaths.has(relPath)) continue;
|
||||||
|
seenPaths.add(relPath);
|
||||||
|
|
||||||
|
const title = path.basename(match.file, '.md');
|
||||||
|
results.push({
|
||||||
|
type: 'knowledge',
|
||||||
|
title,
|
||||||
|
preview: match.line.trim().substring(0, 150),
|
||||||
|
path: relPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// grep failed (no matches or dir issue) — continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filename search — check files whose name matches the query
|
||||||
|
try {
|
||||||
|
const allFiles = await listMarkdownFiles(KNOWLEDGE_DIR);
|
||||||
|
for (const file of allFiles) {
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
const relPath = path.relative(WorkDir, file);
|
||||||
|
if (seenPaths.has(relPath)) continue;
|
||||||
|
|
||||||
|
const basename = path.basename(file, '.md');
|
||||||
|
if (basename.toLowerCase().includes(lowerQuery)) {
|
||||||
|
seenPaths.add(relPath);
|
||||||
|
const preview = await readFirstLines(file, 2);
|
||||||
|
results.push({
|
||||||
|
type: 'knowledge',
|
||||||
|
title: basename,
|
||||||
|
preview,
|
||||||
|
path: relPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search chat history by title and message content.
|
||||||
|
*/
|
||||||
|
async function searchChats(query: string, limit: number): Promise<SearchResult[]> {
|
||||||
|
if (!fs.existsSync(RUNS_DIR)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
// Content search via grep on JSONL files
|
||||||
|
try {
|
||||||
|
const grepMatches = await grepFiles(query, RUNS_DIR, '*.jsonl');
|
||||||
|
for (const match of grepMatches) {
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
const runId = path.basename(match.file, '.jsonl');
|
||||||
|
if (seenIds.has(runId)) continue;
|
||||||
|
|
||||||
|
const meta = await readRunMetadata(match.file);
|
||||||
|
if (meta.agentName !== 'copilot') {
|
||||||
|
seenIds.add(runId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenIds.add(runId);
|
||||||
|
|
||||||
|
// Extract a content preview from the matching line
|
||||||
|
let preview = '';
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(match.line);
|
||||||
|
if (parsed.message?.content && typeof parsed.message.content === 'string') {
|
||||||
|
preview = parsed.message.content.replace(/<attached-files>[\s\S]*?<\/attached-files>/g, '').trim().substring(0, 150);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
preview = match.line.substring(0, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
type: 'chat',
|
||||||
|
title: meta.title || runId,
|
||||||
|
preview,
|
||||||
|
path: runId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// grep failed — continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title search — scan run files for matching titles
|
||||||
|
try {
|
||||||
|
const entries = await fsp.readdir(RUNS_DIR, { withFileTypes: true });
|
||||||
|
const jsonlFiles = entries
|
||||||
|
.filter(e => e.isFile() && e.name.endsWith('.jsonl'))
|
||||||
|
.map(e => e.name)
|
||||||
|
.sort()
|
||||||
|
.reverse(); // newest first
|
||||||
|
|
||||||
|
for (const name of jsonlFiles) {
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
const runId = path.basename(name, '.jsonl');
|
||||||
|
if (seenIds.has(runId)) continue;
|
||||||
|
|
||||||
|
const filePath = path.join(RUNS_DIR, name);
|
||||||
|
const meta = await readRunMetadata(filePath);
|
||||||
|
if (meta.agentName !== 'copilot') {
|
||||||
|
seenIds.add(runId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (meta.title && meta.title.toLowerCase().includes(lowerQuery)) {
|
||||||
|
seenIds.add(runId);
|
||||||
|
results.push({
|
||||||
|
type: 'chat',
|
||||||
|
title: meta.title,
|
||||||
|
preview: meta.title,
|
||||||
|
path: runId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use grep to find files matching a query.
|
||||||
|
*/
|
||||||
|
function grepFiles(query: string, dir: string, includeGlob: string): Promise<Array<{ file: string; line: string }>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
'grep',
|
||||||
|
['-ril', '--include=' + includeGlob, query, dir],
|
||||||
|
{ maxBuffer: 1024 * 1024 },
|
||||||
|
(error, stdout) => {
|
||||||
|
if (error) {
|
||||||
|
// Exit code 1 = no matches
|
||||||
|
if (error.code === 1) {
|
||||||
|
resolve([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = stdout.trim().split('\n').filter(Boolean);
|
||||||
|
// For each matching file, get the first matching line
|
||||||
|
const promises = files.map(file =>
|
||||||
|
getFirstMatchingLine(file, query).then(line => ({ file, line }))
|
||||||
|
);
|
||||||
|
Promise.all(promises).then(resolve).catch(reject);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the first line in a file that matches the query (case-insensitive).
|
||||||
|
*/
|
||||||
|
function getFirstMatchingLine(filePath: string, query: string): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let resolved = false;
|
||||||
|
const done = (value: string) => {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
||||||
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||||
|
|
||||||
|
rl.on('line', (line) => {
|
||||||
|
if (line.toLowerCase().includes(lowerQuery)) {
|
||||||
|
done(line);
|
||||||
|
rl.close();
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.on('close', () => done(''));
|
||||||
|
stream.on('error', () => done(''));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunMetadata {
|
||||||
|
title: string | undefined;
|
||||||
|
agentName: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read metadata from a run JSONL file (agent name from start event, title from first user message).
|
||||||
|
*/
|
||||||
|
function readRunMetadata(filePath: string): Promise<RunMetadata> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let resolved = false;
|
||||||
|
const done = (value: RunMetadata) => {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
||||||
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||||
|
let lineIndex = 0;
|
||||||
|
let agentName: string | undefined;
|
||||||
|
|
||||||
|
rl.on('line', (line) => {
|
||||||
|
if (resolved) return;
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (lineIndex === 0) {
|
||||||
|
// Start event — extract agentName
|
||||||
|
const start = JSON.parse(trimmed);
|
||||||
|
agentName = start.agentName;
|
||||||
|
lineIndex++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = JSON.parse(trimmed);
|
||||||
|
if (event.type === 'message') {
|
||||||
|
const msg = event.message;
|
||||||
|
if (msg?.role === 'user') {
|
||||||
|
const content = msg.content;
|
||||||
|
if (typeof content === 'string' && content.trim()) {
|
||||||
|
let cleaned = content.replace(/<attached-files>[\s\S]*?<\/attached-files>/g, '');
|
||||||
|
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
||||||
|
if (cleaned) {
|
||||||
|
done({ title: cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned, agentName });
|
||||||
|
rl.close();
|
||||||
|
stream.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done({ title: undefined, agentName });
|
||||||
|
rl.close();
|
||||||
|
stream.destroy();
|
||||||
|
return;
|
||||||
|
} else if (msg?.role === 'assistant') {
|
||||||
|
done({ title: undefined, agentName });
|
||||||
|
rl.close();
|
||||||
|
stream.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lineIndex++;
|
||||||
|
} catch {
|
||||||
|
lineIndex++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.on('close', () => done({ title: undefined, agentName }));
|
||||||
|
rl.on('error', () => done({ title: undefined, agentName: undefined }));
|
||||||
|
stream.on('error', () => {
|
||||||
|
rl.close();
|
||||||
|
done({ title: undefined, agentName: undefined });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively list all .md files in a directory.
|
||||||
|
*/
|
||||||
|
async function listMarkdownFiles(dir: string): Promise<string[]> {
|
||||||
|
const results: string[] = [];
|
||||||
|
try {
|
||||||
|
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const nested = await listMarkdownFiles(fullPath);
|
||||||
|
results.push(...nested);
|
||||||
|
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||||
|
results.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the first N non-empty lines of a file for preview.
|
||||||
|
*/
|
||||||
|
async function readFirstLines(filePath: string, n: number): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
||||||
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
rl.on('line', (line) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed && !trimmed.startsWith('#')) {
|
||||||
|
lines.push(trimmed);
|
||||||
|
}
|
||||||
|
if (lines.length >= n) {
|
||||||
|
rl.close();
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.on('close', () => {
|
||||||
|
resolve(lines.join(' ').substring(0, 150));
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('error', () => {
|
||||||
|
resolve('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
170
apps/x/packages/core/src/workspace/wiki-link-rewrite.ts
Normal file
170
apps/x/packages/core/src/workspace/wiki-link-rewrite.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const WIKI_LINK_REGEX = /\[\[([^[\]]+)\]\]/g;
|
||||||
|
const KNOWLEDGE_PREFIX = 'knowledge/';
|
||||||
|
const MARKDOWN_EXTENSION = '.md';
|
||||||
|
|
||||||
|
function normalizeRelPath(relPath: string): string {
|
||||||
|
return relPath.replace(/\\/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKnowledgeMarkdownPath(relPath: string): boolean {
|
||||||
|
const normalized = normalizeRelPath(relPath).replace(/^\/+/, '');
|
||||||
|
const lower = normalized.toLowerCase();
|
||||||
|
return lower.startsWith(KNOWLEDGE_PREFIX) && lower.endsWith(MARKDOWN_EXTENSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripKnowledgePrefix(relPath: string): string {
|
||||||
|
const normalized = normalizeRelPath(relPath).replace(/^\/+/, '');
|
||||||
|
if (!normalized.toLowerCase().startsWith(KNOWLEDGE_PREFIX)) return normalized;
|
||||||
|
return normalized.slice(KNOWLEDGE_PREFIX.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripMarkdownExtension(wikiPath: string): string {
|
||||||
|
return wikiPath.toLowerCase().endsWith(MARKDOWN_EXTENSION)
|
||||||
|
? wikiPath.slice(0, -MARKDOWN_EXTENSION.length)
|
||||||
|
: wikiPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWikiPathCompareKey(wikiPath: string): string {
|
||||||
|
return stripMarkdownExtension(wikiPath).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitWikiPathPrefix(rawPath: string): { pathWithoutPrefix: string; hadKnowledgePrefix: boolean } {
|
||||||
|
let normalized = rawPath.trim().replace(/^\/+/, '').replace(/^\.\//, '');
|
||||||
|
const hadKnowledgePrefix = /^knowledge\//i.test(normalized);
|
||||||
|
if (hadKnowledgePrefix) {
|
||||||
|
normalized = normalized.slice(KNOWLEDGE_PREFIX.length);
|
||||||
|
}
|
||||||
|
return { pathWithoutPrefix: normalized, hadKnowledgePrefix };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteWikiLinksInMarkdown(
|
||||||
|
markdown: string,
|
||||||
|
fromWikiPath: string,
|
||||||
|
toWikiPath: string,
|
||||||
|
opts?: { allowBareSelfNameMatch?: boolean }
|
||||||
|
): string {
|
||||||
|
const fromCompareKey = toWikiPathCompareKey(fromWikiPath);
|
||||||
|
const fromBaseName = stripMarkdownExtension(fromWikiPath).split('/').pop()?.toLowerCase() ?? null;
|
||||||
|
const toWikiPathWithoutExtension = stripMarkdownExtension(toWikiPath);
|
||||||
|
const toBaseName = toWikiPathWithoutExtension.split('/').pop() ?? toWikiPathWithoutExtension;
|
||||||
|
|
||||||
|
return markdown.replace(WIKI_LINK_REGEX, (fullMatch, innerRaw: string) => {
|
||||||
|
const pipeIndex = innerRaw.indexOf('|');
|
||||||
|
const pathAndAnchor = pipeIndex >= 0 ? innerRaw.slice(0, pipeIndex) : innerRaw;
|
||||||
|
const aliasSuffix = pipeIndex >= 0 ? innerRaw.slice(pipeIndex) : '';
|
||||||
|
|
||||||
|
const hashIndex = pathAndAnchor.indexOf('#');
|
||||||
|
const pathPart = hashIndex >= 0 ? pathAndAnchor.slice(0, hashIndex) : pathAndAnchor;
|
||||||
|
const anchorSuffix = hashIndex >= 0 ? pathAndAnchor.slice(hashIndex) : '';
|
||||||
|
|
||||||
|
const leadingWhitespace = pathPart.match(/^\s*/)?.[0] ?? '';
|
||||||
|
const trailingWhitespace = pathPart.match(/\s*$/)?.[0] ?? '';
|
||||||
|
const rawPath = pathPart.trim();
|
||||||
|
if (!rawPath) return fullMatch;
|
||||||
|
|
||||||
|
const { pathWithoutPrefix, hadKnowledgePrefix } = splitWikiPathPrefix(rawPath);
|
||||||
|
if (!pathWithoutPrefix) return fullMatch;
|
||||||
|
|
||||||
|
const matchesFullPath = toWikiPathCompareKey(pathWithoutPrefix) === fromCompareKey;
|
||||||
|
const isBareTarget = !pathWithoutPrefix.includes('/');
|
||||||
|
const targetBaseName = stripMarkdownExtension(pathWithoutPrefix).toLowerCase();
|
||||||
|
const matchesBareSelfName = Boolean(
|
||||||
|
opts?.allowBareSelfNameMatch
|
||||||
|
&& fromBaseName
|
||||||
|
&& isBareTarget
|
||||||
|
&& targetBaseName === fromBaseName
|
||||||
|
);
|
||||||
|
if (!matchesFullPath && !matchesBareSelfName) {
|
||||||
|
return fullMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preserveMarkdownExtension = rawPath.toLowerCase().endsWith(MARKDOWN_EXTENSION);
|
||||||
|
const rewrittenPath = matchesBareSelfName
|
||||||
|
? (preserveMarkdownExtension ? `${toBaseName}.md` : toBaseName)
|
||||||
|
: (preserveMarkdownExtension ? toWikiPath : toWikiPathWithoutExtension);
|
||||||
|
const finalPath = hadKnowledgePrefix ? `${KNOWLEDGE_PREFIX}${rewrittenPath}` : rewrittenPath;
|
||||||
|
|
||||||
|
return `[[${leadingWhitespace}${finalPath}${trailingWhitespace}${anchorSuffix}${aliasSuffix}]]`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectKnowledgeMarkdownFiles(workspaceRoot: string): Promise<string[]> {
|
||||||
|
const knowledgeRoot = path.join(workspaceRoot, 'knowledge');
|
||||||
|
try {
|
||||||
|
const stat = await fs.lstat(knowledgeRoot);
|
||||||
|
if (!stat.isDirectory()) return [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdownFiles: string[] = [];
|
||||||
|
const pendingDirectories: string[] = [knowledgeRoot];
|
||||||
|
|
||||||
|
while (pendingDirectories.length > 0) {
|
||||||
|
const currentDirectory = pendingDirectories.pop();
|
||||||
|
if (!currentDirectory) continue;
|
||||||
|
|
||||||
|
const entries = await fs.readdir(currentDirectory, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name.startsWith('.')) continue;
|
||||||
|
|
||||||
|
const absolutePath = path.join(currentDirectory, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
pendingDirectories.push(absolutePath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
if (!entry.name.toLowerCase().endsWith(MARKDOWN_EXTENSION)) continue;
|
||||||
|
|
||||||
|
const relativePath = normalizeRelPath(path.relative(workspaceRoot, absolutePath));
|
||||||
|
markdownFiles.push(relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return markdownFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rewriteWikiLinksForRenamedKnowledgeFile(
|
||||||
|
workspaceRoot: string,
|
||||||
|
fromRelPath: string,
|
||||||
|
toRelPath: string
|
||||||
|
): Promise<number> {
|
||||||
|
const normalizedFrom = normalizeRelPath(fromRelPath);
|
||||||
|
const normalizedTo = normalizeRelPath(toRelPath);
|
||||||
|
|
||||||
|
if (!isKnowledgeMarkdownPath(normalizedFrom) || !isKnowledgeMarkdownPath(normalizedTo)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromWikiPath = stripKnowledgePrefix(normalizedFrom);
|
||||||
|
const toWikiPath = stripKnowledgePrefix(normalizedTo);
|
||||||
|
if (toWikiPathCompareKey(fromWikiPath) === toWikiPathCompareKey(toWikiPath)) return 0;
|
||||||
|
|
||||||
|
const markdownFiles = await collectKnowledgeMarkdownFiles(workspaceRoot);
|
||||||
|
let rewrittenFiles = 0;
|
||||||
|
|
||||||
|
const normalizedToLower = normalizedTo.toLowerCase();
|
||||||
|
for (const relativePath of markdownFiles) {
|
||||||
|
const absolutePath = path.join(workspaceRoot, ...relativePath.split('/'));
|
||||||
|
try {
|
||||||
|
const markdown = await fs.readFile(absolutePath, 'utf8');
|
||||||
|
if (!markdown.includes('[[')) continue;
|
||||||
|
|
||||||
|
const isRenamedFile = normalizeRelPath(relativePath).toLowerCase() === normalizedToLower;
|
||||||
|
const rewritten = rewriteWikiLinksInMarkdown(markdown, fromWikiPath, toWikiPath, {
|
||||||
|
allowBareSelfNameMatch: isRenamedFile,
|
||||||
|
});
|
||||||
|
if (rewritten === markdown) continue;
|
||||||
|
|
||||||
|
await fs.writeFile(absolutePath, rewritten, 'utf8');
|
||||||
|
rewrittenFiles += 1;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to rewrite wiki links in file:', relativePath, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rewrittenFiles;
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,8 @@ import { workspace } from '@x/shared';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/shared/dist/workspace.js';
|
import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/shared/dist/workspace.js';
|
||||||
import { WorkDir } from '../config/config.js';
|
import { WorkDir } from '../config/config.js';
|
||||||
|
import { rewriteWikiLinksForRenamedKnowledgeFile } from './wiki-link-rewrite.js';
|
||||||
|
import { commitAll } from '../knowledge/version_history.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Path Utilities
|
// Path Utilities
|
||||||
|
|
@ -58,6 +60,11 @@ export function absToRelPosix(absPath: string): string | null {
|
||||||
return relPath.split(path.sep).join('/');
|
return relPath.split(path.sep).join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isKnowledgeMarkdownRelPath(relPath: string): boolean {
|
||||||
|
const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, '').toLowerCase();
|
||||||
|
return normalized.startsWith('knowledge/') && normalized.endsWith('.md');
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// File System Utilities
|
// File System Utilities
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -212,6 +219,21 @@ export async function readFile(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debounced commit for knowledge file edits
|
||||||
|
let knowledgeCommitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function scheduleKnowledgeCommit(filename: string): void {
|
||||||
|
if (knowledgeCommitTimer) {
|
||||||
|
clearTimeout(knowledgeCommitTimer);
|
||||||
|
}
|
||||||
|
knowledgeCommitTimer = setTimeout(() => {
|
||||||
|
knowledgeCommitTimer = null;
|
||||||
|
commitAll(`Edit ${filename}`, 'You').catch(err => {
|
||||||
|
console.error('[VersionHistory] Failed to commit after edit:', err);
|
||||||
|
});
|
||||||
|
}, 3 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
export async function writeFile(
|
export async function writeFile(
|
||||||
relPath: string,
|
relPath: string,
|
||||||
data: string,
|
data: string,
|
||||||
|
|
@ -260,6 +282,11 @@ export async function writeFile(
|
||||||
const stat = statToSchema(stats, 'file');
|
const stat = statToSchema(stats, 'file');
|
||||||
const etag = computeEtag(stats.size, stats.mtimeMs);
|
const etag = computeEtag(stats.size, stats.mtimeMs);
|
||||||
|
|
||||||
|
// Schedule a debounced version history commit for knowledge files
|
||||||
|
if (relPath.startsWith('knowledge/') && relPath.endsWith('.md')) {
|
||||||
|
scheduleKnowledgeCommit(path.basename(relPath));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: relPath,
|
path: relPath,
|
||||||
stat,
|
stat,
|
||||||
|
|
@ -286,6 +313,7 @@ export async function rename(
|
||||||
|
|
||||||
// Check if source exists
|
// Check if source exists
|
||||||
await fs.access(fromPath);
|
await fs.access(fromPath);
|
||||||
|
const fromStats = await fs.lstat(fromPath);
|
||||||
|
|
||||||
// Check if destination exists (only if overwrite is false)
|
// Check if destination exists (only if overwrite is false)
|
||||||
if (!overwrite) {
|
if (!overwrite) {
|
||||||
|
|
@ -309,6 +337,19 @@ export async function rename(
|
||||||
await fs.mkdir(path.dirname(toPath), { recursive: true });
|
await fs.mkdir(path.dirname(toPath), { recursive: true });
|
||||||
|
|
||||||
await fs.rename(fromPath, toPath);
|
await fs.rename(fromPath, toPath);
|
||||||
|
|
||||||
|
if (
|
||||||
|
fromStats.isFile()
|
||||||
|
&& isKnowledgeMarkdownRelPath(from)
|
||||||
|
&& isKnowledgeMarkdownRelPath(to)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await rewriteWikiLinksForRenamedKnowledgeFile(WorkDir, from, to);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to rewrite wiki backlinks after file rename:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -383,4 +424,4 @@ export async function remove(
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { LlmModelConfig } from './models.js';
|
||||||
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
|
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
|
||||||
import { AgentScheduleState } from './agent-schedule-state.js';
|
import { AgentScheduleState } from './agent-schedule-state.js';
|
||||||
import { ServiceEvent } from './service-events.js';
|
import { ServiceEvent } from './service-events.js';
|
||||||
|
import { UserMessageContent } from './message.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Runtime Validation Schemas (Single Source of Truth)
|
// Runtime Validation Schemas (Single Source of Truth)
|
||||||
|
|
@ -128,7 +129,7 @@ const ipcSchemas = {
|
||||||
'runs:createMessage': {
|
'runs:createMessage': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
runId: z.string(),
|
runId: z.string(),
|
||||||
message: z.string(),
|
message: UserMessageContent,
|
||||||
}),
|
}),
|
||||||
res: z.object({
|
res: z.object({
|
||||||
messageId: z.string(),
|
messageId: z.string(),
|
||||||
|
|
@ -244,7 +245,7 @@ const ipcSchemas = {
|
||||||
res: z.object({
|
res: z.object({
|
||||||
config: z.record(z.string(), z.object({
|
config: z.record(z.string(), z.object({
|
||||||
connected: z.boolean(),
|
connected: z.boolean(),
|
||||||
error: z.string().optional(),
|
error: z.string().nullable().optional(),
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
@ -396,6 +397,46 @@ const ipcSchemas = {
|
||||||
req: z.object({ path: z.string() }),
|
req: z.object({ path: z.string() }),
|
||||||
res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }),
|
res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }),
|
||||||
},
|
},
|
||||||
|
// Knowledge version history channels
|
||||||
|
'knowledge:history': {
|
||||||
|
req: z.object({ path: RelPath }),
|
||||||
|
res: z.object({
|
||||||
|
commits: z.array(z.object({
|
||||||
|
oid: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
timestamp: z.number(),
|
||||||
|
author: z.string(),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'knowledge:fileAtCommit': {
|
||||||
|
req: z.object({ path: RelPath, oid: z.string() }),
|
||||||
|
res: z.object({ content: z.string() }),
|
||||||
|
},
|
||||||
|
'knowledge:restore': {
|
||||||
|
req: z.object({ path: RelPath, oid: z.string() }),
|
||||||
|
res: z.object({ ok: z.literal(true) }),
|
||||||
|
},
|
||||||
|
'knowledge:didCommit': {
|
||||||
|
req: z.object({}),
|
||||||
|
res: z.null(),
|
||||||
|
},
|
||||||
|
// Search channels
|
||||||
|
'search:query': {
|
||||||
|
req: z.object({
|
||||||
|
query: z.string(),
|
||||||
|
limit: z.number().optional(),
|
||||||
|
types: z.array(z.enum(['knowledge', 'chat'])).optional(),
|
||||||
|
}),
|
||||||
|
res: z.object({
|
||||||
|
results: z.array(z.object({
|
||||||
|
type: z.enum(['knowledge', 'chat']),
|
||||||
|
title: z.string(),
|
||||||
|
preview: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,30 @@ export const AssistantContentPart = z.union([
|
||||||
ToolCallPart,
|
ToolCallPart,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// A piece of user-typed text within a content array
|
||||||
|
export const UserTextPart = z.object({
|
||||||
|
type: z.literal("text"),
|
||||||
|
text: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// An attachment within a content array
|
||||||
|
export const UserAttachmentPart = z.object({
|
||||||
|
type: z.literal("attachment"),
|
||||||
|
path: z.string(), // absolute file path
|
||||||
|
filename: z.string(), // display name ("photo.png")
|
||||||
|
mimeType: z.string(), // MIME type ("image/png", "text/plain")
|
||||||
|
size: z.number().optional(), // bytes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Any single part of a user message (text or attachment)
|
||||||
|
export const UserContentPart = z.union([UserTextPart, UserAttachmentPart]);
|
||||||
|
|
||||||
|
// Named type for user message content — used everywhere instead of repeating the union
|
||||||
|
export const UserMessageContent = z.union([z.string(), z.array(UserContentPart)]);
|
||||||
|
|
||||||
export const UserMessage = z.object({
|
export const UserMessage = z.object({
|
||||||
role: z.literal("user"),
|
role: z.literal("user"),
|
||||||
content: z.string(),
|
content: UserMessageContent,
|
||||||
providerOptions: ProviderOptions.optional(),
|
providerOptions: ProviderOptions.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,5 @@ export const LlmProvider = z.object({
|
||||||
export const LlmModelConfig = z.object({
|
export const LlmModelConfig = z.object({
|
||||||
provider: LlmProvider,
|
provider: LlmProvider,
|
||||||
model: z.string(),
|
model: z.string(),
|
||||||
|
knowledgeGraphModel: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ export const ToolPermissionResponseEvent = BaseRunEvent.extend({
|
||||||
type: z.literal("tool-permission-response"),
|
type: z.literal("tool-permission-response"),
|
||||||
toolCallId: z.string(),
|
toolCallId: z.string(),
|
||||||
response: z.enum(["approve", "deny"]),
|
response: z.enum(["approve", "deny"]),
|
||||||
|
scope: z.enum(["once", "session", "always"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const RunErrorEvent = BaseRunEvent.extend({
|
export const RunErrorEvent = BaseRunEvent.extend({
|
||||||
|
|
@ -106,6 +107,7 @@ export const ToolPermissionAuthorizePayload = ToolPermissionResponseEvent.pick({
|
||||||
subflow: true,
|
subflow: true,
|
||||||
toolCallId: true,
|
toolCallId: true,
|
||||||
response: true,
|
response: true,
|
||||||
|
scope: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AskHumanResponsePayload = AskHumanResponseEvent.pick({
|
export const AskHumanResponsePayload = AskHumanResponseEvent.pick({
|
||||||
|
|
|
||||||
216
apps/x/pnpm-lock.yaml
generated
216
apps/x/pnpm-lock.yaml
generated
|
|
@ -359,6 +359,9 @@ importers:
|
||||||
googleapis:
|
googleapis:
|
||||||
specifier: ^169.0.0
|
specifier: ^169.0.0
|
||||||
version: 169.0.0
|
version: 169.0.0
|
||||||
|
isomorphic-git:
|
||||||
|
specifier: ^1.29.0
|
||||||
|
version: 1.37.2
|
||||||
mammoth:
|
mammoth:
|
||||||
specifier: ^1.11.0
|
specifier: ^1.11.0
|
||||||
version: 1.11.0
|
version: 1.11.0
|
||||||
|
|
@ -3501,6 +3504,10 @@ packages:
|
||||||
abbrev@1.1.1:
|
abbrev@1.1.1:
|
||||||
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
|
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
|
||||||
|
|
||||||
|
abort-controller@3.0.0:
|
||||||
|
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||||
|
engines: {node: '>=6.5'}
|
||||||
|
|
||||||
abs-svg-path@0.1.1:
|
abs-svg-path@0.1.1:
|
||||||
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
|
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
|
||||||
|
|
||||||
|
|
@ -3627,6 +3634,9 @@ packages:
|
||||||
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
|
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
async-lock@1.4.1:
|
||||||
|
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
|
||||||
|
|
||||||
async@1.5.2:
|
async@1.5.2:
|
||||||
resolution: {integrity: sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==}
|
resolution: {integrity: sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==}
|
||||||
|
|
||||||
|
|
@ -3641,6 +3651,10 @@ packages:
|
||||||
resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==}
|
resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
|
available-typed-arrays@1.0.7:
|
||||||
|
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
awilix@12.0.5:
|
awilix@12.0.5:
|
||||||
resolution: {integrity: sha512-Qf/V/hRo6DK0FoBKJ9QiObasRxHAhcNi0mV6kW2JMawxS3zq6Un+VsZmVAZDUfvB+MjTEiJ2tUJUl4cr0JiUAw==}
|
resolution: {integrity: sha512-Qf/V/hRo6DK0FoBKJ9QiObasRxHAhcNi0mV6kW2JMawxS3zq6Un+VsZmVAZDUfvB+MjTEiJ2tUJUl4cr0JiUAw==}
|
||||||
engines: {node: '>=16.3.0'}
|
engines: {node: '>=16.3.0'}
|
||||||
|
|
@ -3742,6 +3756,9 @@ packages:
|
||||||
buffer@5.7.1:
|
buffer@5.7.1:
|
||||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||||
|
|
||||||
|
buffer@6.0.3:
|
||||||
|
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||||
|
|
||||||
bytes@3.1.2:
|
bytes@3.1.2:
|
||||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
@ -3762,6 +3779,10 @@ packages:
|
||||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
call-bind@1.0.8:
|
||||||
|
resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
call-bound@1.0.4:
|
call-bound@1.0.4:
|
||||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -3825,6 +3846,9 @@ packages:
|
||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
|
clean-git-ref@2.0.1:
|
||||||
|
resolution: {integrity: sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==}
|
||||||
|
|
||||||
clean-stack@2.2.0:
|
clean-stack@2.2.0:
|
||||||
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
|
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
@ -4256,6 +4280,9 @@ packages:
|
||||||
dfa@1.2.0:
|
dfa@1.2.0:
|
||||||
resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
|
resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
|
||||||
|
|
||||||
|
diff3@0.0.3:
|
||||||
|
resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==}
|
||||||
|
|
||||||
dingbat-to-unicode@1.0.1:
|
dingbat-to-unicode@1.0.1:
|
||||||
resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==}
|
resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==}
|
||||||
|
|
||||||
|
|
@ -4496,6 +4523,10 @@ packages:
|
||||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
event-target-shim@5.0.1:
|
||||||
|
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
eventemitter3@5.0.1:
|
eventemitter3@5.0.1:
|
||||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||||
|
|
||||||
|
|
@ -4640,6 +4671,10 @@ packages:
|
||||||
fontkit@2.0.4:
|
fontkit@2.0.4:
|
||||||
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
|
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
|
||||||
|
|
||||||
|
for-each@0.3.5:
|
||||||
|
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
foreground-child@3.3.1:
|
foreground-child@3.3.1:
|
||||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
@ -5104,6 +5139,10 @@ packages:
|
||||||
is-arrayish@0.3.4:
|
is-arrayish@0.3.4:
|
||||||
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
|
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
|
||||||
|
|
||||||
|
is-callable@1.2.7:
|
||||||
|
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
is-core-module@2.16.1:
|
is-core-module@2.16.1:
|
||||||
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
|
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -5170,6 +5209,10 @@ packages:
|
||||||
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
|
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
is-typed-array@1.1.15:
|
||||||
|
resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
is-unicode-supported@0.1.0:
|
is-unicode-supported@0.1.0:
|
||||||
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
|
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -5184,6 +5227,9 @@ packages:
|
||||||
isarray@1.0.0:
|
isarray@1.0.0:
|
||||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||||
|
|
||||||
|
isarray@2.0.5:
|
||||||
|
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
||||||
|
|
||||||
isbinaryfile@4.0.10:
|
isbinaryfile@4.0.10:
|
||||||
resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==}
|
resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==}
|
||||||
engines: {node: '>= 8.0.0'}
|
engines: {node: '>= 8.0.0'}
|
||||||
|
|
@ -5191,6 +5237,11 @@ packages:
|
||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
|
isomorphic-git@1.37.2:
|
||||||
|
resolution: {integrity: sha512-HCQBBKmXIMPdHgYGstSBNp6MNmVcMQBbUqJF8xfywFmlpNseO4KKex59YlXqNxhRxmv3fUZwvNWvMyOdc1VvhA==}
|
||||||
|
engines: {node: '>=14.17'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
jackspeak@3.4.3:
|
jackspeak@3.4.3:
|
||||||
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
||||||
|
|
||||||
|
|
@ -5762,6 +5813,9 @@ packages:
|
||||||
minimist@1.2.8:
|
minimist@1.2.8:
|
||||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||||
|
|
||||||
|
minimisted@2.0.1:
|
||||||
|
resolution: {integrity: sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==}
|
||||||
|
|
||||||
minipass-collect@1.0.2:
|
minipass-collect@1.0.2:
|
||||||
resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==}
|
resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
@ -6169,6 +6223,10 @@ packages:
|
||||||
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
pify@4.0.1:
|
||||||
|
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
pkce-challenge@5.0.1:
|
pkce-challenge@5.0.1:
|
||||||
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
||||||
engines: {node: '>=16.20.0'}
|
engines: {node: '>=16.20.0'}
|
||||||
|
|
@ -6186,6 +6244,10 @@ packages:
|
||||||
points-on-path@0.2.1:
|
points-on-path@0.2.1:
|
||||||
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
|
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
|
||||||
|
|
||||||
|
possible-typed-array-names@1.1.0:
|
||||||
|
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
postcss-value-parser@4.2.0:
|
postcss-value-parser@4.2.0:
|
||||||
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||||
|
|
||||||
|
|
@ -6220,6 +6282,10 @@ packages:
|
||||||
process-nextick-args@2.0.1:
|
process-nextick-args@2.0.1:
|
||||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||||
|
|
||||||
|
process@0.11.10:
|
||||||
|
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||||
|
engines: {node: '>= 0.6.0'}
|
||||||
|
|
||||||
progress@2.0.3:
|
progress@2.0.3:
|
||||||
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
@ -6434,6 +6500,10 @@ packages:
|
||||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
readable-stream@4.7.0:
|
||||||
|
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
|
||||||
|
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||||
|
|
||||||
readdirp@4.1.2:
|
readdirp@4.1.2:
|
||||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||||
engines: {node: '>= 14.18.0'}
|
engines: {node: '>= 14.18.0'}
|
||||||
|
|
@ -6649,12 +6719,21 @@ packages:
|
||||||
server-destroy@1.0.1:
|
server-destroy@1.0.1:
|
||||||
resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==}
|
resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==}
|
||||||
|
|
||||||
|
set-function-length@1.2.2:
|
||||||
|
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
setimmediate@1.0.5:
|
setimmediate@1.0.5:
|
||||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||||
|
|
||||||
setprototypeof@1.2.0:
|
setprototypeof@1.2.0:
|
||||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||||
|
|
||||||
|
sha.js@2.4.12:
|
||||||
|
resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==}
|
||||||
|
engines: {node: '>= 0.10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
shebang-command@1.2.0:
|
shebang-command@1.2.0:
|
||||||
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
|
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -6701,6 +6780,12 @@ packages:
|
||||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
simple-concat@1.0.1:
|
||||||
|
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
|
||||||
|
|
||||||
|
simple-get@4.0.1:
|
||||||
|
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||||
|
|
||||||
simple-swizzle@0.2.4:
|
simple-swizzle@0.2.4:
|
||||||
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
||||||
|
|
||||||
|
|
@ -6928,6 +7013,10 @@ packages:
|
||||||
resolution: {integrity: sha512-DbplOfQFkqG5IHcDyyrs/lkvSr3mPUVsFf/RbDppOshs22yTPnSJWEe6FkYd1txAwU/zcnR905ar2fi4kwF29w==}
|
resolution: {integrity: sha512-DbplOfQFkqG5IHcDyyrs/lkvSr3mPUVsFf/RbDppOshs22yTPnSJWEe6FkYd1txAwU/zcnR905ar2fi4kwF29w==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
to-buffer@1.2.2:
|
||||||
|
resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
to-data-view@1.1.0:
|
to-data-view@1.1.0:
|
||||||
resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==}
|
resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==}
|
||||||
|
|
||||||
|
|
@ -6998,6 +7087,10 @@ packages:
|
||||||
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
|
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
typed-array-buffer@1.0.3:
|
||||||
|
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
typescript-eslint@8.50.1:
|
typescript-eslint@8.50.1:
|
||||||
resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==}
|
resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
@ -7272,6 +7365,10 @@ packages:
|
||||||
whatwg-url@5.0.0:
|
whatwg-url@5.0.0:
|
||||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||||
|
|
||||||
|
which-typed-array@1.1.20:
|
||||||
|
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
which@1.3.1:
|
which@1.3.1:
|
||||||
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
|
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
@ -11316,6 +11413,10 @@ snapshots:
|
||||||
|
|
||||||
abbrev@1.1.1: {}
|
abbrev@1.1.1: {}
|
||||||
|
|
||||||
|
abort-controller@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
event-target-shim: 5.0.1
|
||||||
|
|
||||||
abs-svg-path@0.1.1: {}
|
abs-svg-path@0.1.1: {}
|
||||||
|
|
||||||
accepts@2.0.0:
|
accepts@2.0.0:
|
||||||
|
|
@ -11440,6 +11541,8 @@ snapshots:
|
||||||
|
|
||||||
arrify@2.0.1: {}
|
arrify@2.0.1: {}
|
||||||
|
|
||||||
|
async-lock@1.4.1: {}
|
||||||
|
|
||||||
async@1.5.2:
|
async@1.5.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -11449,6 +11552,10 @@ snapshots:
|
||||||
|
|
||||||
author-regex@1.0.0: {}
|
author-regex@1.0.0: {}
|
||||||
|
|
||||||
|
available-typed-arrays@1.0.7:
|
||||||
|
dependencies:
|
||||||
|
possible-typed-array-names: 1.1.0
|
||||||
|
|
||||||
awilix@12.0.5:
|
awilix@12.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
camel-case: 4.1.2
|
camel-case: 4.1.2
|
||||||
|
|
@ -11568,6 +11675,11 @@ snapshots:
|
||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
ieee754: 1.2.1
|
ieee754: 1.2.1
|
||||||
|
|
||||||
|
buffer@6.0.3:
|
||||||
|
dependencies:
|
||||||
|
base64-js: 1.5.1
|
||||||
|
ieee754: 1.2.1
|
||||||
|
|
||||||
bytes@3.1.2: {}
|
bytes@3.1.2: {}
|
||||||
|
|
||||||
cacache@16.1.3:
|
cacache@16.1.3:
|
||||||
|
|
@ -11610,6 +11722,13 @@ snapshots:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
call-bind@1.0.8:
|
||||||
|
dependencies:
|
||||||
|
call-bind-apply-helpers: 1.0.2
|
||||||
|
es-define-property: 1.0.1
|
||||||
|
get-intrinsic: 1.3.0
|
||||||
|
set-function-length: 1.2.2
|
||||||
|
|
||||||
call-bound@1.0.4:
|
call-bound@1.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
|
|
@ -11672,6 +11791,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
|
|
||||||
|
clean-git-ref@2.0.1: {}
|
||||||
|
|
||||||
clean-stack@2.2.0: {}
|
clean-stack@2.2.0: {}
|
||||||
|
|
||||||
cli-cursor@3.1.0:
|
cli-cursor@3.1.0:
|
||||||
|
|
@ -12061,7 +12182,6 @@ snapshots:
|
||||||
es-define-property: 1.0.1
|
es-define-property: 1.0.1
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
optional: true
|
|
||||||
|
|
||||||
define-properties@1.2.1:
|
define-properties@1.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -12095,6 +12215,8 @@ snapshots:
|
||||||
|
|
||||||
dfa@1.2.0: {}
|
dfa@1.2.0: {}
|
||||||
|
|
||||||
|
diff3@0.0.3: {}
|
||||||
|
|
||||||
dingbat-to-unicode@1.0.1: {}
|
dingbat-to-unicode@1.0.1: {}
|
||||||
|
|
||||||
dir-compare@4.2.0:
|
dir-compare@4.2.0:
|
||||||
|
|
@ -12451,6 +12573,8 @@ snapshots:
|
||||||
|
|
||||||
etag@1.8.1: {}
|
etag@1.8.1: {}
|
||||||
|
|
||||||
|
event-target-shim@5.0.1: {}
|
||||||
|
|
||||||
eventemitter3@5.0.1: {}
|
eventemitter3@5.0.1: {}
|
||||||
|
|
||||||
events@3.3.0: {}
|
events@3.3.0: {}
|
||||||
|
|
@ -12638,6 +12762,10 @@ snapshots:
|
||||||
unicode-properties: 1.4.1
|
unicode-properties: 1.4.1
|
||||||
unicode-trie: 2.0.0
|
unicode-trie: 2.0.0
|
||||||
|
|
||||||
|
for-each@0.3.5:
|
||||||
|
dependencies:
|
||||||
|
is-callable: 1.2.7
|
||||||
|
|
||||||
foreground-child@3.3.1:
|
foreground-child@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
|
|
@ -12983,7 +13111,6 @@ snapshots:
|
||||||
has-property-descriptors@1.0.2:
|
has-property-descriptors@1.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-define-property: 1.0.1
|
es-define-property: 1.0.1
|
||||||
optional: true
|
|
||||||
|
|
||||||
has-symbols@1.1.0: {}
|
has-symbols@1.1.0: {}
|
||||||
|
|
||||||
|
|
@ -13251,6 +13378,8 @@ snapshots:
|
||||||
|
|
||||||
is-arrayish@0.3.4: {}
|
is-arrayish@0.3.4: {}
|
||||||
|
|
||||||
|
is-callable@1.2.7: {}
|
||||||
|
|
||||||
is-core-module@2.16.1:
|
is-core-module@2.16.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
|
|
@ -13300,6 +13429,10 @@ snapshots:
|
||||||
|
|
||||||
is-stream@2.0.1: {}
|
is-stream@2.0.1: {}
|
||||||
|
|
||||||
|
is-typed-array@1.1.15:
|
||||||
|
dependencies:
|
||||||
|
which-typed-array: 1.1.20
|
||||||
|
|
||||||
is-unicode-supported@0.1.0: {}
|
is-unicode-supported@0.1.0: {}
|
||||||
|
|
||||||
is-url@1.2.4: {}
|
is-url@1.2.4: {}
|
||||||
|
|
@ -13310,10 +13443,26 @@ snapshots:
|
||||||
|
|
||||||
isarray@1.0.0: {}
|
isarray@1.0.0: {}
|
||||||
|
|
||||||
|
isarray@2.0.5: {}
|
||||||
|
|
||||||
isbinaryfile@4.0.10: {}
|
isbinaryfile@4.0.10: {}
|
||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
|
isomorphic-git@1.37.2:
|
||||||
|
dependencies:
|
||||||
|
async-lock: 1.4.1
|
||||||
|
clean-git-ref: 2.0.1
|
||||||
|
crc-32: 1.2.2
|
||||||
|
diff3: 0.0.3
|
||||||
|
ignore: 5.3.2
|
||||||
|
minimisted: 2.0.1
|
||||||
|
pako: 1.0.11
|
||||||
|
pify: 4.0.1
|
||||||
|
readable-stream: 4.7.0
|
||||||
|
sha.js: 2.4.12
|
||||||
|
simple-get: 4.0.1
|
||||||
|
|
||||||
jackspeak@3.4.3:
|
jackspeak@3.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@isaacs/cliui': 8.0.2
|
'@isaacs/cliui': 8.0.2
|
||||||
|
|
@ -14139,6 +14288,10 @@ snapshots:
|
||||||
|
|
||||||
minimist@1.2.8: {}
|
minimist@1.2.8: {}
|
||||||
|
|
||||||
|
minimisted@2.0.1:
|
||||||
|
dependencies:
|
||||||
|
minimist: 1.2.8
|
||||||
|
|
||||||
minipass-collect@1.0.2:
|
minipass-collect@1.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
minipass: 3.3.6
|
minipass: 3.3.6
|
||||||
|
|
@ -14506,6 +14659,8 @@ snapshots:
|
||||||
|
|
||||||
pify@2.3.0: {}
|
pify@2.3.0: {}
|
||||||
|
|
||||||
|
pify@4.0.1: {}
|
||||||
|
|
||||||
pkce-challenge@5.0.1: {}
|
pkce-challenge@5.0.1: {}
|
||||||
|
|
||||||
pkg-types@1.3.1:
|
pkg-types@1.3.1:
|
||||||
|
|
@ -14527,6 +14682,8 @@ snapshots:
|
||||||
path-data-parser: 0.1.0
|
path-data-parser: 0.1.0
|
||||||
points-on-curve: 0.2.0
|
points-on-curve: 0.2.0
|
||||||
|
|
||||||
|
possible-typed-array-names@1.1.0: {}
|
||||||
|
|
||||||
postcss-value-parser@4.2.0: {}
|
postcss-value-parser@4.2.0: {}
|
||||||
|
|
||||||
postcss@8.5.6:
|
postcss@8.5.6:
|
||||||
|
|
@ -14565,6 +14722,8 @@ snapshots:
|
||||||
|
|
||||||
process-nextick-args@2.0.1: {}
|
process-nextick-args@2.0.1: {}
|
||||||
|
|
||||||
|
process@0.11.10: {}
|
||||||
|
|
||||||
progress@2.0.3: {}
|
progress@2.0.3: {}
|
||||||
|
|
||||||
promise-inflight@1.0.1: {}
|
promise-inflight@1.0.1: {}
|
||||||
|
|
@ -14887,6 +15046,14 @@ snapshots:
|
||||||
string_decoder: 1.3.0
|
string_decoder: 1.3.0
|
||||||
util-deprecate: 1.0.2
|
util-deprecate: 1.0.2
|
||||||
|
|
||||||
|
readable-stream@4.7.0:
|
||||||
|
dependencies:
|
||||||
|
abort-controller: 3.0.0
|
||||||
|
buffer: 6.0.3
|
||||||
|
events: 3.3.0
|
||||||
|
process: 0.11.10
|
||||||
|
string_decoder: 1.3.0
|
||||||
|
|
||||||
readdirp@4.1.2: {}
|
readdirp@4.1.2: {}
|
||||||
|
|
||||||
rechoir@0.8.0:
|
rechoir@0.8.0:
|
||||||
|
|
@ -15175,10 +15342,25 @@ snapshots:
|
||||||
|
|
||||||
server-destroy@1.0.1: {}
|
server-destroy@1.0.1: {}
|
||||||
|
|
||||||
|
set-function-length@1.2.2:
|
||||||
|
dependencies:
|
||||||
|
define-data-property: 1.1.4
|
||||||
|
es-errors: 1.3.0
|
||||||
|
function-bind: 1.1.2
|
||||||
|
get-intrinsic: 1.3.0
|
||||||
|
gopd: 1.2.0
|
||||||
|
has-property-descriptors: 1.0.2
|
||||||
|
|
||||||
setimmediate@1.0.5: {}
|
setimmediate@1.0.5: {}
|
||||||
|
|
||||||
setprototypeof@1.2.0: {}
|
setprototypeof@1.2.0: {}
|
||||||
|
|
||||||
|
sha.js@2.4.12:
|
||||||
|
dependencies:
|
||||||
|
inherits: 2.0.4
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
to-buffer: 1.2.2
|
||||||
|
|
||||||
shebang-command@1.2.0:
|
shebang-command@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 1.0.0
|
shebang-regex: 1.0.0
|
||||||
|
|
@ -15236,6 +15418,14 @@ snapshots:
|
||||||
|
|
||||||
signal-exit@4.1.0: {}
|
signal-exit@4.1.0: {}
|
||||||
|
|
||||||
|
simple-concat@1.0.1: {}
|
||||||
|
|
||||||
|
simple-get@4.0.1:
|
||||||
|
dependencies:
|
||||||
|
decompress-response: 6.0.0
|
||||||
|
once: 1.4.0
|
||||||
|
simple-concat: 1.0.1
|
||||||
|
|
||||||
simple-swizzle@0.2.4:
|
simple-swizzle@0.2.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-arrayish: 0.3.4
|
is-arrayish: 0.3.4
|
||||||
|
|
@ -15493,6 +15683,12 @@ snapshots:
|
||||||
unorm: 1.6.0
|
unorm: 1.6.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
to-buffer@1.2.2:
|
||||||
|
dependencies:
|
||||||
|
isarray: 2.0.5
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
typed-array-buffer: 1.0.3
|
||||||
|
|
||||||
to-data-view@1.1.0:
|
to-data-view@1.1.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -15550,6 +15746,12 @@ snapshots:
|
||||||
media-typer: 1.1.0
|
media-typer: 1.1.0
|
||||||
mime-types: 3.0.2
|
mime-types: 3.0.2
|
||||||
|
|
||||||
|
typed-array-buffer@1.0.3:
|
||||||
|
dependencies:
|
||||||
|
call-bound: 1.0.4
|
||||||
|
es-errors: 1.3.0
|
||||||
|
is-typed-array: 1.1.15
|
||||||
|
|
||||||
typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
|
typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
|
|
@ -15827,6 +16029,16 @@ snapshots:
|
||||||
tr46: 0.0.3
|
tr46: 0.0.3
|
||||||
webidl-conversions: 3.0.1
|
webidl-conversions: 3.0.1
|
||||||
|
|
||||||
|
which-typed-array@1.1.20:
|
||||||
|
dependencies:
|
||||||
|
available-typed-arrays: 1.0.7
|
||||||
|
call-bind: 1.0.8
|
||||||
|
call-bound: 1.0.4
|
||||||
|
for-each: 0.3.5
|
||||||
|
get-proto: 1.0.1
|
||||||
|
gopd: 1.2.0
|
||||||
|
has-tostringtag: 1.0.2
|
||||||
|
|
||||||
which@1.3.1:
|
which@1.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue