mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2025-12-05 18:20:39 -08:00
Chore/upgrade (#646)
* docs: deps * Update dependencies and type usage for AI features Upgraded multiple dependencies in package.json and pnpm-lock.yaml, including @ai-sdk, @mui, react, and others for improved compatibility and performance. Changed type usage from CoreMessage to ModelMessage in mockOpenAI.test.ts to align with updated ai package. No functional changes to application logic. * feat: i18n * feat: test oauth login and use PKCE * fix: use ollama-ai-provider-v2 * test: github and mock oauth2 login * test: gitea login * Refactor context menu cleanup and error message Moved context menu cleanup for OAuth window to a single closed event handler in Authentication service. Simplified error message formatting in ContextService for missing keys. * lint: AI fix * Add tsx as a dev dependency and update scripts Replaced usage of 'pnpm dlx tsx' with direct 'tsx' command in development and test scripts for improved reliability. Added 'tsx' to devDependencies in package.json.
This commit is contained in:
parent
19ef74a4a6
commit
b76fc17794
75 changed files with 5863 additions and 3733 deletions
|
|
@ -164,10 +164,6 @@ Electron forge webpack don't support pure ESM yet
|
|||
- electron-unhandled
|
||||
- date-fns
|
||||
|
||||
### Use electron forge's recommended version
|
||||
|
||||
- @vercel/webpack-asset-relocator-loader
|
||||
|
||||
## Code Tour
|
||||
|
||||
[FileProtocol](./features/FileProtocol.md)
|
||||
|
|
|
|||
78
docs/features/OAuthFlow.md
Normal file
78
docs/features/OAuthFlow.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Git Service OAuth Flow
|
||||
|
||||
We use [`oidc-client-ts`](https://authts.github.io/oidc-client-ts/) which automatically handles PKCE generation, state management, and token exchange.
|
||||
|
||||
## Codeberg Setup
|
||||
|
||||
To enable one-click login to Codeberg:
|
||||
|
||||
1. Go to <https://codeberg.org/user/settings/applications>
|
||||
2. Create new OAuth2 Application:
|
||||
- **Application Name**: TidGi Desktop
|
||||
- **Redirect URI**: `http://127.0.0.1:3012/tidgi-auth/codeberg`
|
||||
- **⚠️ Do NOT check "Confidential client"** (this allows PKCE)
|
||||
3. Copy the `Client ID` and `Client Secret`
|
||||
4. Update `src/constants/oauthConfig.ts`:
|
||||
|
||||
Note: `Client Secret` is still necessary even PKCE is used. Otherwise we will get error. Use description to inform user only trust TidGi login oauth app when inside TidGi app's window.
|
||||
|
||||
## Configuration
|
||||
|
||||
src/constants/oauthConfig.ts
|
||||
|
||||
```typescript
|
||||
export const OAUTH_CONFIGS: Record<Service, IOAuthConfig> = {
|
||||
github: {
|
||||
authorizePath: 'https://github.com/login/oauth/authorize',
|
||||
tokenPath: 'https://github.com/login/oauth/access_token',
|
||||
userInfoPath: 'https://api.github.com/user',
|
||||
clientId: '...',
|
||||
clientSecret: '...',
|
||||
redirectPath: 'http://127.0.0.1:3012/tidgi-auth/github',
|
||||
scopes: 'user:email,read:user,repo,workflow',
|
||||
},
|
||||
// Gitea/Codeberg use same API structure
|
||||
gitea: {
|
||||
authorizePath: '', // User configures: https://gitea.example.com/login/oauth/authorize
|
||||
// ...
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
### Window setup
|
||||
|
||||
`src/services/windows/handleCreateBasicWindow.ts`
|
||||
|
||||
```typescript
|
||||
window.webContents.on('will-redirect', (event, url) => {
|
||||
const match = isOAuthRedirect(url); // Check all services
|
||||
if (match) {
|
||||
event.preventDefault();
|
||||
const code = new URL(url).searchParams.get('code');
|
||||
// Exchange code for token using match.config.tokenPath
|
||||
// Store token -> triggers userInfo$ update
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Login
|
||||
|
||||
`src/components/TokenForm/gitTokenHooks.ts`
|
||||
|
||||
```typescript
|
||||
const oauthUrl = buildOAuthUrl(storageService);
|
||||
if (oauthUrl) {
|
||||
location.href = oauthUrl;
|
||||
}
|
||||
```
|
||||
|
||||
## Flow
|
||||
|
||||
1. User clicks login → OAuth page (GitHub/Gitea/Codeberg)
|
||||
2. OAuth redirects → `http://127.0.0.1:3012/tidgi-auth/{service}?code=xxx`
|
||||
3. `will-redirect` event → extract code
|
||||
4. Exchange code for token via `config.tokenPath`
|
||||
5. Store token → `userInfo$` emits
|
||||
6. Form updates ✓
|
||||
218
features/oauthLogin.feature
Normal file
218
features/oauthLogin.feature
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
Feature: OAuth Login Flow
|
||||
As a user
|
||||
I want to login via OAuth with PKCE using GitHub OAuth
|
||||
So that I can securely authenticate and sync my wiki
|
||||
|
||||
Background:
|
||||
Given I launch the TidGi application
|
||||
And I wait for the page to load completely
|
||||
And I should see a "page body" element with selector "body"
|
||||
|
||||
@oauth @pkce
|
||||
Scenario: Login with Custom OAuth Server using PKCE
|
||||
# Step 1: Start Mock OAuth Server
|
||||
When I start Mock OAuth Server on port 8888
|
||||
|
||||
# Step 2: Open preferences window
|
||||
When I click on a "settings button" element with selector "#open-preferences-button"
|
||||
When I switch to "preferences" window
|
||||
|
||||
# Step 2: Navigate to Sync section
|
||||
When I click on a "sync section" element with selector "[data-testid='preference-section-sync']"
|
||||
|
||||
# Step 3: Click Custom Server tab
|
||||
When I click on a "custom server tab" element with selector "[data-testid='custom-server-tab']"
|
||||
|
||||
# Step 4: Verify Custom Server form is visible
|
||||
Then I should see "server url input and client id input" elements with selectors:
|
||||
| [data-testid='custom-server-url-input'] |
|
||||
| [data-testid='custom-client-id-input'] |
|
||||
|
||||
# Step 5: Trigger OAuth login
|
||||
# Note: oauth2-mock-server automatically redirects without showing login UI
|
||||
# This tests the token exchange logic which is the most critical part
|
||||
When I click on a "login button" element with selector "[data-testid='custom-oauth-login-button']"
|
||||
And I wait for 3 seconds
|
||||
|
||||
# Step 6: After OAuth completes, page reloads and defaults to GitHub tab
|
||||
# Need to click Custom Server tab again to see the filled token
|
||||
When I click on a "custom server tab" element with selector "[data-testid='custom-server-tab']"
|
||||
|
||||
# Step 7: The token should be filled in the form after OAuth completes
|
||||
Then I should see a "token input with non-empty value" element with selector "[data-testid='custom-token-input'] input:not([value=''])"
|
||||
|
||||
# Step 8: Verify logout button appears
|
||||
Then I should see a "logout button" element with selector "[data-testid='custom-oauth-logout-button']"
|
||||
|
||||
# Step 9: Close preferences window
|
||||
When I close "preferences" window
|
||||
|
||||
# Cleanup
|
||||
And I stop Mock OAuth Server
|
||||
|
||||
# For Github login debugging. Need human to fill in the real password of an one-time test account.
|
||||
# @oauth @github @real @manual
|
||||
# Scenario: Login with Real GitHub OAuth
|
||||
# # NOTE: This test requires GITHUB_CLIENT_SECRET environment variable to be set
|
||||
# # GitHub OAuth Apps don't support PKCE and require client_secret
|
||||
# # Step 1: Open preferences window
|
||||
# When I click on a "settings button" element with selector "#open-preferences-button"
|
||||
# And I wait for 1 seconds
|
||||
# When I switch to "preferences" window
|
||||
# And I wait for 1 seconds
|
||||
|
||||
# # Step 2: Navigate to Sync section
|
||||
# When I click on a "sync section" element with selector "[data-testid='preference-section-sync']"
|
||||
# And I wait for 1 seconds
|
||||
|
||||
# # Step 3: Click GitHub login button (this will open a new OAuth window)
|
||||
# When I click on a "GitHub login button" element with selector "[data-testid='github-login-button']"
|
||||
# And I wait for 1 seconds
|
||||
|
||||
# # Step 4: Switch to the OAuth popup window
|
||||
# When I switch to the newest window
|
||||
# And I wait for 1 seconds
|
||||
|
||||
# # Step 5: Fill in GitHub credentials in the OAuth popup
|
||||
# # GitHub's login page uses 'login' and 'password' as field names
|
||||
# When I type "tiddlygit@gmail.com" in "GitHub email input" element with selector "input[name='login']"
|
||||
# And I wait for 0.5 seconds
|
||||
# When I type "PASSWORD HERE" in "GitHub password input" element with selector "input[name='password']"
|
||||
# And I wait for 0.5 seconds
|
||||
# When I click on a "GitHub sign in button" element with selector "input[type='submit'][name='commit']"
|
||||
# And I wait for 1 seconds
|
||||
|
||||
# # Step 6: Click Authorize button on the OAuth authorization page
|
||||
# # GitHub App requires user to authorize access to their account
|
||||
# # The button is usually green and says "Authorize [AppName]"
|
||||
# # When I click on a "GitHub authorize button" element with selector "button[type='submit'].btn-primary, button[id*='authorize'], button.js-oauth-authorize-btn"
|
||||
# # And I wait for 3 seconds
|
||||
|
||||
# # Step 7: Switch back to preferences window
|
||||
# # The OAuth window should close automatically after authorization
|
||||
# When I switch to "preferences" window
|
||||
# And I wait for 1 seconds
|
||||
|
||||
# # Step 7: Verify token is filled in the form with actual value
|
||||
# Then I should see a "GitHub token field with value" element with selector "[data-testid='github-token-input'] input:not([value=''])"
|
||||
|
||||
# # Step 8: Verify user info is populated with actual value
|
||||
# Then I should see a "GitHub username field with value" element with selector "[data-testid='github-userName-input'] input:not([value=''])"
|
||||
|
||||
# # Step 9: Close preferences window
|
||||
# When I close "preferences" window
|
||||
|
||||
|
||||
# @oauth @codeberg @real @manual
|
||||
# Scenario: Login with Real Codeberg OAuth
|
||||
# # Step 1: Open preferences window
|
||||
# When I click on a "settings button" element with selector "#open-preferences-button"
|
||||
# And I wait for 1 seconds
|
||||
# When I switch to "preferences" window
|
||||
# And I wait for 1 seconds
|
||||
|
||||
# # Step 2: Navigate to Sync section
|
||||
# When I click on a "sync section" element with selector "[data-testid='preference-section-sync']"
|
||||
# And I wait for 1 seconds
|
||||
|
||||
# # Step 3: Click Codeberg tab
|
||||
# When I click on a "Codeberg tab" element with selector "[data-testid='codeberg-tab']"
|
||||
# And I wait for 1 seconds
|
||||
|
||||
# # Step 4: Click Codeberg login button (this will open a new OAuth window)
|
||||
# When I click on a "Codeberg login button" element with selector "[data-testid='codeberg-login-button']"
|
||||
# And I wait for 2 seconds
|
||||
|
||||
# # Step 4: Switch to the OAuth popup window
|
||||
# When I switch to the newest window
|
||||
# And I wait for 1 seconds
|
||||
|
||||
# # Step 5: Fill in Codeberg credentials in the OAuth popup
|
||||
# # Codeberg uses id-based selectors
|
||||
# When I type "USERNAME HERE" in "Codeberg email input" element with selector "#user_name"
|
||||
# When I type "TEMPORARY pswd" in "Codeberg password input" element with selector "#password"
|
||||
# When I click on a "Codeberg sign in button" element with selector "button.ui.primary.button.tw-w-full"
|
||||
# And I wait for 2 seconds
|
||||
|
||||
# # Step 6: Authorize the application (Codeberg requires authorization every time)
|
||||
# # The authorization page has two buttons:
|
||||
# # - Red "Authorize Application" button (id="authorize-app", name="granted" value="true")
|
||||
# # - Gray "Cancel" button (name="granted" value="false")
|
||||
# # We need to click the red one
|
||||
# When I click on a "authorize button" element with selector "#authorize-app"
|
||||
# And I wait for 2 seconds
|
||||
|
||||
# # Step 7: After OAuth completes, switch back to preferences window
|
||||
# When I switch to "preferences" window
|
||||
# And I wait for 1 seconds
|
||||
|
||||
# # Step 8: Switch to Codeberg tab to verify the filled token
|
||||
# When I click on a "codeberg tab" element with selector "[data-testid='codeberg-tab']"
|
||||
# And I wait for 1 seconds
|
||||
|
||||
# # Step 9: Verify token is filled in the form with actual value
|
||||
# Then I should see a "Codeberg token field with value" element with selector "[data-testid='codeberg-token-input'] input:not([value=''])"
|
||||
|
||||
# # Step 10: Verify user info is populated with actual value
|
||||
# Then I should see a "Codeberg username field with value" element with selector "[data-testid='codeberg-userName-input'] input:not([value=''])"
|
||||
|
||||
# # Step 11: Close preferences window
|
||||
# When I close "preferences" window
|
||||
|
||||
# @oauth @gitea @real @manual
|
||||
# Scenario: Login with Real Gitea OAuth
|
||||
# # NOTE: This test uses real Gitea.com OAuth with test credentials
|
||||
# # Gitea supports OAuth Apps with PKCE
|
||||
|
||||
# # Step 1: Open preferences window
|
||||
# When I click on a "settings button" element with selector "#open-preferences-button"
|
||||
# And I wait for 1 seconds
|
||||
# When I switch to "preferences" window
|
||||
# And I wait for 1 seconds
|
||||
|
||||
# # Step 2: Navigate to Sync section
|
||||
# When I click on a "sync section" element with selector "[data-testid='preference-section-sync']"
|
||||
# And I wait for 1 seconds
|
||||
|
||||
# # Step 3: Click Gitea tab
|
||||
# When I click on a "Gitea tab" element with selector "[data-testid='gitea-tab']"
|
||||
# And I wait for 1 seconds
|
||||
|
||||
# # Step 4: Click Gitea login button (this will open a new OAuth window)
|
||||
# When I click on a "Gitea login button" element with selector "[data-testid='gitea-login-button']"
|
||||
# And I wait for 2 seconds
|
||||
|
||||
# # Step 5: Switch to the OAuth popup window
|
||||
# When I switch to the newest window
|
||||
# And I wait for 1 seconds
|
||||
|
||||
# # Step 6: Fill in Gitea credentials in the OAuth popup
|
||||
# # Gitea uses similar id-based selectors as Codeberg
|
||||
# When I type "USERNAME HERE" in "Gitea username input" element with selector "#user_name"
|
||||
# When I type "TEMPORARY pswd" in "Gitea password input" element with selector "#password"
|
||||
# When I click on a "Gitea sign in button" element with selector "button.ui.primary.button.tw-w-full"
|
||||
# And I wait for 2 seconds
|
||||
|
||||
# # Step 7: Authorize the application (Gitea requires authorization every time like Codeberg)
|
||||
# # The authorization page has two buttons similar to Codeberg:
|
||||
# # - Red "Authorize Application" button (id="authorize-app")
|
||||
# # - Gray "Cancel" button
|
||||
# # When I click on a "authorize button" element with selector "#authorize-app"
|
||||
# # And I wait for 2 seconds
|
||||
|
||||
# # Step 8: After OAuth completes, switch back to preferences window
|
||||
# When I switch to "preferences" window
|
||||
# And I wait for 1 seconds
|
||||
|
||||
# # Step 9: Switch to Gitea tab to verify the filled token
|
||||
# When I click on a "gitea tab" element with selector "[data-testid='gitea-tab']"
|
||||
# And I wait for 1 seconds
|
||||
|
||||
# # Step 10: Verify token is filled in the form with actual value
|
||||
# Then I should see a "Gitea token field with value" element with selector "[data-testid='gitea-token-input'] input:not([value=''])"
|
||||
|
||||
# # Step 11: Verify user info is populated with actual value
|
||||
# Then I should see a "Gitea username field with value" element with selector "[data-testid='gitea-userName-input'] input:not([value=''])"
|
||||
|
||||
# # Step 12: Close preferences window
|
||||
# When I close "preferences" window
|
||||
|
|
@ -4,6 +4,7 @@ import path from 'path';
|
|||
import { _electron as electron } from 'playwright';
|
||||
import type { ElectronApplication, Page } from 'playwright';
|
||||
import { windowDimension, WindowNames } from '../../src/services/windows/WindowProperties';
|
||||
import { MockOAuthServer } from '../supports/mockOAuthServer';
|
||||
import { MockOpenAIServer } from '../supports/mockOpenAI';
|
||||
import { logsDirectory, makeSlugPath, screenshotsDirectory } from '../supports/paths';
|
||||
import { getPackedAppPath } from '../supports/paths';
|
||||
|
|
@ -33,6 +34,7 @@ export class ApplicationWorld {
|
|||
mainWindow: Page | undefined; // Keep for compatibility during transition
|
||||
currentWindow: Page | undefined; // New state-managed current window
|
||||
mockOpenAIServer: MockOpenAIServer | undefined;
|
||||
mockOAuthServer: MockOAuthServer | undefined;
|
||||
|
||||
// Helper method to check if window is visible
|
||||
async isWindowVisible(page: Page): Promise<boolean> {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { DataTable, Then } from '@cucumber/cucumber';
|
||||
import { After, DataTable, Then, When } from '@cucumber/cucumber';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { MockOAuthServer } from '../supports/mockOAuthServer';
|
||||
import { logsDirectory } from '../supports/paths';
|
||||
import { ApplicationWorld } from './application';
|
||||
|
||||
|
|
@ -17,3 +18,35 @@ Then('I should find log entries containing', async function(this: ApplicationWor
|
|||
throw new Error(`Missing expected log messages "${missing.map(item => item.slice(0, 10)).join('...", "')}..." on latest log file: ${latestLogFilePath}`);
|
||||
}
|
||||
});
|
||||
|
||||
// OAuth Server Steps
|
||||
When('I start Mock OAuth Server on port {int}', async function(this: ApplicationWorld, port: number) {
|
||||
this.mockOAuthServer = new MockOAuthServer(
|
||||
{ clientId: 'test-client-id' },
|
||||
port,
|
||||
);
|
||||
await this.mockOAuthServer.start();
|
||||
});
|
||||
|
||||
When('I stop Mock OAuth Server', async function(this: ApplicationWorld) {
|
||||
if (this.mockOAuthServer) {
|
||||
await this.mockOAuthServer.stop();
|
||||
this.mockOAuthServer = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up Mock OAuth Server after @oauth tests
|
||||
After({ tags: '@oauth' }, async function(this: ApplicationWorld) {
|
||||
if (this.mockOAuthServer) {
|
||||
try {
|
||||
await Promise.race([
|
||||
this.mockOAuthServer.stop(),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 2000)),
|
||||
]);
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
} finally {
|
||||
this.mockOAuthServer = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -300,6 +300,20 @@ When('I switch to {string} window', async function(this: ApplicationWorld, windo
|
|||
}
|
||||
});
|
||||
|
||||
// Switch to the newest/latest window (useful for OAuth popups)
|
||||
When('I switch to the newest window', async function(this: ApplicationWorld) {
|
||||
if (!this.app) {
|
||||
throw new Error('Application is not available');
|
||||
}
|
||||
const allWindows = this.app.windows().filter(p => !p.isClosed());
|
||||
if (allWindows.length === 0) {
|
||||
throw new Error('No windows available');
|
||||
}
|
||||
// The newest window is the last one in the array
|
||||
const newestWindow = allWindows[allWindows.length - 1];
|
||||
this.currentWindow = newestWindow;
|
||||
});
|
||||
|
||||
// Generic window closing
|
||||
When('I close {string} window', async function(this: ApplicationWorld, windowType: string) {
|
||||
if (!this.app) {
|
||||
|
|
|
|||
87
features/supports/mockOAuthServer.ts
Normal file
87
features/supports/mockOAuthServer.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* Mock OAuth Server using oauth2-mock-server
|
||||
*
|
||||
* Replaced custom implementation with professional OAuth 2 mock server.
|
||||
* Key benefits:
|
||||
* - Standards-compliant OAuth 2.0 + PKCE
|
||||
* - Automatic JWT token generation
|
||||
* - Proper error handling
|
||||
* - Less code to maintain (from 400+ lines to ~100 lines)
|
||||
*
|
||||
* Note: oauth2-mock-server automatically handles authorization.
|
||||
* It doesn't provide a login UI - it immediately redirects with a code.
|
||||
* This is perfect for testing token exchange logic without UI complexity.
|
||||
*
|
||||
* Standard OAuth 2 endpoints:
|
||||
* - /authorize (authorization endpoint)
|
||||
* - /token (token exchange endpoint)
|
||||
* - /userinfo (user info endpoint)
|
||||
*/
|
||||
import { OAuth2Server } from 'oauth2-mock-server';
|
||||
import type { MutableResponse, MutableToken } from 'oauth2-mock-server';
|
||||
|
||||
export class MockOAuthServer {
|
||||
private server: OAuth2Server | null = null;
|
||||
public port = 0;
|
||||
public baseUrl = '';
|
||||
|
||||
constructor(
|
||||
private config: { clientId: string },
|
||||
private fixedPort?: number,
|
||||
) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.server = new OAuth2Server();
|
||||
|
||||
// Generate RSA key for signing JWT tokens
|
||||
await this.server.issuer.keys.generate('RS256');
|
||||
|
||||
// Start server on specified or random port
|
||||
await this.server.start(this.fixedPort || 0, '127.0.0.1');
|
||||
|
||||
const address = this.server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Failed to get server address');
|
||||
}
|
||||
|
||||
this.port = address.port;
|
||||
this.baseUrl = `http://127.0.0.1:${this.port}`;
|
||||
|
||||
// Configure issuer URL
|
||||
this.server.issuer.url = this.baseUrl;
|
||||
|
||||
// Setup custom behavior to match real OAuth servers
|
||||
this.setupCustomBehavior();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.server) {
|
||||
await this.server.stop();
|
||||
this.server = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Customize server behavior to match GitHub/GitLab/Gitea OAuth servers
|
||||
*/
|
||||
private setupCustomBehavior(): void {
|
||||
if (!this.server) return;
|
||||
|
||||
// Customize access token to include GitHub-like claims
|
||||
this.server.service.on('beforeTokenSigning', (token: MutableToken, _request) => {
|
||||
token.payload.scope = 'user:email,read:user,repo,workflow';
|
||||
token.payload.token_type = 'bearer';
|
||||
});
|
||||
|
||||
// Simulate user info endpoint (matches GitHub API response)
|
||||
this.server.service.on('beforeUserinfo', (userInfoResponse: MutableResponse, _request) => {
|
||||
userInfoResponse.body = {
|
||||
login: 'testuser',
|
||||
id: 12345,
|
||||
email: 'testuser@example.com',
|
||||
name: 'Test User',
|
||||
avatar_url: 'https://example.com/avatar.jpg',
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { CoreMessage } from 'ai';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import type { AiAPIConfig } from '../../src/services/agentInstance/promptConcat/promptConcatSchema';
|
||||
import { streamFromProvider } from '../../src/services/externalAPI/callProviderAPI';
|
||||
|
|
@ -265,7 +265,7 @@ describe('Mock OpenAI Server', () => {
|
|||
enabled: true,
|
||||
};
|
||||
|
||||
const messages: CoreMessage[] = [
|
||||
const messages: ModelMessage[] = [
|
||||
{ role: 'user', content: 'Start streaming' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@
|
|||
"FailedToSaveSettings": "Failed to save settings",
|
||||
"FailedToUpdateModel": "Failed to update model",
|
||||
"FailedToUpdateProviderStatus": "Failed to update provider status",
|
||||
"Logout": "Logout",
|
||||
"MaxTokens": "Maximum generation length",
|
||||
"MaxTokensDescription": "The maximum number of characters (measured in tokens) that the model can generate in a single request.",
|
||||
"ModelAddedSuccessfully": "Model added successfully",
|
||||
|
|
@ -313,6 +314,18 @@
|
|||
"TopP": "Top P sampling parameter",
|
||||
"TopPTitle": "Top P"
|
||||
},
|
||||
"Plugin": {
|
||||
"Caption": "",
|
||||
"CaptionTitle": "",
|
||||
"Content": "",
|
||||
"ContentTitle": "",
|
||||
"ForbidOverrides": "",
|
||||
"ForbidOverridesTitle": "",
|
||||
"Id": "",
|
||||
"IdTitle": "",
|
||||
"PluginId": "",
|
||||
"PluginIdTitle": ""
|
||||
},
|
||||
"Position": {
|
||||
"Bottom": "Offset a few messages from the bottom",
|
||||
"BottomTitle": "bottom offset",
|
||||
|
|
@ -442,6 +455,7 @@
|
|||
"SourceTypeTitle": "source type",
|
||||
"Title": "Wiki Search",
|
||||
"Tool": {
|
||||
"Description": "",
|
||||
"Parameters": {
|
||||
"filter": {
|
||||
"Description": "TiddlyWiki Filter Expressions",
|
||||
|
|
@ -467,6 +481,27 @@
|
|||
"Description": "Workspace name or ID to search for",
|
||||
"Title": "Workspace Name"
|
||||
}
|
||||
},
|
||||
"UpdateEmbeddings": {
|
||||
"Description": "",
|
||||
"Parameters": {
|
||||
"forceUpdate": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
},
|
||||
"workspaceName": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
}
|
||||
},
|
||||
"forceUpdate": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
},
|
||||
"workspaceName": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"ToolListPosition": {
|
||||
|
|
@ -503,6 +538,18 @@
|
|||
}
|
||||
},
|
||||
"Tool": {
|
||||
"Plugin": {
|
||||
"Caption": "",
|
||||
"CaptionTitle": "",
|
||||
"Content": "",
|
||||
"ContentTitle": "",
|
||||
"ForbidOverrides": "",
|
||||
"ForbidOverridesTitle": "",
|
||||
"Id": "",
|
||||
"IdTitle": "",
|
||||
"PluginId": "",
|
||||
"PluginIdTitle": ""
|
||||
},
|
||||
"Schema": {
|
||||
"Description": "Description",
|
||||
"Examples": "Usage Examples",
|
||||
|
|
@ -524,6 +571,10 @@
|
|||
"WikiSearch": {
|
||||
"Error": {
|
||||
"ExecutionFailed": "Tool execution failed: {{error}}",
|
||||
"FilterSearchRequiresFilter": "",
|
||||
"VectorSearchFailed": "",
|
||||
"VectorSearchRequiresConfig": "",
|
||||
"VectorSearchRequiresQuery": "",
|
||||
"WorkspaceNotExist": "Workspace {{workspaceID}} does not exist",
|
||||
"WorkspaceNotFound": "Workspace with name or ID \"{{workspaceName}}\" does not exist. Available workspaces: {{availableWorkspaces}}"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -19,10 +19,19 @@
|
|||
"ExistedWikiLocation": "Existed Wiki Location",
|
||||
"ExtractedWikiFolderName": "Converted WIKI folder name",
|
||||
"GitDefaultBranchDescription": "The default branch of your Git, Github changed it from master to main after that event",
|
||||
"GitEmailDescription": "Email used for Git commit, and is used to count daily activities on Github and other online git services",
|
||||
"GitRepoUrl": "Git repo online url",
|
||||
"GitEmailDescription": "Email used for Git commit records, used for counting daily commits on services such as Github",
|
||||
"GitRepoUrl": "Git repository online address",
|
||||
"GitTokenDescription": "The credentials used to log in to Git. Will expire after a certain period of time",
|
||||
"GitUserNameDescription": "The account name used to log in to Git. Not the nickname",
|
||||
"GitUserNameDescription": "The account name used to log in to Git, note that it is the name part of your repository URL",
|
||||
"CustomServerUrl": "Custom Server URL",
|
||||
"CustomServerUrlDescription": "Base URL of the OAuth server (e.g., http://127.0.0.1:8888)",
|
||||
"CustomClientId": "Client ID",
|
||||
"CustomClientIdDescription": "OAuth application client ID",
|
||||
"GitToken": "Git Token",
|
||||
"GitUserName": "Git Username",
|
||||
"GitEmail": "Git Email",
|
||||
"GitBranch": "Git Branch",
|
||||
"GitBranchDescription": "Git branch to use (default: main)",
|
||||
"ImportWiki": "Import Wiki: ",
|
||||
"LocalWikiHtml": "path to html file",
|
||||
"LocalWorkspace": "Local Workspace",
|
||||
|
|
@ -234,6 +243,14 @@
|
|||
"Tags": {
|
||||
}
|
||||
},
|
||||
"KeyboardShortcut": {
|
||||
"Clear": "",
|
||||
"HelpText": "",
|
||||
"None": "",
|
||||
"PressKeys": "",
|
||||
"PressKeysPrompt": "",
|
||||
"RegisterShortcut": ""
|
||||
},
|
||||
"LOG": {
|
||||
"CommitBackupMessage": "Backup with TidGi-Desktop\t",
|
||||
"CommitMessage": "Sync with TidGi-Desktop"
|
||||
|
|
@ -309,6 +326,7 @@
|
|||
"SelectNextWorkspace": "Select Next Workspace",
|
||||
"SelectPreviousWorkspace": "Select Previous Workspace",
|
||||
"TidGi": "TidGi",
|
||||
"TidGiMenuBar": "",
|
||||
"TidGiMiniWindow": "TidGi Mini Window",
|
||||
"View": "View",
|
||||
"Wiki": "Wiki",
|
||||
|
|
@ -324,10 +342,10 @@
|
|||
"AlwaysOnTopDetail": "Keep TidGi’s main window always on top of other windows, and will not be covered by other windows",
|
||||
"AntiAntiLeech": "Some website has Anti-Leech, will prevent some images from being displayed on your wiki, we simulate a request header that looks like visiting that website to bypass this protection.",
|
||||
"AskDownloadLocation": "Ask where to save each file before downloading",
|
||||
"TidgiMiniWindow": "Attach to TidGi mini window",
|
||||
"TidgiMiniWindowShowSidebar": "Attach To TidGi Mini Window Show Sidebar",
|
||||
"TidgiMiniWindowShowSidebarTip": "Generally, TidGi mini window is only used to quickly view the current workspace, so the default synchronization with the main window workspace, do not need a sidebar, the default hidden sidebar.",
|
||||
"TidgiMiniWindowTip": "Make a small TidGi popup window that pop when you click system tray mini icon. Tip: Right-click on mini app icon to access context menu.",
|
||||
"AttachToMenuBar": "",
|
||||
"AttachToMenuBarShowSidebar": "",
|
||||
"AttachToMenuBarShowSidebarTip": "",
|
||||
"AttachToMenuBarTip": "",
|
||||
"AttachToTaskbar": "Attach to taskbar",
|
||||
"AttachToTaskbarShowSidebar": "Attach To Taskbar Show Sidebar",
|
||||
"ChooseLanguage": "Choose Language 选择语言",
|
||||
|
|
@ -355,6 +373,8 @@
|
|||
"HideMenuBarDetail": "Hide the menu bar unless the Alt+M is pressed.",
|
||||
"HideSideBar": "Hide SideBar",
|
||||
"HideSideBarIconDetail": "Hide the icon and only display the name of the workspace to make the workspace list more compact",
|
||||
"HideTidgiMiniWindow": "",
|
||||
"HideTidgiMiniWindowDetail": "",
|
||||
"HideTitleBar": "Hide Title Bar",
|
||||
"HowToEnableNotifications": "<0>TidGi supports notifications out of the box. But for some cases, to receive notifications, you will need to manually configure additional web app settings.</0><1>Learn more</1><2>.</2>",
|
||||
"IgnoreCertificateErrors": "Ignore network certificate errors",
|
||||
|
|
@ -362,16 +382,7 @@
|
|||
"ItIsWorking": "It is working!",
|
||||
"Languages": "Lang/语言",
|
||||
"LightTheme": "Light Theme",
|
||||
"TidgiMiniWindowAlwaysOnTop": "TidGi Mini Window Always on top",
|
||||
"TidgiMiniWindowAlwaysOnTopDetail": "Keep TidGi's Mini Window always on top of other windows, and will not be covered by other windows",
|
||||
"TidgiMiniWindowFixedWorkspace": "Select workspace for fixed TidGi Mini Window",
|
||||
"TidgiMiniWindowShortcutKey": "Set shortcut key to toggle TidGi Mini Window",
|
||||
"TidgiMiniWindowShortcutKeyHelperText": "Set a shortcut key to quickly open or close TidGi Mini Window",
|
||||
"TidgiMiniWindowShortcutKeyPlaceholder": "e.g.: Ctrl+Shift+D",
|
||||
"TidgiMiniWindowSyncWorkspaceWithMainWindow": "TidGi Mini Window syncs with main window workspace",
|
||||
"TidgiMiniWindowSyncWorkspaceWithMainWindowDetail": "When checked, TidGi Mini Window will display the same workspace content as main window",
|
||||
"TidgiMiniWindowShowTitleBar": "Show title bar on TidGi Mini Window",
|
||||
"TidgiMiniWindowShowTitleBarDetail": "Show draggable title bar on TidGi Mini Window",
|
||||
"Logout": "Logout",
|
||||
"Miscellaneous": "Miscellaneous",
|
||||
"MoreWorkspaceSyncSettings": "More Workspace Sync Settings",
|
||||
"MoreWorkspaceSyncSettingsDescription": "Please right-click the workspace icon, open its workspace setting by click on \"Edit Workspace\" context menu item, and configure its independent synchronization settings in it.",
|
||||
|
|
@ -413,6 +424,7 @@
|
|||
"SearchEmbeddingStatusIdle": "No embeddings generated",
|
||||
"SearchEmbeddingUpdate": "Update Embeddings",
|
||||
"SearchNoWorkspaces": "No workspaces found",
|
||||
"SelectWorkspace": "",
|
||||
"ShareBrowsingData": "Share browsing data (cookies, cache) between workspaces, if this is off, you can login into different 3rd party service in each workspace.",
|
||||
"ShowSideBar": "Show SideBar",
|
||||
"ShowSideBarDetail": "Sidebar lets you switch easily between workspaces.",
|
||||
|
|
@ -438,6 +450,21 @@
|
|||
"TestNotificationDescription": "<0>If notifications dont show up, make sure you enable notifications in<1>macOS Preferences → Notifications → TidGi</1>.</0>",
|
||||
"Theme": "Theme",
|
||||
"TiddlyWiki": "TiddlyWiki",
|
||||
"TidgiMiniWindow": "Attach to TidGi mini window",
|
||||
"TidgiMiniWindowAlwaysOnTop": "TidGi Mini Window Always on top",
|
||||
"TidgiMiniWindowAlwaysOnTopDetail": "Keep TidGi's Mini Window always on top of other windows, and will not be covered by other windows",
|
||||
"TidgiMiniWindowFixedWorkspace": "Select workspace for fixed TidGi Mini Window",
|
||||
"TidgiMiniWindowShortcutKey": "Set shortcut key to toggle TidGi Mini Window",
|
||||
"TidgiMiniWindowShortcutKeyHelperText": "Set a shortcut key to quickly open or close TidGi Mini Window",
|
||||
"TidgiMiniWindowShortcutKeyPlaceholder": "e.g.: Ctrl+Shift+D",
|
||||
"TidgiMiniWindowShowSidebar": "Attach To TidGi Mini Window Show Sidebar",
|
||||
"TidgiMiniWindowShowSidebarTip": "Generally, TidGi mini window is only used to quickly view the current workspace, so the default synchronization with the main window workspace, do not need a sidebar, the default hidden sidebar.",
|
||||
"TidgiMiniWindowShowTitleBar": "Show title bar on TidGi Mini Window",
|
||||
"TidgiMiniWindowShowTitleBarDetail": "Show draggable title bar on TidGi Mini Window",
|
||||
"TidgiMiniWindowSyncWorkspaceWithMainWindow": "TidGi Mini Window syncs with main window workspace",
|
||||
"TidgiMiniWindowSyncWorkspaceWithMainWindowDetail": "When checked, TidGi Mini Window will display the same workspace content as main window",
|
||||
"TidgiMiniWindowTip": "Make a small TidGi popup window that pop when you click system tray mini icon. Tip: Right-click on mini app icon to access context menu.",
|
||||
"ToggleMenuBar": "",
|
||||
"ToggleTidgiMiniWindow": "Toggle TidGi Mini Window",
|
||||
"Token": "Git credentials",
|
||||
"TokenDescription": "The credentials used to authenticate to the Git server so you can securely synchronize content. Can be obtained by logging in to storage services (e.g., Github), or manually obtain \"personal access token\" and filled in here.",
|
||||
|
|
|
|||
|
|
@ -325,6 +325,18 @@
|
|||
"TopP": "Paramètre d'échantillonnage Top P",
|
||||
"TopPTitle": "Top P"
|
||||
},
|
||||
"Plugin": {
|
||||
"Caption": "",
|
||||
"CaptionTitle": "",
|
||||
"Content": "",
|
||||
"ContentTitle": "",
|
||||
"ForbidOverrides": "",
|
||||
"ForbidOverridesTitle": "",
|
||||
"Id": "",
|
||||
"IdTitle": "",
|
||||
"PluginId": "",
|
||||
"PluginIdTitle": ""
|
||||
},
|
||||
"Position": {
|
||||
"Bottom": "Décaler quelques messages depuis le bas",
|
||||
"BottomTitle": "décalage du bas",
|
||||
|
|
@ -454,6 +466,7 @@
|
|||
"SourceTypeTitle": "type source",
|
||||
"Title": "Recherche Wiki",
|
||||
"Tool": {
|
||||
"Description": "",
|
||||
"Parameters": {
|
||||
"filter": {
|
||||
"Description": "Expression de filtre TiddlyWiki",
|
||||
|
|
@ -479,6 +492,27 @@
|
|||
"Description": "Nom ou ID de l'espace de travail à rechercher",
|
||||
"Title": "Nom de l'espace de travail"
|
||||
}
|
||||
},
|
||||
"UpdateEmbeddings": {
|
||||
"Description": "",
|
||||
"Parameters": {
|
||||
"forceUpdate": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
},
|
||||
"workspaceName": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
}
|
||||
},
|
||||
"forceUpdate": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
},
|
||||
"workspaceName": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"ToolListPosition": {
|
||||
|
|
@ -515,6 +549,18 @@
|
|||
}
|
||||
},
|
||||
"Tool": {
|
||||
"Plugin": {
|
||||
"Caption": "",
|
||||
"CaptionTitle": "",
|
||||
"Content": "",
|
||||
"ContentTitle": "",
|
||||
"ForbidOverrides": "",
|
||||
"ForbidOverridesTitle": "",
|
||||
"Id": "",
|
||||
"IdTitle": "",
|
||||
"PluginId": "",
|
||||
"PluginIdTitle": ""
|
||||
},
|
||||
"Schema": {
|
||||
"Description": "décrire",
|
||||
"Examples": "Exemple d'utilisation",
|
||||
|
|
@ -536,6 +582,10 @@
|
|||
"WikiSearch": {
|
||||
"Error": {
|
||||
"ExecutionFailed": "Exécution de l'outil échouée : {{error}}",
|
||||
"FilterSearchRequiresFilter": "",
|
||||
"VectorSearchFailed": "",
|
||||
"VectorSearchRequiresConfig": "",
|
||||
"VectorSearchRequiresQuery": "",
|
||||
"WorkspaceNotExist": "L'espace de travail {{workspaceID}} n'existe pas",
|
||||
"WorkspaceNotFound": "Le nom ou l'ID de l'espace de travail \"{{workspaceName}}\" n'existe pas. Espaces de travail disponibles : {{availableWorkspaces}}"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -234,6 +234,14 @@
|
|||
"Tags": {
|
||||
}
|
||||
},
|
||||
"KeyboardShortcut": {
|
||||
"Clear": "",
|
||||
"HelpText": "",
|
||||
"None": "",
|
||||
"PressKeys": "",
|
||||
"PressKeysPrompt": "",
|
||||
"RegisterShortcut": ""
|
||||
},
|
||||
"LOG": {
|
||||
"CommitBackupMessage": "Sauvegarde avec TidGi-Desktop\t",
|
||||
"CommitMessage": "Synchroniser avec TidGi-Desktop"
|
||||
|
|
@ -309,6 +317,7 @@
|
|||
"SelectNextWorkspace": "Sélectionner l'espace de travail suivant",
|
||||
"SelectPreviousWorkspace": "Sélectionner l'espace de travail précédent",
|
||||
"TidGi": "TidGi",
|
||||
"TidGiMenuBar": "",
|
||||
"TidGiMiniWindow": "Mini-fenêtre TidGi",
|
||||
"View": "Vue",
|
||||
"Wiki": "Wiki",
|
||||
|
|
@ -324,10 +333,10 @@
|
|||
"AlwaysOnTopDetail": "Garder la fenêtre principale de TidGi toujours au-dessus des autres fenêtres, et ne sera pas couverte par d'autres fenêtres",
|
||||
"AntiAntiLeech": "Certains sites web ont une protection anti-leech, empêchant certaines images d'être affichées sur votre wiki, nous simulons un en-tête de requête qui ressemble à la visite de ce site web pour contourner cette protection.",
|
||||
"AskDownloadLocation": "Demander où enregistrer chaque fichier avant de télécharger",
|
||||
"TidgiMiniWindow": "Attacher à la mini-fenêtre TidGi",
|
||||
"TidgiMiniWindowShowSidebar": "Attacher à la mini-fenêtre TidGi Afficher la barre latérale",
|
||||
"TidgiMiniWindowShowSidebarTip": "En général, la petite fenêtre TidGi est uniquement utilisée pour visualiser rapidement l'espace de travail actuel, donc la synchronisation avec l'espace de travail de la fenêtre principale n'est pas nécessaire, la barre latérale est masquée par défaut.",
|
||||
"TidgiMiniWindowTip": "Créer une petite fenêtre contextuelle TidGi qui apparaît lorsque vous cliquez sur l'icône mini de la barre d'application. Astuce : Cliquez avec le bouton droit sur l'icône mini de l'application pour accéder au menu contextuel.",
|
||||
"AttachToMenuBar": "",
|
||||
"AttachToMenuBarShowSidebar": "",
|
||||
"AttachToMenuBarShowSidebarTip": "",
|
||||
"AttachToMenuBarTip": "",
|
||||
"AttachToTaskbar": "Attacher à la barre des tâches",
|
||||
"AttachToTaskbarShowSidebar": "Attacher à la barre des tâches Afficher la barre latérale",
|
||||
"ChooseLanguage": "Choisir la langue 选择语言",
|
||||
|
|
@ -349,10 +358,12 @@
|
|||
"General": "UI & Interact",
|
||||
"HibernateAllUnusedWorkspaces": "Mettre en veille les espaces de travail inutilisés au lancement de l'application",
|
||||
"HibernateAllUnusedWorkspacesDescription": "Mettre en veille tous les espaces de travail au lancement, sauf le dernier espace de travail actif.",
|
||||
"HideTidgiMiniWindow": "Masquer la mini-fenêtre TidGi",
|
||||
"HideTidgiMiniWindowDetail": "Masquer la mini-fenêtre TidGi sauf si Alt+M est pressé.",
|
||||
"HideMenuBar": "",
|
||||
"HideMenuBarDetail": "",
|
||||
"HideSideBar": "Masquer la barre latérale",
|
||||
"HideSideBarIconDetail": "Masquer l'icône et n'afficher que le nom de l'espace de travail pour rendre la liste des espaces de travail plus compacte",
|
||||
"HideTidgiMiniWindow": "Masquer la mini-fenêtre TidGi",
|
||||
"HideTidgiMiniWindowDetail": "Masquer la mini-fenêtre TidGi sauf si Alt+M est pressé.",
|
||||
"HideTitleBar": "Masquer la barre de titre",
|
||||
"HowToEnableNotifications": "<0>TidGi prend en charge les notifications dès la sortie de la boîte. Mais dans certains cas, pour recevoir des notifications, vous devrez configurer manuellement des paramètres supplémentaires de l'application web.</0><1>En savoir plus</1><2>.</2>",
|
||||
"IgnoreCertificateErrors": "Ignorer les erreurs de certificat réseau",
|
||||
|
|
@ -360,8 +371,7 @@
|
|||
"ItIsWorking": "Ça fonctionne !",
|
||||
"Languages": "Lang/语言",
|
||||
"LightTheme": "Thème clair",
|
||||
"TidgiMiniWindowAlwaysOnTop": "TidGi Mini Window toujours au-dessus",
|
||||
"TidgiMiniWindowAlwaysOnTopDetail": "Garder la Mini Window de TidGi toujours au-dessus des autres fenêtres, et ne sera pas couverte par d'autres fenêtres",
|
||||
"Logout": "Déconnexion",
|
||||
"Miscellaneous": "Divers",
|
||||
"MoreWorkspaceSyncSettings": "Plus de paramètres de synchronisation de l'espace de travail",
|
||||
"MoreWorkspaceSyncSettingsDescription": "Veuillez cliquer avec le bouton droit sur l'icône de l'espace de travail, ouvrir ses paramètres d'espace de travail en cliquant sur l'élément de menu contextuel \"Modifier l'espace de travail\", et configurer ses paramètres de synchronisation indépendants.",
|
||||
|
|
@ -389,6 +399,7 @@
|
|||
"RunOnBackground": "Exécuter en arrière-plan",
|
||||
"RunOnBackgroundDetail": "Lorsque la fenêtre est fermée, continuer à s'exécuter en arrière-plan sans quitter. Restaurer rapidement la fenêtre lors de la réouverture de l'application.",
|
||||
"RunOnBackgroundDetailNotMac": "Recommandé d'activer Attacher à la barre des tâches. Vous pouvez ainsi restaurer la fenêtre en l'utilisant.",
|
||||
"SelectWorkspace": "",
|
||||
"ShareBrowsingData": "Partager les données de navigation (cookies, cache) entre les espaces de travail, si cette option est désactivée, vous pouvez vous connecter à différents services tiers dans chaque espace de travail.",
|
||||
"ShowSideBar": "Afficher la barre latérale",
|
||||
"ShowSideBarDetail": "La barre latérale vous permet de basculer facilement entre les espaces de travail.",
|
||||
|
|
@ -414,6 +425,21 @@
|
|||
"TestNotificationDescription": "<0>Si les notifications ne s'affichent pas, assurez-vous d'activer les notifications dans<1>Préférences macOS → Notifications → TidGi</1>.</0>",
|
||||
"Theme": "Thème",
|
||||
"TiddlyWiki": "TiddlyWiki",
|
||||
"TidgiMiniWindow": "Attacher à la mini-fenêtre TidGi",
|
||||
"TidgiMiniWindowAlwaysOnTop": "TidGi Mini Window toujours au-dessus",
|
||||
"TidgiMiniWindowAlwaysOnTopDetail": "Garder la Mini Window de TidGi toujours au-dessus des autres fenêtres, et ne sera pas couverte par d'autres fenêtres",
|
||||
"TidgiMiniWindowFixedWorkspace": "",
|
||||
"TidgiMiniWindowShortcutKey": "",
|
||||
"TidgiMiniWindowShortcutKeyHelperText": "",
|
||||
"TidgiMiniWindowShortcutKeyPlaceholder": "",
|
||||
"TidgiMiniWindowShowSidebar": "Attacher à la mini-fenêtre TidGi Afficher la barre latérale",
|
||||
"TidgiMiniWindowShowSidebarTip": "En général, la petite fenêtre TidGi est uniquement utilisée pour visualiser rapidement l'espace de travail actuel, donc la synchronisation avec l'espace de travail de la fenêtre principale n'est pas nécessaire, la barre latérale est masquée par défaut.",
|
||||
"TidgiMiniWindowShowTitleBar": "",
|
||||
"TidgiMiniWindowShowTitleBarDetail": "",
|
||||
"TidgiMiniWindowSyncWorkspaceWithMainWindow": "",
|
||||
"TidgiMiniWindowSyncWorkspaceWithMainWindowDetail": "",
|
||||
"TidgiMiniWindowTip": "Créer une petite fenêtre contextuelle TidGi qui apparaît lorsque vous cliquez sur l'icône mini de la barre d'application. Astuce : Cliquez avec le bouton droit sur l'icône mini de l'application pour accéder au menu contextuel.",
|
||||
"ToggleMenuBar": "",
|
||||
"ToggleTidgiMiniWindow": "Basculer la mini-fenêtre TidGi",
|
||||
"Token": "Informations d'identification Git",
|
||||
"TokenDescription": "Les informations d'identification utilisées pour s'authentifier auprès du serveur Git afin de pouvoir synchroniser le contenu en toute sécurité. Peut être obtenu en se connectant à des services de stockage (par exemple, Github), ou en obtenant manuellement un \"jeton d'accès personnel\" et en le remplissant ici.",
|
||||
|
|
|
|||
|
|
@ -326,6 +326,18 @@
|
|||
"TopP": "Top P サンプリングパラメータ",
|
||||
"TopPTitle": "トップP"
|
||||
},
|
||||
"Plugin": {
|
||||
"Caption": "",
|
||||
"CaptionTitle": "",
|
||||
"Content": "",
|
||||
"ContentTitle": "",
|
||||
"ForbidOverrides": "",
|
||||
"ForbidOverridesTitle": "",
|
||||
"Id": "",
|
||||
"IdTitle": "",
|
||||
"PluginId": "",
|
||||
"PluginIdTitle": ""
|
||||
},
|
||||
"Position": {
|
||||
"Bottom": "下部から数件のメッセージをオフセット",
|
||||
"BottomTitle": "底部オフセット",
|
||||
|
|
@ -455,6 +467,7 @@
|
|||
"SourceTypeTitle": "ソースタイプ",
|
||||
"Title": "Wiki 検索",
|
||||
"Tool": {
|
||||
"Description": "",
|
||||
"Parameters": {
|
||||
"filter": {
|
||||
"Description": "TiddlyWiki フィルター式",
|
||||
|
|
@ -480,6 +493,27 @@
|
|||
"Description": "検索するワークスペース名またはID",
|
||||
"Title": "ワークスペース名"
|
||||
}
|
||||
},
|
||||
"UpdateEmbeddings": {
|
||||
"Description": "",
|
||||
"Parameters": {
|
||||
"forceUpdate": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
},
|
||||
"workspaceName": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
}
|
||||
},
|
||||
"forceUpdate": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
},
|
||||
"workspaceName": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"ToolListPosition": {
|
||||
|
|
@ -516,6 +550,18 @@
|
|||
}
|
||||
},
|
||||
"Tool": {
|
||||
"Plugin": {
|
||||
"Caption": "",
|
||||
"CaptionTitle": "",
|
||||
"Content": "",
|
||||
"ContentTitle": "",
|
||||
"ForbidOverrides": "",
|
||||
"ForbidOverridesTitle": "",
|
||||
"Id": "",
|
||||
"IdTitle": "",
|
||||
"PluginId": "",
|
||||
"PluginIdTitle": ""
|
||||
},
|
||||
"Schema": {
|
||||
"Description": "説明",
|
||||
"Examples": "使用例",
|
||||
|
|
@ -537,6 +583,10 @@
|
|||
"WikiSearch": {
|
||||
"Error": {
|
||||
"ExecutionFailed": "ツールの実行に失敗しました:{{error}}",
|
||||
"FilterSearchRequiresFilter": "",
|
||||
"VectorSearchFailed": "",
|
||||
"VectorSearchRequiresConfig": "",
|
||||
"VectorSearchRequiresQuery": "",
|
||||
"WorkspaceNotExist": "ワークスペース{{workspaceID}}は存在しません",
|
||||
"WorkspaceNotFound": "ワークスペース名またはID「{{workspaceName}}」は存在しません。利用可能なワークスペース:{{availableWorkspaces}}"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -234,6 +234,14 @@
|
|||
"Tags": {
|
||||
}
|
||||
},
|
||||
"KeyboardShortcut": {
|
||||
"Clear": "",
|
||||
"HelpText": "",
|
||||
"None": "",
|
||||
"PressKeys": "",
|
||||
"PressKeysPrompt": "",
|
||||
"RegisterShortcut": ""
|
||||
},
|
||||
"LOG": {
|
||||
"CommitBackupMessage": "太記デスクトップ版を使用してバックアップ",
|
||||
"CommitMessage": "太記デスクトップ版を使用して同期する"
|
||||
|
|
@ -310,6 +318,7 @@
|
|||
"SelectPreviousWorkspace": "前のワークスペースを選択",
|
||||
"TidGi": "TidGi",
|
||||
"TidGiMenuBar": "TidGiメニューバー",
|
||||
"TidGiMiniWindow": "",
|
||||
"View": "表示",
|
||||
"Wiki": "Wiki",
|
||||
"Window": "ウィンドウ",
|
||||
|
|
@ -352,6 +361,8 @@
|
|||
"HideMenuBarDetail": "Alt + M を押すと、非表示になっているメニューバーが表示されます。",
|
||||
"HideSideBar": "サイドバーを隠す",
|
||||
"HideSideBarIconDetail": "アイコンを非表示にしてワークスペース名のみを表示し、ワークスペースリストをよりコンパクトにします",
|
||||
"HideTidgiMiniWindow": "",
|
||||
"HideTidgiMiniWindowDetail": "",
|
||||
"HideTitleBar": "タイトルバーを隠す",
|
||||
"HowToEnableNotifications": "<0>TidGiはネイティブ通知機能をサポートしています。ただし、場合によっては通知を受け取るために、Webアプリの設定を手動で構成する必要があります。</0><1>詳細を見る</1><2>。</2>",
|
||||
"IgnoreCertificateErrors": "ネットワーク証明書エラーを無視する",
|
||||
|
|
@ -359,8 +370,7 @@
|
|||
"ItIsWorking": "使いやすい!",
|
||||
"Languages": "言語/ランゲージ",
|
||||
"LightTheme": "明るい色のテーマ",
|
||||
"TidgiMiniWindowAlwaysOnTop": "太記小ウィンドウを他のウィンドウの上に保持する",
|
||||
"TidgiMiniWindowAlwaysOnTopDetail": "太記の小ウィンドウを常に他のウィンドウの上に表示させ、他のウィンドウで覆われないようにします。",
|
||||
"Logout": "ログアウト",
|
||||
"Miscellaneous": "その他の設定",
|
||||
"MoreWorkspaceSyncSettings": "さらに多くのワークスペース同期設定",
|
||||
"MoreWorkspaceSyncSettingsDescription": "ワークスペースアイコンを右クリックし、右クリックメニューから「ワークスペースの編集」を選択して、ワークスペース設定を開いてください。そこで各ワークスペースの同期設定を行います。",
|
||||
|
|
@ -388,6 +398,7 @@
|
|||
"RunOnBackground": "バックグラウンドで実行を維持する",
|
||||
"RunOnBackgroundDetail": "ウィンドウを閉じても終了せず、バックグラウンドで動作を継続します。再度アプリを開くと、すばやくウィンドウが復元されます。",
|
||||
"RunOnBackgroundDetailNotMac": "太記の小窓を開くことをお勧めします。これにより、メニューバー/タスクバーのアイコンからウィンドウを再び開くことができます。",
|
||||
"SelectWorkspace": "",
|
||||
"ShareBrowsingData": "ワークスペース間でブラウザデータ(クッキー、キャッシュなど)を共有し、閉じた後は各ワークスペースで異なるサードパーティサービスのアカウントにログインできます。",
|
||||
"ShowSideBar": "サイドバーを表示",
|
||||
"ShowSideBarDetail": "サイドバーを使用すると、ワークスペース間を素早く切り替えることができます。",
|
||||
|
|
@ -413,7 +424,22 @@
|
|||
"TestNotificationDescription": "<0>通知が表示されない場合は、<1>macOSの環境設定 → 通知 → TidGi</1>で通知が有効になっていることを確認してください</0>",
|
||||
"Theme": "テーマカラー",
|
||||
"TiddlyWiki": "太微(TiddlyWiki)",
|
||||
"TidgiMiniWindow": "",
|
||||
"TidgiMiniWindowAlwaysOnTop": "太記小ウィンドウを他のウィンドウの上に保持する",
|
||||
"TidgiMiniWindowAlwaysOnTopDetail": "太記の小ウィンドウを常に他のウィンドウの上に表示させ、他のウィンドウで覆われないようにします。",
|
||||
"TidgiMiniWindowFixedWorkspace": "",
|
||||
"TidgiMiniWindowShortcutKey": "",
|
||||
"TidgiMiniWindowShortcutKeyHelperText": "",
|
||||
"TidgiMiniWindowShortcutKeyPlaceholder": "",
|
||||
"TidgiMiniWindowShowSidebar": "",
|
||||
"TidgiMiniWindowShowSidebarTip": "",
|
||||
"TidgiMiniWindowShowTitleBar": "",
|
||||
"TidgiMiniWindowShowTitleBarDetail": "",
|
||||
"TidgiMiniWindowSyncWorkspaceWithMainWindow": "",
|
||||
"TidgiMiniWindowSyncWorkspaceWithMainWindowDetail": "",
|
||||
"TidgiMiniWindowTip": "",
|
||||
"ToggleMenuBar": "メニューバーの表示/非表示を切り替える",
|
||||
"ToggleTidgiMiniWindow": "",
|
||||
"Token": "Git認証情報",
|
||||
"TokenDescription": "Gitサーバーへの認証とコンテンツ同期に使用する認証情報は、Githubなどのオンラインストレージサービスにログインして取得するか、「Personal Access Token」を手動で取得し、ここに入力することができます。",
|
||||
"Translatium": "翻訳素APP",
|
||||
|
|
|
|||
|
|
@ -326,6 +326,18 @@
|
|||
"TopP": "Параметр выборки Top P",
|
||||
"TopPTitle": "Топ P"
|
||||
},
|
||||
"Plugin": {
|
||||
"Caption": "",
|
||||
"CaptionTitle": "",
|
||||
"Content": "",
|
||||
"ContentTitle": "",
|
||||
"ForbidOverrides": "",
|
||||
"ForbidOverridesTitle": "",
|
||||
"Id": "",
|
||||
"IdTitle": "",
|
||||
"PluginId": "",
|
||||
"PluginIdTitle": ""
|
||||
},
|
||||
"Position": {
|
||||
"Bottom": "смещение нескольких сообщений снизу",
|
||||
"BottomTitle": "смещение дна",
|
||||
|
|
@ -455,6 +467,7 @@
|
|||
"SourceTypeTitle": "тип источника",
|
||||
"Title": "Поиск в Вики",
|
||||
"Tool": {
|
||||
"Description": "",
|
||||
"Parameters": {
|
||||
"filter": {
|
||||
"Description": "TiddlyWiki выражения фильтров",
|
||||
|
|
@ -480,6 +493,27 @@
|
|||
"Description": "Имя или идентификатор рабочей области для поиска",
|
||||
"Title": "название рабочего пространства"
|
||||
}
|
||||
},
|
||||
"UpdateEmbeddings": {
|
||||
"Description": "",
|
||||
"Parameters": {
|
||||
"forceUpdate": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
},
|
||||
"workspaceName": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
}
|
||||
},
|
||||
"forceUpdate": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
},
|
||||
"workspaceName": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"ToolListPosition": {
|
||||
|
|
@ -516,6 +550,18 @@
|
|||
}
|
||||
},
|
||||
"Tool": {
|
||||
"Plugin": {
|
||||
"Caption": "",
|
||||
"CaptionTitle": "",
|
||||
"Content": "",
|
||||
"ContentTitle": "",
|
||||
"ForbidOverrides": "",
|
||||
"ForbidOverridesTitle": "",
|
||||
"Id": "",
|
||||
"IdTitle": "",
|
||||
"PluginId": "",
|
||||
"PluginIdTitle": ""
|
||||
},
|
||||
"Schema": {
|
||||
"Description": "описание",
|
||||
"Examples": "пример использования",
|
||||
|
|
@ -537,6 +583,10 @@
|
|||
"WikiSearch": {
|
||||
"Error": {
|
||||
"ExecutionFailed": "Выполнение инструмента завершилось неудачей: {{error}}",
|
||||
"FilterSearchRequiresFilter": "",
|
||||
"VectorSearchFailed": "",
|
||||
"VectorSearchRequiresConfig": "",
|
||||
"VectorSearchRequiresQuery": "",
|
||||
"WorkspaceNotExist": "Рабочее пространство {{workspaceID}} не существует",
|
||||
"WorkspaceNotFound": "Название или идентификатор рабочего пространства \"{{workspaceName}}\" не существует. Доступные рабочие пространства: {{availableWorkspaces}}"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -234,6 +234,14 @@
|
|||
"Tags": {
|
||||
}
|
||||
},
|
||||
"KeyboardShortcut": {
|
||||
"Clear": "",
|
||||
"HelpText": "",
|
||||
"None": "",
|
||||
"PressKeys": "",
|
||||
"PressKeysPrompt": "",
|
||||
"RegisterShortcut": ""
|
||||
},
|
||||
"LOG": {
|
||||
"CommitBackupMessage": "Использование TaiJi Desktop для резервного копирования",
|
||||
"CommitMessage": "Синхронизация с использованием TaiJi Desktop версии."
|
||||
|
|
@ -309,6 +317,7 @@
|
|||
"SelectNextWorkspace": "Выбрать следующее рабочее пространство",
|
||||
"SelectPreviousWorkspace": "Выбрать предыдущее рабочее пространство",
|
||||
"TidGi": "TidGi",
|
||||
"TidGiMenuBar": "",
|
||||
"TidGiMiniWindow": "Мини-окно TidGi",
|
||||
"View": "Просмотр",
|
||||
"Wiki": "Wiki",
|
||||
|
|
@ -352,6 +361,8 @@
|
|||
"HideMenuBarDetail": "Скрыть детали меню",
|
||||
"HideSideBar": "Скрыть боковую панель",
|
||||
"HideSideBarIconDetail": "Скрыть детали иконки боковой панели",
|
||||
"HideTidgiMiniWindow": "",
|
||||
"HideTidgiMiniWindowDetail": "",
|
||||
"HideTitleBar": "Скрыть заголовок",
|
||||
"HowToEnableNotifications": "<0>TidGi поддерживает уведомления из коробки. Но в некоторых случаях, чтобы получать уведомления, вам нужно вручную настроить дополнительные параметры веб-приложения.</0><1>Узнать больше</1><2>.</2>",
|
||||
"IgnoreCertificateErrors": "Игнорировать ошибки сертификатов",
|
||||
|
|
@ -359,8 +370,7 @@
|
|||
"ItIsWorking": "Работает!",
|
||||
"Languages": "Языки",
|
||||
"LightTheme": "Светлая тема",
|
||||
"TidgiMiniWindowAlwaysOnTop": "TidGi мини-окно всегда сверху",
|
||||
"TidgiMiniWindowAlwaysOnTopDetail": "Держать мини-окно TidGi всегда поверх других окон",
|
||||
"Logout": "Выйти",
|
||||
"Miscellaneous": "Разное",
|
||||
"MoreWorkspaceSyncSettings": "Дополнительные настройки синхронизации рабочего пространства",
|
||||
"MoreWorkspaceSyncSettingsDescription": "Описание дополнительных настроек синхронизации рабочего пространства",
|
||||
|
|
@ -388,6 +398,7 @@
|
|||
"RunOnBackground": "Запуск в фоновом режиме",
|
||||
"RunOnBackgroundDetail": "Детали запуска в фоновом режиме",
|
||||
"RunOnBackgroundDetailNotMac": "Детали запуска в фоновом режиме (не для Mac)",
|
||||
"SelectWorkspace": "",
|
||||
"ShareBrowsingData": "Делиться данными браузера",
|
||||
"ShowSideBar": "Показать боковую панель",
|
||||
"ShowSideBarDetail": "Показать детали боковой панели",
|
||||
|
|
@ -413,7 +424,22 @@
|
|||
"TestNotificationDescription": "<0>Если уведомление не отображается, убедитесь, что уведомления включены в <1>настройках macOS → Уведомления → TidGi</1></0>",
|
||||
"Theme": "Тема",
|
||||
"TiddlyWiki": "TiddlyWiki",
|
||||
"TidgiMiniWindow": "",
|
||||
"TidgiMiniWindowAlwaysOnTop": "TidGi мини-окно всегда сверху",
|
||||
"TidgiMiniWindowAlwaysOnTopDetail": "Держать мини-окно TidGi всегда поверх других окон",
|
||||
"TidgiMiniWindowFixedWorkspace": "",
|
||||
"TidgiMiniWindowShortcutKey": "",
|
||||
"TidgiMiniWindowShortcutKeyHelperText": "",
|
||||
"TidgiMiniWindowShortcutKeyPlaceholder": "",
|
||||
"TidgiMiniWindowShowSidebar": "",
|
||||
"TidgiMiniWindowShowSidebarTip": "",
|
||||
"TidgiMiniWindowShowTitleBar": "",
|
||||
"TidgiMiniWindowShowTitleBarDetail": "",
|
||||
"TidgiMiniWindowSyncWorkspaceWithMainWindow": "",
|
||||
"TidgiMiniWindowSyncWorkspaceWithMainWindowDetail": "",
|
||||
"TidgiMiniWindowTip": "",
|
||||
"ToggleMenuBar": "Переключить меню",
|
||||
"ToggleTidgiMiniWindow": "",
|
||||
"Token": "Токен",
|
||||
"TokenDescription": "Описание токена",
|
||||
"Translatium": "Translatium",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,15 @@
|
|||
"GitRepoUrl": "Git仓库线上网址",
|
||||
"GitTokenDescription": "用于登录Git的凭证,一定时间后会过期",
|
||||
"GitUserNameDescription": "用于登录Git的账户名,注意是你的仓库网址中你的名字部分",
|
||||
"CustomServerUrl": "自定义服务器地址",
|
||||
"CustomServerUrlDescription": "OAuth 服务器的基础 URL(例如:http://127.0.0.1:8888)",
|
||||
"CustomClientId": "客户端 ID",
|
||||
"CustomClientIdDescription": "OAuth 应用的客户端 ID",
|
||||
"GitToken": "Git 凭证",
|
||||
"GitUserName": "Git 用户名",
|
||||
"GitEmail": "Git 邮箱",
|
||||
"GitBranch": "Git 分支",
|
||||
"GitBranchDescription": "要使用的 Git 分支(默认:main)",
|
||||
"ImportWiki": "导入知识库: ",
|
||||
"LocalWikiHtml": "HTML文件的路径",
|
||||
"LocalWorkspace": "本地知识库",
|
||||
|
|
@ -317,6 +326,7 @@
|
|||
"SelectNextWorkspace": "选择下一个工作区",
|
||||
"SelectPreviousWorkspace": "选择前一个工作区",
|
||||
"TidGi": "太记",
|
||||
"TidGiMenuBar": "",
|
||||
"TidGiMiniWindow": "太记小窗",
|
||||
"View": "查看",
|
||||
"Wiki": "知识库",
|
||||
|
|
@ -332,10 +342,10 @@
|
|||
"AlwaysOnTopDetail": "让太记的主窗口永远保持在其它窗口上方,不会被其他窗口覆盖",
|
||||
"AntiAntiLeech": "有的网站做了防盗链,会阻止某些图片在你的知识库上显示,我们通过模拟访问该网站的请求头来绕过这种限制。",
|
||||
"AskDownloadLocation": "下载前询问每个文件的保存位置",
|
||||
"TidgiMiniWindow": "附加到太记小窗",
|
||||
"TidgiMiniWindowShowSidebar": "太记小窗包含侧边栏",
|
||||
"TidgiMiniWindowShowSidebarTip": "一般太记小窗仅用于快速查看当前工作区,所以默认与主窗口工作区同步,不需要侧边栏,默认隐藏侧边栏。",
|
||||
"TidgiMiniWindowTip": "创建一个点击系统托盘图标会弹出的小太记窗口。提示:右键单击小图标以访问上下文菜单。",
|
||||
"AttachToMenuBar": "",
|
||||
"AttachToMenuBarShowSidebar": "",
|
||||
"AttachToMenuBarShowSidebarTip": "",
|
||||
"AttachToMenuBarTip": "",
|
||||
"AttachToTaskbar": "附加到任务栏",
|
||||
"AttachToTaskbarShowSidebar": "附加到任务栏的窗口包含侧边栏",
|
||||
"ChooseLanguage": "选择语言 Choose Language",
|
||||
|
|
@ -364,6 +374,8 @@
|
|||
"HideMenuBarDetail": "按下 Alt + M 可以显示被隐藏的菜单栏",
|
||||
"HideSideBar": "隐藏侧边栏",
|
||||
"HideSideBarIconDetail": "隐藏图标只显示工作区的名字,让工作区列表更紧凑",
|
||||
"HideTidgiMiniWindow": "",
|
||||
"HideTidgiMiniWindowDetail": "",
|
||||
"HideTitleBar": "隐藏标题栏",
|
||||
"HowToEnableNotifications": "<0>TidGi支持原生通知功能。但在某些情况下,要接收通知,您需要手动配置一些Web应用设置。</0><1>了解详情</1><2>。</2>",
|
||||
"IgnoreCertificateErrors": "忽略网络证书错误",
|
||||
|
|
@ -371,16 +383,7 @@
|
|||
"ItIsWorking": "好使的!",
|
||||
"Languages": "语言/Lang",
|
||||
"LightTheme": "亮色主题",
|
||||
"TidgiMiniWindowAlwaysOnTop": "保持太记小窗口在其他窗口上方",
|
||||
"TidgiMiniWindowAlwaysOnTopDetail": "让太记的小窗口永远保持在其它窗口上方,不会被其他窗口覆盖",
|
||||
"TidgiMiniWindowFixedWorkspace": "为固定的太记小窗口选择工作区",
|
||||
"TidgiMiniWindowShortcutKey": "设置快捷键来切换太记小窗口",
|
||||
"TidgiMiniWindowShortcutKeyHelperText": "设置一个快捷键来快速打开或关闭太记小窗口",
|
||||
"TidgiMiniWindowShortcutKeyPlaceholder": "例如:Ctrl+Shift+D",
|
||||
"TidgiMiniWindowSyncWorkspaceWithMainWindow": "小窗和主窗口展示同样的工作区",
|
||||
"TidgiMiniWindowSyncWorkspaceWithMainWindowDetail": "勾选后,小窗将与主窗口同步显示相同的工作区内容",
|
||||
"TidgiMiniWindowShowTitleBar": "小窗显示标题栏",
|
||||
"TidgiMiniWindowShowTitleBarDetail": "在太记小窗口上显示可拖动的标题栏",
|
||||
"Logout": "登出",
|
||||
"Miscellaneous": "其他设置",
|
||||
"MoreWorkspaceSyncSettings": "更多工作区同步设置",
|
||||
"MoreWorkspaceSyncSettingsDescription": "请右键工作区图标,点右键菜单里的「编辑工作区」来打开工作区设置,在里面配各个工作区的同步设置。",
|
||||
|
|
@ -448,6 +451,21 @@
|
|||
"TestNotificationDescription": "<0>如果通知未显示,请确保在<1>macOS首选项 → 通知 → TidGi中启用通知</1></0>",
|
||||
"Theme": "主题色",
|
||||
"TiddlyWiki": "太微",
|
||||
"TidgiMiniWindow": "附加到太记小窗",
|
||||
"TidgiMiniWindowAlwaysOnTop": "保持太记小窗口在其他窗口上方",
|
||||
"TidgiMiniWindowAlwaysOnTopDetail": "让太记的小窗口永远保持在其它窗口上方,不会被其他窗口覆盖",
|
||||
"TidgiMiniWindowFixedWorkspace": "为固定的太记小窗口选择工作区",
|
||||
"TidgiMiniWindowShortcutKey": "设置快捷键来切换太记小窗口",
|
||||
"TidgiMiniWindowShortcutKeyHelperText": "设置一个快捷键来快速打开或关闭太记小窗口",
|
||||
"TidgiMiniWindowShortcutKeyPlaceholder": "例如:Ctrl+Shift+D",
|
||||
"TidgiMiniWindowShowSidebar": "太记小窗包含侧边栏",
|
||||
"TidgiMiniWindowShowSidebarTip": "一般太记小窗仅用于快速查看当前工作区,所以默认与主窗口工作区同步,不需要侧边栏,默认隐藏侧边栏。",
|
||||
"TidgiMiniWindowShowTitleBar": "小窗显示标题栏",
|
||||
"TidgiMiniWindowShowTitleBarDetail": "在太记小窗口上显示可拖动的标题栏",
|
||||
"TidgiMiniWindowSyncWorkspaceWithMainWindow": "小窗和主窗口展示同样的工作区",
|
||||
"TidgiMiniWindowSyncWorkspaceWithMainWindowDetail": "勾选后,小窗将与主窗口同步显示相同的工作区内容",
|
||||
"TidgiMiniWindowTip": "创建一个点击系统托盘图标会弹出的小太记窗口。提示:右键单击小图标以访问上下文菜单。",
|
||||
"ToggleMenuBar": "",
|
||||
"ToggleTidgiMiniWindow": "切换太记小窗",
|
||||
"Token": "Git身份凭证",
|
||||
"TokenDescription": "用于向Git服务器验证身份并同步内容的凭证,可通过登录在线存储服务(如Github)来取得,也可以手动获取「Personal Access Token」后填到这里。",
|
||||
|
|
@ -471,6 +489,7 @@
|
|||
"UpdateAvailable": "有新版本!"
|
||||
},
|
||||
"Unknown": "未知",
|
||||
"Update": "",
|
||||
"Updater": {
|
||||
"CheckUpdate": "检查更新",
|
||||
"CheckingFailed": "检查更新失败(网络错误)",
|
||||
|
|
|
|||
|
|
@ -313,6 +313,18 @@
|
|||
"TopP": "Top P 採樣參數",
|
||||
"TopPTitle": "Top P"
|
||||
},
|
||||
"Plugin": {
|
||||
"Caption": "",
|
||||
"CaptionTitle": "",
|
||||
"Content": "",
|
||||
"ContentTitle": "",
|
||||
"ForbidOverrides": "",
|
||||
"ForbidOverridesTitle": "",
|
||||
"Id": "",
|
||||
"IdTitle": "",
|
||||
"PluginId": "",
|
||||
"PluginIdTitle": ""
|
||||
},
|
||||
"Position": {
|
||||
"Bottom": "自底部偏移幾條消息",
|
||||
"BottomTitle": "底部偏移",
|
||||
|
|
@ -442,6 +454,7 @@
|
|||
"SourceTypeTitle": "源類型",
|
||||
"Title": "Wiki 搜索",
|
||||
"Tool": {
|
||||
"Description": "",
|
||||
"Parameters": {
|
||||
"filter": {
|
||||
"Description": "TiddlyWiki 篩選器表達式",
|
||||
|
|
@ -469,11 +482,24 @@
|
|||
}
|
||||
},
|
||||
"UpdateEmbeddings": {
|
||||
"Description": "",
|
||||
"Parameters": {
|
||||
"forceUpdate": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
},
|
||||
"workspaceName": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
}
|
||||
},
|
||||
"forceUpdate": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
},
|
||||
"workspaceName": {
|
||||
"Description": "",
|
||||
"Title": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -511,6 +537,18 @@
|
|||
}
|
||||
},
|
||||
"Tool": {
|
||||
"Plugin": {
|
||||
"Caption": "",
|
||||
"CaptionTitle": "",
|
||||
"Content": "",
|
||||
"ContentTitle": "",
|
||||
"ForbidOverrides": "",
|
||||
"ForbidOverridesTitle": "",
|
||||
"Id": "",
|
||||
"IdTitle": "",
|
||||
"PluginId": "",
|
||||
"PluginIdTitle": ""
|
||||
},
|
||||
"Schema": {
|
||||
"Description": "描述",
|
||||
"Examples": "使用範例",
|
||||
|
|
@ -532,6 +570,10 @@
|
|||
"WikiSearch": {
|
||||
"Error": {
|
||||
"ExecutionFailed": "工具執行失敗:{{error}}",
|
||||
"FilterSearchRequiresFilter": "",
|
||||
"VectorSearchFailed": "",
|
||||
"VectorSearchRequiresConfig": "",
|
||||
"VectorSearchRequiresQuery": "",
|
||||
"WorkspaceNotExist": "工作區{{workspaceID}}不存在",
|
||||
"WorkspaceNotFound": "工作區名稱或ID\"{{workspaceName}}\"不存在。可用工作區:{{availableWorkspaces}}"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -234,6 +234,14 @@
|
|||
"Tags": {
|
||||
}
|
||||
},
|
||||
"KeyboardShortcut": {
|
||||
"Clear": "",
|
||||
"HelpText": "",
|
||||
"None": "",
|
||||
"PressKeys": "",
|
||||
"PressKeysPrompt": "",
|
||||
"RegisterShortcut": ""
|
||||
},
|
||||
"LOG": {
|
||||
"CommitBackupMessage": "使用太記桌面版備份",
|
||||
"CommitMessage": "使用太記桌面版同步"
|
||||
|
|
@ -309,6 +317,7 @@
|
|||
"SelectNextWorkspace": "選擇下一個工作區",
|
||||
"SelectPreviousWorkspace": "選擇前一個工作區",
|
||||
"TidGi": "太記",
|
||||
"TidGiMenuBar": "",
|
||||
"TidGiMiniWindow": "太記小窗",
|
||||
"View": "查看",
|
||||
"Wiki": "知識庫",
|
||||
|
|
@ -324,10 +333,10 @@
|
|||
"AlwaysOnTopDetail": "讓太記的主窗口永遠保持在其它窗口上方,不會被其他窗口覆蓋",
|
||||
"AntiAntiLeech": "有的網站做了防盜鏈,會阻止某些圖片在你的知識庫上顯示,我們透過模擬訪問該網站的請求頭來繞過這種限制。",
|
||||
"AskDownloadLocation": "下載前詢問每個文件的保存位置",
|
||||
"TidgiMiniWindow": "附加到太記小窗",
|
||||
"TidgiMiniWindowShowSidebar": "太記小窗包含側邊欄",
|
||||
"TidgiMiniWindowShowSidebarTip": "一般太記小窗僅用於快速查看當前工作區,所以默認與主窗口工作區同步,不需要側邊欄,默認隱藏側邊欄。",
|
||||
"TidgiMiniWindowTip": "創建一個點擊系統托盤圖示會彈出的小太記窗口。提示:右鍵單擊小圖示以訪問上下文菜單。",
|
||||
"AttachToMenuBar": "",
|
||||
"AttachToMenuBarShowSidebar": "",
|
||||
"AttachToMenuBarShowSidebarTip": "",
|
||||
"AttachToMenuBarTip": "",
|
||||
"AttachToTaskbar": "附加到任務欄",
|
||||
"AttachToTaskbarShowSidebar": "附加到任務欄的窗口包含側邊欄",
|
||||
"ChooseLanguage": "選擇語言 Choose Language",
|
||||
|
|
@ -356,6 +365,8 @@
|
|||
"HideMenuBarDetail": "按下 Alt + M 可以顯示被隱藏的選單欄",
|
||||
"HideSideBar": "隱藏側邊欄",
|
||||
"HideSideBarIconDetail": "隱藏圖示只顯示工作區的名字,讓工作區列表更緊湊",
|
||||
"HideTidgiMiniWindow": "",
|
||||
"HideTidgiMiniWindowDetail": "",
|
||||
"HideTitleBar": "隱藏標題欄",
|
||||
"HowToEnableNotifications": "<0>TidGi支持原生通知功能。但在某些情況下,要接收通知,您需要手動配置一些Web應用設定。</0><1>了解詳情</1><2>。</2>",
|
||||
"IgnoreCertificateErrors": "忽略網路證書錯誤",
|
||||
|
|
@ -363,8 +374,7 @@
|
|||
"ItIsWorking": "好使的!",
|
||||
"Languages": "語言/Lang",
|
||||
"LightTheme": "亮色主題",
|
||||
"TidgiMiniWindowAlwaysOnTop": "保持太記小窗口在其他窗口上方",
|
||||
"TidgiMiniWindowAlwaysOnTopDetail": "讓太記的小窗口永遠保持在其它窗口上方,不會被其他窗口覆蓋",
|
||||
"Logout": "登出",
|
||||
"Miscellaneous": "其他設置",
|
||||
"MoreWorkspaceSyncSettings": "更多工作區同步設定",
|
||||
"MoreWorkspaceSyncSettingsDescription": "請右鍵工作區圖示,點右鍵菜單裡的「編輯工作區」來打開工作區設置,在裡面配各個工作區的同步設定。",
|
||||
|
|
@ -406,6 +416,7 @@
|
|||
"SearchEmbeddingStatusIdle": "未生成嵌入",
|
||||
"SearchEmbeddingUpdate": "更新嵌入",
|
||||
"SearchNoWorkspaces": "未找到工作區",
|
||||
"SelectWorkspace": "",
|
||||
"ShareBrowsingData": "在工作區之間共享瀏覽器數據(cookies、快取等),關閉後可以每個工作區登不同的第三方服務帳號。",
|
||||
"ShowSideBar": "顯示側邊欄",
|
||||
"ShowSideBarDetail": "側邊欄讓你可以在工作區之間快速切換",
|
||||
|
|
@ -431,6 +442,21 @@
|
|||
"TestNotificationDescription": "<0>如果通知未顯示,請確保在<1>macOS首選項 → 通知 → TidGi中啟用通知</1></0>",
|
||||
"Theme": "主題色",
|
||||
"TiddlyWiki": "太微",
|
||||
"TidgiMiniWindow": "附加到太記小窗",
|
||||
"TidgiMiniWindowAlwaysOnTop": "保持太記小窗口在其他窗口上方",
|
||||
"TidgiMiniWindowAlwaysOnTopDetail": "讓太記的小窗口永遠保持在其它窗口上方,不會被其他窗口覆蓋",
|
||||
"TidgiMiniWindowFixedWorkspace": "",
|
||||
"TidgiMiniWindowShortcutKey": "",
|
||||
"TidgiMiniWindowShortcutKeyHelperText": "",
|
||||
"TidgiMiniWindowShortcutKeyPlaceholder": "",
|
||||
"TidgiMiniWindowShowSidebar": "太記小窗包含側邊欄",
|
||||
"TidgiMiniWindowShowSidebarTip": "一般太記小窗僅用於快速查看當前工作區,所以默認與主窗口工作區同步,不需要側邊欄,默認隱藏側邊欄。",
|
||||
"TidgiMiniWindowShowTitleBar": "",
|
||||
"TidgiMiniWindowShowTitleBarDetail": "",
|
||||
"TidgiMiniWindowSyncWorkspaceWithMainWindow": "",
|
||||
"TidgiMiniWindowSyncWorkspaceWithMainWindowDetail": "",
|
||||
"TidgiMiniWindowTip": "創建一個點擊系統托盤圖示會彈出的小太記窗口。提示:右鍵單擊小圖示以訪問上下文菜單。",
|
||||
"ToggleMenuBar": "",
|
||||
"ToggleTidgiMiniWindow": "切換太記小窗",
|
||||
"Token": "Git身份憑證",
|
||||
"TokenDescription": "用於向Git伺服器驗證身份並同步內容的憑證,可透過登錄在線儲存服務(如Github)來取得,也可以手動獲取「Personal Access Token」後填到這裡。",
|
||||
|
|
|
|||
147
package.json
147
package.json
|
|
@ -6,20 +6,19 @@
|
|||
"license": "MPL 2.0",
|
||||
"packageManager": "pnpm@10.18.2",
|
||||
"scripts": {
|
||||
"start:init": "pnpm run clean && pnpm run init:git-submodule && pnpm run build:plugin && cross-env NODE_ENV=development pnpm dlx tsx scripts/developmentMkdir.ts && pnpm run start:dev",
|
||||
"start:init": "pnpm run clean && pnpm run init:git-submodule && pnpm run build:plugin && cross-env NODE_ENV=development tsx scripts/developmentMkdir.ts && pnpm run start:dev",
|
||||
"start:dev": "cross-env NODE_ENV=development electron-forge start",
|
||||
"clean": "rimraf -- ./out ./logs ./userData-dev ./userData-test ./wiki-dev ./wiki-test ./node_modules/tiddlywiki/plugins/linonetwo && pnpm run clean:cache",
|
||||
"clean:cache": "rimraf -- ./.vite ./node_modules/.cache",
|
||||
"clean": "rimraf -- ./out ./logs ./userData-dev ./userData-test ./wiki-dev ./wiki-test ./node_modules/tiddlywiki/plugins/linonetwo",
|
||||
"start:dev:debug-worker": "cross-env NODE_ENV=development DEBUG_WORKER=true electron-forge start",
|
||||
"start:dev:debug-main": "cross-env NODE_ENV=development DEBUG_MAIN=true electron-forge start",
|
||||
"start:dev:debug-vite": "cross-env NODE_ENV=development DEBUG=electron-forge:* electron-forge start",
|
||||
"start:dev:debug-react": "cross-env NODE_ENV=development DEBUG_REACT=true electron-forge start",
|
||||
"build:plugin": "zx scripts/compilePlugins.mjs",
|
||||
"test": "pnpm run test:unit && pnpm run test:prepare-e2e && pnpm run test:e2e",
|
||||
"test:unit": "cross-env ELECTRON_RUN_AS_NODE=1 ./node_modules/.bin/electron ./node_modules/vitest/vitest.mjs run",
|
||||
"test:unit": "cross-env ELECTRON_RUN_AS_NODE=1 ./node_modules/.bin/electron --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run",
|
||||
"test:unit:coverage": "pnpm run test:unit --coverage",
|
||||
"test:prepare-e2e": "cross-env READ_DOC_BEFORE_USING='docs/Testing.md' && pnpm run clean && pnpm run build:plugin && cross-env NODE_ENV=test DEBUG=electron-forge:* electron-forge package",
|
||||
"test:e2e": "rimraf -- ./userData-test ./wiki-test && cross-env NODE_ENV=test pnpm dlx tsx scripts/developmentMkdir.ts && cross-env NODE_ENV=test cucumber-js --config features/cucumber.config.js",
|
||||
"test:e2e": "rimraf -- ./userData-test ./wiki-test && cross-env NODE_ENV=test tsx scripts/developmentMkdir.ts && cross-env NODE_ENV=test cucumber-js --config features/cucumber.config.js",
|
||||
"make": "pnpm run build:plugin && cross-env NODE_ENV=production electron-forge make",
|
||||
"make:analyze": "cross-env ANALYZE=true pnpm run make",
|
||||
"init:git-submodule": "git submodule update --init --recursive && git submodule update --remote",
|
||||
|
|
@ -32,34 +31,34 @@
|
|||
"author": "Lin Onetwo <linonetwo012@gmail.com>, Quang Lam <quang.lam2807@gmail.com>",
|
||||
"main": ".vite/build/main.js",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^1.2.11",
|
||||
"@ai-sdk/deepseek": "^0.2.14",
|
||||
"@ai-sdk/openai": "^1.3.22",
|
||||
"@ai-sdk/openai-compatible": "^0.2.14",
|
||||
"@algolia/autocomplete-js": "^1.19.1",
|
||||
"@algolia/autocomplete-theme-classic": "^1.19.1",
|
||||
"@ai-sdk/anthropic": "^2.0.35",
|
||||
"@ai-sdk/deepseek": "^1.0.23",
|
||||
"@ai-sdk/openai": "^2.0.53",
|
||||
"@ai-sdk/openai-compatible": "^1.0.22",
|
||||
"@algolia/autocomplete-js": "^1.19.4",
|
||||
"@algolia/autocomplete-theme-classic": "^1.19.4",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@dr.pogodin/react-helmet": "^3.0.2",
|
||||
"@fontsource/roboto": "^5.1.1",
|
||||
"@dr.pogodin/react-helmet": "^3.0.4",
|
||||
"@fontsource/roboto": "^5.2.8",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@mui/icons-material": "^7.1.1",
|
||||
"@mui/material": "^7.1.1",
|
||||
"@mui/system": "^7.1.1",
|
||||
"@mui/types": "^7.4.3",
|
||||
"@mui/x-date-pickers": "^8.4.0",
|
||||
"@mui/icons-material": "^7.3.4",
|
||||
"@mui/material": "^7.3.4",
|
||||
"@mui/system": "^7.3.3",
|
||||
"@mui/types": "^7.4.7",
|
||||
"@mui/x-date-pickers": "^8.14.1",
|
||||
"@rjsf/core": "6.0.0-beta.8",
|
||||
"@rjsf/mui": "6.0.0-beta.10",
|
||||
"@rjsf/utils": "6.0.0-beta.10",
|
||||
"@rjsf/validator-ajv8": "6.0.0-beta.8",
|
||||
"ai": "^4.3.15",
|
||||
"ai": "^5.0.76",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"app-path": "^4.0.0",
|
||||
"beautiful-react-hooks": "5.0.3",
|
||||
"best-effort-json-parser": "1.1.3",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"best-effort-json-parser": "1.2.1",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"bluebird": "3.7.2",
|
||||
"date-fns": "3.6.0",
|
||||
"default-gateway": "6.0.3",
|
||||
|
|
@ -69,58 +68,59 @@
|
|||
"electron-settings": "5.0.0",
|
||||
"electron-unhandled": "4.0.1",
|
||||
"electron-window-state": "5.0.3",
|
||||
"espree": "^10.3.0",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"fs-extra": "11.3.0",
|
||||
"espree": "^10.4.0",
|
||||
"exponential-backoff": "^3.1.3",
|
||||
"fs-extra": "11.3.2",
|
||||
"git-sync-js": "^2.0.5",
|
||||
"graphql-hooks": "8.2.0",
|
||||
"html-minifier-terser": "^7.2.0",
|
||||
"i18next": "25.2.1",
|
||||
"i18next": "25.6.0",
|
||||
"i18next-electron-fs-backend": "3.0.3",
|
||||
"i18next-fs-backend": "2.6.0",
|
||||
"immer": "^10.1.1",
|
||||
"immer": "^10.1.3",
|
||||
"intercept-stdout": "0.1.2",
|
||||
"inversify": "6.2.1",
|
||||
"inversify": "7.10.3",
|
||||
"ipaddr.js": "2.2.0",
|
||||
"jimp": "1.6.0",
|
||||
"json5": "^2.2.3",
|
||||
"lodash": "4.17.21",
|
||||
"material-ui-popup-state": "^5.3.6",
|
||||
"menubar": "9.5.1",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"nanoid": "^5.0.9",
|
||||
"new-github-issue-url": "^1.0.0",
|
||||
"menubar": "9.5.2",
|
||||
"monaco-editor": "^0.54.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"new-github-issue-url": "^1.1.0",
|
||||
"node-fetch": "3.3.2",
|
||||
"ollama-ai-provider": "^1.2.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-i18next": "15.5.2",
|
||||
"oidc-client-ts": "^3.3.0",
|
||||
"ollama-ai-provider-v2": "^1.5.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-i18next": "16.1.3",
|
||||
"react-masonry-css": "^1.0.16",
|
||||
"react-window": "^1.8.11",
|
||||
"react-window": "^2.2.1",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"registry-js": "1.16.1",
|
||||
"rotating-file-stream": "^3.2.5",
|
||||
"rotating-file-stream": "^3.2.7",
|
||||
"rxjs": "7.8.2",
|
||||
"semver": "7.7.2",
|
||||
"semver": "7.7.3",
|
||||
"serialize-error": "^12.0.0",
|
||||
"simplebar": "6.3.1",
|
||||
"simplebar-react": "3.3.0",
|
||||
"simplebar": "6.3.2",
|
||||
"simplebar-react": "3.3.2",
|
||||
"source-map-support": "0.5.21",
|
||||
"sqlite-vec": "0.1.7-alpha.2",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tapable": "^2.2.2",
|
||||
"tiddlywiki": "5.3.7",
|
||||
"type-fest": "4.41.0",
|
||||
"typeorm": "^0.3.22",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"tapable": "^2.3.0",
|
||||
"tiddlywiki": "5.3.8",
|
||||
"type-fest": "5.1.0",
|
||||
"typeorm": "^0.3.27",
|
||||
"typescript-styled-is": "^2.1.0",
|
||||
"v8-compile-cache-lib": "^3.0.1",
|
||||
"winston": "3.17.0",
|
||||
"winston": "3.18.3",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
"winston-transport": "4.9.0",
|
||||
"wouter": "^3.7.1",
|
||||
"zod": "^3.25.28",
|
||||
"zustand": "^5.0.4",
|
||||
"zx": "8.5.5"
|
||||
"zod": "^4.1.12",
|
||||
"zustand": "^5.0.8",
|
||||
"zx": "8.8.5"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@electron-forge/maker-deb": "7.10.2",
|
||||
|
|
@ -132,54 +132,55 @@
|
|||
"@reforged/maker-appimage": "5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cucumber/cucumber": "^11.2.0",
|
||||
"@cucumber/cucumber": "^12.2.0",
|
||||
"@electron-forge/cli": "7.10.2",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "7.10.2",
|
||||
"@electron-forge/plugin-vite": "7.10.2",
|
||||
"@electron/rebuild": "^4.0.1",
|
||||
"@fetsorn/vite-node-worker": "^1.0.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/bluebird": "3.5.42",
|
||||
"@types/chai": "5.0.1",
|
||||
"@types/chai": "5.2.3",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/html-minifier-terser": "^7.0.2",
|
||||
"@types/i18next-fs-backend": "1.1.5",
|
||||
"@types/intercept-stdout": "0.1.3",
|
||||
"@types/lodash": "4.17.15",
|
||||
"@types/node": "22.13.0",
|
||||
"@types/react": "19.0.8",
|
||||
"@types/react-dom": "19.0.3",
|
||||
"@types/intercept-stdout": "0.1.4",
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/node": "24.9.1",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"@types/react-jsonschema-form": "^1.7.13",
|
||||
"@types/semver": "7.5.8",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/source-map-support": "0.5.10",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"@vitest/coverage-v8": "^3.2.3",
|
||||
"@vitest/ui": "^3.2.3",
|
||||
"chai": "5.1.2",
|
||||
"cross-env": "7.0.3",
|
||||
"dprint": "^0.50.0",
|
||||
"electron": "36.4.0",
|
||||
"electron-chrome-web-store": "^0.12.0",
|
||||
"esbuild": "^0.25.2",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"chai": "6.2.0",
|
||||
"cross-env": "10.1.0",
|
||||
"dprint": "^0.50.2",
|
||||
"electron": "38.3.0",
|
||||
"electron-chrome-web-store": "^0.13.0",
|
||||
"esbuild": "^0.25.11",
|
||||
"eslint-config-tidgi": "^2.2.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"jsdom": "^27.0.1",
|
||||
"memory-fs": "^0.5.0",
|
||||
"node-loader": "2.1.0",
|
||||
"oauth2-mock-server": "^8.1.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"playwright": "^1.53.0",
|
||||
"playwright": "^1.56.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"ts-node": "10.9.2",
|
||||
"tsx": "^4.20.6",
|
||||
"tw5-typed": "^0.6.3",
|
||||
"typescript": "5.8.3",
|
||||
"typescript": "5.9.3",
|
||||
"typesync": "0.14.3",
|
||||
"unplugin-swc": "^1.5.5",
|
||||
"vite": "^7.1.9",
|
||||
"unplugin-swc": "^1.5.8",
|
||||
"vite": "^7.1.11",
|
||||
"vite-bundle-analyzer": "^1.2.3",
|
||||
"vitest": "^3.2.3"
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
|
|
|
|||
6749
pnpm-lock.yaml
generated
6749
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -61,7 +61,11 @@ export const serviceInstances: {
|
|||
wikiOperationInServer: vi.fn().mockResolvedValue([]) as IWikiService['wikiOperationInServer'],
|
||||
},
|
||||
auth: {
|
||||
get: vi.fn().mockResolvedValue(undefined),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
getStorageServiceUserInfo: vi.fn().mockResolvedValue(undefined),
|
||||
getUserInfos: vi.fn().mockResolvedValue({ userName: '' }),
|
||||
setUserInfos: vi.fn(),
|
||||
},
|
||||
context: {
|
||||
get: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
|
|||
121
src/components/TokenForm/CustomServerTokenForm.tsx
Normal file
121
src/components/TokenForm/CustomServerTokenForm.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { Button, TextField } from '@mui/material';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { SupportedStorageServices } from '@services/types';
|
||||
import { useState } from 'react';
|
||||
import { useAuth, useGetGithubUserInfoOnLoad } from './gitTokenHooks';
|
||||
import { useTokenForm } from './useTokenForm';
|
||||
|
||||
const AuthingLoginButton = styled(Button)`
|
||||
width: 100%;
|
||||
`;
|
||||
const GitTokenInput = styled((props: React.ComponentProps<typeof TextField> & { helperText?: string }) => <TextField fullWidth variant='standard' {...props} />)`
|
||||
color: ${({ theme }) => theme.palette.text.primary};
|
||||
input {
|
||||
color: ${({ theme }) => theme.palette.text.primary};
|
||||
}
|
||||
p,
|
||||
label {
|
||||
color: ${({ theme }) => theme.palette.text.secondary};
|
||||
}
|
||||
`;
|
||||
|
||||
export function CustomServerTokenForm(): React.JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const storageService = SupportedStorageServices.testOAuth;
|
||||
|
||||
const [onClickLogin, onClickLogout] = useAuth(storageService);
|
||||
useGetGithubUserInfoOnLoad();
|
||||
|
||||
const { token, userName, email, branch, isLoggedIn, isReady, tokenSetter, userNameSetter, emailSetter, branchSetter } = useTokenForm(storageService);
|
||||
|
||||
// Custom server configuration
|
||||
const [serverUrl, serverUrlSetter] = useState<string>('http://127.0.0.1:8888');
|
||||
const [clientId, clientIdSetter] = useState<string>('test-client-id');
|
||||
|
||||
// Store custom server config before OAuth login
|
||||
const handleLogin = async () => {
|
||||
// Save custom server configuration
|
||||
await window.service.auth.set('testOAuth-serverUrl', serverUrl);
|
||||
await window.service.auth.set('testOAuth-clientId', clientId);
|
||||
// Then trigger OAuth login
|
||||
await onClickLogin();
|
||||
};
|
||||
|
||||
if (!isReady) {
|
||||
return <div>{t('Loading')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GitTokenInput
|
||||
label={t('AddWorkspace.CustomServerUrl')}
|
||||
helperText={t('AddWorkspace.CustomServerUrlDescription')}
|
||||
value={serverUrl}
|
||||
onChange={(event) => {
|
||||
serverUrlSetter(event.target.value);
|
||||
}}
|
||||
placeholder='http://127.0.0.1:8888'
|
||||
data-testid='custom-server-url-input'
|
||||
/>
|
||||
<GitTokenInput
|
||||
label={t('AddWorkspace.CustomClientId')}
|
||||
helperText={t('AddWorkspace.CustomClientIdDescription')}
|
||||
value={clientId}
|
||||
onChange={(event) => {
|
||||
clientIdSetter(event.target.value);
|
||||
}}
|
||||
placeholder='client-id'
|
||||
data-testid='custom-client-id-input'
|
||||
/>
|
||||
{!isLoggedIn && (
|
||||
<AuthingLoginButton onClick={handleLogin} data-testid='custom-oauth-login-button'>
|
||||
{t('AddWorkspace.LogoutToGetStorageServiceToken')}
|
||||
</AuthingLoginButton>
|
||||
)}
|
||||
{isLoggedIn && (
|
||||
<AuthingLoginButton onClick={onClickLogout} color='secondary' data-testid='custom-oauth-logout-button'>
|
||||
{t('Preference.Logout')}
|
||||
</AuthingLoginButton>
|
||||
)}
|
||||
<GitTokenInput
|
||||
label={t('AddWorkspace.GitToken')}
|
||||
helperText={t('AddWorkspace.GitTokenDescription')}
|
||||
onChange={(event) => {
|
||||
tokenSetter(event.target.value);
|
||||
}}
|
||||
value={token}
|
||||
data-testid='custom-token-input'
|
||||
/>
|
||||
<GitTokenInput
|
||||
label={t('AddWorkspace.GitUserName')}
|
||||
helperText={t('AddWorkspace.GitUserNameDescription')}
|
||||
onChange={(event) => {
|
||||
userNameSetter(event.target.value);
|
||||
}}
|
||||
value={userName}
|
||||
data-testid='custom-username-input'
|
||||
/>
|
||||
<GitTokenInput
|
||||
label={t('AddWorkspace.GitEmail')}
|
||||
helperText={t('AddWorkspace.GitEmailDescription')}
|
||||
onChange={(event) => {
|
||||
emailSetter(event.target.value);
|
||||
}}
|
||||
value={email}
|
||||
data-testid='custom-email-input'
|
||||
/>
|
||||
<GitTokenInput
|
||||
label={t('AddWorkspace.GitBranch')}
|
||||
helperText={t('AddWorkspace.GitBranchDescription')}
|
||||
onChange={(event) => {
|
||||
branchSetter(event.target.value);
|
||||
}}
|
||||
value={branch}
|
||||
placeholder='main'
|
||||
data-testid='custom-branch-input'
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +1,10 @@
|
|||
import { Button, TextField } from '@mui/material';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import useDebouncedCallback from 'beautiful-react-hooks/useDebouncedCallback';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useUserInfoObservable } from '@services/auth/hooks';
|
||||
import { IUserInfos } from '@services/auth/interface';
|
||||
import { SupportedStorageServices } from '@services/types';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuth, useGetGithubUserInfoOnLoad } from './gitTokenHooks';
|
||||
import { useTokenForm } from './useTokenForm';
|
||||
|
||||
const AuthingLoginButton = styled(Button)`
|
||||
width: 100%;
|
||||
|
|
@ -30,66 +27,51 @@ export function GitTokenForm(props: {
|
|||
const { children, storageService } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const userInfo = useUserInfoObservable();
|
||||
const [onClickLogin] = useAuth(storageService);
|
||||
const [onClickLogin, onClickLogout] = useAuth(storageService);
|
||||
useGetGithubUserInfoOnLoad();
|
||||
// local state for text inputs
|
||||
const [token, tokenSetter] = useState<string | undefined>(undefined);
|
||||
const [userName, userNameSetter] = useState<string | undefined>(undefined);
|
||||
const [email, emailSetter] = useState<string | undefined>(undefined);
|
||||
const [branch, branchSetter] = useState<string | undefined>(undefined);
|
||||
|
||||
const debouncedSet = useDebouncedCallback(
|
||||
<K extends keyof IUserInfos>(key: K, value: IUserInfos[K]) => {
|
||||
void window.service.auth.set(key, value);
|
||||
},
|
||||
[],
|
||||
500,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (userInfo === undefined) return;
|
||||
if (token === undefined) tokenSetter(userInfo[`${storageService}-token`]);
|
||||
if (userName === undefined) userNameSetter(userInfo[`${storageService}-userName`]);
|
||||
if (email === undefined) emailSetter(userInfo[`${storageService}-email`]);
|
||||
if (branch === undefined) branchSetter(userInfo[`${storageService}-branch`]);
|
||||
}, [branch, email, storageService, token, userInfo, userName]);
|
||||
if (userInfo === undefined) {
|
||||
const { token, userName, email, branch, isLoggedIn, isReady, tokenSetter, userNameSetter, emailSetter, branchSetter } = useTokenForm(storageService);
|
||||
|
||||
if (!isReady) {
|
||||
return <div>{t('Loading')}</div>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<AuthingLoginButton onClick={onClickLogin}>{t('AddWorkspace.LogoutToGetStorageServiceToken')}</AuthingLoginButton>
|
||||
{!isLoggedIn && (
|
||||
<AuthingLoginButton onClick={onClickLogin} data-testid={`${storageService}-login-button`}>{t('AddWorkspace.LogoutToGetStorageServiceToken')}</AuthingLoginButton>
|
||||
)}
|
||||
{isLoggedIn && <AuthingLoginButton onClick={onClickLogout} color='secondary' data-testid={`${storageService}-logout-button`}>{t('Preference.Logout')}</AuthingLoginButton>}
|
||||
<GitTokenInput
|
||||
helperText={t('AddWorkspace.GitTokenDescription')}
|
||||
onChange={(event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||
tokenSetter(event.target.value);
|
||||
debouncedSet(`${storageService}-token`, event.target.value);
|
||||
}}
|
||||
value={token}
|
||||
data-testid={`${storageService}-token-input`}
|
||||
/>
|
||||
<GitTokenInput
|
||||
helperText={t('AddWorkspace.GitUserNameDescription')}
|
||||
onChange={(event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||
userNameSetter(event.target.value);
|
||||
debouncedSet(`${storageService}-userName`, event.target.value);
|
||||
}}
|
||||
value={userName}
|
||||
data-testid={`${storageService}-userName-input`}
|
||||
/>
|
||||
<GitTokenInput
|
||||
helperText={t('AddWorkspace.GitEmailDescription')}
|
||||
onChange={(event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||
emailSetter(event.target.value);
|
||||
debouncedSet(`${storageService}-email`, event.target.value);
|
||||
}}
|
||||
value={email}
|
||||
data-testid={`${storageService}-email-input`}
|
||||
/>
|
||||
<GitTokenInput
|
||||
helperText={t('AddWorkspace.GitDefaultBranchDescription')}
|
||||
onChange={(event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||
branchSetter(event.target.value);
|
||||
debouncedSet(`${storageService}-branch`, event.target.value);
|
||||
}}
|
||||
value={branch}
|
||||
data-testid={`${storageService}-branch-input`}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
|
|
|
|||
168
src/components/TokenForm/__tests__/GitTokenForm.test.tsx
Normal file
168
src/components/TokenForm/__tests__/GitTokenForm.test.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import type { IUserInfos } from '@services/auth/interface';
|
||||
import { SupportedStorageServices } from '@services/types';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { GitTokenForm } from '../GitTokenForm';
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock('../gitTokenHooks', () => ({
|
||||
useAuth: vi.fn(() => [vi.fn(), vi.fn()]),
|
||||
useGetGithubUserInfoOnLoad: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('GitTokenForm', () => {
|
||||
let userInfoSubject: BehaviorSubject<IUserInfos | undefined>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh observable for each test
|
||||
userInfoSubject = new BehaviorSubject<IUserInfos | undefined>({
|
||||
userName: '',
|
||||
'github-token': '',
|
||||
'github-userName': '',
|
||||
'github-email': '',
|
||||
'github-branch': 'main',
|
||||
});
|
||||
|
||||
// Override the window.observables.auth.userInfo$
|
||||
Object.defineProperty(window.observables.auth, 'userInfo$', {
|
||||
value: userInfoSubject.asObservable(),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should display initial userInfo values in the form', async () => {
|
||||
userInfoSubject.next({
|
||||
userName: 'TestUser',
|
||||
'github-token': 'test-token-123',
|
||||
'github-userName': 'githubUser',
|
||||
'github-email': 'test@example.com',
|
||||
'github-branch': 'develop',
|
||||
});
|
||||
|
||||
render(<GitTokenForm storageService={SupportedStorageServices.github} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that the values are displayed
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
expect((inputs[0] as HTMLInputElement).value).toBe('test-token-123');
|
||||
expect((inputs[1] as HTMLInputElement).value).toBe('githubUser');
|
||||
expect((inputs[2] as HTMLInputElement).value).toBe('test@example.com');
|
||||
expect((inputs[3] as HTMLInputElement).value).toBe('develop');
|
||||
});
|
||||
|
||||
it('should update form when userInfo changes after OAuth login (BUG TEST)', async () => {
|
||||
// Start with empty userInfo (before OAuth)
|
||||
userInfoSubject.next({
|
||||
userName: '',
|
||||
'github-token': '',
|
||||
'github-userName': '',
|
||||
'github-email': '',
|
||||
'github-branch': 'main',
|
||||
});
|
||||
|
||||
render(<GitTokenForm storageService={SupportedStorageServices.github} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify initial empty state
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
expect((inputs[0] as HTMLInputElement).value).toBe('');
|
||||
expect((inputs[1] as HTMLInputElement).value).toBe('');
|
||||
expect((inputs[2] as HTMLInputElement).value).toBe('');
|
||||
|
||||
// Simulate OAuth callback - userInfo gets updated with token and user data
|
||||
// Wrap in act to acknowledge state update
|
||||
await waitFor(() => {
|
||||
userInfoSubject.next({
|
||||
userName: 'TestUser',
|
||||
'github-token': 'oauth-token-xyz',
|
||||
'github-userName': 'githubOAuthUser',
|
||||
'github-email': 'oauth@example.com',
|
||||
'github-branch': 'main',
|
||||
});
|
||||
});
|
||||
|
||||
// The form should update to show the new values
|
||||
await waitFor(() => {
|
||||
const updatedInputs = screen.getAllByRole('textbox');
|
||||
expect((updatedInputs[0] as HTMLInputElement).value).toBe('oauth-token-xyz');
|
||||
expect((updatedInputs[1] as HTMLInputElement).value).toBe('githubOAuthUser');
|
||||
expect((updatedInputs[2] as HTMLInputElement).value).toBe('oauth@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
it('should call auth.set when user types in input fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
const setSpy = vi.spyOn(window.service.auth, 'set');
|
||||
|
||||
render(<GitTokenForm storageService={SupportedStorageServices.github} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
|
||||
// Type in token input - userEvent.type() fires onChange for each character
|
||||
await user.type(inputs[0], 'new-token');
|
||||
|
||||
// Wait for debounced call (500ms) and check that it was called with the last character
|
||||
// Since debounce fires after each keystroke, we just verify the function was called
|
||||
await waitFor(() => {
|
||||
expect(setSpy).toHaveBeenCalled();
|
||||
}, { timeout: 1000 });
|
||||
|
||||
// Verify it was eventually called with a value containing our input
|
||||
expect(setSpy).toHaveBeenCalledWith('github-token', expect.any(String));
|
||||
});
|
||||
|
||||
it('should update form when userInfo is overwritten', async () => {
|
||||
// Start with initial userInfo
|
||||
userInfoSubject.next({
|
||||
userName: '',
|
||||
'github-token': 'initial-token',
|
||||
'github-userName': 'initial-username',
|
||||
'github-email': 'initial@example.com',
|
||||
'github-branch': 'main',
|
||||
});
|
||||
|
||||
render(<GitTokenForm storageService={SupportedStorageServices.github} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify initial values
|
||||
let inputs = screen.getAllByRole('textbox');
|
||||
expect((inputs[0] as HTMLInputElement).value).toBe('initial-token');
|
||||
expect((inputs[1] as HTMLInputElement).value).toBe('initial-username');
|
||||
|
||||
// Simulate OAuth update with new values
|
||||
// Wrap in waitFor to handle the state update
|
||||
await waitFor(() => {
|
||||
userInfoSubject.next({
|
||||
userName: '',
|
||||
'github-token': 'oauth-token',
|
||||
'github-userName': 'oauth-username',
|
||||
'github-email': 'oauth@example.com',
|
||||
'github-branch': 'main',
|
||||
});
|
||||
});
|
||||
|
||||
// The form should update with OAuth values since userInfo is the source of truth
|
||||
await waitFor(() => {
|
||||
inputs = screen.getAllByRole('textbox');
|
||||
expect((inputs[0] as HTMLInputElement).value).toBe('oauth-token');
|
||||
expect((inputs[1] as HTMLInputElement).value).toBe('oauth-username');
|
||||
expect((inputs[2] as HTMLInputElement).value).toBe('oauth@example.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,15 +1,34 @@
|
|||
/* eslint-disable @typescript-eslint/no-confusing-void-expression */
|
||||
/* eslint-disable @typescript-eslint/await-thenable */
|
||||
|
||||
import { getOAuthConfig } from '@/constants/oauthConfig';
|
||||
import { SupportedStorageServices } from '@services/types';
|
||||
import { truncate } from 'lodash';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
export function useAuth(storageService: SupportedStorageServices): [() => Promise<void>, () => Promise<void>] {
|
||||
const onClickLogout = useCallback(async () => {
|
||||
try {
|
||||
// Clear tokens
|
||||
await window.service.auth.set(`${storageService}-token`, '');
|
||||
// await window.service.window.clearStorageData();
|
||||
await window.service.auth.set(`${storageService}-userName`, '');
|
||||
await window.service.auth.set(`${storageService}-email`, '');
|
||||
|
||||
// Clear browser cookies for specific OAuth provider domain
|
||||
// This clears the "remember me" state for only this service
|
||||
const config = getOAuthConfig(storageService);
|
||||
if (config?.authorizePath) {
|
||||
try {
|
||||
const oauthUrl = new URL(config.authorizePath);
|
||||
const domain = oauthUrl.hostname;
|
||||
|
||||
// Clear cookies for this specific domain only
|
||||
await window.service.auth.clearCookiesForDomain(domain);
|
||||
} catch (error) {
|
||||
void window.service.native.log('error', 'Failed to parse OAuth URL or clear cookies', { function: 'useAuth', error });
|
||||
}
|
||||
}
|
||||
|
||||
void window.service.native.log('info', 'Logged out from service', { function: 'useAuth', storageService });
|
||||
} catch (error) {
|
||||
void window.service.native.log('error', 'TokenForm: auth operation failed', { function: 'useAuth', error });
|
||||
}
|
||||
|
|
@ -18,51 +37,60 @@ export function useAuth(storageService: SupportedStorageServices): [() => Promis
|
|||
const onClickLogin = useCallback(async () => {
|
||||
await onClickLogout();
|
||||
try {
|
||||
// redirect current page to oauth login page
|
||||
switch (storageService) {
|
||||
case SupportedStorageServices.github: {
|
||||
location.href = await window.service.context.get('GITHUB_OAUTH_PATH');
|
||||
}
|
||||
}
|
||||
void window.service.native.log('info', 'Initiating OAuth login', {
|
||||
function: 'useAuth',
|
||||
storageService,
|
||||
});
|
||||
|
||||
// Let main process handle everything (OAuth URL generation, PKCE, state management)
|
||||
// using oidc-client-ts internally
|
||||
await window.service.auth.openOAuthWindow(storageService);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
void window.service.native.log('error', 'Failed to open OAuth login window', { function: 'useAuth', error });
|
||||
}
|
||||
}, [onClickLogout, storageService]);
|
||||
|
||||
return [onClickLogin, onClickLogout];
|
||||
}
|
||||
|
||||
const log = (message: string, meta?: Record<string, unknown>): void => {
|
||||
void window.service.native.log('debug', message, { function: 'useGetGithubUserInfoOnLoad', ...meta });
|
||||
};
|
||||
export function useGetGithubUserInfoOnLoad(): void {
|
||||
useEffect(() => {
|
||||
void window.service.native.log('debug', 'useGetGithubUserInfoOnLoad: hook mounted', { function: 'useGetGithubUserInfoOnLoad' });
|
||||
void Promise.all([window.service.auth.get('userName'), window.service.auth.getUserInfos()]).then(async ([userName, userInfo]) => {
|
||||
try {
|
||||
const token = userInfo[`${SupportedStorageServices.github}-token`];
|
||||
void window.service.native.log('debug', 'useGetGithubUserInfoOnLoad: checking token', {
|
||||
function: 'useGetGithubUserInfoOnLoad',
|
||||
hasToken: !!token,
|
||||
tokenLength: token?.length ?? 0,
|
||||
});
|
||||
|
||||
if (token) {
|
||||
log(`get user name and email using github api using token: ${truncate(token ?? '', { length: 6 })}...`);
|
||||
// get user name and email using github api
|
||||
const response = await fetch('https://api.github.com/user', {
|
||||
const config = getOAuthConfig(SupportedStorageServices.github);
|
||||
if (!config) return;
|
||||
|
||||
// get user name and email using OAuth provider's API
|
||||
const response = await fetch(config.userInfoPath, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
const githubUserInfo = await (response.json() as Promise<{ email: string; login: string; name: string }>);
|
||||
log(`Get githubUserInfo`, { githubUserInfo });
|
||||
const gitUserInfo = await (response.json() as Promise<{ email: string; login: string; name: string }>);
|
||||
|
||||
// this hook will execute on every open of GitTokenForm, so we need to check if we already have the info, not overwrite userInfo that user manually written.
|
||||
if (!userInfo[`${SupportedStorageServices.github}-userName`] && githubUserInfo.login) {
|
||||
userInfo[`${SupportedStorageServices.github}-userName`] = githubUserInfo.login;
|
||||
if (!userInfo[`${SupportedStorageServices.github}-userName`] && gitUserInfo.login) {
|
||||
userInfo[`${SupportedStorageServices.github}-userName`] = gitUserInfo.login;
|
||||
}
|
||||
if (!userInfo[`${SupportedStorageServices.github}-email`] && githubUserInfo.email) {
|
||||
userInfo[`${SupportedStorageServices.github}-email`] = githubUserInfo.email;
|
||||
if (!userInfo[`${SupportedStorageServices.github}-email`] && gitUserInfo.email) {
|
||||
userInfo[`${SupportedStorageServices.github}-email`] = gitUserInfo.email;
|
||||
}
|
||||
// sometimes user already pick a Chinese username that is different from the one on Github
|
||||
if (!userName && githubUserInfo.name) {
|
||||
userInfo.userName = githubUserInfo.name;
|
||||
// sometimes user already pick a Chinese username that is different from the one on Git service
|
||||
if (!userName && gitUserInfo.name) {
|
||||
userInfo.userName = gitUserInfo.name;
|
||||
}
|
||||
log(`Store userInfo`);
|
||||
|
||||
await window.service.auth.setUserInfos(userInfo);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useEffect, useState } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ListItemText } from '../ListItem';
|
||||
import { CustomServerTokenForm } from './CustomServerTokenForm';
|
||||
import { GitTokenForm } from './GitTokenForm';
|
||||
|
||||
const Container = styled('div')`
|
||||
|
|
@ -83,23 +84,29 @@ export function TokenForm({ storageProvider, storageProviderSetter }: Props): Re
|
|||
value={currentTab}
|
||||
aria-label='Vertical tabs example'
|
||||
>
|
||||
<Tab label='GitHub' value={SupportedStorageServices.github} />
|
||||
<Tab label='GitLab' value={SupportedStorageServices.gitlab} />
|
||||
<Tab label='Gitee' value={SupportedStorageServices.gitee} />
|
||||
<Tab label='GitHub' value={SupportedStorageServices.github} data-testid='github-tab' />
|
||||
<Tab label='Codeberg' value={SupportedStorageServices.codeberg} data-testid='codeberg-tab' />
|
||||
<Tab label='Gitea.com' value={SupportedStorageServices.gitea} data-testid='gitea-tab' />
|
||||
<Tab label='Custom Server' value={SupportedStorageServices.testOAuth} data-testid='custom-server-tab' />
|
||||
</Tabs>
|
||||
{currentTab === SupportedStorageServices.github && (
|
||||
<TabPanel>
|
||||
<GitTokenForm storageService={SupportedStorageServices.github} />
|
||||
</TabPanel>
|
||||
)}
|
||||
{currentTab === SupportedStorageServices.gitlab && (
|
||||
{currentTab === SupportedStorageServices.codeberg && (
|
||||
<TabPanel>
|
||||
<GitTokenForm storageService={SupportedStorageServices.gitlab} />
|
||||
<GitTokenForm storageService={SupportedStorageServices.codeberg} />
|
||||
</TabPanel>
|
||||
)}
|
||||
{currentTab === SupportedStorageServices.gitee && (
|
||||
{currentTab === SupportedStorageServices.gitea && (
|
||||
<TabPanel>
|
||||
Gitee(码云)一直不愿意支持 OAuth2 ,所以我们没法适配它的登录系统,如果你认识相关开发人员,请催促他们尽快支持,与国际接轨。
|
||||
<GitTokenForm storageService={SupportedStorageServices.gitea} />
|
||||
</TabPanel>
|
||||
)}
|
||||
{currentTab === SupportedStorageServices.testOAuth && (
|
||||
<TabPanel>
|
||||
<CustomServerTokenForm />
|
||||
</TabPanel>
|
||||
)}
|
||||
</TabsContainer>
|
||||
|
|
|
|||
186
src/components/TokenForm/useTokenForm.ts
Normal file
186
src/components/TokenForm/useTokenForm.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { useUserInfoObservable } from '@services/auth/hooks';
|
||||
import { IUserInfos } from '@services/auth/interface';
|
||||
import { SupportedStorageServices } from '@services/types';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface TokenFormState {
|
||||
branch: string;
|
||||
email: string;
|
||||
token: string;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
interface UseTokenFormReturn extends TokenFormState {
|
||||
branchSetter: (value: string) => void;
|
||||
emailSetter: (value: string) => void;
|
||||
isLoggedIn: boolean;
|
||||
isReady: boolean;
|
||||
tokenSetter: (value: string) => void;
|
||||
userNameSetter: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for managing token form state
|
||||
* Handles sync between userInfo observable and local form state
|
||||
* Uses uncontrolled updates with useRef to prevent input lag
|
||||
*/
|
||||
export function useTokenForm(storageService: SupportedStorageServices): UseTokenFormReturn {
|
||||
const userInfo = useUserInfoObservable();
|
||||
|
||||
// Local state for form inputs
|
||||
const [state, setState] = useState<TokenFormState>({
|
||||
token: '',
|
||||
userName: '',
|
||||
email: '',
|
||||
branch: '',
|
||||
});
|
||||
|
||||
// Track if we're ready (userInfo loaded)
|
||||
const isReady = userInfo !== undefined;
|
||||
|
||||
// Check if user is logged in
|
||||
const isLoggedIn = state.token.length > 0;
|
||||
|
||||
// Use ref to track pending updates to avoid excessive re-renders
|
||||
const pendingUpdatesReference = useRef<Partial<IUserInfos>>({});
|
||||
const updateTimeoutReference = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Debounced update function
|
||||
const scheduleUpdate = useCallback(() => {
|
||||
if (updateTimeoutReference.current) {
|
||||
clearTimeout(updateTimeoutReference.current);
|
||||
}
|
||||
|
||||
updateTimeoutReference.current = setTimeout(() => {
|
||||
const updates = pendingUpdatesReference.current;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
// Batch all updates into a single call
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
void window.service.auth.set(key as keyof IUserInfos, value);
|
||||
});
|
||||
pendingUpdatesReference.current = {};
|
||||
}
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (updateTimeoutReference.current) {
|
||||
clearTimeout(updateTimeoutReference.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Sync userInfo changes to local state (only when userInfo changes from backend)
|
||||
useEffect(() => {
|
||||
if (!userInfo) {
|
||||
void window.service.native.log('debug', 'useTokenForm: userInfo is undefined', {
|
||||
function: 'useTokenForm.useEffect',
|
||||
storageService,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newToken = userInfo[`${storageService}-token`] ?? '';
|
||||
const newUserName = userInfo[`${storageService}-userName`] ?? '';
|
||||
const newEmail = userInfo[`${storageService}-email`] ?? '';
|
||||
const newBranch = userInfo[`${storageService}-branch`] ?? '';
|
||||
|
||||
void window.service.native.log('debug', 'useTokenForm: userInfo changed', {
|
||||
function: 'useTokenForm.useEffect',
|
||||
storageService,
|
||||
hasToken: !!newToken,
|
||||
tokenLength: newToken.length,
|
||||
hasUserName: !!newUserName,
|
||||
hasEmail: !!newEmail,
|
||||
hasBranch: !!newBranch,
|
||||
});
|
||||
|
||||
// Only update if values actually changed
|
||||
setState((currentState) => {
|
||||
const updates: Partial<TokenFormState> = {};
|
||||
let hasChanges = false;
|
||||
|
||||
if (currentState.token !== newToken) {
|
||||
updates.token = newToken;
|
||||
hasChanges = true;
|
||||
void window.service.native.log('debug', 'useTokenForm: token changed', {
|
||||
function: 'useTokenForm.useEffect',
|
||||
storageService,
|
||||
oldLength: currentState.token.length,
|
||||
newLength: newToken.length,
|
||||
});
|
||||
}
|
||||
if (currentState.userName !== newUserName) {
|
||||
updates.userName = newUserName;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (currentState.email !== newEmail) {
|
||||
updates.email = newEmail;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (currentState.branch !== newBranch) {
|
||||
updates.branch = newBranch;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
void window.service.native.log('info', 'useTokenForm: state updated with new values', {
|
||||
function: 'useTokenForm.useEffect',
|
||||
storageService,
|
||||
updates: Object.keys(updates),
|
||||
});
|
||||
}
|
||||
|
||||
return hasChanges ? { ...currentState, ...updates } : currentState;
|
||||
});
|
||||
}, [userInfo, storageService]);
|
||||
|
||||
// Optimized setters - update local state immediately, debounce backend updates
|
||||
const tokenSetter = useCallback(
|
||||
(value: string) => {
|
||||
setState((previous) => ({ ...previous, token: value }));
|
||||
pendingUpdatesReference.current[`${storageService}-token`] = value;
|
||||
scheduleUpdate();
|
||||
},
|
||||
[storageService, scheduleUpdate],
|
||||
);
|
||||
|
||||
const userNameSetter = useCallback(
|
||||
(value: string) => {
|
||||
setState((previous) => ({ ...previous, userName: value }));
|
||||
pendingUpdatesReference.current[`${storageService}-userName`] = value;
|
||||
scheduleUpdate();
|
||||
},
|
||||
[storageService, scheduleUpdate],
|
||||
);
|
||||
|
||||
const emailSetter = useCallback(
|
||||
(value: string) => {
|
||||
setState((previous) => ({ ...previous, email: value }));
|
||||
pendingUpdatesReference.current[`${storageService}-email`] = value;
|
||||
scheduleUpdate();
|
||||
},
|
||||
[storageService, scheduleUpdate],
|
||||
);
|
||||
|
||||
const branchSetter = useCallback(
|
||||
(value: string) => {
|
||||
setState((previous) => ({ ...previous, branch: value }));
|
||||
pendingUpdatesReference.current[`${storageService}-branch`] = value;
|
||||
scheduleUpdate();
|
||||
},
|
||||
[storageService, scheduleUpdate],
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
isLoggedIn,
|
||||
isReady,
|
||||
tokenSetter,
|
||||
userNameSetter,
|
||||
emailSetter,
|
||||
branchSetter,
|
||||
};
|
||||
}
|
||||
|
|
@ -2,15 +2,3 @@ export const GITHUB_GRAPHQL_API = 'https://api.github.com/graphql';
|
|||
export const TIDGI_AUTH_TOKEN_HEADER = 'x-tidgi-auth-token';
|
||||
export const getTidGiAuthHeaderWithToken = (authToken: string) => `${TIDGI_AUTH_TOKEN_HEADER}-${authToken}`;
|
||||
export const DEFAULT_USER_NAME = 'TidGi User';
|
||||
/**
|
||||
* Github OAuth Apps TidGi-SignIn Setting in https://github.com/organizations/tiddly-gittly/settings/applications/1326590
|
||||
*/
|
||||
export const GITHUB_LOGIN_REDIRECT_PATH = 'http://127.0.0.1:3012/tidgi-auth/github';
|
||||
export const GITHUB_OAUTH_APP_CLIENT_ID = '7b6e0fc33f4afd71a4bb';
|
||||
export const GITHUB_OAUTH_APP_CLIENT_SECRET = 'e356c4499e1e38548a44da5301ef42c11ec14173';
|
||||
const GITHUB_SCOPES = 'user:email,read:user,repo,workflow';
|
||||
/**
|
||||
* Will redirect to `http://127.0.0.1:3012/tidgi-auth/github?code=65xxxxxxx` after login, which is 404, and handled by src/preload/common/authRedirect.ts
|
||||
*/
|
||||
export const GITHUB_OAUTH_PATH =
|
||||
`https://github.com/login/oauth/authorize?client_id=${GITHUB_OAUTH_APP_CLIENT_ID}&scope=${GITHUB_SCOPES}&redirect_uri=${GITHUB_LOGIN_REDIRECT_PATH}`;
|
||||
|
|
|
|||
156
src/constants/oauthConfig.ts
Normal file
156
src/constants/oauthConfig.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* @docs docs/features/OAuthFlow.md
|
||||
*/
|
||||
import { SupportedStorageServices } from '@services/types';
|
||||
|
||||
export interface IOAuthConfig {
|
||||
/** OAuth authorization endpoint */
|
||||
authorizePath: string;
|
||||
/** OAuth client ID */
|
||||
clientId: string;
|
||||
/** OAuth client secret (optional if using PKCE) */
|
||||
clientSecret?: string;
|
||||
/** Local redirect path for OAuth callback */
|
||||
redirectPath: string;
|
||||
/** OAuth scopes */
|
||||
scopes: string;
|
||||
/** Token exchange endpoint */
|
||||
tokenPath: string;
|
||||
/** Use PKCE (Proof Key for Code Exchange) instead of client_secret */
|
||||
usePKCE?: boolean;
|
||||
/** User info API endpoint */
|
||||
userInfoPath: string;
|
||||
}
|
||||
|
||||
const BASE_REDIRECT_PATH = 'http://127.0.0.1:3012/tidgi-auth';
|
||||
|
||||
export const OAUTH_CONFIGS: Partial<Record<SupportedStorageServices, IOAuthConfig>> = {
|
||||
[SupportedStorageServices.github]: {
|
||||
authorizePath: 'https://github.com/login/oauth/authorize',
|
||||
tokenPath: 'https://github.com/login/oauth/access_token',
|
||||
userInfoPath: 'https://api.github.com/user',
|
||||
clientId: '7b6e0fc33f4afd71a4bb',
|
||||
clientSecret: 'e356c4499e1e38548a44da5301ef42c11ec14173',
|
||||
usePKCE: true,
|
||||
redirectPath: `${BASE_REDIRECT_PATH}/github`,
|
||||
scopes: 'user:email,read:user,repo,workflow',
|
||||
},
|
||||
[SupportedStorageServices.codeberg]: {
|
||||
// https://codeberg.org/user/settings/applications
|
||||
authorizePath: 'https://codeberg.org/login/oauth/authorize',
|
||||
tokenPath: 'https://codeberg.org/login/oauth/access_token',
|
||||
userInfoPath: 'https://codeberg.org/api/v1/user',
|
||||
clientId: '0b008a26-9681-4139-9bf2-579df7c6d9cd',
|
||||
clientSecret: 'gto_ykvx4f2gmoknjxtd62ssenjvl5ss4ufcu6sjjnq6kkujbis4hiva',
|
||||
usePKCE: true,
|
||||
redirectPath: `${BASE_REDIRECT_PATH}/codeberg`,
|
||||
scopes: 'read:user,write:repository',
|
||||
},
|
||||
// Gitea.com - official Gitea instance
|
||||
[SupportedStorageServices.gitea]: {
|
||||
// https://gitea.com/user/settings/applications
|
||||
authorizePath: 'https://gitea.com/login/oauth/authorize',
|
||||
tokenPath: 'https://gitea.com/login/oauth/access_token',
|
||||
userInfoPath: 'https://gitea.com/api/v1/user',
|
||||
clientId: '2e29bcd7-650b-4c4d-8482-26b44a0107cb',
|
||||
clientSecret: 'gto_loqgvzvj3cnno27kllt2povrz6jaa7svyus7p7z6pptsciffmbaq',
|
||||
usePKCE: true,
|
||||
redirectPath: `${BASE_REDIRECT_PATH}/gitea`,
|
||||
scopes: 'read:user,write:repository',
|
||||
},
|
||||
// Local Test OAuth Server (for testing only)
|
||||
// Uses standard OAuth 2 endpoints compatible with oauth2-mock-server
|
||||
[SupportedStorageServices.testOAuth]: {
|
||||
authorizePath: 'http://127.0.0.1:8888/authorize', // Standard OAuth 2 endpoint
|
||||
tokenPath: 'http://127.0.0.1:8888/token', // Standard OAuth 2 endpoint
|
||||
userInfoPath: 'http://127.0.0.1:8888/userinfo', // Standard OAuth 2 endpoint
|
||||
clientId: 'test-client-id',
|
||||
usePKCE: true,
|
||||
redirectPath: `${BASE_REDIRECT_PATH}/testOAuth`,
|
||||
scopes: 'user:email,read:user',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get OAuth configuration for a service
|
||||
*/
|
||||
export function getOAuthConfig(service: SupportedStorageServices): IOAuthConfig | undefined {
|
||||
return OAUTH_CONFIGS[service];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier and challenge
|
||||
* @returns { codeVerifier, codeChallenge }
|
||||
*/
|
||||
export function generatePKCEChallenge(): { codeVerifier: string } {
|
||||
// Generate random code_verifier (43-128 characters)
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
const codeVerifier = btoa(String.fromCharCode(...array))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
|
||||
return { codeVerifier };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SHA-256 hash for PKCE code_challenge
|
||||
*/
|
||||
async function sha256(plain: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(plain);
|
||||
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||
const bytes = new Uint8Array(hash);
|
||||
return btoa(String.fromCharCode(...bytes))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build OAuth authorization URL
|
||||
*/
|
||||
export async function buildOAuthUrl(
|
||||
service: SupportedStorageServices,
|
||||
): Promise<{ codeVerifier?: string; url: string } | undefined> {
|
||||
const config = getOAuthConfig(service);
|
||||
if (!config || !config.authorizePath || !config.clientId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const queryParameters: Record<string, string> = {
|
||||
client_id: config.clientId,
|
||||
redirect_uri: config.redirectPath,
|
||||
scope: config.scopes,
|
||||
};
|
||||
|
||||
let codeVerifier: string | undefined;
|
||||
|
||||
// Add PKCE parameters if enabled
|
||||
if (config.usePKCE) {
|
||||
const pkce = generatePKCEChallenge();
|
||||
codeVerifier = pkce.codeVerifier;
|
||||
const codeChallenge = await sha256(codeVerifier);
|
||||
|
||||
queryParameters.code_challenge = codeChallenge;
|
||||
queryParameters.code_challenge_method = 'S256';
|
||||
}
|
||||
|
||||
const urlParameters = new URLSearchParams(queryParameters);
|
||||
const url = `${config.authorizePath}?${urlParameters.toString()}`;
|
||||
|
||||
return { url, codeVerifier };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL matches any OAuth redirect path
|
||||
*/
|
||||
export function isOAuthRedirect(url: string): { service: SupportedStorageServices; config: IOAuthConfig } | undefined {
|
||||
for (const [service, config] of Object.entries(OAUTH_CONFIGS)) {
|
||||
if (config && url.startsWith(config.redirectPath)) {
|
||||
return { service: service as SupportedStorageServices, config };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import ip from 'ipaddr.js';
|
|||
import { networkInterfaces, platform, type } from 'os';
|
||||
|
||||
/**
|
||||
* Copy from https://github.com/sindresorhus/internal-ip, to fi xsilverwind/default-gateway 's bug
|
||||
* Copy from https://github.com/sindresorhus/internal-ip, to fix silverwind/default-gateway 's bug
|
||||
* @returns
|
||||
*/
|
||||
function findIp(gateway: string): string | undefined {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { AgentPromptDescription, IPrompt } from '@services/agentInstance/promptConcat/promptConcatSchema';
|
||||
import type { CoreMessage } from 'ai';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { StateCreator } from 'zustand';
|
||||
import { AgentChatStoreType, PreviewActions } from '../types';
|
||||
|
||||
|
|
@ -102,7 +102,7 @@ export const previewActionsMiddleware: StateCreator<AgentChatStoreType, [], [],
|
|||
previewCurrentPlugin: null,
|
||||
});
|
||||
|
||||
type PreviewResult = { flatPrompts: CoreMessage[]; processedPrompts: IPrompt[] } | null;
|
||||
type PreviewResult = { flatPrompts: ModelMessage[]; processedPrompts: IPrompt[] } | null;
|
||||
let finalResult: PreviewResult = null;
|
||||
let completed = false;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { AgentDefinition } from '@services/agentDefinition/interface';
|
||||
import type { AgentInstance, AgentInstanceMessage } from '@services/agentInstance/interface';
|
||||
import type { AgentPromptDescription, IPrompt } from '@services/agentInstance/promptConcat/promptConcatSchema';
|
||||
import { CoreMessage } from 'ai';
|
||||
import { ModelMessage } from 'ai';
|
||||
|
||||
// Type for agent data without messages - exported for use in other components
|
||||
export interface AgentWithoutMessages extends Omit<AgentInstance, 'messages'> {
|
||||
|
|
@ -33,7 +33,7 @@ export interface PreviewDialogState {
|
|||
previewCurrentStep: string; // current processing step description
|
||||
previewCurrentPlugin: string | null; // current plugin being processed
|
||||
previewResult: {
|
||||
flatPrompts: CoreMessage[];
|
||||
flatPrompts: ModelMessage[];
|
||||
processedPrompts: IPrompt[];
|
||||
} | null;
|
||||
lastUpdated: Date | null;
|
||||
|
|
@ -196,7 +196,7 @@ export interface PreviewActions {
|
|||
handlerConfig: AgentPromptDescription['handlerConfig'],
|
||||
) => Promise<
|
||||
{
|
||||
flatPrompts: CoreMessage[];
|
||||
flatPrompts: ModelMessage[];
|
||||
processedPrompts: IPrompt[];
|
||||
} | null
|
||||
>;
|
||||
|
|
|
|||
|
|
@ -115,6 +115,16 @@ export const APILogsDialog: React.FC<APILogsDialogProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const hasResponseContent = (rc: unknown): boolean => {
|
||||
if (rc == null) return false;
|
||||
if (typeof rc === 'string') return rc.length > 0;
|
||||
try {
|
||||
return JSON.stringify(rc).length > 0;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
|
|
@ -225,7 +235,7 @@ export const APILogsDialog: React.FC<APILogsDialogProps> = ({
|
|||
{t('APILogs.ResponseContent')}
|
||||
</Typography>
|
||||
<LogContent>
|
||||
{log.responseContent && String(log.responseContent).length > 0
|
||||
{hasResponseContent(log.responseContent)
|
||||
? log.responseContent
|
||||
: t('APILogs.NoResponse')}
|
||||
</LogContent>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Box, styled } from '@mui/material';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import { CoreMessage } from 'ai';
|
||||
import { ModelMessage } from 'ai';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -67,8 +67,8 @@ export const PreviewTabsView: React.FC<PreviewTabsViewProps> = ({
|
|||
const formattedPreview = useMemo(() => {
|
||||
return previewResult
|
||||
? {
|
||||
flatPrompts: previewResult.flatPrompts.map((message: CoreMessage) => ({
|
||||
role: String(message.role),
|
||||
flatPrompts: previewResult.flatPrompts.map((message: ModelMessage) => ({
|
||||
role: message.role as string,
|
||||
content: getFormattedContent(message.content),
|
||||
})),
|
||||
processedPrompts: previewResult.processedPrompts,
|
||||
|
|
|
|||
|
|
@ -80,10 +80,10 @@ export const TagsWidget: React.FC<WidgetProps> = ({
|
|||
renderInput={(parameters) => (
|
||||
<TextField
|
||||
{...parameters}
|
||||
placeholder={placeholder || String(t('PromptConfig.Tags.Placeholder'))}
|
||||
placeholder={placeholder || t('PromptConfig.Tags.Placeholder')}
|
||||
required={required}
|
||||
size='small'
|
||||
helperText={String(t('PromptConfig.Tags.HelperText'))}
|
||||
helperText={t('PromptConfig.Tags.HelperText')}
|
||||
/>
|
||||
)}
|
||||
sx={{
|
||||
|
|
@ -91,9 +91,9 @@ export const TagsWidget: React.FC<WidgetProps> = ({
|
|||
margin: '2px',
|
||||
},
|
||||
}}
|
||||
getOptionLabel={(option) => String(option)}
|
||||
isOptionEqualToValue={(option, valueItem) => String(option) === String(valueItem)}
|
||||
noOptionsText={String(t('PromptConfig.Tags.NoOptions'))}
|
||||
getOptionLabel={(option) => `${option}`}
|
||||
isOptionEqualToValue={(option, valueItem) => `${option}` === `${valueItem}`}
|
||||
noOptionsText={t('PromptConfig.Tags.NoOptions')}
|
||||
clearOnBlur
|
||||
selectOnFocus
|
||||
handleHomeEndKeys
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { lightTheme } from '@services/theme/defaultTheme';
|
|||
import { useAgentChatStore } from '@/pages/Agent/store/agentChatStore/index';
|
||||
import defaultAgents from '@services/agentInstance/buildInAgentHandlers/defaultAgents.json';
|
||||
import { IPrompt } from '@services/agentInstance/promptConcat/promptConcatSchema';
|
||||
import { CoreMessage } from 'ai';
|
||||
import { ModelMessage } from 'ai';
|
||||
import { PromptPreviewDialog } from '../index';
|
||||
|
||||
// Mock handler config management hook
|
||||
|
|
@ -109,7 +109,7 @@ describe('PromptPreviewDialog - Tool Information Rendering', () => {
|
|||
});
|
||||
|
||||
// Type guard for preview result shape
|
||||
const isPreviewResult = (v: unknown): v is { flatPrompts: CoreMessage[]; processedPrompts: IPrompt[] } => {
|
||||
const isPreviewResult = (v: unknown): v is { flatPrompts: ModelMessage[]; processedPrompts: IPrompt[] } => {
|
||||
if (!v || typeof v !== 'object') return false;
|
||||
return Object.prototype.hasOwnProperty.call(v, 'flatPrompts') && Object.prototype.hasOwnProperty.call(v, 'processedPrompts');
|
||||
};
|
||||
|
|
@ -125,7 +125,7 @@ describe('PromptPreviewDialog - Tool Information Rendering', () => {
|
|||
const observable = window.observables.agentInstance.concatPrompt({ handlerConfig } as never, messages);
|
||||
|
||||
const results: unknown[] = [];
|
||||
let finalResult: { flatPrompts: CoreMessage[]; processedPrompts: IPrompt[] } | undefined;
|
||||
let finalResult: { flatPrompts: ModelMessage[]; processedPrompts: IPrompt[] } | undefined;
|
||||
await new Promise<void>((resolve) => {
|
||||
observable.subscribe({
|
||||
next: (state) => {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
import { CoreMessage } from 'ai';
|
||||
import { ModelMessage } from 'ai';
|
||||
|
||||
export interface PreviewMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface CoreMessageContent {
|
||||
export interface ModelMessageContent {
|
||||
text?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert CoreMessage content to string safely
|
||||
* Convert ModelMessage content to string safely
|
||||
*/
|
||||
export function getFormattedContent(content: CoreMessage['content']): string {
|
||||
export function getFormattedContent(content: ModelMessage['content']): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ export function getFormattedContent(content: CoreMessage['content']): string {
|
|||
return content
|
||||
.map(part => {
|
||||
if (typeof part === 'string') return part;
|
||||
const typedPart = part as CoreMessageContent;
|
||||
const typedPart = part as ModelMessageContent;
|
||||
if (typedPart.text) return typedPart.text;
|
||||
if (typedPart.content) return typedPart.content;
|
||||
return '';
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export function useScrollHandling() {
|
|||
* Check if initial scroll has been done for a given agent
|
||||
*/
|
||||
const hasInitialScrollBeenDone = (agentId: string): boolean => {
|
||||
return !!initialScrollDoneReference.current[agentId];
|
||||
return initialScrollDoneReference.current[agentId] ?? false;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export default function Main(): React.JSX.Element {
|
|||
<Helmet>
|
||||
<title>{t('Menu.TidGi')}{isTidgiMiniWindow ? ` - ${t('Menu.TidGiMiniWindow')}` : ''}</title>
|
||||
</Helmet>
|
||||
<Root data-windowName={windowName} data-showSidebar={showSidebar}>
|
||||
<Root data-windowname={windowName} data-showsidebar={showSidebar}>
|
||||
{showSidebar && <SideBar />}
|
||||
<ContentRoot $sidebar={showSidebar}>
|
||||
<FindInPage />
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
// on production build, if we try to redirect to http://localhost:3012 , we will reach chrome-error://chromewebdata/ , but we can easily get back
|
||||
// this happens when we are redirected by OAuth login
|
||||
import { CreateWorkspaceTabs } from '@/windows/AddWorkspace/constants';
|
||||
import { PreferenceSections } from '@services/preferences/interface';
|
||||
import { SupportedStorageServices } from '@services/types';
|
||||
import { WindowMeta, WindowNames } from '@services/windows/WindowProperties';
|
||||
import { truncate } from 'lodash';
|
||||
import { windowName } from './browserViewMetaData';
|
||||
import { context, window as windowService } from './services';
|
||||
|
||||
const CHECK_LOADED_INTERVAL = 500;
|
||||
let constantsFetched = false;
|
||||
let CHROME_ERROR_PATH: string | undefined;
|
||||
let GITHUB_LOGIN_REDIRECT_PATH: string | undefined;
|
||||
let MAIN_WINDOW_WEBPACK_ENTRY: string | undefined;
|
||||
let GITHUB_OAUTH_APP_CLIENT_SECRET: string | undefined;
|
||||
let GITHUB_OAUTH_APP_CLIENT_ID: string | undefined;
|
||||
|
||||
const log = (message: string): void => {
|
||||
void window.service.native.log('debug', message, { function: 'authRedirect.refresh' });
|
||||
};
|
||||
async function refresh(): Promise<void> {
|
||||
// get path from src/constants/paths.ts
|
||||
if (!constantsFetched) {
|
||||
await Promise.all([
|
||||
context.get('CHROME_ERROR_PATH').then((pathName) => {
|
||||
CHROME_ERROR_PATH = pathName;
|
||||
}),
|
||||
context.get('MAIN_WINDOW_WEBPACK_ENTRY').then((pathName) => {
|
||||
MAIN_WINDOW_WEBPACK_ENTRY = pathName;
|
||||
}),
|
||||
context.get('GITHUB_LOGIN_REDIRECT_PATH').then((pathName) => {
|
||||
GITHUB_LOGIN_REDIRECT_PATH = pathName;
|
||||
}),
|
||||
context.get('GITHUB_OAUTH_APP_CLIENT_SECRET').then((pathName) => {
|
||||
GITHUB_OAUTH_APP_CLIENT_SECRET = pathName;
|
||||
}),
|
||||
context.get('GITHUB_OAUTH_APP_CLIENT_ID').then((pathName) => {
|
||||
GITHUB_OAUTH_APP_CLIENT_ID = pathName;
|
||||
}),
|
||||
]);
|
||||
constantsFetched = true;
|
||||
await refresh();
|
||||
return;
|
||||
}
|
||||
if (window.location.href.startsWith(GITHUB_LOGIN_REDIRECT_PATH!)) {
|
||||
log(`window.location.href.startsWith(GITHUB_LOGIN_REDIRECT_PATH!)`);
|
||||
// currently content will be something like `/tidgi-auth/github 404 not found`, we need to write something to tell user we are handling login, this is normal.
|
||||
// center the text and make it large
|
||||
document.body.innerHTML = '<div style="text-align: center; font-size: 2rem;">Handling Github login, please wait...</div>';
|
||||
// get the code
|
||||
try {
|
||||
const code = window.location.href.split('code=')[1];
|
||||
log(`code: ${truncate(code ?? '', { length: 6 })}...`);
|
||||
if (code) {
|
||||
// exchange the code for an access token in github
|
||||
const response = await fetch('https://github.com/login/oauth/access_token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: GITHUB_OAUTH_APP_CLIENT_ID,
|
||||
client_secret: GITHUB_OAUTH_APP_CLIENT_SECRET,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
// get the access token from the response
|
||||
const { access_token: token } = await (response.json() as Promise<{ access_token: string }>);
|
||||
log(`code: ${truncate(token ?? '', { length: 6 })}...`);
|
||||
await window.service.auth.set(`${SupportedStorageServices.github}-token`, token);
|
||||
}
|
||||
log('updateWindowMeta');
|
||||
await windowService.updateWindowMeta(
|
||||
windowName,
|
||||
{
|
||||
addWorkspaceTab: CreateWorkspaceTabs.CloneOnlineWiki,
|
||||
preferenceGotoTab: PreferenceSections.sync,
|
||||
} satisfies WindowMeta[WindowNames.addWorkspace] & WindowMeta[WindowNames.preferences],
|
||||
);
|
||||
log('Done handling Github login, redirecting to main window');
|
||||
await windowService.loadURL(windowName, MAIN_WINDOW_WEBPACK_ENTRY);
|
||||
} catch (error) {
|
||||
await window.service.native.log('error', 'Github login failed', { function: 'authRedirect.refresh', error });
|
||||
await windowService.loadURL(windowName, MAIN_WINDOW_WEBPACK_ENTRY);
|
||||
}
|
||||
} else if (window.location.href === CHROME_ERROR_PATH) {
|
||||
log(`window.location.href === CHROME_ERROR_PATH`);
|
||||
await windowService.loadURL(windowName, MAIN_WINDOW_WEBPACK_ENTRY);
|
||||
} else {
|
||||
setTimeout(() => void refresh(), CHECK_LOADED_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setting window and add workspace window may be used to login, and will be redirect, we catch it and redirect back.
|
||||
*/
|
||||
if (![WindowNames.main, WindowNames.view].includes(windowName)) {
|
||||
setTimeout(() => void refresh(), CHECK_LOADED_INTERVAL);
|
||||
}
|
||||
|
|
@ -12,7 +12,6 @@ import { ViewChannel } from '@/constants/channels';
|
|||
import type { IPossibleWindowMeta } from '@services/windows/WindowProperties';
|
||||
import { WindowNames } from '@services/windows/WindowProperties';
|
||||
import { browserViewMetaData } from './common/browserViewMetaData';
|
||||
import './common/authRedirect';
|
||||
import './view';
|
||||
import { syncTidgiStateWhenWikiLoads } from './appState';
|
||||
import { fixAlertConfirm } from './fixer/fixAlertConfirm';
|
||||
|
|
|
|||
|
|
@ -69,23 +69,6 @@ export function validateAndConvertWikiTiddlerToAgentTemplate(
|
|||
return null;
|
||||
}
|
||||
|
||||
// Try to parse the tiddler text as JSON for agent configuration
|
||||
let handlerConfig: Record<string, unknown>;
|
||||
try {
|
||||
const textContent = typeof tiddler.text === 'string' ? tiddler.text : String(tiddler.text || '{}');
|
||||
const parsed = JSON.parse(textContent) as unknown;
|
||||
|
||||
// Ensure handlerConfig is a valid object
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
logger.warn('Invalid handlerConfig in tiddler', { function: 'validateAndConvertWikiTiddlerToAgentTemplate', title: String(tiddler.title), reason: 'not an object' });
|
||||
return null;
|
||||
}
|
||||
handlerConfig = parsed as Record<string, unknown>;
|
||||
} catch (parseError) {
|
||||
logger.warn('Failed to parse agent template from tiddler', { function: 'validateAndConvertWikiTiddlerToAgentTemplate', title: String(tiddler.title), error: parseError });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper function to safely get string value from tiddler field
|
||||
const getStringField = (field: unknown, fallback = ''): string => {
|
||||
if (typeof field === 'string') return field;
|
||||
|
|
@ -132,6 +115,31 @@ export function validateAndConvertWikiTiddlerToAgentTemplate(
|
|||
return undefined;
|
||||
};
|
||||
|
||||
// Try to parse the tiddler text as JSON for agent configuration
|
||||
let handlerConfig: Record<string, unknown>;
|
||||
try {
|
||||
const textContent = typeof tiddler.text === 'string' ? tiddler.text : JSON.stringify(tiddler.text || '{}');
|
||||
const parsed = JSON.parse(textContent) as unknown;
|
||||
|
||||
// Ensure handlerConfig is a valid object
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
logger.warn('Invalid handlerConfig in tiddler', {
|
||||
function: 'validateAndConvertWikiTiddlerToAgentTemplate',
|
||||
title: getStringField(tiddler.title),
|
||||
reason: 'not an object',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
handlerConfig = parsed as Record<string, unknown>;
|
||||
} catch (parseError) {
|
||||
logger.warn('Failed to parse agent template from tiddler', {
|
||||
function: 'validateAndConvertWikiTiddlerToAgentTemplate',
|
||||
title: getStringField(tiddler.title),
|
||||
error: parseError,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create AgentDefinition from tiddler
|
||||
const agentTemplate: AgentDefinition = {
|
||||
id: `wiki-template-${getStringField(tiddler.title).replace(/[^a-zA-Z0-9-_]/g, '-')}`,
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ describe('AgentInstance failure path - external API logs on error', () => {
|
|||
|
||||
// Mock provider stream to throw error (ai sdk failure)
|
||||
vi.spyOn(callProvider, 'streamFromProvider').mockImplementation((_cfg: AiAPIConfig) => {
|
||||
throw new Error('Invalid prompt: message must be a CoreMessage or a UI message');
|
||||
throw new Error('Invalid prompt: message must be a ModelMessage or a UI message');
|
||||
});
|
||||
|
||||
// Initialize services
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const mockErrorDetail = {
|
|||
name: 'AIProviderError',
|
||||
code: 'INVALID_PROMPT',
|
||||
provider: 'siliconflow',
|
||||
message: 'Invalid prompt: message must be a CoreMessage or a UI message',
|
||||
message: 'Invalid prompt: message must be a ModelMessage or a UI message',
|
||||
};
|
||||
|
||||
function makeContext(agentId: string, agentDefId: string, messages: AgentInstanceMessage[]): AgentHandlerContext {
|
||||
|
|
|
|||
|
|
@ -537,7 +537,10 @@ describe('Wiki Search Plugin - Comprehensive Tests', () => {
|
|||
searchSimilar: vi.fn(),
|
||||
};
|
||||
// Replace the service in container
|
||||
container.rebind(serviceIdentifier.WikiEmbedding).toConstantValue(mockWikiEmbeddingService);
|
||||
if (container.isBound(serviceIdentifier.WikiEmbedding)) {
|
||||
await container.unbind(serviceIdentifier.WikiEmbedding);
|
||||
}
|
||||
container.bind(serviceIdentifier.WikiEmbedding).toConstantValue(mockWikiEmbeddingService);
|
||||
});
|
||||
|
||||
it('should execute vector search when searchType=vector', async () => {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
*/
|
||||
|
||||
import { logger } from '@services/libs/log';
|
||||
import { CoreMessage } from 'ai';
|
||||
import { ModelMessage } from 'ai';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { AgentHandlerContext } from '../buildInAgentHandlers/type';
|
||||
import { AgentInstanceMessage } from '../interface';
|
||||
|
|
@ -89,8 +89,8 @@ export function findPromptById(
|
|||
* @param prompts Tree-structured prompt array
|
||||
* @returns Flattened array of prompts
|
||||
*/
|
||||
export function flattenPrompts(prompts: IPrompt[]): CoreMessage[] {
|
||||
const result: CoreMessage[] = [];
|
||||
export function flattenPrompts(prompts: IPrompt[]): ModelMessage[] {
|
||||
const result: ModelMessage[] = [];
|
||||
|
||||
// Process prompt tree recursively - collect non-role children text
|
||||
function processPrompt(prompt: IPrompt): string {
|
||||
|
|
@ -140,7 +140,7 @@ export function flattenPrompts(prompts: IPrompt[]): CoreMessage[] {
|
|||
{
|
||||
role: prompt.role || 'system' as const,
|
||||
content: content.trim() || '',
|
||||
} as CoreMessage,
|
||||
} as ModelMessage,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -169,7 +169,7 @@ export function flattenPrompts(prompts: IPrompt[]): CoreMessage[] {
|
|||
// Support 'tool' role in child prompts
|
||||
role: child.role,
|
||||
content: childContent.trim() || '',
|
||||
} as CoreMessage);
|
||||
} as ModelMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -187,7 +187,7 @@ export interface PromptConcatStreamState {
|
|||
/** Current processed prompts */
|
||||
processedPrompts: IPrompt[];
|
||||
/** Current flat prompts for LLM */
|
||||
flatPrompts: CoreMessage[];
|
||||
flatPrompts: ModelMessage[];
|
||||
/** Current processing step */
|
||||
step: 'plugin' | 'finalize' | 'flatten' | 'complete';
|
||||
/** Current plugin being processed (if step is 'plugin') */
|
||||
|
|
@ -345,7 +345,7 @@ export async function promptConcat(
|
|||
messages: AgentInstanceMessage[],
|
||||
handlerContext: AgentHandlerContext,
|
||||
): Promise<{
|
||||
flatPrompts: CoreMessage[];
|
||||
flatPrompts: ModelMessage[];
|
||||
processedPrompts: IPrompt[];
|
||||
}> {
|
||||
// Use the streaming version and just return the final result
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import { getOAuthConfig } from '@/constants/oauthConfig';
|
||||
import { container } from '@services/container';
|
||||
import type { IDatabaseService } from '@services/database/interface';
|
||||
import type { IGitUserInfos } from '@services/git/interface';
|
||||
import { logger } from '@services/libs/log';
|
||||
import type { IMenuService } from '@services/menu/interface';
|
||||
import serviceIdentifier from '@services/serviceIdentifier';
|
||||
import { SupportedStorageServices } from '@services/types';
|
||||
import type { IWorkspace } from '@services/workspaces/interface';
|
||||
import { isWikiWorkspace } from '@services/workspaces/interface';
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { injectable } from 'inversify';
|
||||
import { truncate } from 'lodash';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import type { IAuthenticationService, IUserInfos, ServiceBranchTypes, ServiceEmailTypes, ServiceTokenTypes, ServiceUserNameTypes } from './interface';
|
||||
import { setupOAuthRedirectHandler as setupOAuthHandler } from './oauthRedirectHandler';
|
||||
|
||||
const defaultUserInfos = {
|
||||
userName: '',
|
||||
|
|
@ -64,7 +67,6 @@ export class Authentication implements IAuthenticationService {
|
|||
}
|
||||
|
||||
public setUserInfos(newUserInfos: IUserInfos): void {
|
||||
logger.debug('Storing authInfos', { function: 'setUserInfos' });
|
||||
this.cachedUserInfo = newUserInfos;
|
||||
const databaseService = container.get<IDatabaseService>(serviceIdentifier.Database);
|
||||
databaseService.setSetting('userInfos', newUserInfos);
|
||||
|
|
@ -87,7 +89,6 @@ export class Authentication implements IAuthenticationService {
|
|||
}
|
||||
|
||||
public async set<K extends keyof IUserInfos>(key: K, value: IUserInfos[K]): Promise<void> {
|
||||
logger.debug('Setting auth, debug value is truncated for privacy', { key, value: truncate(value, { length: 10 }), function: 'Authentication.set' });
|
||||
let userInfo = this.getUserInfos();
|
||||
userInfo[key] = value;
|
||||
userInfo = { ...userInfo, ...this.sanitizeUserInfo(userInfo) };
|
||||
|
|
@ -116,4 +117,217 @@ export class Authentication implements IAuthenticationService {
|
|||
const userName = (isWikiWorkspace(workspace) ? workspace.userName : '') || (await this.get('userName')) || '';
|
||||
return userName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cookies for a specific OAuth domain
|
||||
* Used during logout to clear "remember me" state
|
||||
*/
|
||||
public async clearCookiesForDomain(domain: string): Promise<void> {
|
||||
const { session } = await import('electron');
|
||||
try {
|
||||
const cookies = await session.defaultSession.cookies.get({ domain });
|
||||
|
||||
await Promise.all(
|
||||
cookies.map(async (cookie) => {
|
||||
const url = `http${cookie.secure ? 's' : ''}://${cookie.domain}${cookie.path}`;
|
||||
await session.defaultSession.cookies.remove(url, cookie.name);
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to clear cookies for domain', { error, domain, function: 'clearCookiesForDomain' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OAuth authorization URL using oidc-client-ts
|
||||
* This ensures PKCE state is properly managed
|
||||
*/
|
||||
public async generateOAuthUrl(service: SupportedStorageServices): Promise<string | undefined> {
|
||||
try {
|
||||
const { createOAuthClientManager } = await import('./oauthClient');
|
||||
const client = createOAuthClientManager(service);
|
||||
|
||||
if (!client) {
|
||||
logger.error('Failed to create OAuth client', { service, function: 'generateOAuthUrl' });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = await client.createAuthorizationUrl();
|
||||
if (!result) {
|
||||
logger.error('Failed to generate OAuth URL', { service, function: 'generateOAuthUrl' });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
logger.info('OAuth URL generated', { service, function: 'generateOAuthUrl' });
|
||||
return result.url;
|
||||
} catch (error) {
|
||||
logger.error('Error generating OAuth URL', { service, error, function: 'generateOAuthUrl' });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open OAuth login in a new popup window
|
||||
* The window will be automatically closed after OAuth completes
|
||||
* @param service - The OAuth service to authenticate with (e.g., 'github')
|
||||
*/
|
||||
public async openOAuthWindow(service: SupportedStorageServices): Promise<void> {
|
||||
try {
|
||||
// Generate OAuth URL using oidc-client-ts (ensures proper state management)
|
||||
const url = await this.generateOAuthUrl(service);
|
||||
if (!url) {
|
||||
throw new Error(`Failed to generate OAuth URL for ${service}`);
|
||||
}
|
||||
|
||||
logger.info('Opening OAuth window', { function: 'openOAuthWindow', service, url: url.substring(0, 100) });
|
||||
|
||||
// Create a new popup window for OAuth
|
||||
const oauthWindow = new BrowserWindow({
|
||||
width: 600,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
},
|
||||
title: 'OAuth Login',
|
||||
resizable: true,
|
||||
minimizable: false,
|
||||
fullscreenable: false,
|
||||
show: false, // Don't show until ready
|
||||
});
|
||||
|
||||
// Show window when ready
|
||||
oauthWindow.once('ready-to-show', () => {
|
||||
oauthWindow.show();
|
||||
logger.debug('OAuth window shown', { function: 'openOAuthWindow' });
|
||||
});
|
||||
|
||||
// Add context menu (right-click menu with DevTools) for debugging
|
||||
const menuService = container.get<IMenuService>(serviceIdentifier.MenuService);
|
||||
const unregisterContextMenu = await menuService.initContextMenuForWindowWebContents(oauthWindow.webContents);
|
||||
|
||||
// Setup OAuth redirect handler for this window
|
||||
this.setupOAuthRedirectHandler(
|
||||
oauthWindow,
|
||||
() => '', // Not needed for popup window
|
||||
'', // Not needed for popup window
|
||||
false, // Don't navigate after auth - just close the window
|
||||
);
|
||||
|
||||
// Clean up when window is closed
|
||||
oauthWindow.on('closed', () => {
|
||||
unregisterContextMenu();
|
||||
logger.info('OAuth window closed', { function: 'openOAuthWindow' });
|
||||
});
|
||||
|
||||
// Load OAuth URL
|
||||
await oauthWindow.loadURL(url);
|
||||
logger.debug('OAuth URL loaded in popup window', { function: 'openOAuthWindow' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to open OAuth window', { error, function: 'openOAuthWindow' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup OAuth redirect handler for a BrowserWindow (simplified version using oidc-client-ts)
|
||||
* @param window The BrowserWindow to setup OAuth handling for
|
||||
* @param getMainWindowEntry Function to get the main window entry URL
|
||||
* @param preferencesPath The path to navigate to after OAuth completes
|
||||
* @param shouldNavigateAfterAuth Whether to navigate after authentication (false for popup windows)
|
||||
*/
|
||||
public setupOAuthRedirectHandler(
|
||||
window: BrowserWindow,
|
||||
getMainWindowEntry: () => string,
|
||||
preferencesPath: string,
|
||||
shouldNavigateAfterAuth = true,
|
||||
): void {
|
||||
const handleSuccess = async (service: SupportedStorageServices, accessToken: string) => {
|
||||
logger.info('OAuth authentication successful', {
|
||||
service,
|
||||
tokenLength: accessToken.length,
|
||||
function: 'setupOAuthRedirectHandler.handleSuccess',
|
||||
});
|
||||
|
||||
try {
|
||||
// Store access token
|
||||
await this.set(`${service}-token`, accessToken);
|
||||
logger.debug('Access token stored', { service, function: 'setupOAuthRedirectHandler.handleSuccess' });
|
||||
|
||||
// Fetch and store user info
|
||||
const config = getOAuthConfig(service);
|
||||
if (config?.userInfoPath) {
|
||||
try {
|
||||
const response = await fetch(config.userInfoPath, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const userInfo = await response.json() as { email?: string; login?: string; name?: string };
|
||||
|
||||
if (userInfo.login) {
|
||||
await this.set(`${service}-userName`, userInfo.login);
|
||||
logger.debug('User name stored', { service, userName: userInfo.login });
|
||||
}
|
||||
if (userInfo.email) {
|
||||
await this.set(`${service}-email`, userInfo.email);
|
||||
logger.debug('User email stored', { service, email: userInfo.email });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to fetch user info', { service, error });
|
||||
}
|
||||
}
|
||||
|
||||
// Force immediate save to disk
|
||||
const databaseService = container.get<IDatabaseService>(serviceIdentifier.Database);
|
||||
await databaseService.immediatelyStoreSettingsToFile();
|
||||
logger.debug('Settings saved to disk', { service });
|
||||
|
||||
// Update observable
|
||||
this.updateUserInfoSubject();
|
||||
logger.debug('UserInfo observable updated', { service });
|
||||
|
||||
// Navigate or close window
|
||||
if (shouldNavigateAfterAuth) {
|
||||
const targetUrl = `${getMainWindowEntry()}#/${preferencesPath}`;
|
||||
await window.webContents.loadURL(targetUrl);
|
||||
logger.info('Navigated to preferences', { service, targetUrl });
|
||||
} else {
|
||||
if (!window.isDestroyed()) {
|
||||
window.close();
|
||||
}
|
||||
logger.info('OAuth popup window closed', { service });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling OAuth success', { service, error });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = async (service: SupportedStorageServices, error: string) => {
|
||||
logger.error('OAuth authentication failed', {
|
||||
service,
|
||||
error,
|
||||
function: 'setupOAuthRedirectHandler.handleError',
|
||||
});
|
||||
|
||||
// Navigate back to preferences on error (if not a popup)
|
||||
if (shouldNavigateAfterAuth) {
|
||||
const targetUrl = `${getMainWindowEntry()}#/${preferencesPath}`;
|
||||
await window.webContents.loadURL(targetUrl);
|
||||
logger.debug('Navigated to preferences after error', { service });
|
||||
} else {
|
||||
if (!window.isDestroyed()) {
|
||||
window.close();
|
||||
}
|
||||
logger.debug('OAuth popup window closed after error', { service });
|
||||
}
|
||||
};
|
||||
|
||||
// Use the simplified redirect handler from oauthRedirectHandler.ts
|
||||
setupOAuthHandler(window, handleSuccess, handleError);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ export const getServiceBranchTypes = (serviceType: SupportedStorageServices): Se
|
|||
/** Git push: Git commit message branch, you may use different branch for different storage service */
|
||||
type BranchRecord = Record<ServiceBranchTypes, string>;
|
||||
|
||||
/** Custom OAuth server configuration types */
|
||||
export type ServiceServerUrlTypes = `${SupportedStorageServices}-serverUrl`;
|
||||
export type ServiceClientIdTypes = `${SupportedStorageServices}-clientId`;
|
||||
type ServerUrlRecord = Partial<Record<ServiceServerUrlTypes, string>>;
|
||||
type ClientIdRecord = Partial<Record<ServiceClientIdTypes, string>>;
|
||||
|
||||
export type IUserInfos =
|
||||
& {
|
||||
/** Default UserName in TiddlyWiki, each wiki can have different username, but fallback to this if not specific on */
|
||||
|
|
@ -33,12 +39,18 @@ export type IUserInfos =
|
|||
& Partial<TokenRecord>
|
||||
& Partial<UserNameRecord>
|
||||
& Partial<EmailRecord>
|
||||
& Partial<BranchRecord>;
|
||||
& Partial<BranchRecord>
|
||||
& ServerUrlRecord
|
||||
& ClientIdRecord;
|
||||
|
||||
/**
|
||||
* Handle login to Github GitLab Coding.net
|
||||
*/
|
||||
export interface IAuthenticationService {
|
||||
/**
|
||||
* Clear cookies for a specific OAuth domain
|
||||
*/
|
||||
clearCookiesForDomain(domain: string): Promise<void>;
|
||||
generateOneTimeAdminAuthTokenForWorkspace(workspaceID: string): Promise<string>;
|
||||
/**
|
||||
* This is for internal use
|
||||
|
|
@ -61,6 +73,20 @@ export interface IAuthenticationService {
|
|||
* Batch update all UserInfos
|
||||
*/
|
||||
setUserInfos(newUserInfos: IUserInfos): void;
|
||||
/**
|
||||
* Setup OAuth redirect handler for a BrowserWindow
|
||||
* This intercepts OAuth callbacks and exchanges authorization codes for tokens
|
||||
*/
|
||||
setupOAuthRedirectHandler(window: unknown, getMainWindowEntry: () => string, preferencesPath: string): void;
|
||||
/**
|
||||
* Generate OAuth authorization URL using oidc-client-ts
|
||||
*/
|
||||
generateOAuthUrl(service: SupportedStorageServices): Promise<string | undefined>;
|
||||
/**
|
||||
* Open OAuth login in a new popup window
|
||||
* @param service - The OAuth service (e.g., 'github')
|
||||
*/
|
||||
openOAuthWindow(service: SupportedStorageServices): Promise<void>;
|
||||
/**
|
||||
* Manually refresh the observable's content, that will be received by react component.
|
||||
*/
|
||||
|
|
@ -70,6 +96,8 @@ export interface IAuthenticationService {
|
|||
export const AuthenticationServiceIPCDescriptor = {
|
||||
channel: AuthenticationChannel.name,
|
||||
properties: {
|
||||
clearCookiesForDomain: ProxyPropertyType.Function,
|
||||
generateOAuthUrl: ProxyPropertyType.Function,
|
||||
generateOneTimeAdminAuthTokenForWorkspace: ProxyPropertyType.Function,
|
||||
get: ProxyPropertyType.Function,
|
||||
getRandomStorageServiceUserInfo: ProxyPropertyType.Function,
|
||||
|
|
@ -79,6 +107,7 @@ export const AuthenticationServiceIPCDescriptor = {
|
|||
reset: ProxyPropertyType.Function,
|
||||
set: ProxyPropertyType.Function,
|
||||
setUserInfos: ProxyPropertyType.Function,
|
||||
openOAuthWindow: ProxyPropertyType.Function,
|
||||
updateUserInfoSubject: ProxyPropertyType.Value$,
|
||||
userInfo$: ProxyPropertyType.Value$,
|
||||
},
|
||||
|
|
|
|||
174
src/services/auth/oauthClient.ts
Normal file
174
src/services/auth/oauthClient.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* OAuth 2.0 client using oidc-client-ts
|
||||
* Simplified OAuth flow for Electron main process
|
||||
*
|
||||
* This wrapper adapts oidc-client-ts (designed for browsers) to work in Electron's main process.
|
||||
* We use oidc-client-ts for:
|
||||
* - PKCE generation (automatic)
|
||||
* - Token exchange
|
||||
* - State management
|
||||
*
|
||||
* But handle BrowserWindow navigation ourselves.
|
||||
*/
|
||||
import { getOAuthConfig } from '@/constants/oauthConfig';
|
||||
import type { SupportedStorageServices } from '@services/types';
|
||||
import type { OidcClientSettings, SigninResponse, StateStore } from 'oidc-client-ts';
|
||||
import { OidcClient } from 'oidc-client-ts';
|
||||
import { logger } from '../libs/log';
|
||||
|
||||
/**
|
||||
* In-memory state store for Electron main process (SINGLETON)
|
||||
* oidc-client-ts requires a state store to save PKCE verifiers and state
|
||||
* Must be a singleton to ensure state persists across OAuthClientManager instances
|
||||
*/
|
||||
class InMemoryStateStore implements StateStore {
|
||||
private static instance: InMemoryStateStore;
|
||||
private store = new Map<string, string>();
|
||||
|
||||
// Private constructor for singleton pattern
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): InMemoryStateStore {
|
||||
if (!InMemoryStateStore.instance) {
|
||||
InMemoryStateStore.instance = new InMemoryStateStore();
|
||||
logger.debug('Singleton InMemoryStateStore created', { function: 'InMemoryStateStore.getInstance' });
|
||||
}
|
||||
return InMemoryStateStore.instance;
|
||||
}
|
||||
|
||||
async set(key: string, value: string): Promise<void> {
|
||||
this.store.set(key, value);
|
||||
logger.debug('State stored', { key, storeSize: this.store.size, function: 'InMemoryStateStore.set' });
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
const value = this.store.get(key) || null;
|
||||
logger.debug('State retrieved', { key, hasValue: !!value, storeSize: this.store.size, function: 'InMemoryStateStore.get' });
|
||||
return value;
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<string | null> {
|
||||
const value = this.store.get(key) || null;
|
||||
this.store.delete(key);
|
||||
logger.debug('State removed', { key, hadValue: !!value, storeSize: this.store.size, function: 'InMemoryStateStore.remove' });
|
||||
return value;
|
||||
}
|
||||
|
||||
async getAllKeys(): Promise<string[]> {
|
||||
return Array.from(this.store.keys());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth client manager for a specific service
|
||||
* Keeps the OidcClient instance and provides helper methods
|
||||
*/
|
||||
export class OAuthClientManager {
|
||||
private client: OidcClient;
|
||||
private service: SupportedStorageServices;
|
||||
|
||||
constructor(service: SupportedStorageServices) {
|
||||
const config = getOAuthConfig(service);
|
||||
if (!config) {
|
||||
throw new Error(`OAuth config not found for service: ${service}`);
|
||||
}
|
||||
|
||||
this.service = service;
|
||||
|
||||
// Configure oidc-client-ts for OAuth 2.0 (not OIDC)
|
||||
const settings: OidcClientSettings = {
|
||||
authority: config.authorizePath.replace(/\/login\/oauth\/.*$/, '') || 'https://example.com', // Dummy authority
|
||||
client_id: config.clientId,
|
||||
redirect_uri: config.redirectPath,
|
||||
response_type: 'code',
|
||||
scope: config.scopes,
|
||||
|
||||
// Client secret for confidential clients
|
||||
...(config.clientSecret && {
|
||||
client_secret: config.clientSecret,
|
||||
}),
|
||||
|
||||
// Use singleton in-memory state store (main process doesn't have localStorage)
|
||||
stateStore: InMemoryStateStore.getInstance(),
|
||||
|
||||
// Explicit metadata (no OIDC discovery)
|
||||
metadata: {
|
||||
issuer: config.authorizePath.replace(/\/login\/oauth\/.*$/, '') || 'https://example.com',
|
||||
authorization_endpoint: config.authorizePath,
|
||||
token_endpoint: config.tokenPath,
|
||||
userinfo_endpoint: config.userInfoPath,
|
||||
},
|
||||
|
||||
// Disable OIDC-specific features
|
||||
loadUserInfo: false,
|
||||
};
|
||||
|
||||
this.client = new OidcClient(settings);
|
||||
logger.debug('OAuth client created', { service, function: 'OAuthClientManager.constructor' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create authorization URL
|
||||
* PKCE is automatically added by oidc-client-ts
|
||||
*/
|
||||
async createAuthorizationUrl(): Promise<{ url: string } | undefined> {
|
||||
try {
|
||||
const request = await this.client.createSigninRequest({
|
||||
// oidc-client-ts will auto-generate state and PKCE
|
||||
state: undefined,
|
||||
});
|
||||
|
||||
logger.debug('Authorization URL created', {
|
||||
service: this.service,
|
||||
hasState: !!request.state?.id,
|
||||
function: 'OAuthClientManager.createAuthorizationUrl',
|
||||
});
|
||||
|
||||
return { url: request.url };
|
||||
} catch (error) {
|
||||
logger.error('Failed to create authorization URL', { service: this.service, error, function: 'OAuthClientManager.createAuthorizationUrl' });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token
|
||||
* Handles PKCE verification automatically
|
||||
*/
|
||||
async exchangeCodeForToken(
|
||||
callbackUrl: string,
|
||||
): Promise<{ accessToken: string; error?: never } | { accessToken?: never; error: string }> {
|
||||
try {
|
||||
// Process the callback and exchange code for token
|
||||
const response: SigninResponse = await this.client.processSigninResponse(callbackUrl);
|
||||
|
||||
if (!response.access_token) {
|
||||
return { error: 'No access token in response' };
|
||||
}
|
||||
|
||||
logger.info('Token exchange successful', {
|
||||
service: this.service,
|
||||
tokenLength: response.access_token.length,
|
||||
function: 'OAuthClientManager.exchangeCodeForToken',
|
||||
});
|
||||
|
||||
return { accessToken: response.access_token };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error('Token exchange failed', { service: this.service, error, function: 'OAuthClientManager.exchangeCodeForToken' });
|
||||
return { error: errorMessage };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create OAuth client manager for a service
|
||||
*/
|
||||
export function createOAuthClientManager(service: SupportedStorageServices): OAuthClientManager | undefined {
|
||||
try {
|
||||
return new OAuthClientManager(service);
|
||||
} catch (error) {
|
||||
logger.error('Failed to create OAuth client manager', { service, error, function: 'createOAuthClientManager' });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
151
src/services/auth/oauthRedirectHandler.ts
Normal file
151
src/services/auth/oauthRedirectHandler.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* Simplified OAuth redirect handler using oidc-client-ts
|
||||
* Replaces the complex manual implementation in auth/index.ts
|
||||
*/
|
||||
import { isOAuthRedirect } from '@/constants/oauthConfig';
|
||||
import type { SupportedStorageServices } from '@services/types';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { logger } from '../libs/log';
|
||||
import { createOAuthClientManager } from './oauthClient';
|
||||
|
||||
/**
|
||||
* Setup OAuth redirect handler for a BrowserWindow
|
||||
* Uses oidc-client-ts to handle token exchange
|
||||
*
|
||||
* @param window - The BrowserWindow to monitor for OAuth redirects
|
||||
* @param onSuccess - Callback when OAuth completes successfully with access token
|
||||
* @param onError - Callback when OAuth fails
|
||||
*/
|
||||
export function setupOAuthRedirectHandler(
|
||||
window: BrowserWindow,
|
||||
onSuccess: (service: SupportedStorageServices, accessToken: string) => Promise<void>,
|
||||
onError: (service: SupportedStorageServices, error: string) => Promise<void>,
|
||||
): void {
|
||||
logger.info('Setting up simplified OAuth redirect handler', { function: 'setupOAuthRedirectHandler' });
|
||||
|
||||
/**
|
||||
* Handle OAuth redirect (will-redirect event fires before navigation)
|
||||
*/
|
||||
window.webContents.on('will-redirect', async (event, url) => {
|
||||
const oauthMatch = isOAuthRedirect(url);
|
||||
|
||||
if (!oauthMatch) {
|
||||
return; // Not an OAuth redirect, ignore
|
||||
}
|
||||
|
||||
logger.debug('OAuth redirect detected', {
|
||||
service: oauthMatch.service,
|
||||
url: url.substring(0, 100), // Log first 100 chars
|
||||
function: 'setupOAuthRedirectHandler.will-redirect',
|
||||
});
|
||||
|
||||
// Prevent navigation to non-existent localhost:3012
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
// Create OAuth client for this service
|
||||
const client = createOAuthClientManager(oauthMatch.service);
|
||||
if (!client) {
|
||||
throw new Error(`Failed to create OAuth client for ${oauthMatch.service}`);
|
||||
}
|
||||
|
||||
// Use oidc-client-ts to exchange code for token
|
||||
const result = await client.exchangeCodeForToken(url);
|
||||
|
||||
if (result.error) {
|
||||
logger.error('Token exchange failed', {
|
||||
service: oauthMatch.service,
|
||||
error: result.error,
|
||||
function: 'setupOAuthRedirectHandler.will-redirect',
|
||||
});
|
||||
await onError(oauthMatch.service, result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Token exchange successful', {
|
||||
service: oauthMatch.service,
|
||||
tokenLength: result.accessToken?.length || 0,
|
||||
function: 'setupOAuthRedirectHandler.will-redirect',
|
||||
});
|
||||
|
||||
// Call success callback
|
||||
if (result.accessToken) {
|
||||
await onSuccess(oauthMatch.service, result.accessToken);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error('OAuth redirect handler error', {
|
||||
service: oauthMatch.service,
|
||||
error: errorMessage,
|
||||
function: 'setupOAuthRedirectHandler.will-redirect',
|
||||
});
|
||||
await onError(oauthMatch.service, errorMessage);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle did-fail-load (connection to localhost:3012 fails as expected)
|
||||
*/
|
||||
window.webContents.on('did-fail-load', async (_, errorCode, __, validatedURL) => {
|
||||
logger.debug('did-fail-load event', {
|
||||
errorCode,
|
||||
url: validatedURL,
|
||||
function: 'setupOAuthRedirectHandler.did-fail-load',
|
||||
});
|
||||
|
||||
// Only handle -102 (ERR_CONNECTION_REFUSED) for localhost redirects
|
||||
if (errorCode !== -102) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oauthMatch = isOAuthRedirect(validatedURL);
|
||||
if (!oauthMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('OAuth redirect detected via did-fail-load', {
|
||||
service: oauthMatch.service,
|
||||
errorCode,
|
||||
function: 'setupOAuthRedirectHandler.did-fail-load',
|
||||
});
|
||||
|
||||
try {
|
||||
const client = createOAuthClientManager(oauthMatch.service);
|
||||
if (!client) {
|
||||
throw new Error(`Failed to create OAuth client for ${oauthMatch.service}`);
|
||||
}
|
||||
|
||||
const result = await client.exchangeCodeForToken(validatedURL);
|
||||
|
||||
if (result.error) {
|
||||
logger.error('Token exchange failed (did-fail-load)', {
|
||||
service: oauthMatch.service,
|
||||
error: result.error,
|
||||
function: 'setupOAuthRedirectHandler.did-fail-load',
|
||||
});
|
||||
await onError(oauthMatch.service, result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('Token exchange successful (did-fail-load)', {
|
||||
service: oauthMatch.service,
|
||||
tokenLength: result.accessToken?.length || 0,
|
||||
function: 'setupOAuthRedirectHandler.did-fail-load',
|
||||
});
|
||||
|
||||
if (result.accessToken) {
|
||||
await onSuccess(oauthMatch.service, result.accessToken);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error('OAuth redirect handler error (did-fail-load)', {
|
||||
service: oauthMatch.service,
|
||||
error: errorMessage,
|
||||
function: 'setupOAuthRedirectHandler.did-fail-load',
|
||||
});
|
||||
await onError(oauthMatch.service, errorMessage);
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug('OAuth redirect handler setup complete', { function: 'setupOAuthRedirectHandler' });
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
import * as appPaths from '@/constants/appPaths';
|
||||
import * as auth from '@/constants/auth';
|
||||
import * as paths from '@/constants/paths';
|
||||
import { ContextService } from '@services/context';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('ContextService exposes constants from paths/appPaths/auth', () => {
|
||||
describe('ContextService exposes constants from paths/appPaths', () => {
|
||||
const svc = new ContextService();
|
||||
|
||||
it('should expose all keys exported from src/constants/paths.ts', async () => {
|
||||
|
|
@ -25,13 +24,4 @@ describe('ContextService exposes constants from paths/appPaths/auth', () => {
|
|||
expect(value).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should expose all keys exported from src/constants/auth.ts', async () => {
|
||||
const keys = Object.keys(auth) as Array<keyof typeof auth>;
|
||||
for (const k of keys) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const value = await svc.get(k as any);
|
||||
expect(value).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,17 +5,15 @@ import os from 'os';
|
|||
import process from 'process';
|
||||
|
||||
import * as appPaths from '@/constants/appPaths';
|
||||
import * as auth from '@/constants/auth';
|
||||
import { supportedLanguagesMap, tiddlywikiLanguagesMap } from '@/constants/languages';
|
||||
import * as paths from '@/constants/paths';
|
||||
import { getMainWindowEntry } from '@services/windows/viteEntry';
|
||||
import type { IAuthConstants, IConstants, IContext, IContextService, IPaths } from './interface';
|
||||
import type { IConstants, IContext, IContextService, IPaths } from './interface';
|
||||
|
||||
@injectable()
|
||||
export class ContextService implements IContextService {
|
||||
// @ts-expect-error Property 'MAIN_WINDOW_WEBPACK_ENTRY' is missing, esbuild will make it `pathConstants = { ..._constants_paths__WEBPACK_IMPORTED_MODULE_4__, ..._constants_appPaths__WEBPACK_IMPORTED_MODULE_5__, 'http://localhost:3012/main_window' };`
|
||||
private readonly pathConstants: IPaths = { ...paths, ...appPaths };
|
||||
private readonly authConstants: IAuthConstants = { ...auth };
|
||||
private readonly constants: IConstants = {
|
||||
isDevelopment: isElectronDevelopment,
|
||||
platform: process.platform,
|
||||
|
|
@ -33,7 +31,6 @@ export class ContextService implements IContextService {
|
|||
this.context = {
|
||||
...this.pathConstants,
|
||||
...this.constants,
|
||||
...this.authConstants,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +39,7 @@ export class ContextService implements IContextService {
|
|||
return this.context[key];
|
||||
}
|
||||
|
||||
throw new Error(`${String(key)} not existed in ContextService`);
|
||||
throw new Error(`${key} not existed in ContextService`);
|
||||
}
|
||||
|
||||
public async isOnline(): Promise<boolean> {
|
||||
|
|
|
|||
|
|
@ -18,12 +18,6 @@ export interface IPaths {
|
|||
V8_CACHE_FOLDER: string;
|
||||
}
|
||||
|
||||
export interface IAuthConstants {
|
||||
GITHUB_LOGIN_REDIRECT_PATH: string;
|
||||
GITHUB_OAUTH_APP_CLIENT_ID: string;
|
||||
GITHUB_OAUTH_APP_CLIENT_SECRET: string;
|
||||
GITHUB_OAUTH_PATH: string;
|
||||
}
|
||||
/**
|
||||
* Available values about running environment
|
||||
*/
|
||||
|
|
@ -38,7 +32,7 @@ export interface IConstants {
|
|||
tiddlywikiLanguagesMap: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface IContext extends IPaths, IConstants, IAuthConstants {}
|
||||
export interface IContext extends IPaths, IConstants {}
|
||||
|
||||
/**
|
||||
* Manage constant value like `isDevelopment` and many else, so you can know about about running environment in main and renderer process easily.
|
||||
|
|
|
|||
|
|
@ -459,7 +459,6 @@ export class DatabaseService implements IDatabaseService {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
logger.debug('Saving settings to file start', { function: 'immediatelyStoreSettingsToFile', storeSettingsToFileLock: this.storeSettingsToFileLock });
|
||||
if (this.storeSettingsToFileLock) return;
|
||||
this.storeSettingsToFileLock = true;
|
||||
await settings.set(this.settingFileContent as any);
|
||||
|
|
@ -470,7 +469,6 @@ export class DatabaseService implements IDatabaseService {
|
|||
fs.writeJSONSync(settings.file(), this.settingFileContent);
|
||||
} finally {
|
||||
this.storeSettingsToFileLock = false;
|
||||
logger.debug('Saving settings to file done', { function: 'immediatelyStoreSettingsToFile' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { AgentDefinitionEntity } from '@services/database/schema/agent';
|
|||
import type { AIGlobalSettings, AIStreamResponse } from '@services/externalAPI/interface';
|
||||
import type { IPreferenceService } from '@services/preferences/interface';
|
||||
import serviceIdentifier from '@services/serviceIdentifier';
|
||||
import { CoreMessage } from 'ai';
|
||||
import { ModelMessage } from 'ai';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('ExternalAPIService logging', () => {
|
||||
|
|
@ -52,7 +52,7 @@ describe('ExternalAPIService logging', () => {
|
|||
// Mock getSetting to return our test AI settings
|
||||
vi.spyOn(db, 'getSetting').mockImplementation((k: string) => (k === 'aiSettings' ? aiSettings : undefined));
|
||||
|
||||
const messages: CoreMessage[] = [{ role: 'user', content: 'hi' }];
|
||||
const messages: ModelMessage[] = [{ role: 'user', content: 'hi' }];
|
||||
const config = await externalAPI.getAIConfig();
|
||||
|
||||
const events: AIStreamResponse[] = [];
|
||||
|
|
@ -85,7 +85,7 @@ describe('ExternalAPIService logging', () => {
|
|||
// Mock getSetting to return our test AI settings
|
||||
vi.spyOn(db, 'getSetting').mockImplementation((k: string) => (k === 'aiSettings' ? aiSettings : undefined));
|
||||
|
||||
const messages: CoreMessage[] = [{ role: 'user', content: 'hi' }];
|
||||
const messages: ModelMessage[] = [{ role: 'user', content: 'hi' }];
|
||||
const config = await svc.getAIConfig();
|
||||
|
||||
const events: AIStreamResponse[] = [];
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ import { createDeepSeek } from '@ai-sdk/deepseek';
|
|||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
import { logger } from '@services/libs/log';
|
||||
import { CoreMessage, Message, streamText } from 'ai';
|
||||
import { createOllama } from 'ollama-ai-provider';
|
||||
import { ModelMessage, streamText } from 'ai';
|
||||
import { createOllama } from 'ollama-ai-provider-v2';
|
||||
|
||||
import { getFormattedContent } from '@/pages/ChatTabContent/components/types';
|
||||
import { AiAPIConfig } from '@services/agentInstance/promptConcat/promptConcatSchema';
|
||||
import { AuthenticationError, MissingAPIKeyError, MissingBaseURLError, parseProviderError } from './errors';
|
||||
import type { AIProviderConfig } from './interface';
|
||||
|
|
@ -46,7 +47,7 @@ export function createProviderClient(providerConfig: { provider: string; provide
|
|||
|
||||
export function streamFromProvider(
|
||||
config: AiAPIConfig,
|
||||
messages: Array<CoreMessage> | Array<Omit<Message, 'id'>>,
|
||||
messages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
providerConfig?: AIProviderConfig,
|
||||
): AIStreamResult {
|
||||
|
|
@ -76,16 +77,17 @@ export function streamFromProvider(
|
|||
|
||||
// Extract system message from messages if present, otherwise use fallback
|
||||
const systemMessage = messages.find(message => message.role === 'system');
|
||||
const systemPrompt = (typeof systemMessage?.content === 'string' ? systemMessage.content : undefined) || fallbackSystemPrompt;
|
||||
const systemPrompt = (systemMessage ? getFormattedContent(systemMessage.content) : undefined) || fallbackSystemPrompt;
|
||||
|
||||
// Filter out system messages from the messages array since we're handling them separately
|
||||
const nonSystemMessages = messages.filter(message => message.role !== 'system') as typeof messages;
|
||||
const nonSystemMessages = messages.filter(message => message.role !== 'system');
|
||||
|
||||
// Ensure we have at least one message to avoid AI library errors
|
||||
const finalMessages = nonSystemMessages.length > 0 ? nonSystemMessages : [{ role: 'user' as const, content: 'Hi' }];
|
||||
const finalMessages: Array<ModelMessage> = nonSystemMessages.length > 0 ? nonSystemMessages : [{ role: 'user' as const, content: 'Hi' }];
|
||||
|
||||
const providerModel = client(model);
|
||||
return streamText({
|
||||
model: client(model),
|
||||
model: providerModel,
|
||||
system: systemPrompt,
|
||||
messages: finalMessages,
|
||||
temperature,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { ExternalAPICallType, ExternalAPILogEntity, RequestMetadata, ResponseMet
|
|||
import { logger } from '@services/libs/log';
|
||||
import type { IPreferenceService } from '@services/preferences/interface';
|
||||
import serviceIdentifier from '@services/serviceIdentifier';
|
||||
import { CoreMessage, Message } from 'ai';
|
||||
import { ModelMessage } from 'ai';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { generateEmbeddingsFromProvider } from './callEmbeddingAPI';
|
||||
import { generateImageFromProvider } from './callImageGenerationAPI';
|
||||
|
|
@ -269,7 +269,7 @@ export class ExternalAPIService implements IExternalAPIService {
|
|||
this.activeRequests.delete(requestId);
|
||||
}
|
||||
|
||||
streamFromAI(messages: Array<CoreMessage> | Array<Omit<Message, 'id'>>, config: AiAPIConfig, options?: { agentInstanceId?: string }): Observable<AIStreamResponse> {
|
||||
streamFromAI(messages: Array<ModelMessage>, config: AiAPIConfig, options?: { agentInstanceId?: string }): Observable<AIStreamResponse> {
|
||||
// Use defer to create a new observable stream for each subscription
|
||||
return defer(() => {
|
||||
// Prepare request context
|
||||
|
|
@ -295,7 +295,7 @@ export class ExternalAPIService implements IExternalAPIService {
|
|||
}
|
||||
|
||||
async *generateFromAI(
|
||||
messages: Array<CoreMessage> | Array<Omit<Message, 'id'>>,
|
||||
messages: Array<ModelMessage>,
|
||||
config: AiAPIConfig,
|
||||
options?: { agentInstanceId?: string; awaitLogs?: boolean },
|
||||
): AsyncGenerator<AIStreamResponse, void, unknown> {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Observable } from 'rxjs';
|
|||
import { ExternalAPIChannel } from '@/constants/channels';
|
||||
import { AiAPIConfig } from '@services/agentInstance/promptConcat/promptConcatSchema';
|
||||
import type { ExternalAPILogEntity } from '@services/database/schema/externalAPILog';
|
||||
import { CoreMessage, Message } from 'ai';
|
||||
import { ModelMessage } from 'ai';
|
||||
|
||||
/**
|
||||
* AI streaming response status interface
|
||||
|
|
@ -210,7 +210,7 @@ export interface IExternalAPIService {
|
|||
* requestId will be automatically generated and returned in the AIStreamResponse
|
||||
*/
|
||||
streamFromAI(
|
||||
messages: Array<CoreMessage> | Array<Omit<Message, 'id'>>,
|
||||
messages: Array<ModelMessage>,
|
||||
config: AiAPIConfig,
|
||||
options?: { agentInstanceId?: string; awaitLogs?: boolean },
|
||||
): Observable<AIStreamResponse>;
|
||||
|
|
@ -221,7 +221,7 @@ export interface IExternalAPIService {
|
|||
* requestId will be automatically generated and returned in the AIStreamResponse
|
||||
*/
|
||||
generateFromAI(
|
||||
messages: Array<CoreMessage> | Array<Omit<Message, 'id'>>,
|
||||
messages: Array<ModelMessage>,
|
||||
config: AiAPIConfig,
|
||||
options?: { agentInstanceId?: string; awaitLogs?: boolean },
|
||||
): AsyncGenerator<AIStreamResponse, void, unknown>;
|
||||
|
|
|
|||
|
|
@ -18,10 +18,11 @@ export function mainBindings(): void {
|
|||
const localeFilePath = path.join(LOCALIZATION_FOLDER, readFileArguments.filename);
|
||||
const windowService = container.get<IWindowService>(serviceIdentifier.Window);
|
||||
fs.readFile(localeFilePath, 'utf8', (error, data) => {
|
||||
const text = typeof data === 'string' ? data : (data ? String(data) : '');
|
||||
void windowService.sendToAllWindows(I18NChannels.readFileResponse, {
|
||||
key: readFileArguments.key,
|
||||
error,
|
||||
data: data !== undefined && data !== null ? data.toString() : '',
|
||||
data: text,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ function safeInterpolate(interpolator: unknown, template: string, variables: { [
|
|||
// naive replacement for common tokens
|
||||
const lngToken = typeof variables.lng === 'string' ? variables.lng : '';
|
||||
const nsToken = typeof variables.ns === 'string' ? variables.ns : '';
|
||||
return String(template ?? '').replace('{{lng}}', lngToken).replace('{{ns}}', nsToken);
|
||||
return (template ?? '').replace('{{lng}}', lngToken).replace('{{ns}}', nsToken);
|
||||
}
|
||||
// https://stackoverflow.com/a/34890276/1837080
|
||||
const groupByArray = function<T extends Record<string, unknown>>(xs: T[], key: string) {
|
||||
|
|
@ -279,7 +279,7 @@ export class Backend implements BackendModule {
|
|||
|
||||
// Reads a given translation file
|
||||
read(language: string, namespace: string, callback: ReadCallback) {
|
||||
const loadPathString = String(this.backendOptions.loadPath ?? defaultOptions.loadPath);
|
||||
const loadPathString = this.backendOptions.loadPath ?? defaultOptions.loadPath;
|
||||
const filename = safeInterpolate(this.services.interpolator, loadPathString, { lng: language, ns: namespace });
|
||||
this.requestFileRead(filename, (error?: unknown, data?: unknown) => {
|
||||
type ReadCallbackParameters = Parameters<ReadCallback>;
|
||||
|
|
@ -304,7 +304,7 @@ export class Backend implements BackendModule {
|
|||
const languageList = Array.isArray(languages) ? languages : [languages];
|
||||
// Create the missing translation for all languages
|
||||
for (const language of languageList) {
|
||||
const addPathString = String(addPath ?? defaultOptions.addPath);
|
||||
const addPathString = addPath ?? defaultOptions.addPath;
|
||||
filename = safeInterpolate(this.services.interpolator, addPathString, { lng: language, ns: namespace });
|
||||
// If we are currently writing missing translations from writeQueue,
|
||||
// temporarily store the requests in writeQueueOverflow until we are
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export async function requestChangeLanguage(newLanguage: string): Promise<void>
|
|||
reject(new Error(errorMessage));
|
||||
} else {
|
||||
if (viewCount === 0) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const tasks: Array<Promise<void>> = [];
|
||||
|
|
@ -50,7 +51,6 @@ export async function requestChangeLanguage(newLanguage: string): Promise<void>
|
|||
}
|
||||
}),
|
||||
// update menu
|
||||
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression
|
||||
await menuService.buildMenu(),
|
||||
menuService.buildMenu(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,15 @@ export default async function appPath(appName: string): Promise<string | null> {
|
|||
isDevelopmentOrTest ? path.join(repoFolder, 'node_modules', 'app-path', 'main') : path.join(process.resourcesPath, 'node_modules', 'app-path', 'main'),
|
||||
[appName],
|
||||
);
|
||||
return stdout.toString().replace('\n', '');
|
||||
let outText: string;
|
||||
if (Buffer.isBuffer(stdout)) {
|
||||
outText = stdout.toString('utf8');
|
||||
} else if (typeof stdout === 'string') {
|
||||
outText = stdout;
|
||||
} else {
|
||||
outText = String(stdout);
|
||||
}
|
||||
return outText.replace('\n', '');
|
||||
} catch (error) {
|
||||
throw improveError(error as Error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export class NativeService implements INativeService {
|
|||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
|
||||
public async registerKeyboardShortcut<T>(serviceName: keyof typeof serviceIdentifier, methodName: keyof T, shortcut: string): Promise<void> {
|
||||
try {
|
||||
const key = `${String(serviceName)}.${String(methodName)}`;
|
||||
const key = `${serviceName as unknown as string}.${methodName as unknown as string}`;
|
||||
logger.info('Starting keyboard shortcut registration', { key, shortcut, serviceName, methodName, function: 'NativeService.registerKeyboardShortcut' });
|
||||
|
||||
// Save to preferences
|
||||
|
|
@ -87,7 +87,7 @@ export class NativeService implements INativeService {
|
|||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
|
||||
public async unregisterKeyboardShortcut<T>(serviceName: keyof typeof serviceIdentifier, methodName: keyof T): Promise<void> {
|
||||
try {
|
||||
const key = `${String(serviceName)}.${String(methodName)}`;
|
||||
const key = `${serviceName as unknown as string}.${methodName as unknown as string}`;
|
||||
|
||||
// Get the current shortcut string before removing from preferences
|
||||
const shortcuts = await this.getKeyboardShortcuts();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,13 @@ export enum SupportedStorageServices {
|
|||
github = 'github',
|
||||
/** Open source git service */
|
||||
gitlab = 'gitlab',
|
||||
/** Self-hosted Git service, lightweight fork of Gogs */
|
||||
gitea = 'gitea',
|
||||
/** Self-hosted Git service, hard fork of Gitea, focused on federation */
|
||||
codeberg = 'codeberg',
|
||||
local = 'local',
|
||||
/** SocialLinkedData, a privacy first DApp platform leading by Tim Berners-Lee, you can run a server by you own */
|
||||
solid = 'solid',
|
||||
/** Local test OAuth server (for E2E testing only) */
|
||||
testOAuth = 'testOAuth',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -247,8 +247,8 @@ export class View implements IViewService {
|
|||
return existedView === undefined;
|
||||
};
|
||||
const checkNotExistResult = await Promise.all([
|
||||
checkNotExist(workspace, WindowNames.main),
|
||||
this.preferenceService.get('tidgiMiniWindow').then((tidgiMiniWindow) => tidgiMiniWindow && checkNotExist(workspace, WindowNames.tidgiMiniWindow)),
|
||||
Promise.resolve(checkNotExist(workspace, WindowNames.main)),
|
||||
this.preferenceService.get('tidgiMiniWindow').then((tidgiMiniWindow) => (tidgiMiniWindow && checkNotExist(workspace, WindowNames.tidgiMiniWindow)) ? true : false),
|
||||
]);
|
||||
return checkNotExistResult.every((result) => !result);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ function handleFileLink(details: Electron.OnBeforeRequestListenerDetails, callba
|
|||
) {
|
||||
logger.debug('open file protocol', {
|
||||
function: 'handleFileLink',
|
||||
absolutePath: String(absolutePath),
|
||||
absolutePath: absolutePath ?? '',
|
||||
});
|
||||
callback({
|
||||
cancel: false,
|
||||
|
|
@ -90,7 +90,7 @@ function handleFileLink(details: Electron.OnBeforeRequestListenerDetails, callba
|
|||
} else {
|
||||
logger.info('redirecting file protocol', {
|
||||
function: 'handleFileLink',
|
||||
absolutePath: String(absolutePath),
|
||||
absolutePath: absolutePath ?? '',
|
||||
});
|
||||
callback({
|
||||
cancel: false,
|
||||
|
|
|
|||
|
|
@ -131,14 +131,14 @@ export class Wiki implements IWikiService {
|
|||
await workspaceService.updateMetaData(workspaceID, { isLoading: true });
|
||||
if (tokenAuth && authToken) {
|
||||
logger.debug('getOneTimeAdminAuthTokenForWorkspaceSync', {
|
||||
tokenAuth: String(tokenAuth),
|
||||
tokenAuth,
|
||||
authToken,
|
||||
function: 'startWiki',
|
||||
});
|
||||
}
|
||||
const workerData: IStartNodeJSWikiConfigs = {
|
||||
authToken,
|
||||
constants: { TIDDLYWIKI_PACKAGE_FOLDER: String(TIDDLYWIKI_PACKAGE_FOLDER) },
|
||||
constants: { TIDDLYWIKI_PACKAGE_FOLDER },
|
||||
enableHTTPAPI,
|
||||
excludedPlugins,
|
||||
homePath: wikiFolderLocation,
|
||||
|
|
|
|||
|
|
@ -169,8 +169,8 @@ class TidGiIPCSyncAdaptor {
|
|||
this.recipe = status.space.recipe;
|
||||
// Check if we're logged in
|
||||
this.isLoggedIn = status.username !== 'GUEST';
|
||||
this.isReadOnly = !!status.read_only;
|
||||
this.isAnonymous = !!status.anonymous;
|
||||
this.isReadOnly = status.read_only ?? false;
|
||||
this.isAnonymous = status.anonymous ?? false;
|
||||
// this.logoutIsAvailable = 'logout_is_available' in status ? !!status.logout_is_available : true;
|
||||
|
||||
callback?.(null, this.isLoggedIn, status.username, this.isReadOnly, this.isAnonymous);
|
||||
|
|
|
|||
|
|
@ -41,11 +41,15 @@ export function startNodeJSWiki({
|
|||
void isDev;
|
||||
observer.next({ type: 'control', actions: WikiControlActions.start, argv: fullBootArgv });
|
||||
intercept(
|
||||
(newStdOut: string) => {
|
||||
observer.next({ type: 'stdout', message: newStdOut });
|
||||
(newStdOut: string | Uint8Array) => {
|
||||
const message = typeof newStdOut === 'string' ? newStdOut : new TextDecoder().decode(newStdOut);
|
||||
observer.next({ type: 'stdout', message });
|
||||
return message;
|
||||
},
|
||||
(newStdError: string) => {
|
||||
observer.next({ type: 'control', source: 'intercept', actions: WikiControlActions.error, message: newStdError, argv: fullBootArgv });
|
||||
(newStdError: string | Uint8Array) => {
|
||||
const message = typeof newStdError === 'string' ? newStdError : new TextDecoder().decode(newStdError);
|
||||
observer.next({ type: 'control', source: 'intercept', actions: WikiControlActions.error, message, argv: fullBootArgv });
|
||||
return message;
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -352,8 +352,8 @@ export class WikiEmbeddingService implements IWikiEmbeddingService {
|
|||
|
||||
// Process notes using async iterator to avoid memory pressure
|
||||
for await (const note of this.getWikiNotesIterator(workspaceId)) {
|
||||
const noteTitle = String(note.title || '');
|
||||
const noteContent = String(note.text || '');
|
||||
const noteTitle = typeof note.title === 'string' ? note.title : (note.title ? JSON.stringify(note.title) : '');
|
||||
const noteContent = typeof note.text === 'string' ? note.text : (note.text ? JSON.stringify(note.text) : '');
|
||||
// const modifiedTime = String(note.modified || '');
|
||||
|
||||
// Re-ensure repositories before each note processing
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { isTest } from '@/constants/environment';
|
||||
import type { IAuthenticationService } from '@services/auth/interface';
|
||||
import { container } from '@services/container';
|
||||
import type { IMenuService } from '@services/menu/interface';
|
||||
import serviceIdentifier from '@services/serviceIdentifier';
|
||||
|
|
@ -28,6 +29,13 @@ export async function handleCreateBasicWindow<N extends WindowNames>(
|
|||
windowService.set(windowName, undefined);
|
||||
unregisterContextMenu();
|
||||
});
|
||||
|
||||
// Handle OAuth redirect for preferences/addWorkspace windows
|
||||
if (windowName === WindowNames.preferences || windowName === WindowNames.addWorkspace) {
|
||||
const authService = container.get<IAuthenticationService>(serviceIdentifier.Authentication);
|
||||
authService.setupOAuthRedirectHandler(newWindow, getMainWindowEntry, WindowNames.preferences);
|
||||
}
|
||||
|
||||
let webContentLoadingPromise: Promise<void> | undefined;
|
||||
if (windowName === WindowNames.main) {
|
||||
// handle window show and Webview/browserView show
|
||||
|
|
|
|||
|
|
@ -223,7 +223,10 @@ describe('TidGiMiniWindow Component', () => {
|
|||
preferenceSubject.next(createMockPreference({ tidgiMiniWindow: false }));
|
||||
await renderComponent();
|
||||
|
||||
const switches = screen.getAllByRole('checkbox');
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('switch')).toHaveLength(1);
|
||||
});
|
||||
const switches = screen.getAllByRole('switch');
|
||||
const attachSwitch = switches[0];
|
||||
expect(attachSwitch).not.toBeChecked();
|
||||
});
|
||||
|
|
@ -233,7 +236,10 @@ describe('TidGiMiniWindow Component', () => {
|
|||
preferenceSubject.next(createMockPreference({ tidgiMiniWindow: false }));
|
||||
await renderComponent();
|
||||
|
||||
const switches = screen.getAllByRole('checkbox');
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('switch')).toHaveLength(1);
|
||||
});
|
||||
const switches = screen.getAllByRole('switch');
|
||||
const attachSwitch = switches[0];
|
||||
|
||||
await user.click(attachSwitch);
|
||||
|
|
@ -248,7 +254,10 @@ describe('TidGiMiniWindow Component', () => {
|
|||
preferenceSubject.next(createMockPreference({ tidgiMiniWindow: false }));
|
||||
await renderComponent();
|
||||
|
||||
const switches = screen.getAllByRole('checkbox');
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('switch')).toHaveLength(1);
|
||||
});
|
||||
const switches = screen.getAllByRole('switch');
|
||||
const attachSwitch = switches[0];
|
||||
|
||||
await user.click(attachSwitch);
|
||||
|
|
@ -628,8 +637,11 @@ describe('TidGiMiniWindow Component', () => {
|
|||
// Verify additional settings are hidden initially
|
||||
expect(screen.queryByText('Preference.TidgiMiniWindowAlwaysOnTop')).not.toBeInTheDocument();
|
||||
|
||||
// Click the attach to tidgi mini window toggle
|
||||
const switches = screen.getAllByRole('checkbox');
|
||||
// Wait for and click the attach to tidgi mini window toggle
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('switch')).toHaveLength(1);
|
||||
});
|
||||
const switches = screen.getAllByRole('switch');
|
||||
const attachSwitch = switches[0];
|
||||
await user.click(attachSwitch);
|
||||
|
||||
|
|
|
|||
|
|
@ -45,17 +45,17 @@ export default defineConfig({
|
|||
],
|
||||
},
|
||||
|
||||
pool: 'threads',
|
||||
pool: 'forks',
|
||||
poolOptions: {
|
||||
threads: {
|
||||
useAtomics: true,
|
||||
forks: {
|
||||
maxForks: 6,
|
||||
minForks: 2,
|
||||
},
|
||||
isolate: true,
|
||||
},
|
||||
|
||||
// Performance settings
|
||||
testTimeout: 5000,
|
||||
hookTimeout: 5000,
|
||||
testTimeout: 30000,
|
||||
hookTimeout: 30000,
|
||||
reporters: ['default', 'hanging-process'],
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue