Merge branch 'v2' into v2-toast

This commit is contained in:
MyPrototypeWhat 2026-01-05 18:44:33 +08:00 committed by GitHub
commit 508467f994
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
428 changed files with 42175 additions and 11301 deletions

2
.github/CODEOWNERS vendored
View File

@ -9,6 +9,8 @@
/src/main/data/ @0xfullex
/src/renderer/src/data/ @0xfullex
/v2-refactor-temp/ @0xfullex
/docs/en/references/data/ @0xfullex
/docs/zh/references/data/ @0xfullex
/packages/ui/ @MyPrototypeWhat

View File

@ -54,7 +54,7 @@ jobs:
yarn install
- name: 🏃‍♀️ Translate
run: yarn sync:i18n && yarn auto:i18n
run: yarn i18n:sync && yarn i18n:translate
- name: 🔍 Format
run: yarn format
@ -73,7 +73,7 @@ jobs:
- name: 🚀 Create Pull Request if changes exist
if: steps.git_status.outputs.has_changes == 'true'
uses: peter-evans/create-pull-request@v6
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }} # Use the built-in GITHUB_TOKEN for bot actions
commit-message: "feat(bot): Weekly automated script run"

View File

@ -58,7 +58,7 @@ jobs:
run: yarn typecheck
- name: i18n Check
run: yarn check:i18n
run: yarn i18n:check
- name: Test
run: yarn test

View File

@ -216,6 +216,7 @@ jobs:
local filename=$(basename "$file")
local max_retries=3
local retry=0
local curl_status=0
echo "Uploading: $filename"
@ -224,34 +225,45 @@ jobs:
while [ $retry -lt $max_retries ]; do
# Get upload URL
curl_status=0
UPLOAD_INFO=$(curl -s --connect-timeout 30 --max-time 60 \
-H "Authorization: Bearer ${GITCODE_TOKEN}" \
"${API_URL}/repos/${GITCODE_OWNER}/${GITCODE_REPO}/releases/${TAG_NAME}/upload_url?file_name=${encoded_filename}")
"${API_URL}/repos/${GITCODE_OWNER}/${GITCODE_REPO}/releases/${TAG_NAME}/upload_url?file_name=${encoded_filename}") || curl_status=$?
UPLOAD_URL=$(echo "$UPLOAD_INFO" | jq -r '.url // empty')
if [ $curl_status -eq 0 ]; then
UPLOAD_URL=$(echo "$UPLOAD_INFO" | jq -r '.url // empty')
if [ -n "$UPLOAD_URL" ]; then
# Write headers to temp file to avoid shell escaping issues
echo "$UPLOAD_INFO" | jq -r '.headers | to_entries[] | "header = \"" + .key + ": " + .value + "\""' > /tmp/upload_headers.txt
if [ -n "$UPLOAD_URL" ]; then
# Write headers to temp file to avoid shell escaping issues
echo "$UPLOAD_INFO" | jq -r '.headers | to_entries[] | "header = \"" + .key + ": " + .value + "\""' > /tmp/upload_headers.txt
# Upload file using PUT with headers from file
UPLOAD_RESPONSE=$(curl -s -w "\n%{http_code}" -X PUT \
-K /tmp/upload_headers.txt \
--data-binary "@${file}" \
"$UPLOAD_URL")
# Upload file using PUT with headers from file
curl_status=0
UPLOAD_RESPONSE=$(curl -s -w "\n%{http_code}" -X PUT \
-K /tmp/upload_headers.txt \
--data-binary "@${file}" \
"$UPLOAD_URL") || curl_status=$?
HTTP_CODE=$(echo "$UPLOAD_RESPONSE" | tail -n1)
RESPONSE_BODY=$(echo "$UPLOAD_RESPONSE" | sed '$d')
if [ $curl_status -eq 0 ]; then
HTTP_CODE=$(echo "$UPLOAD_RESPONSE" | tail -n1)
RESPONSE_BODY=$(echo "$UPLOAD_RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
echo " Uploaded: $filename"
return 0
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
echo " Uploaded: $filename"
return 0
else
echo " Failed (HTTP $HTTP_CODE), retry $((retry + 1))/$max_retries"
echo " Response: $RESPONSE_BODY"
fi
else
echo " Upload request failed (curl exit $curl_status), retry $((retry + 1))/$max_retries"
fi
else
echo " Failed (HTTP $HTTP_CODE), retry $((retry + 1))/$max_retries"
echo " Response: $RESPONSE_BODY"
echo " Failed to get upload URL, retry $((retry + 1))/$max_retries"
echo " Response: $UPLOAD_INFO"
fi
else
echo " Failed to get upload URL, retry $((retry + 1))/$max_retries"
echo " Failed to get upload URL (curl exit $curl_status), retry $((retry + 1))/$max_retries"
echo " Response: $UPLOAD_INFO"
fi

View File

@ -1,5 +1,5 @@
diff --git a/dist/index.js b/dist/index.js
index 51ce7e423934fb717cb90245cdfcdb3dae6780e6..0f7f7009e2f41a79a8669d38c8a44867bbff5e1f 100644
index d004b415c5841a1969705823614f395265ea5a8a..6b1e0dad4610b0424393ecc12e9114723bbe316b 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -474,7 +474,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
@ -12,7 +12,7 @@ index 51ce7e423934fb717cb90245cdfcdb3dae6780e6..0f7f7009e2f41a79a8669d38c8a44867
// src/google-generative-ai-options.ts
diff --git a/dist/index.mjs b/dist/index.mjs
index f4b77e35c0cbfece85a3ef0d4f4e67aa6dde6271..8d2fecf8155a226006a0bde72b00b6036d4014b6 100644
index 1780dd2391b7f42224a0b8048c723d2f81222c44..1f12ed14399d6902107ce9b435d7d8e6cc61e06b 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -480,7 +480,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
@ -24,3 +24,14 @@ index f4b77e35c0cbfece85a3ef0d4f4e67aa6dde6271..8d2fecf8155a226006a0bde72b00b603
}
// src/google-generative-ai-options.ts
@@ -1909,8 +1909,7 @@ function createGoogleGenerativeAI(options = {}) {
}
var google = createGoogleGenerativeAI();
export {
- VERSION,
createGoogleGenerativeAI,
- google
+ google, VERSION
};
//# sourceMappingURL=index.mjs.map
\ No newline at end of file

View File

@ -1,140 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
index 73045a7d38faafdc7f7d2cd79d7ff0e2b031056b..8d948c9ac4ea4b474db9ef3c5491961e7fcf9a07 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -421,6 +421,17 @@ var OpenAICompatibleChatLanguageModel = class {
text: reasoning
});
}
+ if (choice.message.images) {
+ for (const image of choice.message.images) {
+ const match1 = image.image_url.url.match(/^data:([^;]+)/)
+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/);
+ content.push({
+ type: 'file',
+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg',
+ data: match2 ? match2[1] : image.image_url.url,
+ });
+ }
+ }
if (choice.message.tool_calls != null) {
for (const toolCall of choice.message.tool_calls) {
content.push({
@@ -598,6 +609,17 @@ var OpenAICompatibleChatLanguageModel = class {
delta: delta.content
});
}
+ if (delta.images) {
+ for (const image of delta.images) {
+ const match1 = image.image_url.url.match(/^data:([^;]+)/)
+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/);
+ controller.enqueue({
+ type: 'file',
+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg',
+ data: match2 ? match2[1] : image.image_url.url,
+ });
+ }
+ }
if (delta.tool_calls != null) {
for (const toolCallDelta of delta.tool_calls) {
const index = toolCallDelta.index;
@@ -765,6 +787,14 @@ var OpenAICompatibleChatResponseSchema = import_v43.z.object({
arguments: import_v43.z.string()
})
})
+ ).nullish(),
+ images: import_v43.z.array(
+ import_v43.z.object({
+ type: import_v43.z.literal('image_url'),
+ image_url: import_v43.z.object({
+ url: import_v43.z.string(),
+ })
+ })
).nullish()
}),
finish_reason: import_v43.z.string().nullish()
@@ -795,6 +825,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => import_v43.z.union(
arguments: import_v43.z.string().nullish()
})
})
+ ).nullish(),
+ images: import_v43.z.array(
+ import_v43.z.object({
+ type: import_v43.z.literal('image_url'),
+ image_url: import_v43.z.object({
+ url: import_v43.z.string(),
+ })
+ })
).nullish()
}).nullish(),
finish_reason: import_v43.z.string().nullish()
diff --git a/dist/index.mjs b/dist/index.mjs
index 1c2b9560bbfbfe10cb01af080aeeed4ff59db29c..2c8ddc4fc9bfc5e7e06cfca105d197a08864c427 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -405,6 +405,17 @@ var OpenAICompatibleChatLanguageModel = class {
text: reasoning
});
}
+ if (choice.message.images) {
+ for (const image of choice.message.images) {
+ const match1 = image.image_url.url.match(/^data:([^;]+)/)
+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/);
+ content.push({
+ type: 'file',
+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg',
+ data: match2 ? match2[1] : image.image_url.url,
+ });
+ }
+ }
if (choice.message.tool_calls != null) {
for (const toolCall of choice.message.tool_calls) {
content.push({
@@ -582,6 +593,17 @@ var OpenAICompatibleChatLanguageModel = class {
delta: delta.content
});
}
+ if (delta.images) {
+ for (const image of delta.images) {
+ const match1 = image.image_url.url.match(/^data:([^;]+)/)
+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/);
+ controller.enqueue({
+ type: 'file',
+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg',
+ data: match2 ? match2[1] : image.image_url.url,
+ });
+ }
+ }
if (delta.tool_calls != null) {
for (const toolCallDelta of delta.tool_calls) {
const index = toolCallDelta.index;
@@ -749,6 +771,14 @@ var OpenAICompatibleChatResponseSchema = z3.object({
arguments: z3.string()
})
})
+ ).nullish(),
+ images: z3.array(
+ z3.object({
+ type: z3.literal('image_url'),
+ image_url: z3.object({
+ url: z3.string(),
+ })
+ })
).nullish()
}),
finish_reason: z3.string().nullish()
@@ -779,6 +809,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => z3.union([
arguments: z3.string().nullish()
})
})
+ ).nullish(),
+ images: z3.array(
+ z3.object({
+ type: z3.literal('image_url'),
+ image_url: z3.object({
+ url: z3.string(),
+ })
+ })
).nullish()
}).nullish(),
finish_reason: z3.string().nullish()

View File

@ -0,0 +1,266 @@
diff --git a/dist/index.d.ts b/dist/index.d.ts
index 48e2f6263c6ee4c75d7e5c28733e64f6ebe92200..00d0729c4a3cbf9a48e8e1e962c7e2b256b75eba 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -7,6 +7,7 @@ declare const openaiCompatibleProviderOptions: z.ZodObject<{
user: z.ZodOptional<z.ZodString>;
reasoningEffort: z.ZodOptional<z.ZodString>;
textVerbosity: z.ZodOptional<z.ZodString>;
+ sendReasoning: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>;
type OpenAICompatibleProviderOptions = z.infer<typeof openaiCompatibleProviderOptions>;
diff --git a/dist/index.js b/dist/index.js
index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e5bfe0f9a 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -41,7 +41,7 @@ function getOpenAIMetadata(message) {
var _a, _b;
return (_b = (_a = message == null ? void 0 : message.providerOptions) == null ? void 0 : _a.openaiCompatible) != null ? _b : {};
}
-function convertToOpenAICompatibleChatMessages(prompt) {
+function convertToOpenAICompatibleChatMessages({prompt, options}) {
const messages = [];
for (const { role, content, ...message } of prompt) {
const metadata = getOpenAIMetadata({ ...message });
@@ -91,6 +91,7 @@ function convertToOpenAICompatibleChatMessages(prompt) {
}
case "assistant": {
let text = "";
+ let reasoning_text = "";
const toolCalls = [];
for (const part of content) {
const partMetadata = getOpenAIMetadata(part);
@@ -99,6 +100,12 @@ function convertToOpenAICompatibleChatMessages(prompt) {
text += part.text;
break;
}
+ case "reasoning": {
+ if (options.sendReasoning) {
+ reasoning_text += part.text;
+ }
+ break;
+ }
case "tool-call": {
toolCalls.push({
id: part.toolCallId,
@@ -116,6 +123,7 @@ function convertToOpenAICompatibleChatMessages(prompt) {
messages.push({
role: "assistant",
content: text,
+ reasoning_content: reasoning_text ?? undefined,
tool_calls: toolCalls.length > 0 ? toolCalls : void 0,
...metadata
});
@@ -200,7 +208,8 @@ var openaiCompatibleProviderOptions = import_v4.z.object({
/**
* Controls the verbosity of the generated text. Defaults to `medium`.
*/
- textVerbosity: import_v4.z.string().optional()
+ textVerbosity: import_v4.z.string().optional(),
+ sendReasoning: import_v4.z.boolean().optional()
});
// src/openai-compatible-error.ts
@@ -378,7 +387,7 @@ var OpenAICompatibleChatLanguageModel = class {
reasoning_effort: compatibleOptions.reasoningEffort,
verbosity: compatibleOptions.textVerbosity,
// messages:
- messages: convertToOpenAICompatibleChatMessages(prompt),
+ messages: convertToOpenAICompatibleChatMessages({prompt, options: compatibleOptions}),
// tools:
tools: openaiTools,
tool_choice: openaiToolChoice
@@ -421,6 +430,17 @@ var OpenAICompatibleChatLanguageModel = class {
text: reasoning
});
}
+ if (choice.message.images) {
+ for (const image of choice.message.images) {
+ const match1 = image.image_url.url.match(/^data:([^;]+)/)
+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/);
+ content.push({
+ type: 'file',
+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg',
+ data: match2 ? match2[1] : image.image_url.url,
+ });
+ }
+ }
if (choice.message.tool_calls != null) {
for (const toolCall of choice.message.tool_calls) {
content.push({
@@ -598,6 +618,17 @@ var OpenAICompatibleChatLanguageModel = class {
delta: delta.content
});
}
+ if (delta.images) {
+ for (const image of delta.images) {
+ const match1 = image.image_url.url.match(/^data:([^;]+)/)
+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/);
+ controller.enqueue({
+ type: 'file',
+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg',
+ data: match2 ? match2[1] : image.image_url.url,
+ });
+ }
+ }
if (delta.tool_calls != null) {
for (const toolCallDelta of delta.tool_calls) {
const index = toolCallDelta.index;
@@ -765,6 +796,14 @@ var OpenAICompatibleChatResponseSchema = import_v43.z.object({
arguments: import_v43.z.string()
})
})
+ ).nullish(),
+ images: import_v43.z.array(
+ import_v43.z.object({
+ type: import_v43.z.literal('image_url'),
+ image_url: import_v43.z.object({
+ url: import_v43.z.string(),
+ })
+ })
).nullish()
}),
finish_reason: import_v43.z.string().nullish()
@@ -795,6 +834,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => import_v43.z.union(
arguments: import_v43.z.string().nullish()
})
})
+ ).nullish(),
+ images: import_v43.z.array(
+ import_v43.z.object({
+ type: import_v43.z.literal('image_url'),
+ image_url: import_v43.z.object({
+ url: import_v43.z.string(),
+ })
+ })
).nullish()
}).nullish(),
finish_reason: import_v43.z.string().nullish()
diff --git a/dist/index.mjs b/dist/index.mjs
index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..565b605eeacd9830b2b0e817e58ad0c5700264de 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -23,7 +23,7 @@ function getOpenAIMetadata(message) {
var _a, _b;
return (_b = (_a = message == null ? void 0 : message.providerOptions) == null ? void 0 : _a.openaiCompatible) != null ? _b : {};
}
-function convertToOpenAICompatibleChatMessages(prompt) {
+function convertToOpenAICompatibleChatMessages({prompt, options}) {
const messages = [];
for (const { role, content, ...message } of prompt) {
const metadata = getOpenAIMetadata({ ...message });
@@ -73,6 +73,7 @@ function convertToOpenAICompatibleChatMessages(prompt) {
}
case "assistant": {
let text = "";
+ let reasoning_text = "";
const toolCalls = [];
for (const part of content) {
const partMetadata = getOpenAIMetadata(part);
@@ -81,6 +82,12 @@ function convertToOpenAICompatibleChatMessages(prompt) {
text += part.text;
break;
}
+ case "reasoning": {
+ if (options.sendReasoning) {
+ reasoning_text += part.text;
+ }
+ break;
+ }
case "tool-call": {
toolCalls.push({
id: part.toolCallId,
@@ -98,6 +105,7 @@ function convertToOpenAICompatibleChatMessages(prompt) {
messages.push({
role: "assistant",
content: text,
+ reasoning_content: reasoning_text ?? undefined,
tool_calls: toolCalls.length > 0 ? toolCalls : void 0,
...metadata
});
@@ -182,7 +190,8 @@ var openaiCompatibleProviderOptions = z.object({
/**
* Controls the verbosity of the generated text. Defaults to `medium`.
*/
- textVerbosity: z.string().optional()
+ textVerbosity: z.string().optional(),
+ sendReasoning: z.boolean().optional()
});
// src/openai-compatible-error.ts
@@ -362,7 +371,7 @@ var OpenAICompatibleChatLanguageModel = class {
reasoning_effort: compatibleOptions.reasoningEffort,
verbosity: compatibleOptions.textVerbosity,
// messages:
- messages: convertToOpenAICompatibleChatMessages(prompt),
+ messages: convertToOpenAICompatibleChatMessages({prompt, options: compatibleOptions}),
// tools:
tools: openaiTools,
tool_choice: openaiToolChoice
@@ -405,6 +414,17 @@ var OpenAICompatibleChatLanguageModel = class {
text: reasoning
});
}
+ if (choice.message.images) {
+ for (const image of choice.message.images) {
+ const match1 = image.image_url.url.match(/^data:([^;]+)/)
+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/);
+ content.push({
+ type: 'file',
+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg',
+ data: match2 ? match2[1] : image.image_url.url,
+ });
+ }
+ }
if (choice.message.tool_calls != null) {
for (const toolCall of choice.message.tool_calls) {
content.push({
@@ -582,6 +602,17 @@ var OpenAICompatibleChatLanguageModel = class {
delta: delta.content
});
}
+ if (delta.images) {
+ for (const image of delta.images) {
+ const match1 = image.image_url.url.match(/^data:([^;]+)/)
+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/);
+ controller.enqueue({
+ type: 'file',
+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg',
+ data: match2 ? match2[1] : image.image_url.url,
+ });
+ }
+ }
if (delta.tool_calls != null) {
for (const toolCallDelta of delta.tool_calls) {
const index = toolCallDelta.index;
@@ -749,6 +780,14 @@ var OpenAICompatibleChatResponseSchema = z3.object({
arguments: z3.string()
})
})
+ ).nullish(),
+ images: z3.array(
+ z3.object({
+ type: z3.literal('image_url'),
+ image_url: z3.object({
+ url: z3.string(),
+ })
+ })
).nullish()
}),
finish_reason: z3.string().nullish()
@@ -779,6 +818,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => z3.union([
arguments: z3.string().nullish()
})
})
+ ).nullish(),
+ images: z3.array(
+ z3.object({
+ type: z3.literal('image_url'),
+ image_url: z3.object({
+ url: z3.string(),
+ })
+ })
).nullish()
}).nullish(),
finish_reason: z3.string().nullish()

View File

@ -7,7 +7,7 @@ index 8dd9b498050dbecd8dd6b901acf1aa8ca38a49af..ed644349c9d38fe2a66b2fb44214f7c1
type OllamaChatModelId = "athene-v2" | "athene-v2:72b" | "aya-expanse" | "aya-expanse:8b" | "aya-expanse:32b" | "codegemma" | "codegemma:2b" | "codegemma:7b" | "codellama" | "codellama:7b" | "codellama:13b" | "codellama:34b" | "codellama:70b" | "codellama:code" | "codellama:python" | "command-r" | "command-r:35b" | "command-r-plus" | "command-r-plus:104b" | "command-r7b" | "command-r7b:7b" | "deepseek-r1" | "deepseek-r1:1.5b" | "deepseek-r1:7b" | "deepseek-r1:8b" | "deepseek-r1:14b" | "deepseek-r1:32b" | "deepseek-r1:70b" | "deepseek-r1:671b" | "deepseek-coder-v2" | "deepseek-coder-v2:16b" | "deepseek-coder-v2:236b" | "deepseek-v3" | "deepseek-v3:671b" | "devstral" | "devstral:24b" | "dolphin3" | "dolphin3:8b" | "exaone3.5" | "exaone3.5:2.4b" | "exaone3.5:7.8b" | "exaone3.5:32b" | "falcon2" | "falcon2:11b" | "falcon3" | "falcon3:1b" | "falcon3:3b" | "falcon3:7b" | "falcon3:10b" | "firefunction-v2" | "firefunction-v2:70b" | "gemma" | "gemma:2b" | "gemma:7b" | "gemma2" | "gemma2:2b" | "gemma2:9b" | "gemma2:27b" | "gemma3" | "gemma3:1b" | "gemma3:4b" | "gemma3:12b" | "gemma3:27b" | "granite3-dense" | "granite3-dense:2b" | "granite3-dense:8b" | "granite3-guardian" | "granite3-guardian:2b" | "granite3-guardian:8b" | "granite3-moe" | "granite3-moe:1b" | "granite3-moe:3b" | "granite3.1-dense" | "granite3.1-dense:2b" | "granite3.1-dense:8b" | "granite3.1-moe" | "granite3.1-moe:1b" | "granite3.1-moe:3b" | "llama2" | "llama2:7b" | "llama2:13b" | "llama2:70b" | "llama3" | "llama3:8b" | "llama3:70b" | "llama3-chatqa" | "llama3-chatqa:8b" | "llama3-chatqa:70b" | "llama3-gradient" | "llama3-gradient:8b" | "llama3-gradient:70b" | "llama3.1" | "llama3.1:8b" | "llama3.1:70b" | "llama3.1:405b" | "llama3.2" | "llama3.2:1b" | "llama3.2:3b" | "llama3.2-vision" | "llama3.2-vision:11b" | "llama3.2-vision:90b" | "llama3.3" | "llama3.3:70b" | "llama4" | "llama4:16x17b" | "llama4:128x17b" | "llama-guard3" | "llama-guard3:1b" | "llama-guard3:8b" | "llava" | "llava:7b" | "llava:13b" | "llava:34b" | "llava-llama3" | "llava-llama3:8b" | "llava-phi3" | "llava-phi3:3.8b" | "marco-o1" | "marco-o1:7b" | "mistral" | "mistral:7b" | "mistral-large" | "mistral-large:123b" | "mistral-nemo" | "mistral-nemo:12b" | "mistral-small" | "mistral-small:22b" | "mixtral" | "mixtral:8x7b" | "mixtral:8x22b" | "moondream" | "moondream:1.8b" | "openhermes" | "openhermes:v2.5" | "nemotron" | "nemotron:70b" | "nemotron-mini" | "nemotron-mini:4b" | "olmo" | "olmo:7b" | "olmo:13b" | "opencoder" | "opencoder:1.5b" | "opencoder:8b" | "phi3" | "phi3:3.8b" | "phi3:14b" | "phi3.5" | "phi3.5:3.8b" | "phi4" | "phi4:14b" | "qwen" | "qwen:7b" | "qwen:14b" | "qwen:32b" | "qwen:72b" | "qwen:110b" | "qwen2" | "qwen2:0.5b" | "qwen2:1.5b" | "qwen2:7b" | "qwen2:72b" | "qwen2.5" | "qwen2.5:0.5b" | "qwen2.5:1.5b" | "qwen2.5:3b" | "qwen2.5:7b" | "qwen2.5:14b" | "qwen2.5:32b" | "qwen2.5:72b" | "qwen2.5-coder" | "qwen2.5-coder:0.5b" | "qwen2.5-coder:1.5b" | "qwen2.5-coder:3b" | "qwen2.5-coder:7b" | "qwen2.5-coder:14b" | "qwen2.5-coder:32b" | "qwen3" | "qwen3:0.6b" | "qwen3:1.7b" | "qwen3:4b" | "qwen3:8b" | "qwen3:14b" | "qwen3:30b" | "qwen3:32b" | "qwen3:235b" | "qwq" | "qwq:32b" | "sailor2" | "sailor2:1b" | "sailor2:8b" | "sailor2:20b" | "shieldgemma" | "shieldgemma:2b" | "shieldgemma:9b" | "shieldgemma:27b" | "smallthinker" | "smallthinker:3b" | "smollm" | "smollm:135m" | "smollm:360m" | "smollm:1.7b" | "tinyllama" | "tinyllama:1.1b" | "tulu3" | "tulu3:8b" | "tulu3:70b" | (string & {});
declare const ollamaProviderOptions: z.ZodObject<{
- think: z.ZodOptional<z.ZodBoolean>;
+ think: z.ZodOptional<z.ZodUnion<[z.ZodBoolean, z.ZodEnum<['low', 'medium', 'high']>]>>;
+ think: z.ZodOptional<z.ZodUnion<[z.ZodBoolean, z.ZodLiteral<"low">, z.ZodLiteral<"medium">, z.ZodLiteral<"high">]>>;
options: z.ZodOptional<z.ZodObject<{
num_ctx: z.ZodOptional<z.ZodNumber>;
repeat_last_n: z.ZodOptional<z.ZodNumber>;
@ -29,7 +29,7 @@ index 8dd9b498050dbecd8dd6b901acf1aa8ca38a49af..ed644349c9d38fe2a66b2fb44214f7c1
declare const ollamaCompletionProviderOptions: z.ZodObject<{
- think: z.ZodOptional<z.ZodBoolean>;
+ think: z.ZodOptional<z.ZodUnion<[z.ZodBoolean, z.ZodEnum<['low', 'medium', 'high']>]>>;
+ think: z.ZodOptional<z.ZodUnion<[z.ZodBoolean, z.ZodLiteral<"low">, z.ZodLiteral<"medium">, z.ZodLiteral<"high">]>>;
user: z.ZodOptional<z.ZodString>;
suffix: z.ZodOptional<z.ZodString>;
echo: z.ZodOptional<z.ZodBoolean>;
@ -42,7 +42,7 @@ index 35b5142ce8476ce2549ed7c2ec48e7d8c46c90d9..2ef64dc9a4c2be043e6af608241a6a83
// src/completion/ollama-completion-language-model.ts
var ollamaCompletionProviderOptions = import_v42.z.object({
- think: import_v42.z.boolean().optional(),
+ think: import_v42.z.union([import_v42.z.boolean(), import_v42.z.enum(['low', 'medium', 'high'])]).optional(),
+ think: import_v42.z.union([import_v42.z.boolean(), import_v42.z.literal('low'), import_v42.z.literal('medium'), import_v42.z.literal('high')]).optional(),
user: import_v42.z.string().optional(),
suffix: import_v42.z.string().optional(),
echo: import_v42.z.boolean().optional()
@ -64,7 +64,7 @@ index 35b5142ce8476ce2549ed7c2ec48e7d8c46c90d9..2ef64dc9a4c2be043e6af608241a6a83
* Only supported by certain models like DeepSeek R1 and Qwen 3.
*/
- think: import_v44.z.boolean().optional(),
+ think: import_v44.z.union([import_v44.z.boolean(), import_v44.z.enum(['low', 'medium', 'high'])]).optional(),
+ think: import_v44.z.union([import_v44.z.boolean(), import_v44.z.literal('low'), import_v44.z.literal('medium'), import_v44.z.literal('high')]).optional(),
options: import_v44.z.object({
num_ctx: import_v44.z.number().optional(),
repeat_last_n: import_v44.z.number().optional(),
@ -97,7 +97,7 @@ index e2a634a78d80ac9542f2cc4f96cf2291094b10cf..67b23efce3c1cf4f026693d3ff924698
// src/completion/ollama-completion-language-model.ts
var ollamaCompletionProviderOptions = z2.object({
- think: z2.boolean().optional(),
+ think: z2.union([z2.boolean(), z2.enum(['low', 'medium', 'high'])]).optional(),
+ think: z2.union([z2.boolean(), z2.literal('low'), z2.literal('medium'), z2.literal('high')]).optional(),
user: z2.string().optional(),
suffix: z2.string().optional(),
echo: z2.boolean().optional()
@ -119,7 +119,7 @@ index e2a634a78d80ac9542f2cc4f96cf2291094b10cf..67b23efce3c1cf4f026693d3ff924698
* Only supported by certain models like DeepSeek R1 and Qwen 3.
*/
- think: z4.boolean().optional(),
+ think: z4.union([z4.boolean(), z4.enum(['low', 'medium', 'high'])]).optional(),
+ think: z4.union([z4.boolean(), z4.literal('low'), z4.literal('medium'), z4.literal('high')]).optional(),
options: z4.object({
num_ctx: z4.number().optional(),
repeat_last_n: z4.number().optional(),

View File

@ -29,7 +29,7 @@ When creating a Pull Request, you MUST:
- **Development**: `yarn dev` - Runs Electron app in development mode with hot reload
- **Debug**: `yarn debug` - Starts with debugging enabled, use `chrome://inspect` to attach debugger
- **Build Check**: `yarn build:check` - **REQUIRED** before commits (lint + test + typecheck)
- If having i18n sort issues, run `yarn sync:i18n` first to sync template
- If having i18n sort issues, run `yarn i18n:sync` first to sync template
- If having formatting issues, run `yarn format` first
- **Test**: `yarn test` - Run all tests (Vitest) across main and renderer processes
- **Single Test**:
@ -41,39 +41,24 @@ When creating a Pull Request, you MUST:
## Project Architecture
### Electron Structure
- **Main Process** (`src/main/`): Node.js backend with services (MCP, Knowledge, Storage, etc.)
- **Renderer Process** (`src/renderer/`): React UI with Redux state management
- **Renderer Process** (`src/renderer/`): React UI
- **Preload Scripts** (`src/preload/`): Secure IPC bridge
### Key Architectural Components
#### Main Process Services (`src/main/services/`)
- **MCPService**: Model Context Protocol server management
- **KnowledgeService**: Document processing and knowledge base management
- **FileStorage/S3Storage/WebDav**: Multiple storage backends
- **WindowService**: Multi-window management (main, mini, selection windows)
- **ProxyManager**: Network proxy handling
- **SearchService**: Full-text search capabilities
#### AI Core (`src/renderer/src/aiCore/`)
- **Middleware System**: Composable pipeline for AI request processing
- **Client Factory**: Supports multiple AI providers (OpenAI, Anthropic, Gemini, etc.)
- **Stream Processing**: Real-time response handling
#### Data Management
- **Cache System**: Three-layer caching (memory/shared/persist) with React hooks integration
- **Preferences**: Type-safe configuration management with multi-window synchronization
- **User Data**: SQLite-based storage with Drizzle ORM for business data
**MUST READ**: [docs/en/references/data/README.md](docs/en/references/data/README.md) for system selection, architecture, and patterns.
#### Knowledge Management
| System | Use Case | APIs |
| ---------- | ---------------------------- | ----------------------------------------------- |
| Cache | Temp data (can lose) | `useCache`, `useSharedCache`, `usePersistCache` |
| Preference | User settings | `usePreference` |
| DataApi | Business data (**critical**) | `useQuery`, `useMutation` |
- **Embeddings**: Vector search with multiple providers (OpenAI, Voyage, etc.)
- **OCR**: Document text extraction (system OCR, Doc2x, Mineru)
- **Preprocessing**: Document preparation pipeline
- **Loaders**: Support for various file formats (PDF, DOCX, EPUB, etc.)
Database: SQLite + Drizzle ORM, schemas in `src/main/data/db/schemas/`, migrations via `yarn db:migrations:generate`
### Build System
@ -98,63 +83,36 @@ When creating a Pull Request, you MUST:
- **Multi-language Support**: i18n with dynamic loading
- **Theme System**: Light/dark themes with custom CSS variables
### UI Design
## v2 Refactoring (In Progress)
The project is in the process of migrating from antd & styled-components to Tailwind CSS and Shadcn UI. Please use components from `@packages/ui` to build UI components. The use of antd and styled-components is prohibited.
The v2 branch is undergoing a major refactoring effort:
UI Library: `@packages/ui`
### Data Layer
### Database Architecture
- **Removing**: Redux, Dexie
- **Adopting**: Cache / Preference / DataApi architecture (see [Data Management](#data-management))
- **Database**: SQLite (`cherrystudio.sqlite`) + libsql driver
- **ORM**: Drizzle ORM with comprehensive migration system
- **Schemas**: Located in `src/main/data/db/schemas/` directory
### UI Layer
#### Database Standards
- **Removing**: antd, HeroUI, styled-components
- **Adopting**: `@cherrystudio/ui` (located in `packages/ui`, Tailwind CSS + Shadcn UI)
- **Prohibited**: antd, HeroUI, styled-components
- **Table Naming**: Use singular form with snake_case (e.g., `topic`, `message`, `app_state`)
- **Schema Exports**: Export using `xxxTable` pattern (e.g., `topicTable`, `appStateTable`)
- **Field Definition**: Drizzle auto-infers field names, no need to add default field names
- **JSON Fields**: For JSON support, add `{ mode: 'json' }`, refer to `preference.ts` table definition
- **JSON Serialization**: For JSON fields, no need to manually serialize/deserialize when reading/writing to database, Drizzle handles this automatically
- **Timestamps**: Use existing `crudTimestamps` utility
- **Migrations**: Generate via `yarn run migrations:generate`
### File Naming Convention
## Data Access Patterns
During migration, use `*.v2.ts` suffix for files not yet fully migrated:
The application uses three distinct data management systems. Choose the appropriate system based on data characteristics:
### Cache System
- **Purpose**: Temporary data that can be regenerated
- **Lifecycle**: Component-level (memory), window-level (shared), or persistent (survives restart)
- **Use Cases**: API response caching, computed results, temporary UI state
- **APIs**: `useCache`, `useSharedCache`, `usePersistCache` hooks, or `cacheService`
### Preference System
- **Purpose**: User configuration and application settings
- **Lifecycle**: Permanent until user changes
- **Use Cases**: Theme, language, editor settings, user preferences
- **APIs**: `usePreference`, `usePreferences` hooks, or `preferenceService`
### User Data API
- **Purpose**: Core business data (conversations, files, notes, etc.)
- **Lifecycle**: Permanent business records
- **Use Cases**: Topics, messages, files, knowledge base, user-generated content
- **APIs**: `useDataApi` hook or `dataApiService` for direct calls
### Selection Guidelines
- **Use Cache** for data that can be lost without impact (computed values, API responses)
- **Use Preferences** for user settings that affect app behavior (UI configuration, feature flags)
- **Use User Data API** for irreplaceable business data (conversations, documents, user content)
- Indicates work-in-progress refactoring
- Avoids conflicts with existing code
- **Post-completion**: These files will be renamed or merged into their final locations
## Logging Standards
### Usage
```typescript
import { loggerService } from '@logger'
const logger = loggerService.withContext('moduleName')
import { loggerService } from "@logger";
const logger = loggerService.withContext("moduleName");
// Renderer: loggerService.initWindowSource('windowName') first
logger.info('message', CONTEXT)
logger.info("message", CONTEXT);
```

View File

@ -34,7 +34,7 @@
</a>
</h1>
<p align="center">English | <a href="./docs/zh/README.md">中文</a> | <a href="https://cherry-ai.com">Official Site</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/en-us">Documents</a> | <a href="./docs/en/guides/development.md">Development</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">Feedback</a><br></p>
<p align="center">English | <a href="./docs/zh/README.md">中文</a> | <a href="https://cherry-ai.com">Official Site</a> | <a href="https://docs.cherry-ai.com/docs/en-us">Documents</a> | <a href="./docs/en/guides/development.md">Development</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">Feedback</a><br></p>
<div align="center">
@ -242,12 +242,12 @@ The Enterprise Edition addresses core challenges in team collaboration by centra
## Version Comparison
| Feature | Community Edition | Enterprise Edition |
| :---------------- | :----------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- |
| **Open Source** | ✅ Yes | ⭕️ Partially released to customers |
| Feature | Community Edition | Enterprise Edition |
| :---------------- | :----------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- |
| **Open Source** | ✅ Yes | ⭕️ Partially released to customers |
| **Cost** | [AGPL-3.0 License](https://github.com/CherryHQ/cherry-studio?tab=AGPL-3.0-1-ov-file) | Buyout / Subscription Fee |
| **Admin Backend** | — | ● Centralized **Model** Access<br>**Employee** Management<br>● Shared **Knowledge Base**<br>**Access** Control<br>**Data** Backup |
| **Server** | — | ✅ Dedicated Private Deployment |
| **Admin Backend** | — | ● Centralized **Model** Access<br>**Employee** Management<br>● Shared **Knowledge Base**<br>**Access** Control<br>**Data** Backup |
| **Server** | — | ✅ Dedicated Private Deployment |
## Get the Enterprise Edition
@ -275,7 +275,7 @@ We believe the Enterprise Edition will become your team's AI productivity engine
# 📊 GitHub Stats
![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg 'Repobeats analytics image')
![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg "Repobeats analytics image")
# ⭐️ Star History

View File

@ -23,7 +23,7 @@
},
"files": {
"ignoreUnknown": false,
"includes": ["**", "!**/.claude/**", "!**/.vscode/**"],
"includes": ["**", "!**/.claude/**", "!**/.vscode/**", "!**/.conductor/**"],
"maxSize": 2097152
},
"formatter": {

View File

@ -12,8 +12,13 @@
; https://github.com/electron-userland/electron-builder/issues/1122
!ifndef BUILD_UNINSTALLER
; Check VC++ Redistributable based on architecture stored in $1
Function checkVCRedist
ReadRegDWORD $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed"
${If} $1 == "arm64"
ReadRegDWORD $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\ARM64" "Installed"
${Else}
ReadRegDWORD $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed"
${EndIf}
FunctionEnd
Function checkArchitectureCompatibility
@ -97,29 +102,47 @@
Call checkVCRedist
${If} $0 != "1"
MessageBox MB_YESNO "\
NOTE: ${PRODUCT_NAME} requires $\r$\n\
'Microsoft Visual C++ Redistributable'$\r$\n\
to function properly.$\r$\n$\r$\n\
Download and install now?" /SD IDYES IDYES InstallVCRedist IDNO DontInstall
InstallVCRedist:
inetc::get /CAPTION " " /BANNER "Downloading Microsoft Visual C++ Redistributable..." "https://aka.ms/vs/17/release/vc_redist.x64.exe" "$TEMP\vc_redist.x64.exe"
ExecWait "$TEMP\vc_redist.x64.exe /install /norestart"
;IfErrors InstallError ContinueInstall ; vc_redist exit code is unreliable :(
Call checkVCRedist
${If} $0 == "1"
Goto ContinueInstall
${EndIf}
; VC++ is required - install automatically since declining would abort anyway
; Select download URL based on system architecture (stored in $1)
${If} $1 == "arm64"
StrCpy $2 "https://aka.ms/vs/17/release/vc_redist.arm64.exe"
StrCpy $3 "$TEMP\vc_redist.arm64.exe"
${Else}
StrCpy $2 "https://aka.ms/vs/17/release/vc_redist.x64.exe"
StrCpy $3 "$TEMP\vc_redist.x64.exe"
${EndIf}
;InstallError:
MessageBox MB_ICONSTOP "\
There was an unexpected error installing$\r$\n\
Microsoft Visual C++ Redistributable.$\r$\n\
The installation of ${PRODUCT_NAME} cannot continue."
DontInstall:
inetc::get /CAPTION " " /BANNER "Downloading Microsoft Visual C++ Redistributable..." \
$2 $3 /END
Pop $0 ; Get download status from inetc::get
${If} $0 != "OK"
MessageBox MB_ICONSTOP|MB_YESNO "\
Failed to download Microsoft Visual C++ Redistributable.$\r$\n$\r$\n\
Error: $0$\r$\n$\r$\n\
Would you like to open the download page in your browser?$\r$\n\
$2" IDYES openDownloadUrl IDNO skipDownloadUrl
openDownloadUrl:
ExecShell "open" $2
skipDownloadUrl:
Abort
${EndIf}
ExecWait "$3 /install /quiet /norestart"
; Note: vc_redist exit code is unreliable, verify via registry check instead
Call checkVCRedist
${If} $0 != "1"
MessageBox MB_ICONSTOP|MB_YESNO "\
Microsoft Visual C++ Redistributable installation failed.$\r$\n$\r$\n\
Would you like to open the download page in your browser?$\r$\n\
$2$\r$\n$\r$\n\
The installation of ${PRODUCT_NAME} cannot continue." IDYES openInstallUrl IDNO skipInstallUrl
openInstallUrl:
ExecShell "open" $2
skipInstallUrl:
Abort
${EndIf}
${EndIf}
ContinueInstall:
Pop $4
Pop $3
Pop $2

View File

@ -36,7 +36,7 @@ yarn install
### ENV
```bash
copy .env.example .env
cp .env.example .env
```
### Start

View File

@ -71,7 +71,7 @@ Tools like i18n Ally cannot parse dynamic content within template strings, resul
```javascript
// Not recommended - Plugin cannot resolve
const message = t(`fruits.${fruit}`)
const message = t(`fruits.${fruit}`);
```
#### 2. **No Real-time Rendering in Editor**
@ -91,14 +91,14 @@ For example:
```ts
// src/renderer/src/i18n/label.ts
const themeModeKeyMap = {
dark: 'settings.theme.dark',
light: 'settings.theme.light',
system: 'settings.theme.system'
} as const
dark: "settings.theme.dark",
light: "settings.theme.light",
system: "settings.theme.system",
} as const;
export const getThemeModeLabel = (key: string): string => {
return themeModeKeyMap[key] ? t(themeModeKeyMap[key]) : key
}
return themeModeKeyMap[key] ? t(themeModeKeyMap[key]) : key;
};
```
By avoiding template strings, you gain better developer experience, more reliable translation checks, and a more maintainable codebase.
@ -107,7 +107,7 @@ By avoiding template strings, you gain better developer experience, more reliabl
The project includes several scripts to automate i18n-related tasks:
### `check:i18n` - Validate i18n Structure
### `i18n:check` - Validate i18n Structure
This script checks:
@ -116,10 +116,10 @@ This script checks:
- Whether keys are properly sorted
```bash
yarn check:i18n
yarn i18n:check
```
### `sync:i18n` - Synchronize JSON Structure and Sort Order
### `i18n:sync` - Synchronize JSON Structure and Sort Order
This script uses `zh-cn.json` as the source of truth to sync structure across all language files, including:
@ -128,14 +128,14 @@ This script uses `zh-cn.json` as the source of truth to sync structure across al
3. Sorting keys automatically
```bash
yarn sync:i18n
yarn i18n:sync
```
### `auto:i18n` - Automatically Translate Pending Texts
### `i18n:translate` - Automatically Translate Pending Texts
This script fills in texts marked as `[to be translated]` using machine translation.
Typically, after adding new texts in `zh-cn.json`, run `sync:i18n`, then `auto:i18n` to complete translations.
Typically, after adding new texts in `zh-cn.json`, run `i18n:sync`, then `i18n:translate` to complete translations.
Before using this script, set the required environment variables:
@ -148,30 +148,20 @@ MODEL="qwen-plus-latest"
Alternatively, add these variables directly to your `.env` file.
```bash
yarn auto:i18n
```
### `update:i18n` - Object-level Translation Update
Updates translations in language files under `src/renderer/src/i18n/translate` at the object level, preserving existing translations and only updating new content.
**Not recommended** — prefer `auto:i18n` for translation tasks.
```bash
yarn update:i18n
yarn i18n:translate
```
### Workflow
1. During development, first add the required text in `zh-cn.json`
2. Confirm it displays correctly in the Chinese environment
3. Run `yarn sync:i18n` to propagate the keys to other language files
4. Run `yarn auto:i18n` to perform machine translation
3. Run `yarn i18n:sync` to propagate the keys to other language files
4. Run `yarn i18n:translate` to perform machine translation
5. Grab a coffee and let the magic happen!
## Best Practices
1. **Use Chinese as Source Language**: All development starts in Chinese, then translates to other languages.
2. **Run Check Script Before Commit**: Use `yarn check:i18n` to catch i18n issues early.
2. **Run Check Script Before Commit**: Use `yarn i18n:check` to catch i18n issues early.
3. **Translate in Small Increments**: Avoid accumulating a large backlog of untranslated content.
4. **Keep Keys Semantically Clear**: Keys should clearly express their purpose, e.g., `user.profile.avatar.upload.error`

View File

@ -0,0 +1,197 @@
# Data System Reference
This is the main entry point for Cherry Studio's data management documentation. The application uses three distinct data systems based on data characteristics.
## Quick Navigation
### System Overview (Architecture)
- [Cache Overview](./cache-overview.md) - Three-tier caching architecture
- [Preference Overview](./preference-overview.md) - User settings management
- [DataApi Overview](./data-api-overview.md) - Business data API architecture
### Usage Guides (Code Examples)
- [Cache Usage](./cache-usage.md) - useCache hooks, CacheService examples
- [Preference Usage](./preference-usage.md) - usePreference hook, PreferenceService examples
- [DataApi in Renderer](./data-api-in-renderer.md) - useQuery/useMutation, DataApiService
- [DataApi in Main](./data-api-in-main.md) - Handlers, Services, Repositories patterns
### Reference Guides (Coding Standards)
- [API Design Guidelines](./api-design-guidelines.md) - RESTful design rules
- [Database Patterns](./database-patterns.md) - DB naming, schema patterns
- [API Types](./api-types.md) - API type system, schemas, error handling
- [V2 Migration Guide](./v2-migration-guide.md) - Migration system
### Testing
- [Test Mocks](../../../../tests/__mocks__/README.md) - Unified mocks for Cache, Preference, and DataApi
---
## Choosing the Right System
### Quick Decision Table
| Service | Data Characteristics | Lifecycle | Data Loss Impact | Examples |
|---------|---------------------|-----------|------------------|----------|
| **CacheService** | Regenerable, temporary | ≤ App process or survives restart | None to minimal | API responses, computed results, UI state |
| **PreferenceService** | User settings, key-value | Permanent until changed | Low (can rebuild) | Theme, language, font size, shortcuts |
| **DataApiService** | Business data, structured | Permanent | **Severe** (irreplaceable) | Topics, messages, files, knowledge base |
### Decision Flowchart
Ask these questions in order:
1. **Can this data be regenerated or lost without affecting the user?**
- Yes → **CacheService**
- No → Continue to #2
2. **Is this a user-configurable setting that affects app behavior?**
- Yes → Does it have a fixed key and stable value structure?
- Yes → **PreferenceService**
- No (structure changes often) → **DataApiService**
- No → Continue to #3
3. **Is this business data created/accumulated through user activity?**
- Yes → **DataApiService**
- No → Reconsider #1 (most data falls into one of these categories)
---
## System Characteristics
### CacheService - Runtime & Cache Data
Use CacheService when:
- Data can be **regenerated or lost without user impact**
- No backup or cross-device synchronization needed
- Lifecycle is tied to component, window, or app session
**Two sub-categories**:
1. **Performance cache**: Computed results, API responses, expensive calculations
2. **UI state cache**: Temporary settings, scroll positions, panel states
**Three tiers based on persistence needs**:
- `useCache` (memory): Lost on app restart, component-level sharing
- `useSharedCache` (shared): Cross-window sharing, lost on restart
- `usePersistCache` (persist): Survives app restarts via localStorage
```typescript
// Good: Temporary computed results
const [searchResults, setSearchResults] = useCache('search.results', [])
// Good: UI state that can be lost
const [sidebarCollapsed, setSidebarCollapsed] = useSharedCache('ui.sidebar.collapsed', false)
// Good: Recent items (nice to have, not critical)
const [recentSearches, setRecentSearches] = usePersistCache('search.recent', [])
```
### PreferenceService - User Preferences
Use PreferenceService when:
- Data is a **user-modifiable setting that affects app behavior**
- Structure is key-value with **predefined keys** (users modify values, not keys)
- **Value structure is stable** (won't change frequently)
- Data loss has **low impact** (user can reconfigure)
**Key characteristics**:
- Auto-syncs across all windows
- Each preference item should be **atomic** (one setting = one key)
- Values are typically: boolean, string, number, or simple array/object
```typescript
// Good: App behavior settings
const [theme, setTheme] = usePreference('app.theme.mode')
const [language, setLanguage] = usePreference('app.language')
const [fontSize, setFontSize] = usePreference('chat.message.font_size')
// Good: Feature toggles
const [showTimestamp, setShowTimestamp] = usePreference('chat.display.show_timestamp')
```
### DataApiService - User Data
Use DataApiService when:
- Data is **business data accumulated through user activity**
- Data is **structured with dedicated schemas/tables**
- Users can **create, delete, modify records** (no fixed limit)
- Data loss would be **severe and irreplaceable**
- Data volume can be **large** (potentially GBs)
**Key characteristics**:
- No automatic window sync (fetch on demand for fresh data)
- May contain sensitive data (encryption consideration)
- Requires proper CRUD operations and transactions
```typescript
// Good: User-generated business data
const { data: topics } = useQuery('/topics')
const { trigger: createTopic } = useMutation('/topics', 'POST')
// Good: Conversation history (irreplaceable)
const { data: messages } = useQuery('/messages', { query: { topicId } })
// Good: User files and knowledge base
const { data: files } = useQuery('/files')
```
---
## Common Anti-patterns
| Wrong Choice | Why It's Wrong | Correct Choice |
|--------------|----------------|----------------|
| Storing AI provider configs in Cache | User loses configured providers on restart | **PreferenceService** |
| Storing conversation history in Preferences | Unbounded growth, complex structure | **DataApiService** |
| Storing topic list in Preferences | User-created records, can grow large | **DataApiService** |
| Storing theme/language in DataApi | Overkill for simple key-value settings | **PreferenceService** |
| Storing API responses in DataApi | Regenerable data, doesn't need persistence | **CacheService** |
| Storing window positions in Preferences | Can be lost without impact | **CacheService** (persist tier) |
## Edge Cases
- **Recently used items** (e.g., recent files, recent searches): Use `usePersistCache` - nice to have but not critical if lost
- **Draft content** (e.g., unsaved message): Use `useSharedCache` for cross-window, consider auto-save to DataApi for recovery
- **Computed statistics**: Use `useCache` with TTL - regenerate when expired
- **User-created templates/presets**: Use **DataApiService** - user-generated content that can grow
---
## Architecture Overview
```
┌─────────────────┐
│ React Components│
└─────────┬───────┘
┌─────────▼───────┐
│ React Hooks │ ← useDataApi, usePreference, useCache
└─────────┬───────┘
┌─────────▼───────┐
│ Services │ ← DataApiService, PreferenceService, CacheService
└─────────┬───────┘
┌─────────▼───────┐
│ IPC Layer │ ← Main Process Communication
└─────────────────┘
```
## Related Source Code
### Type Definitions
- `packages/shared/data/api/` - API type system
- `packages/shared/data/cache/` - Cache type definitions
- `packages/shared/data/preference/` - Preference type definitions
### Main Process Implementation
- `src/main/data/api/` - API server and handlers
- `src/main/data/CacheService.ts` - Cache service
- `src/main/data/PreferenceService.ts` - Preference service
- `src/main/data/db/` - Database schemas
### Renderer Process Implementation
- `src/renderer/src/data/DataApiService.ts` - API client
- `src/renderer/src/data/CacheService.ts` - Cache service
- `src/renderer/src/data/PreferenceService.ts` - Preference service
- `src/renderer/src/data/hooks/` - React hooks

View File

@ -0,0 +1,250 @@
# API Design Guidelines
Guidelines for designing RESTful APIs in the Cherry Studio Data API system.
## Path Naming
| Rule | Example | Notes |
|------|---------|-------|
| Use plural nouns for collections | `/topics`, `/messages` | Resources are collections |
| Use kebab-case for multi-word paths | `/user-settings` | Not camelCase or snake_case |
| Express hierarchy via nesting | `/topics/:topicId/messages` | Parent-child relationships |
| Avoid verbs for CRUD operations | `/topics` not `/getTopics` | HTTP methods express action |
## HTTP Method Semantics
| Method | Purpose | Idempotent | Typical Response |
|--------|---------|------------|------------------|
| GET | Retrieve resource(s) | Yes | 200 + data |
| POST | Create resource | No | 201 + created entity |
| PUT | Replace entire resource | Yes | 200 + updated entity |
| PATCH | Partial update | Yes | 200 + updated entity |
| DELETE | Remove resource | Yes | 204 / void |
## Standard Endpoint Patterns
```typescript
// Collection operations
'/topics': {
GET: { ... } // List with pagination/filtering
POST: { ... } // Create new resource
}
// Individual resource operations
'/topics/:id': {
GET: { ... } // Get single resource
PUT: { ... } // Replace resource
PATCH: { ... } // Partial update
DELETE: { ... } // Remove resource
}
// Nested resources (use for parent-child relationships)
'/topics/:topicId/messages': {
GET: { ... } // List messages under topic
POST: { ... } // Create message in topic
}
```
## PATCH vs Dedicated Endpoints
### Decision Criteria
Use this decision tree to determine the appropriate approach:
```
Operation characteristics:
├── Simple field update with no side effects?
│ └── Yes → Use PATCH
├── High-frequency operation with clear business meaning?
│ └── Yes → Use dedicated endpoint (noun-based sub-resource)
├── Operation triggers complex side effects or validation?
│ └── Yes → Use dedicated endpoint
├── Operation creates new resources?
│ └── Yes → Use POST to dedicated endpoint
└── Default → Use PATCH
```
### Guidelines
| Scenario | Approach | Example |
|----------|----------|---------|
| Simple field update | PATCH | `PATCH /messages/:id { data: {...} }` |
| High-frequency + business meaning | Dedicated sub-resource | `PUT /topics/:id/active-node { nodeId }` |
| Complex validation/side effects | Dedicated endpoint | `POST /messages/:id/move { newParentId }` |
| Creates new resources | POST dedicated | `POST /messages/:id/duplicate` |
### Naming for Dedicated Endpoints
- **Prefer noun-based paths** over verb-based when possible
- Treat the operation target as a sub-resource: `/topics/:id/active-node` not `/topics/:id/switch-branch`
- Use POST for actions that create resources or have non-idempotent side effects
- Use PUT for setting/replacing a sub-resource value
### Examples
```typescript
// ✅ Good: Noun-based sub-resource for high-frequency operation
PUT /topics/:id/active-node
{ nodeId: string }
// ✅ Good: Simple field update via PATCH
PATCH /messages/:id
{ data: MessageData }
// ✅ Good: POST for resource creation
POST /messages/:id/duplicate
{ includeDescendants?: boolean }
// ❌ Avoid: Verb in path when noun works
POST /topics/:id/switch-branch // Use PUT /topics/:id/active-node instead
// ❌ Avoid: Dedicated endpoint for simple updates
POST /messages/:id/update-content // Use PATCH /messages/:id instead
```
## Non-CRUD Operations
Use verb-based paths for operations that don't fit CRUD semantics:
```typescript
// Search
'/topics/search': {
GET: { query: { q: string } }
}
// Statistics / Aggregations
'/topics/stats': {
GET: { response: { total: number, ... } }
}
// Resource actions (state changes, triggers)
'/topics/:id/archive': {
POST: { response: { archived: boolean } }
}
'/topics/:id/duplicate': {
POST: { response: Topic }
}
```
## Query Parameters
| Purpose | Pattern | Example |
|---------|---------|---------|
| Pagination | `page` + `limit` | `?page=1&limit=20` |
| Sorting | `orderBy` + `order` | `?orderBy=createdAt&order=desc` |
| Filtering | direct field names | `?status=active&type=chat` |
| Search | `q` or `search` | `?q=keyword` |
## Response Status Codes
Use standard HTTP status codes consistently:
| Status | Usage | Example |
|--------|-------|---------|
| 200 OK | Successful GET/PUT/PATCH | Return updated resource |
| 201 Created | Successful POST | Return created resource |
| 204 No Content | Successful DELETE | No body |
| 400 Bad Request | Invalid request format | Malformed JSON |
| 400 Invalid Operation | Business rule violation | Delete root without cascade, cycle creation |
| 401 Unauthorized | Authentication required | Missing/invalid token |
| 403 Permission Denied | Insufficient permissions | Access denied to resource |
| 404 Not Found | Resource not found | Invalid ID |
| 409 Conflict | Concurrent modification or data inconsistency | Version conflict, data corruption |
| 422 Unprocessable | Validation failed | Invalid field values |
| 423 Locked | Resource temporarily locked | File being exported |
| 429 Too Many Requests | Rate limit exceeded | Throttling |
| 500 Internal Error | Server error | Unexpected failure |
| 503 Service Unavailable | Service temporarily down | Maintenance mode |
| 504 Timeout | Request timed out | Long-running operation |
## Error Response Format
All error responses follow the `SerializedDataApiError` structure (transmitted via IPC):
```typescript
interface SerializedDataApiError {
code: ErrorCode | string // ErrorCode enum value (e.g., 'NOT_FOUND')
message: string // Human-readable error message
status: number // HTTP status code
details?: Record<string, unknown> // Additional context (e.g., field errors)
requestContext?: { // Request context for debugging
requestId: string
path: string
method: HttpMethod
timestamp?: number
}
// Note: stack trace is NOT transmitted via IPC - rely on Main process logs
}
```
**Examples:**
```typescript
// 404 Not Found
{
code: 'NOT_FOUND',
message: "Topic with id 'abc123' not found",
status: 404,
details: { resource: 'Topic', id: 'abc123' },
requestContext: { requestId: 'req_123', path: '/topics/abc123', method: 'GET' }
}
// 422 Validation Error
{
code: 'VALIDATION_ERROR',
message: 'Request validation failed',
status: 422,
details: {
fieldErrors: {
name: ['Name is required', 'Name must be at least 3 characters'],
email: ['Invalid email format']
}
}
}
// 504 Timeout
{
code: 'TIMEOUT',
message: 'Request timeout: fetch topics (3000ms)',
status: 504,
details: { operation: 'fetch topics', timeoutMs: 3000 }
}
// 400 Invalid Operation
{
code: 'INVALID_OPERATION',
message: 'Invalid operation: delete root message - cascade=true required',
status: 400,
details: { operation: 'delete root message', reason: 'cascade=true required' }
}
```
Use `DataApiErrorFactory` utilities to create consistent errors:
```typescript
import { DataApiErrorFactory, DataApiError } from '@shared/data/api'
// Using factory methods (recommended)
throw DataApiErrorFactory.notFound('Topic', id)
throw DataApiErrorFactory.validation({ name: ['Required'] })
throw DataApiErrorFactory.database(error, 'insert topic')
throw DataApiErrorFactory.timeout('fetch topics', 3000)
throw DataApiErrorFactory.dataInconsistent('Topic', 'parent reference broken')
throw DataApiErrorFactory.invalidOperation('delete root message', 'cascade=true required')
// Check if error is retryable
if (error instanceof DataApiError && error.isRetryable) {
await retry(operation)
}
```
## Naming Conventions Summary
| Element | Case | Example |
|---------|------|---------|
| Paths | kebab-case, plural | `/user-settings`, `/topics` |
| Path params | camelCase | `:topicId`, `:messageId` |
| Query params | camelCase | `orderBy`, `pageSize` |
| Body fields | camelCase | `createdAt`, `userName` |
| Error codes | SCREAMING_SNAKE | `NOT_FOUND`, `VALIDATION_ERROR` |

View File

@ -0,0 +1,338 @@
# Data API Type System
This directory contains the type definitions and utilities for Cherry Studio's Data API system, which provides type-safe IPC communication between renderer and main processes.
## Directory Structure
```
packages/shared/data/api/
├── index.ts # Barrel export for infrastructure types
├── apiTypes.ts # Core request/response types and API utilities
├── apiPaths.ts # Path template literal type utilities
├── apiErrors.ts # Error handling: ErrorCode, DataApiError class, factory
└── schemas/
├── index.ts # Schema composition (merges all domain schemas)
└── test.ts # Test API schema and DTOs
```
## File Responsibilities
| File | Purpose |
|------|---------|
| `apiTypes.ts` | Core types (`DataRequest`, `DataResponse`, `ApiClient`) and schema utilities |
| `apiPaths.ts` | Template literal types for path resolution (`/items/:id` → `/items/${string}`) |
| `apiErrors.ts` | `ErrorCode` enum, `DataApiError` class, `DataApiErrorFactory`, retryability config |
| `index.ts` | Unified export of infrastructure types (not domain DTOs) |
| `schemas/index.ts` | Composes all domain schemas into `ApiSchemas` using intersection types |
| `schemas/*.ts` | Domain-specific API definitions and DTOs |
## Import Conventions
### Infrastructure Types (via barrel export)
Use the barrel export for common API infrastructure:
```typescript
import type {
DataRequest,
DataResponse,
ApiClient,
// Pagination types
OffsetPaginationParams,
OffsetPaginationResponse,
CursorPaginationParams,
CursorPaginationResponse,
PaginationResponse,
// Query parameter types
SortParams,
SearchParams
} from '@shared/data/api'
import {
ErrorCode,
DataApiError,
DataApiErrorFactory,
isDataApiError,
toDataApiError,
// Pagination type guards
isOffsetPaginationResponse,
isCursorPaginationResponse
} from '@shared/data/api'
```
### Domain DTOs (directly from schema files)
Import domain-specific types directly from their schema files:
```typescript
// Topic domain
import type { Topic, CreateTopicDto, UpdateTopicDto } from '@shared/data/api/schemas/topic'
// Message domain
import type { Message, CreateMessageDto } from '@shared/data/api/schemas/message'
// Test domain (development)
import type { TestItem, CreateTestItemDto } from '@shared/data/api/schemas/test'
```
## Pagination Types
The API system supports two pagination modes with composable query parameters.
### Request Parameters
| Type | Fields | Use Case |
|------|--------|----------|
| `OffsetPaginationParams` | `page?`, `limit?` | Traditional page-based navigation |
| `CursorPaginationParams` | `cursor?`, `limit?` | Infinite scroll, real-time feeds |
| `SortParams` | `sortBy?`, `sortOrder?` | Sorting (combine as needed) |
| `SearchParams` | `search?` | Text search (combine as needed) |
### Cursor Semantics
The `cursor` in `CursorPaginationParams` marks an **exclusive boundary** - the cursor item itself is never included in the response.
**Common patterns:**
| Pattern | Use Case | Behavior |
|---------|----------|----------|
| "after cursor" | Forward pagination, new items | Returns items AFTER cursor |
| "before cursor" | Backward/historical loading | Returns items BEFORE cursor |
The specific semantic depends on the API endpoint. For example:
- `GET /topics/:id/messages` uses "before cursor" for loading historical messages
- Other endpoints may use "after cursor" for forward pagination
**Example: Loading historical messages**
```typescript
// First request - get most recent messages
const res1 = await api.get('/topics/123/messages', { query: { limit: 20 } })
// res1: { items: [msg80...msg99], nextCursor: 'msg80-id', activeNodeId: '...' }
// Load more - get older messages before the cursor
const res2 = await api.get('/topics/123/messages', {
query: { cursor: res1.nextCursor, limit: 20 }
})
// res2: { items: [msg60...msg79], nextCursor: 'msg60-id', activeNodeId: '...' }
// Note: msg80 is NOT in res2 (cursor is exclusive)
```
### Response Types
| Type | Fields | Description |
|------|--------|-------------|
| `OffsetPaginationResponse<T>` | `items`, `total`, `page` | Page-based results |
| `CursorPaginationResponse<T>` | `items`, `nextCursor?` | Cursor-based results |
| `PaginationResponse<T>` | Union of both | When either mode is acceptable |
### Usage Examples
```typescript
// Offset pagination with sort and search
query?: OffsetPaginationParams & SortParams & SearchParams & {
type?: string
}
response: OffsetPaginationResponse<Item>
// Cursor pagination for infinite scroll
query?: CursorPaginationParams & {
userId: string
}
response: CursorPaginationResponse<Message>
```
### Client-side Calculations
For `OffsetPaginationResponse`, clients can calculate:
```typescript
const pageCount = Math.ceil(total / limit)
const hasNext = page * limit < total
const hasPrev = page > 1
```
For `CursorPaginationResponse`:
```typescript
const hasNext = nextCursor !== undefined
```
## Adding a New Domain Schema
1. Create the schema file (e.g., `schemas/topic.ts`):
```typescript
import type {
OffsetPaginationParams,
OffsetPaginationResponse,
SearchParams,
SortParams
} from '../apiTypes'
// Domain models
export interface Topic {
id: string
name: string
createdAt: string
}
export interface CreateTopicDto {
name: string
}
// API Schema - validation happens via AssertValidSchemas in index.ts
export interface TopicSchemas {
'/topics': {
GET: {
query?: OffsetPaginationParams & SortParams & SearchParams
response: OffsetPaginationResponse<Topic> // response is required
}
POST: {
body: CreateTopicDto
response: Topic
}
}
'/topics/:id': {
GET: {
params: { id: string }
response: Topic
}
}
}
```
**Validation**: Schemas are validated at composition level via `AssertValidSchemas` in `schemas/index.ts`:
- Ensures only valid HTTP methods (GET, POST, PUT, DELETE, PATCH)
- Requires `response` field for each endpoint
- Invalid schemas cause TypeScript errors at the composition point
> **Design Guidelines**: Before creating new schemas, review the [API Design Guidelines](./api-design-guidelines.md) for path naming, HTTP methods, and error handling conventions.
2. Register in `schemas/index.ts`:
```typescript
import type { TopicSchemas } from './topic'
// AssertValidSchemas provides fallback validation even if ValidateSchema is forgotten
export type ApiSchemas = AssertValidSchemas<TestSchemas & TopicSchemas>
```
3. Implement handlers in `src/main/data/api/handlers/`
## Type Safety Features
### Path Resolution
The system uses template literal types to map concrete paths to schema paths:
```typescript
// Concrete path '/topics/abc123' maps to schema path '/topics/:id'
api.get('/topics/abc123') // TypeScript knows this returns Topic
```
### Exhaustive Handler Checking
`ApiImplementation` type ensures all schema endpoints have handlers:
```typescript
// TypeScript will error if any endpoint is missing
const handlers: ApiImplementation = {
'/topics': {
GET: async () => { /* ... */ },
POST: async ({ body }) => { /* ... */ }
}
// Missing '/topics/:id' would cause compile error
}
```
### Type-Safe Client
`ApiClient` provides fully typed methods:
```typescript
const topic = await api.get('/topics/123') // Returns Topic
const topics = await api.get('/topics', {
query: { page: 1, limit: 20, search: 'hello' }
}) // Returns OffsetPaginationResponse<Topic>
await api.post('/topics', { body: { name: 'New' } }) // Body is typed as CreateTopicDto
```
## Error Handling
The error system provides type-safe error handling with automatic retryability detection:
```typescript
import {
DataApiError,
DataApiErrorFactory,
ErrorCode,
isDataApiError,
toDataApiError
} from '@shared/data/api'
// Create errors using the factory (recommended)
throw DataApiErrorFactory.notFound('Topic', id)
throw DataApiErrorFactory.validation({ name: ['Name is required'] })
throw DataApiErrorFactory.timeout('fetch topics', 3000)
throw DataApiErrorFactory.database(originalError, 'insert topic')
// Or create directly with the class
throw new DataApiError(
ErrorCode.NOT_FOUND,
'Topic not found',
404,
{ resource: 'Topic', id: 'abc123' }
)
// Check if error is retryable (for automatic retry logic)
if (error instanceof DataApiError && error.isRetryable) {
await retry(operation)
}
// Check error type
if (error instanceof DataApiError) {
if (error.isClientError) {
// 4xx - issue with the request
} else if (error.isServerError) {
// 5xx - server-side issue
}
}
// Convert any error to DataApiError
const apiError = toDataApiError(unknownError, 'context')
// Serialize for IPC (Main → Renderer)
const serialized = apiError.toJSON()
// Reconstruct from IPC response (Renderer)
const reconstructed = DataApiError.fromJSON(response.error)
```
### Retryable Error Codes
The following errors are automatically considered retryable:
- `SERVICE_UNAVAILABLE` (503)
- `TIMEOUT` (504)
- `RATE_LIMIT_EXCEEDED` (429)
- `DATABASE_ERROR` (500)
- `INTERNAL_SERVER_ERROR` (500)
- `RESOURCE_LOCKED` (423)
## Architecture Overview
```
Renderer Main
────────────────────────────────────────────────────
DataApiService ──IPC──► IpcAdapter ──► ApiServer
│ │
│ ▼
ApiClient MiddlewareEngine
(typed) │
Handlers
(typed)
```
- **Renderer**: Uses `DataApiService` with type-safe `ApiClient` interface
- **IPC**: Requests serialized via `IpcAdapter`
- **Main**: `ApiServer` routes to handlers through `MiddlewareEngine`
- **Type Safety**: End-to-end types from client call to handler implementation

View File

@ -0,0 +1,142 @@
# Cache System Overview
The Cache system provides a three-tier caching architecture for temporary and regenerable data across the Cherry Studio application.
## Purpose
CacheService handles data that:
- Can be **regenerated or lost without user impact**
- Requires no backup or cross-device synchronization
- Has lifecycle tied to component, window, or app session
## Three-Tier Architecture
| Tier | Scope | Persistence | Use Case |
|------|-------|-------------|----------|
| **Memory Cache** | Component-level | Lost on app restart | API responses, computed results |
| **Shared Cache** | Cross-window | Lost on app restart | Window state, cross-window coordination |
| **Persist Cache** | Cross-window + localStorage | Survives app restarts | Recent items, non-critical preferences |
### Memory Cache
- Fastest access, in-process memory
- Isolated per renderer process
- Best for: expensive computations, API response caching
### Shared Cache
- Synchronized bidirectionally between Main and all Renderer windows via IPC
- Main process maintains authoritative copy and provides initialization sync for new windows
- New windows fetch complete shared cache state from Main on startup
- Best for: window layouts, shared UI state
### Persist Cache
- Backed by localStorage in renderer
- Main process maintains authoritative copy
- Best for: recent files, search history, non-critical state
## Key Features
### TTL (Time To Live) Support
```typescript
// Cache with 30-second expiration
cacheService.set('temp.calculation', result, 30000)
```
### Hook Reference Tracking
- Prevents deletion of cache entries while React hooks are subscribed
- Automatic cleanup when components unmount
### Cross-Window Synchronization
- Shared and Persist caches sync across all windows
- Uses IPC broadcast for real-time updates
- Main process resolves conflicts
### Type Safety
- **Fixed keys**: Schema-based keys for compile-time checking (e.g., `'app.user.avatar'`)
- **Template keys**: Dynamic patterns with automatic type inference (e.g., `'scroll.position.${id}'` matches `'scroll.position.topic123'`)
- **Casual methods**: For completely dynamic keys with manual typing (blocked from using schema-defined keys)
Note: Template keys follow the same dot-separated naming pattern as fixed keys. When `${xxx}` is treated as a literal string, the key must match the format: `xxx.yyy.zzz_www`
## Data Categories
### Performance Cache (Memory tier)
- Computed results from expensive operations
- API response caching
- Parsed/transformed data
### UI State Cache (Shared tier)
- Sidebar collapsed state
- Panel dimensions
- Scroll positions
### Non-Critical Persistence (Persist tier)
- Recently used items
- Search history
- User-customized but regenerable data
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ Renderer Process │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ useCache │ │useSharedCache│ │usePersistCache│ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ CacheService │ │
│ │ (Renderer) │ │
│ └──────────┬──────────┘ │
└─────────────────────────┼────────────────────────────────────┘
│ IPC (shared/persist only)
┌─────────────────────────┼────────────────────────────────────┐
│ Main Process ▼ │
│ ┌─────────────────────┐ │
│ │ CacheService │ │
│ │ (Main) │ │
│ └─────────────────────┘ │
│ - Source of truth for shared/persist │
│ - Broadcasts updates to all windows │
└──────────────────────────────────────────────────────────────┘
```
## Main vs Renderer Responsibilities
### Main Process CacheService
- Manages internal cache for Main process services
- Maintains authoritative SharedCache with type-safe access (`getShared`, `setShared`, `hasShared`, `deleteShared`)
- Provides `getAllShared()` for new window initialization sync
- Handles IPC requests from renderers and broadcasts updates to all windows
- Manages TTL expiration using absolute timestamps (`expireAt`) for precise cross-window sync
### Renderer Process CacheService
- Manages local memory cache and SharedCache local copy
- Syncs SharedCache from Main on window initialization (async, non-blocking)
- Provides ready state tracking via `isSharedCacheReady()` and `onSharedCacheReady()`
- Broadcasts cache updates to Main for cross-window sync
- Handles hook subscriptions and updates
- Local TTL management for memory cache
## Usage Summary
For detailed code examples and API usage, see [Cache Usage Guide](./cache-usage.md).
### Key Types
| Type | Example Schema | Example Usage | Type Inference |
|------|----------------|---------------|----------------|
| Fixed key | `'app.user.avatar': string` | `get('app.user.avatar')` | Automatic |
| Template key | `'scroll.position.${id}': number` | `get('scroll.position.topic123')` | Automatic |
| Casual key | N/A | `getCasual<T>('my.custom.key')` | Manual |
### API Reference
| Method | Tier | Key Type |
|--------|------|----------|
| `useCache` / `get` / `set` | Memory | Fixed + Template keys |
| `getCasual` / `setCasual` | Memory | Dynamic keys only (schema keys blocked) |
| `useSharedCache` / `getShared` / `setShared` | Shared | Fixed keys only |
| `getSharedCasual` / `setSharedCasual` | Shared | Dynamic keys only (schema keys blocked) |
| `usePersistCache` / `getPersist` / `setPersist` | Persist | Fixed keys only |

View File

@ -0,0 +1,424 @@
# Cache Usage Guide
This guide covers how to use the Cache system in React components and services.
## React Hooks
### useCache (Memory Cache)
Memory cache is lost on app restart. Best for temporary computed results.
```typescript
import { useCache } from '@data/hooks/useCache'
// Basic usage with default value
const [counter, setCounter] = useCache('ui.counter', 0)
// Update the value
setCounter(counter + 1)
// With TTL (30 seconds)
const [searchResults, setSearchResults] = useCache('search.results', [], { ttl: 30000 })
```
### useSharedCache (Cross-Window Cache)
Shared cache syncs across all windows, lost on app restart.
```typescript
import { useSharedCache } from '@data/hooks/useCache'
// Cross-window state
const [layout, setLayout] = useSharedCache('window.layout', defaultLayout)
// Sidebar state shared between windows
const [sidebarCollapsed, setSidebarCollapsed] = useSharedCache('ui.sidebar.collapsed', false)
```
### usePersistCache (Persistent Cache)
Persist cache survives app restarts via localStorage.
```typescript
import { usePersistCache } from '@data/hooks/useCache'
// Recent files list (survives restart)
const [recentFiles, setRecentFiles] = usePersistCache('app.recent_files', [])
// Search history
const [searchHistory, setSearchHistory] = usePersistCache('search.history', [])
```
## CacheService Direct Usage
For non-React code or more control, use CacheService directly.
### Memory Cache
```typescript
import { cacheService } from '@data/CacheService'
// Type-safe (schema key)
cacheService.set('temp.calculation', result)
const result = cacheService.get('temp.calculation')
// With TTL (30 seconds)
cacheService.set('temp.calculation', result, 30000)
// Casual (dynamic key, manual type)
cacheService.setCasual<TopicCache>(`topic:${id}`, topicData)
const topic = cacheService.getCasual<TopicCache>(`topic:${id}`)
// Check existence
if (cacheService.has('temp.calculation')) {
// ...
}
// Delete
cacheService.delete('temp.calculation')
cacheService.deleteCasual(`topic:${id}`)
```
### Shared Cache
```typescript
// Type-safe (schema key)
cacheService.setShared('window.layout', layoutConfig)
const layout = cacheService.getShared('window.layout')
// Casual (dynamic key)
cacheService.setSharedCasual<WindowState>(`window:${windowId}`, state)
const state = cacheService.getSharedCasual<WindowState>(`window:${windowId}`)
// Delete
cacheService.deleteShared('window.layout')
cacheService.deleteSharedCasual(`window:${windowId}`)
```
### Persist Cache
```typescript
// Schema keys only (no Casual methods for persist)
cacheService.setPersist('app.recent_files', recentFiles)
const files = cacheService.getPersist('app.recent_files')
// Delete
cacheService.deletePersist('app.recent_files')
```
## Main Process Usage
Main process CacheService provides SharedCache for cross-window state management.
### SharedCache in Main Process
```typescript
import { cacheService } from '@main/data/CacheService'
// Type-safe (schema key) - matches Renderer's type system
cacheService.setShared('window.layout', layoutConfig)
const layout = cacheService.getShared('window.layout')
// With TTL (30 seconds)
cacheService.setShared('temp.state', state, 30000)
// Check existence
if (cacheService.hasShared('window.layout')) {
// ...
}
// Delete
cacheService.deleteShared('window.layout')
```
**Note**: Main CacheService does NOT support Casual methods (`getSharedCasual`, etc.). Only schema-based type-safe access is available in Main process.
### Sync Strategy
- **Renderer → Main**: When Renderer calls `setShared()`, it broadcasts to Main via IPC. Main updates its SharedCache and relays to other windows.
- **Main → Renderer**: When Main calls `setShared()`, it broadcasts to all Renderer windows.
- **New Window Initialization**: New windows fetch complete SharedCache state from Main via `getAllShared()`. Uses Main-priority override strategy for conflicts.
## Type-Safe vs Casual Methods
### Type-Safe Methods
- Use predefined keys from cache schema
- Full auto-completion and type inference
- Compile-time key validation
```typescript
// Key 'ui.counter' must exist in schema
const [counter, setCounter] = useCache('ui.counter', 0)
```
### Casual Methods
- Use dynamically constructed keys
- Require manual type specification via generics
- No compile-time key validation
- **Cannot use keys that match schema patterns** (including template keys)
```typescript
// Dynamic key, must specify type
const topic = cacheService.getCasual<TopicCache>(`my.custom.key`)
// Compile error: cannot use schema keys with Casual methods
cacheService.getCasual('app.user.avatar') // Error: matches fixed key
cacheService.getCasual('scroll.position.topic123') // Error: matches template key
```
### Template Keys
Template keys provide type-safe caching for dynamic key patterns. Define a template in the schema using `${variable}` syntax, and TypeScript will automatically match and infer types for concrete keys.
**Important**: Template keys follow the same dot-separated naming pattern as fixed keys. When `${xxx}` is treated as a literal string, the key must match the format: `xxx.yyy.zzz_www`
#### Defining Template Keys
```typescript
// packages/shared/data/cache/cacheSchemas.ts
export type UseCacheSchema = {
// Fixed key
'app.user.avatar': string
// Template keys - use ${variable} for dynamic segments
// Must follow dot-separated pattern like fixed keys
'scroll.position.${topicId}': number
'entity.cache.${type}_${id}': EntityData
}
// Default values for templates (shared by all instances)
export const DefaultUseCache: UseCacheSchema = {
'app.user.avatar': '',
'scroll.position.${topicId}': 0,
'entity.cache.${type}_${id}': { loaded: false }
}
```
#### Using Template Keys
```typescript
// TypeScript infers the value type from schema
const [scrollPos, setScrollPos] = useCache('scroll.position.topic123')
// scrollPos is inferred as `number`
const [entity, setEntity] = useCache('entity.cache.user_456')
// entity is inferred as `EntityData`
// Direct CacheService usage
cacheService.set('scroll.position.mytopic', 150) // OK: value must be number
cacheService.set('scroll.position.mytopic', 'hi') // Error: type mismatch
```
#### Template Key Benefits
| Feature | Fixed Keys | Template Keys | Casual Methods |
|---------|-----------|---------------|----------------|
| Type inference | ✅ Automatic | ✅ Automatic | ❌ Manual |
| Auto-completion | ✅ Full | ✅ Partial (prefix) | ❌ None |
| Compile-time validation | ✅ Yes | ✅ Yes | ❌ No |
| Dynamic IDs | ❌ No | ✅ Yes | ✅ Yes |
| Default values | ✅ Yes | ✅ Shared per template | ❌ No |
### When to Use Which
| Scenario | Method | Example |
|----------|--------|---------|
| Fixed cache keys | Type-safe | `useCache('ui.counter')` |
| Dynamic keys with known pattern | Template key | `useCache('scroll.position.topic123')` |
| Entity caching by ID | Template key | `get('entity.cache.user_456')` |
| Completely dynamic keys | Casual | `getCasual<T>(\`custom.dynamic.${x}\`)` |
| UI state | Type-safe | `useSharedCache('window.layout')` |
## Common Patterns
### Caching Expensive Computations
```typescript
function useExpensiveData(input: string) {
const [cached, setCached] = useCache(`computed:${input}`, null)
useEffect(() => {
if (cached === null) {
const result = expensiveComputation(input)
setCached(result)
}
}, [input, cached, setCached])
return cached
}
```
### Cross-Window Coordination
```typescript
// Window A: Update shared state
const [activeFile, setActiveFile] = useSharedCache('editor.activeFile', null)
setActiveFile(selectedFile)
// Window B: Reacts to change automatically
const [activeFile] = useSharedCache('editor.activeFile', null)
// activeFile updates when Window A changes it
```
### Recent Items with Limit
```typescript
const [recentItems, setRecentItems] = usePersistCache('app.recentItems', [])
const addRecentItem = (item: Item) => {
setRecentItems(prev => {
const filtered = prev.filter(i => i.id !== item.id)
return [item, ...filtered].slice(0, 10) // Keep last 10
})
}
```
### Cache with Expiration Check
```typescript
interface CachedData<T> {
data: T
timestamp: number
}
function useCachedWithExpiry<T>(key: string, fetcher: () => Promise<T>, maxAge: number) {
const [cached, setCached] = useCache<CachedData<T> | null>(key, null)
const [data, setData] = useState<T | null>(cached?.data ?? null)
useEffect(() => {
const isExpired = !cached || Date.now() - cached.timestamp > maxAge
if (isExpired) {
fetcher().then(result => {
setCached({ data: result, timestamp: Date.now() })
setData(result)
})
}
}, [key, maxAge])
return data
}
```
## Adding New Cache Keys
### Adding Fixed Keys
#### 1. Add to Cache Schema
```typescript
// packages/shared/data/cache/cacheSchemas.ts
export type UseCacheSchema = {
// Existing keys...
'myFeature.data': MyDataType
}
export const DefaultUseCache: UseCacheSchema = {
// Existing defaults...
'myFeature.data': { items: [], lastUpdated: 0 }
}
```
#### 2. Define Value Type (if complex)
```typescript
// packages/shared/data/cache/cacheValueTypes.ts
export interface MyDataType {
items: string[]
lastUpdated: number
}
```
#### 3. Use in Code
```typescript
// Now type-safe
const [data, setData] = useCache('myFeature.data')
```
### Adding Template Keys
#### 1. Add Template to Schema
```typescript
// packages/shared/data/cache/cacheSchemas.ts
export type UseCacheSchema = {
// Existing keys...
// Template key with dynamic segment
'scroll.position.${topicId}': number
}
export const DefaultUseCache: UseCacheSchema = {
// Existing defaults...
// Default shared by all instances of this template
'scroll.position.${topicId}': 0
}
```
#### 2. Use in Code
```typescript
// TypeScript infers number from template pattern
const [scrollPos, setScrollPos] = useCache(`scroll.position.${topicId}`)
// Works with any string in the dynamic segment
const [pos1, setPos1] = useCache('scroll.position.topic123')
const [pos2, setPos2] = useCache('scroll.position.conversationabc')
```
### Key Naming Convention
All keys (fixed and template) must follow the same naming convention:
- **Format**: `namespace.sub.key_name` (template `${xxx}` treated as a literal string segment)
- **Rules**:
- Start with lowercase letter
- Use lowercase letters, numbers, and underscores
- Separate segments with dots (`.`)
- Template placeholders `${xxx}` are treated as literal string segments
- **Examples**:
- ✅ `app.user.avatar`
- ✅ `scroll.position.${id}`
- ✅ `entity.cache.${type}_${id}`
- ❌ `scroll.position:${id}` (colon not allowed)
- ❌ `UserAvatar` (no dots)
- ❌ `App.User` (uppercase)
## Shared Cache Ready State
Renderer CacheService provides ready state tracking for SharedCache initialization sync.
```typescript
import { cacheService } from '@data/CacheService'
// Check if shared cache is ready
if (cacheService.isSharedCacheReady()) {
// SharedCache has been synced from Main
}
// Register callback when ready
const unsubscribe = cacheService.onSharedCacheReady(() => {
// Called immediately if already ready, or when sync completes
console.log('SharedCache ready!')
})
// Cleanup
unsubscribe()
```
**Behavior notes**:
- `getShared()` returns `undefined` before ready (expected behavior)
- `setShared()` works immediately and broadcasts to Main (Main updates its cache)
- Hooks like `useSharedCache` work normally - they set initial values and update when sync completes
- Main-priority override: when sync completes, Main's values override local values
## Best Practices
1. **Choose the right tier**: Memory for temp, Shared for cross-window, Persist for survival
2. **Use TTL for stale data**: Prevent serving outdated cached values
3. **Prefer type-safe keys**: Add to schema when possible
4. **Use template keys for patterns**: When you have a recurring pattern (e.g., caching by ID), define a template key instead of using casual methods
5. **Reserve casual for truly dynamic keys**: Only use casual methods when the key pattern is completely unknown at development time
6. **Clean up dynamic keys**: Remove casual cache entries when no longer needed
7. **Consider data size**: Persist cache uses localStorage (limited to ~5MB)
8. **Use absolute timestamps for sync**: CacheSyncMessage uses `expireAt` (absolute Unix timestamp) for precise cross-window TTL sync

View File

@ -0,0 +1,360 @@
# DataApi in Main Process
This guide covers how to implement API handlers, services, and repositories in the Main process.
## Architecture Layers
```
Handlers → Services → Repositories → Database
```
- **Handlers**: Thin layer, extract params, call service, transform response
- **Services**: Business logic, validation, transaction coordination
- **Repositories**: Data access (for complex domains)
- **Database**: Drizzle ORM + SQLite
## Implementing Handlers
### Location
`src/main/data/api/handlers/`
### Handler Responsibilities
- Extract parameters from request
- Delegate to business service
- Transform response for IPC
- **NO business logic here**
### Example Handler
```typescript
// handlers/topic.ts
import type { ApiImplementation } from '@shared/data/api'
import { TopicService } from '@data/services/TopicService'
export const topicHandlers: Partial<ApiImplementation> = {
'/topics': {
GET: async ({ query }) => {
const { page = 1, limit = 20 } = query ?? {}
return await TopicService.getInstance().list({ page, limit })
},
POST: async ({ body }) => {
return await TopicService.getInstance().create(body)
}
},
'/topics/:id': {
GET: async ({ params }) => {
return await TopicService.getInstance().getById(params.id)
},
PUT: async ({ params, body }) => {
return await TopicService.getInstance().replace(params.id, body)
},
PATCH: async ({ params, body }) => {
return await TopicService.getInstance().update(params.id, body)
},
DELETE: async ({ params }) => {
await TopicService.getInstance().delete(params.id)
}
}
}
```
### Register Handlers
```typescript
// handlers/index.ts
import { topicHandlers } from './topic'
import { messageHandlers } from './message'
export const allHandlers: ApiImplementation = {
...topicHandlers,
...messageHandlers
}
```
## Implementing Services
### Location
`src/main/data/services/`
### Service Responsibilities
- Business validation
- Transaction coordination
- Domain workflows
- Call repositories or direct Drizzle
### Example Service
```typescript
// services/TopicService.ts
import { DbService } from '@data/db/DbService'
import { TopicRepository } from '@data/repositories/TopicRepository'
import { DataApiErrorFactory } from '@shared/data/api'
export class TopicService {
private static instance: TopicService
private topicRepo: TopicRepository
private constructor() {
this.topicRepo = new TopicRepository()
}
static getInstance(): TopicService {
if (!this.instance) {
this.instance = new TopicService()
}
return this.instance
}
async list(options: { page: number; limit: number }) {
return await this.topicRepo.findAll(options)
}
async getById(id: string) {
const topic = await this.topicRepo.findById(id)
if (!topic) {
throw DataApiErrorFactory.notFound('Topic', id)
}
return topic
}
async create(data: CreateTopicDto) {
// Business validation
this.validateTopicData(data)
return await this.topicRepo.create(data)
}
async update(id: string, data: Partial<UpdateTopicDto>) {
const existing = await this.getById(id) // Throws if not found
return await this.topicRepo.update(id, data)
}
async delete(id: string) {
await this.getById(id) // Throws if not found
await this.topicRepo.delete(id)
}
private validateTopicData(data: CreateTopicDto) {
if (!data.name?.trim()) {
throw DataApiErrorFactory.validation({ name: ['Name is required'] })
}
}
}
```
### Service with Transaction
```typescript
async createTopicWithMessage(data: CreateTopicWithMessageDto) {
return await DbService.transaction(async (tx) => {
// Create topic
const topic = await this.topicRepo.create(data.topic, tx)
// Create initial message
const message = await this.messageRepo.create({
...data.message,
topicId: topic.id
}, tx)
return { topic, message }
})
}
```
## Implementing Repositories
### When to Use Repository Pattern
Use repositories for **complex domains**:
- ✅ Complex queries (joins, subqueries, aggregations)
- ✅ GB-scale data requiring pagination
- ✅ Complex transactions involving multiple tables
- ✅ Reusable data access patterns
- ✅ High testing requirements
### When to Use Direct Drizzle
Use direct Drizzle for **simple domains**:
- ✅ Simple CRUD operations
- ✅ Small datasets (< 100MB)
- ✅ Domain-specific queries with no reuse
- ✅ Fast development is priority
### Example Repository
```typescript
// repositories/TopicRepository.ts
import { eq, desc, sql } from 'drizzle-orm'
import { DbService } from '@data/db/DbService'
import { topicTable } from '@data/db/schemas/topic'
export class TopicRepository {
async findAll(options: { page: number; limit: number }) {
const { page, limit } = options
const offset = (page - 1) * limit
const [items, countResult] = await Promise.all([
DbService.db
.select()
.from(topicTable)
.orderBy(desc(topicTable.updatedAt))
.limit(limit)
.offset(offset),
DbService.db
.select({ count: sql<number>`count(*)` })
.from(topicTable)
])
return {
items,
total: countResult[0].count,
page,
limit
}
}
async findById(id: string, tx?: Transaction) {
const db = tx || DbService.db
const [topic] = await db
.select()
.from(topicTable)
.where(eq(topicTable.id, id))
.limit(1)
return topic ?? null
}
async create(data: CreateTopicDto, tx?: Transaction) {
const db = tx || DbService.db
const [topic] = await db
.insert(topicTable)
.values(data)
.returning()
return topic
}
async update(id: string, data: Partial<UpdateTopicDto>, tx?: Transaction) {
const db = tx || DbService.db
const [topic] = await db
.update(topicTable)
.set(data)
.where(eq(topicTable.id, id))
.returning()
return topic
}
async delete(id: string, tx?: Transaction) {
const db = tx || DbService.db
await db
.delete(topicTable)
.where(eq(topicTable.id, id))
}
}
```
### Example: Direct Drizzle in Service
For simple domains, skip the repository:
```typescript
// services/TagService.ts
import { eq } from 'drizzle-orm'
import { DbService } from '@data/db/DbService'
import { tagTable } from '@data/db/schemas/tag'
export class TagService {
async getAll() {
return await DbService.db.select().from(tagTable)
}
async create(name: string) {
const [tag] = await DbService.db
.insert(tagTable)
.values({ name })
.returning()
return tag
}
async delete(id: string) {
await DbService.db
.delete(tagTable)
.where(eq(tagTable.id, id))
}
}
```
## Error Handling
### Using DataApiErrorFactory
```typescript
import { DataApiErrorFactory } from '@shared/data/api'
// Not found
throw DataApiErrorFactory.notFound('Topic', id)
// Validation error
throw DataApiErrorFactory.validation({
name: ['Name is required', 'Name must be at least 3 characters'],
email: ['Invalid email format']
})
// Database error
try {
await db.insert(table).values(data)
} catch (error) {
throw DataApiErrorFactory.database(error, 'insert topic')
}
// Invalid operation
throw DataApiErrorFactory.invalidOperation(
'delete root message',
'cascade=true required'
)
// Conflict
throw DataApiErrorFactory.conflict('Topic name already exists')
// Timeout
throw DataApiErrorFactory.timeout('fetch topics', 3000)
```
## Adding New Endpoints
### Step-by-Step
1. **Define schema** in `packages/shared/data/api/schemas/`
```typescript
// schemas/topic.ts
export interface TopicSchemas {
'/topics': {
GET: { response: PaginatedResponse<Topic> }
POST: { body: CreateTopicDto; response: Topic }
}
}
```
2. **Register schema** in `schemas/index.ts`
```typescript
export type ApiSchemas = AssertValidSchemas<TopicSchemas & MessageSchemas>
```
3. **Create service** in `services/`
4. **Create repository** (if complex) in `repositories/`
5. **Implement handler** in `handlers/`
6. **Register handler** in `handlers/index.ts`
## Best Practices
1. **Keep handlers thin**: Only extract params and call services
2. **Put logic in services**: All business rules belong in services
3. **Use repositories selectively**: Simple CRUD doesn't need a repository
4. **Always use `.returning()`**: Get inserted/updated data without re-querying
5. **Support transactions**: Accept optional `tx` parameter in repositories
6. **Validate in services**: Business validation belongs in the service layer
7. **Use error factory**: Consistent error creation with `DataApiErrorFactory`

View File

@ -0,0 +1,314 @@
# DataApi in Renderer
This guide covers how to use the DataApi system in React components and the renderer process.
## React Hooks
### useQuery (GET Requests)
Fetch data with automatic caching and revalidation via SWR.
```typescript
import { useQuery } from '@data/hooks/useDataApi'
// Basic usage
const { data, isLoading, error } = useQuery('/topics')
// With query parameters
const { data: messages } = useQuery('/messages', {
query: { topicId: 'abc123', page: 1, limit: 20 }
})
// With path parameters (inferred from path)
const { data: topic } = useQuery('/topics/abc123')
// Conditional fetching
const { data } = useQuery('/topics', { enabled: !!topicId })
// With refresh callback
const { data, mutate, refetch } = useQuery('/topics')
// Refresh data
refetch() // or await mutate()
```
### useMutation (POST/PUT/PATCH/DELETE)
Perform data modifications with loading states.
```typescript
import { useMutation } from '@data/hooks/useDataApi'
// Create (POST)
const { trigger: createTopic, isLoading } = useMutation('POST', '/topics')
const newTopic = await createTopic({ body: { name: 'New Topic' } })
// Update (PUT - full replacement)
const { trigger: replaceTopic } = useMutation('PUT', '/topics/abc123')
await replaceTopic({ body: { name: 'Updated Name', description: '...' } })
// Partial Update (PATCH)
const { trigger: updateTopic } = useMutation('PATCH', '/topics/abc123')
await updateTopic({ body: { name: 'New Name' } })
// Delete
const { trigger: deleteTopic } = useMutation('DELETE', '/topics/abc123')
await deleteTopic()
// With auto-refresh of other queries
const { trigger } = useMutation('POST', '/topics', {
refresh: ['/topics'], // Refresh these keys on success
onSuccess: (data) => console.log('Created:', data)
})
```
### useInfiniteQuery (Cursor-based Infinite Scroll)
For infinite scroll UIs with "Load More" pattern.
```typescript
import { useInfiniteQuery } from '@data/hooks/useDataApi'
const { items, isLoading, hasNext, loadNext } = useInfiniteQuery('/messages', {
query: { topicId: 'abc123' },
limit: 20
})
// items: all loaded items flattened
// loadNext(): load next page
// hasNext: true if more pages available
```
### usePaginatedQuery (Offset-based Pagination)
For page-by-page navigation with previous/next controls.
```typescript
import { usePaginatedQuery } from '@data/hooks/useDataApi'
const { items, page, total, hasNext, hasPrev, nextPage, prevPage } =
usePaginatedQuery('/topics', { limit: 10 })
// items: current page items
// page/total: current page number and total count
// nextPage()/prevPage(): navigate between pages
```
### Choosing Pagination Hooks
| Use Case | Hook |
|----------|------|
| Infinite scroll, chat, feeds | `useInfiniteQuery` |
| Page navigation, tables | `usePaginatedQuery` |
| Manual control | `useQuery` |
## DataApiService Direct Usage
For non-React code or more control.
```typescript
import { dataApiService } from '@data/DataApiService'
// GET request
const topics = await dataApiService.get('/topics')
const topic = await dataApiService.get('/topics/abc123')
const messages = await dataApiService.get('/topics/abc123/messages', {
query: { page: 1, limit: 20 }
})
// POST request
const newTopic = await dataApiService.post('/topics', {
body: { name: 'New Topic' }
})
// PUT request (full replacement)
const updatedTopic = await dataApiService.put('/topics/abc123', {
body: { name: 'Updated', description: 'Full update' }
})
// PATCH request (partial update)
const patchedTopic = await dataApiService.patch('/topics/abc123', {
body: { name: 'Just update name' }
})
// DELETE request
await dataApiService.delete('/topics/abc123')
```
## Error Handling
### With Hooks
```typescript
function TopicList() {
const { data, isLoading, error } = useQuery('/topics')
if (isLoading) return <Loading />
if (error) {
if (error.code === ErrorCode.NOT_FOUND) {
return <NotFound />
}
return <Error message={error.message} />
}
return <List items={data} />
}
```
### With Try-Catch
```typescript
import { DataApiError, ErrorCode } from '@shared/data/api'
try {
await dataApiService.post('/topics', { body: data })
} catch (error) {
if (error instanceof DataApiError) {
switch (error.code) {
case ErrorCode.VALIDATION_ERROR:
// Handle validation errors
const fieldErrors = error.details?.fieldErrors
break
case ErrorCode.NOT_FOUND:
// Handle not found
break
case ErrorCode.CONFLICT:
// Handle conflict
break
default:
// Handle other errors
}
}
}
```
### Retryable Errors
```typescript
if (error instanceof DataApiError && error.isRetryable) {
// Safe to retry: SERVICE_UNAVAILABLE, TIMEOUT, etc.
await retry(operation)
}
```
## Common Patterns
### Create Form
```typescript
function CreateTopicForm() {
// Use refresh option to auto-refresh /topics after creation
const { trigger: createTopic, isLoading } = useMutation('POST', '/topics', {
refresh: ['/topics']
})
const handleSubmit = async (data: CreateTopicDto) => {
try {
await createTopic({ body: data })
toast.success('Topic created')
} catch (error) {
toast.error('Failed to create topic')
}
}
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
<button disabled={isLoading}>
{isLoading ? 'Creating...' : 'Create'}
</button>
</form>
)
}
```
### Optimistic Updates
```typescript
function TopicItem({ topic }: { topic: Topic }) {
// Use optimisticData for automatic optimistic updates with rollback
const { trigger: updateTopic } = useMutation('PATCH', `/topics/${topic.id}`, {
optimisticData: { ...topic, starred: !topic.starred }
})
const handleToggleStar = async () => {
try {
await updateTopic({ body: { starred: !topic.starred } })
} catch (error) {
// Rollback happens automatically when optimisticData is set
toast.error('Failed to update')
}
}
return (
<div>
<span>{topic.name}</span>
<button onClick={handleToggleStar}>
{topic.starred ? '★' : '☆'}
</button>
</div>
)
}
```
### Dependent Queries
```typescript
function MessageList({ topicId }: { topicId: string }) {
// First query: get topic
const { data: topic } = useQuery(`/topics/${topicId}`)
// Second query: depends on first (only runs when topic exists)
const { data: messages } = useQuery(
topic ? `/topics/${topicId}/messages` : null
)
if (!topic) return <Loading />
return (
<div>
<h1>{topic.name}</h1>
<MessageList messages={messages} />
</div>
)
}
```
### Polling for Updates
```typescript
function LiveTopicList() {
const { data } = useQuery('/topics', {
refreshInterval: 5000 // Poll every 5 seconds
})
return <List items={data} />
}
```
## Type Safety
The API is fully typed based on schema definitions:
```typescript
// Types are inferred from schema
const { data } = useQuery('/topics')
// data is typed as PaginatedResponse<Topic>
const { trigger } = useMutation('POST', '/topics')
// trigger expects { body: CreateTopicDto }
// returns Topic
// Path parameters are type-checked
const { data: topic } = useQuery('/topics/abc123')
// TypeScript knows this returns Topic
```
## Best Practices
1. **Use hooks for components**: `useQuery` and `useMutation` handle loading/error states
2. **Choose the right pagination hook**: Use `useInfiniteQuery` for infinite scroll, `usePaginatedQuery` for page navigation
3. **Handle loading states**: Always show feedback while data is loading
4. **Handle errors gracefully**: Provide meaningful error messages to users
5. **Revalidate after mutations**: Use `refresh` option to keep the UI in sync
6. **Use conditional fetching**: Set `enabled: false` to skip queries when dependencies aren't ready
7. **Batch related operations**: Consider using transactions for multiple updates

View File

@ -0,0 +1,158 @@
# DataApi System Overview
The DataApi system provides type-safe IPC communication for business data operations between the Renderer and Main processes.
## Purpose
DataApiService handles data that:
- Is **business data accumulated through user activity**
- Has **dedicated database schemas/tables**
- Users can **create, delete, modify records** without fixed limits
- Would be **severe and irreplaceable** if lost
- Can grow to **large volumes** (potentially GBs)
## Key Characteristics
### Type-Safe Communication
- End-to-end TypeScript types from client call to handler
- Path parameter inference from route definitions
- Compile-time validation of request/response shapes
### RESTful-Style API
- Familiar HTTP semantics (GET, POST, PUT, PATCH, DELETE)
- Resource-based URL patterns (`/topics/:id/messages`)
- Standard status codes and error responses
### On-Demand Data Access
- No automatic caching (fetch fresh data when needed)
- Explicit cache control via query options
- Supports large datasets with pagination
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────┐
│ Renderer Process │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ React Components │ │
│ │ - useQuery('/topics') │ │
│ │ - useMutation('/topics', 'POST') │ │
│ └──────────────────────────┬──────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ DataApiService (Renderer) │ │
│ │ - Type-safe ApiClient interface │ │
│ │ - Request serialization │ │
│ │ - Automatic retry with exponential backoff │ │
│ │ - Error handling and transformation │ │
│ └──────────────────────────┬──────────────────────────────┘ │
└──────────────────────────────┼───────────────────────────────────┘
│ IPC
┌──────────────────────────────┼───────────────────────────────────┐
│ Main Process ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ IpcAdapter │ │
│ │ - Receives IPC requests │ │
│ │ - Routes to ApiServer │ │
│ └──────────────────────────┬──────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ApiServer │ │
│ │ - Request routing by path and method │ │
│ │ - Middleware pipeline processing │ │
│ └──────────────────────────┬──────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Handlers (api/handlers/) │ │
│ │ - Thin layer: extract params, call service, transform │ │
│ │ - NO business logic here │ │
│ └──────────────────────────┬──────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Services (services/) │ │
│ │ - Business logic and validation │ │
│ │ - Transaction coordination │ │
│ │ - Domain workflows │ │
│ └──────────────────────────┬──────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────┴─────────────────────┐ │
│ ▼ ▼ │
│ ┌───────────────┐ ┌─────────────────────┐ │
│ │ Repositories │ │ Direct Drizzle │ │
│ │ (Complex) │ │ (Simple domains) │ │
│ │ - Query logic │ │ - Inline queries │ │
│ └───────┬───────┘ └──────────┬──────────┘ │
│ │ │ │
│ └────────────────────┬───────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SQLite Database (via Drizzle ORM) │ │
│ │ - topic, message, file tables │ │
│ │ - Full-text search indexes │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
## Four-Layer Architecture
### 1. API Layer (Handlers)
- **Location**: `src/main/data/api/handlers/`
- **Responsibility**: HTTP-like interface layer
- **Does**: Extract parameters, call services, transform responses
- **Does NOT**: Contain business logic
### 2. Business Logic Layer (Services)
- **Location**: `src/main/data/services/`
- **Responsibility**: Domain logic and workflows
- **Does**: Validation, transaction coordination, orchestration
- **Uses**: Repositories or direct Drizzle queries
### 3. Data Access Layer (Repositories)
- **Location**: `src/main/data/repositories/`
- **Responsibility**: Complex data operations
- **When to use**: Complex queries, large datasets, reusable patterns
- **Alternative**: Direct Drizzle for simple CRUD
### 4. Database Layer
- **Location**: `src/main/data/db/`
- **Technology**: SQLite + Drizzle ORM
- **Schemas**: `db/schemas/` directory
## Data Access Pattern Decision
### Use Repository Pattern When:
- ✅ Complex queries (joins, subqueries, aggregations)
- ✅ GB-scale data requiring optimization and pagination
- ✅ Complex transactions involving multiple tables
- ✅ Reusable data access patterns across services
- ✅ High testing requirements (mock data access)
### Use Direct Drizzle When:
- ✅ Simple CRUD operations
- ✅ Small datasets (< 100MB)
- ✅ Domain-specific queries with no reuse potential
- ✅ Fast development is priority
## Key Features
### Automatic Retry
- Exponential backoff for transient failures
- Configurable retry count and delays
- Skips retry for client errors (4xx)
### Error Handling
- Typed error codes (`ErrorCode` enum)
- `DataApiError` class with retryability detection
- Factory methods for consistent error creation
### Request Timeout
- Configurable per-request timeouts
- Automatic cancellation of stale requests
## Usage Summary
For detailed code examples, see:
- [DataApi in Renderer](./data-api-in-renderer.md) - Client-side usage
- [DataApi in Main](./data-api-in-main.md) - Server-side implementation
- [API Design Guidelines](./api-design-guidelines.md) - RESTful conventions
- [API Types](./api-types.md) - Type system details

View File

@ -0,0 +1,207 @@
# Database Schema Guidelines
## Naming Conventions
- **Table names**: Use **singular** form with snake_case (e.g., `topic`, `message`, `app_state`)
- **Export names**: Use `xxxTable` pattern (e.g., `topicTable`, `messageTable`)
- **Column names**: Drizzle auto-infers from property names, no need to specify explicitly
## Column Helpers
All helpers are exported from `./schemas/columnHelpers.ts`.
### Primary Keys
| Helper | UUID Version | Use Case |
|--------|--------------|----------|
| `uuidPrimaryKey()` | v4 (random) | General purpose tables |
| `uuidPrimaryKeyOrdered()` | v7 (time-ordered) | Large tables with time-based queries |
**Usage:**
```typescript
import { uuidPrimaryKey, uuidPrimaryKeyOrdered } from './columnHelpers'
// General purpose table
export const topicTable = sqliteTable('topic', {
id: uuidPrimaryKey(),
name: text(),
...
})
// Large table with time-ordered data
export const messageTable = sqliteTable('message', {
id: uuidPrimaryKeyOrdered(),
content: text(),
...
})
```
**Behavior:**
- ID is auto-generated if not provided during insert
- Can be manually specified for migration scenarios
- Use `.returning()` to get the generated ID after insert
### Timestamps
| Helper | Fields | Use Case |
|--------|--------|----------|
| `createUpdateTimestamps` | `createdAt`, `updatedAt` | Tables without soft delete |
| `createUpdateDeleteTimestamps` | `createdAt`, `updatedAt`, `deletedAt` | Tables with soft delete |
**Usage:**
```typescript
import { createUpdateTimestamps, createUpdateDeleteTimestamps } from './columnHelpers'
// Without soft delete
export const tagTable = sqliteTable('tag', {
id: uuidPrimaryKey(),
name: text(),
...createUpdateTimestamps
})
// With soft delete
export const topicTable = sqliteTable('topic', {
id: uuidPrimaryKey(),
name: text(),
...createUpdateDeleteTimestamps
})
```
**Behavior:**
- `createdAt`: Auto-set to `Date.now()` on insert
- `updatedAt`: Auto-set on insert, auto-updated on update
- `deletedAt`: `null` by default, set to timestamp for soft delete
## JSON Fields
For JSON column support, use `{ mode: 'json' }`:
```typescript
data: text({ mode: 'json' }).$type<MyDataType>()
```
Drizzle handles JSON serialization/deserialization automatically.
## Foreign Keys
### Basic Usage
```typescript
// SET NULL: preserve record when referenced record is deleted
groupId: text().references(() => groupTable.id, { onDelete: 'set null' })
// CASCADE: delete record when referenced record is deleted
topicId: text().references(() => topicTable.id, { onDelete: 'cascade' })
```
### Self-Referencing Foreign Keys
For self-referencing foreign keys (e.g., tree structures with parentId), **always use the `foreignKey` operator** in the table's third parameter:
```typescript
import { foreignKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const messageTable = sqliteTable(
'message',
{
id: uuidPrimaryKeyOrdered(),
parentId: text(), // Do NOT use .references() here
// ...other fields
},
(t) => [
// Use foreignKey operator for self-referencing
foreignKey({ columns: [t.parentId], foreignColumns: [t.id] }).onDelete('set null')
]
)
```
**Why this approach:**
- Avoids TypeScript circular reference issues (no need for `AnySQLiteColumn` type annotation)
- More explicit and readable
- Allows chaining `.onDelete()` / `.onUpdate()` actions
### Circular Foreign Key References
**Avoid circular foreign key references between tables.** For example:
```typescript
// ❌ BAD: Circular FK between tables
// tableA.currentItemId -> tableB.id
// tableB.ownerId -> tableA.id
```
If you encounter a scenario that seems to require circular references:
1. **Identify which relationship is "weaker"** - typically the one that can be null or is less critical for data integrity
2. **Remove the FK constraint from the weaker side** - let the application layer handle validation and consistency (this is known as "soft references" pattern)
3. **Document the application-layer constraint** in code comments
```typescript
// ✅ GOOD: Break the cycle by handling one side at application layer
export const topicTable = sqliteTable('topic', {
id: uuidPrimaryKey(),
// Application-managed reference (no FK constraint)
// Validated by TopicService.setCurrentMessage()
currentMessageId: text(),
})
export const messageTable = sqliteTable('message', {
id: uuidPrimaryKeyOrdered(),
// Database-enforced FK
topicId: text().references(() => topicTable.id, { onDelete: 'cascade' }),
})
```
**Why soft references for SQLite:**
- SQLite does not support `DEFERRABLE` constraints (unlike PostgreSQL/Oracle)
- Application-layer validation provides equivalent data integrity
- Simplifies insert/update operations without transaction ordering concerns
## Migrations
Generate migrations after schema changes:
```bash
yarn db:migrations:generate
```
## Field Generation Rules
The schema uses Drizzle's auto-generation features. Follow these rules:
### Auto-generated fields (NEVER set manually)
- `id`: Uses `$defaultFn()` with UUID v4/v7, auto-generated on insert
- `createdAt`: Uses `$defaultFn()` with `Date.now()`, auto-generated on insert
- `updatedAt`: Uses `$defaultFn()` and `$onUpdateFn()`, auto-updated on every update
### Using `.returning()` pattern
Always use `.returning()` to get inserted/updated data instead of re-querying:
```typescript
// Good: Use returning()
const [row] = await db.insert(table).values(data).returning()
return rowToEntity(row)
// Avoid: Re-query after insert (unnecessary database round-trip)
await db.insert(table).values({ id, ...data })
return this.getById(id)
```
### Soft delete support
The schema supports soft delete via `deletedAt` field (see `createUpdateDeleteTimestamps`).
Business logic can choose to use soft delete or hard delete based on requirements.
## Custom SQL
Drizzle cannot manage triggers and virtual tables (e.g., FTS5). These are defined in `customSql.ts` and run automatically after every migration.
**Why**: SQLite's `DROP TABLE` removes associated triggers. When Drizzle modifies a table schema, it drops and recreates the table, losing triggers in the process.
**Adding new custom SQL**: Define statements as `string[]` in the relevant schema file, then spread into `CUSTOM_SQL_STATEMENTS` in `customSql.ts`. All statements must use `IF NOT EXISTS` to be idempotent.

View File

@ -0,0 +1,144 @@
# Preference System Overview
The Preference system provides centralized management for user configuration and application settings with cross-window synchronization.
## Purpose
PreferenceService handles data that:
- Is a **user-modifiable setting that affects app behavior**
- Has a **fixed key structure** with stable value types
- Needs to **persist permanently** until explicitly changed
- Should **sync automatically** across all application windows
## Key Characteristics
### Fixed Key Structure
- Predefined keys in the schema (users modify values, not keys)
- Supports 158 configuration items
- Nested key paths supported (e.g., `app.theme.mode`)
### Atomic Values
- Each preference item represents one logical setting
- Values are typically: boolean, string, number, or simple array/object
- Changes are independent (updating one doesn't affect others)
### Cross-Window Synchronization
- Changes automatically broadcast to all windows
- Consistent state across main window, mini window, etc.
- Conflict resolution handled by Main process
## Update Strategies
### Optimistic Updates (Default)
```typescript
// UI updates immediately, then syncs to database
await preferenceService.set('app.theme.mode', 'dark')
```
- Best for: frequent, non-critical settings
- Behavior: Local state updates first, then persists
- Rollback: Automatic revert if persistence fails
### Pessimistic Updates
```typescript
// Waits for database confirmation before updating UI
await preferenceService.set('api.key', 'secret', { optimistic: false })
```
- Best for: critical settings (API keys, security options)
- Behavior: Persists first, then updates local state
- No rollback needed: UI only updates on success
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ Renderer Process │
│ ┌─────────────────────────────────────────────────┐ │
│ │ usePreference Hook │ │
│ │ - Subscribe to preference changes │ │
│ │ - Optimistic/pessimistic update support │ │
│ └──────────────────────┬──────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ PreferenceService (Renderer) │ │
│ │ - Local cache for fast reads │ │
│ │ - IPC proxy to Main process │ │
│ │ - Subscription management │ │
│ └──────────────────────┬──────────────────────────┘ │
└─────────────────────────┼────────────────────────────────────┘
│ IPC
┌─────────────────────────┼────────────────────────────────────┐
│ Main Process ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ PreferenceService (Main) │ │
│ │ - Full memory cache of all preferences │ │
│ │ - SQLite persistence via Drizzle ORM │ │
│ │ - Cross-window broadcast │ │
│ └──────────────────────┬──────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ SQLite Database (preference table) │ │
│ │ - scope + key structure │ │
│ │ - JSON value storage │ │
│ └─────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
## Main vs Renderer Responsibilities
### Main Process PreferenceService
- **Source of truth** for all preferences
- Full memory cache for fast access
- SQLite persistence via preference table
- Broadcasts changes to all renderer windows
- Handles batch operations and transactions
### Renderer Process PreferenceService
- Local cache for read performance
- Proxies write operations to Main
- Manages React hook subscriptions
- Handles optimistic update rollbacks
- Listens for cross-window updates
## Database Schema
Preferences are stored in the `preference` table:
```typescript
// Simplified schema
{
scope: string // e.g., 'default', 'user'
key: string // e.g., 'app.theme.mode'
value: json // The preference value
createdAt: number
updatedAt: number
}
```
## Preference Categories
### Application Settings
- Theme mode, language, font sizes
- Window behavior, startup options
### Feature Toggles
- Show/hide UI elements
- Enable/disable features
### User Customization
- Keyboard shortcuts
- Default values for operations
### Provider Configuration
- AI provider settings
- API endpoints and tokens
## Usage Summary
For detailed code examples and API usage, see [Preference Usage Guide](./preference-usage.md).
| Operation | Hook | Service Method |
|-----------|------|----------------|
| Read single | `usePreference(key)` | `preferenceService.get(key)` |
| Write single | `setPreference(value)` | `preferenceService.set(key, value)` |
| Read multiple | `usePreferences([...keys])` | `preferenceService.getMultiple([...keys])` |
| Write multiple | - | `preferenceService.setMultiple({...})` |

View File

@ -0,0 +1,260 @@
# Preference Usage Guide
This guide covers how to use the Preference system in React components and services.
## React Hooks
### usePreference (Single Preference)
```typescript
import { usePreference } from '@data/hooks/usePreference'
// Basic usage - optimistic updates (default)
const [theme, setTheme] = usePreference('app.theme.mode')
// Update the value
await setTheme('dark')
// With pessimistic updates (wait for confirmation)
const [apiKey, setApiKey] = usePreference('api.key', { optimistic: false })
```
### usePreferences (Multiple Preferences)
```typescript
import { usePreferences } from '@data/hooks/usePreference'
// Read multiple preferences at once
const { theme, language, fontSize } = usePreferences([
'app.theme.mode',
'app.language',
'chat.message.font_size'
])
```
## Update Strategies
### Optimistic Updates (Default)
UI updates immediately, then syncs to database. Automatic rollback on failure.
```typescript
const [theme, setTheme] = usePreference('app.theme.mode')
const handleThemeChange = async (newTheme: string) => {
try {
await setTheme(newTheme) // UI updates immediately
} catch (error) {
// UI automatically rolls back
console.error('Theme update failed:', error)
}
}
```
**Best for:**
- Frequent changes (theme, font size)
- Non-critical settings
- Better perceived performance
### Pessimistic Updates
Waits for database confirmation before updating UI.
```typescript
const [apiKey, setApiKey] = usePreference('api.key', { optimistic: false })
const handleApiKeyChange = async (newKey: string) => {
try {
await setApiKey(newKey) // Waits for DB confirmation
toast.success('API key saved')
} catch (error) {
toast.error('Failed to save API key')
}
}
```
**Best for:**
- Security-sensitive settings (API keys, passwords)
- Settings that affect external services
- When confirmation feedback is important
## PreferenceService Direct Usage
For non-React code or batch operations.
### Get Preferences
```typescript
import { preferenceService } from '@data/PreferenceService'
// Get single preference
const theme = await preferenceService.get('app.theme.mode')
// Get multiple preferences
const settings = await preferenceService.getMultiple([
'app.theme.mode',
'app.language'
])
// Returns: { 'app.theme.mode': 'dark', 'app.language': 'en' }
// Get with default value
const fontSize = await preferenceService.get('chat.message.font_size') ?? 14
```
### Set Preferences
```typescript
// Set single preference (optimistic by default)
await preferenceService.set('app.theme.mode', 'dark')
// Set with pessimistic update
await preferenceService.set('api.key', 'secret', { optimistic: false })
// Set multiple preferences at once
await preferenceService.setMultiple({
'app.theme.mode': 'dark',
'app.language': 'en',
'chat.message.font_size': 16
})
```
### Subscribe to Changes
```typescript
// Subscribe to preference changes (useful in services)
const unsubscribe = preferenceService.subscribe('app.theme.mode', (newValue) => {
console.log('Theme changed to:', newValue)
})
// Cleanup when done
unsubscribe()
```
## Common Patterns
### Settings Form
```typescript
function SettingsForm() {
const [theme, setTheme] = usePreference('app.theme.mode')
const [language, setLanguage] = usePreference('app.language')
const [fontSize, setFontSize] = usePreference('chat.message.font_size')
return (
<form>
<select value={theme} onChange={e => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
<select value={language} onChange={e => setLanguage(e.target.value)}>
<option value="en">English</option>
<option value="zh">中文</option>
</select>
<input
type="number"
value={fontSize}
onChange={e => setFontSize(Number(e.target.value))}
min={12}
max={24}
/>
</form>
)
}
```
### Feature Toggle
```typescript
function ChatMessage({ message }) {
const [showTimestamp] = usePreference('chat.display.show_timestamp')
return (
<div className="message">
<p>{message.content}</p>
{showTimestamp && <span className="timestamp">{message.createdAt}</span>}
</div>
)
}
```
### Conditional Rendering Based on Settings
```typescript
function App() {
const [theme] = usePreference('app.theme.mode')
const [sidebarPosition] = usePreference('app.sidebar.position')
return (
<div className={`app theme-${theme}`}>
{sidebarPosition === 'left' && <Sidebar />}
<MainContent />
{sidebarPosition === 'right' && <Sidebar />}
</div>
)
}
```
### Batch Settings Update
```typescript
async function resetToDefaults() {
await preferenceService.setMultiple({
'app.theme.mode': 'system',
'app.language': 'en',
'chat.message.font_size': 14,
'chat.display.show_timestamp': true
})
}
```
## Adding New Preference Keys
### 1. Add to Preference Schema
```typescript
// packages/shared/data/preference/preferenceSchemas.ts
export interface PreferenceSchema {
// Existing keys...
'myFeature.enabled': boolean
'myFeature.options': MyFeatureOptions
}
```
### 2. Set Default Value
```typescript
// Same file or separate defaults file
export const preferenceDefaults: Partial<PreferenceSchema> = {
// Existing defaults...
'myFeature.enabled': true,
'myFeature.options': { mode: 'auto', limit: 100 }
}
```
### 3. Use in Code
```typescript
// Now type-safe with auto-completion
const [enabled, setEnabled] = usePreference('myFeature.enabled')
```
## Best Practices
1. **Choose update strategy wisely**: Optimistic for UX, pessimistic for critical settings
2. **Batch related updates**: Use `setMultiple` when changing multiple related settings
3. **Provide sensible defaults**: All preferences should have default values
4. **Keep values atomic**: One preference = one logical setting
5. **Use consistent naming**: Follow `domain.feature.setting` pattern
## Preference vs Other Storage
| Scenario | Use |
|----------|-----|
| User theme preference | `usePreference('app.theme.mode')` |
| Window position | `usePersistCache` (can be lost without impact) |
| API key | `usePreference` with pessimistic updates |
| Search history | `usePersistCache` (nice to have) |
| Conversation history | `DataApiService` (business data) |

View File

@ -0,0 +1,72 @@
# Migration V2 (Main Process)
Architecture for the new one-shot migration from the legacy Dexie + Redux Persist stores into the SQLite schema. This module owns orchestration, data access helpers, migrator plugins, and IPC entry points used by the renderer migration window.
## Directory Layout
```
src/main/data/migration/v2/
├── core/ # Engine + shared context
├── migrators/ # Domain-specific migrators and mappings
├── utils/ # Data source readers (Redux, Dexie, streaming JSON)
├── window/ # IPC handlers + migration window manager
└── index.ts # Public exports for main process
```
## Core Contracts
- `core/MigrationEngine.ts` coordinates all migrators in order, surfaces progress to the UI, and marks status in `app_state.key = 'migration_v2_status'`. It will clear new-schema tables before running and abort on any validation failure.
- `core/MigrationContext.ts` builds the shared context passed to every migrator:
- `sources`: `ConfigManager` (ElectronStore), `ReduxStateReader` (parsed Redux Persist data), `DexieFileReader` (JSON exports)
- `db`: current SQLite connection
- `sharedData`: `Map` for passing cross-cutting info between migrators
- `logger`: `loggerService` scoped to migration
- `@shared/data/migration/v2/types` defines stages, results, and validation stats used across main and renderer.
## Migrators
- Base contract: extend `migrators/BaseMigrator.ts` and implement:
- `id`, `name`, `description`, `order` (lower runs first)
- `prepare(ctx)`: dry-run checks, counts, and staging data; return `PrepareResult`
- `execute(ctx)`: perform inserts/updates; manage your own transactions; report progress via `reportProgress`
- `validate(ctx)`: verify counts and integrity; return `ValidateResult` with stats (`sourceCount`, `targetCount`, `skippedCount`) and any `errors`
- Registration: list migrators (in order) in `migrators/index.ts` so the engine can sort and run them.
- Current migrators (see `migrators/README-<name>.md` for detailed documentation):
- `PreferencesMigrator` (implemented): maps ElectronStore + Redux settings to the `preference` table using `mappings/PreferencesMappings.ts`.
- `ChatMigrator` (implemented): migrates topics and messages from Dexie to SQLite. See [`README-ChatMigrator.md`](../../../src/main/data/migration/v2/migrators/README-ChatMigrator.md).
- `AssistantMigrator`, `KnowledgeMigrator` (placeholders): scaffolding and TODO notes for future tables.
- Conventions:
- All logging goes through `loggerService` with a migrator-specific context.
- Use `MigrationContext.sources` instead of accessing raw files/stores directly.
- Use `sharedData` to pass IDs or lookup tables between migrators (e.g., assistant -> chat references) instead of re-reading sources.
- Stream large Dexie exports (`JSONStreamReader`) and batch inserts to avoid memory spikes.
- Count validation is mandatory; engine will fail the run if `targetCount < sourceCount - skippedCount` or if `ValidateResult.errors` is non-empty.
- Keep migrations idempotent per run—engine clears target tables before it starts, but each migrator should tolerate retries within the same run.
## Utilities
- `utils/ReduxStateReader.ts`: safe accessor for categorized Redux Persist data with dot-path lookup.
- `utils/DexieFileReader.ts`: reads exported Dexie JSON tables; can stream large tables.
- `utils/JSONStreamReader.ts`: streaming reader with batching, counting, and sampling helpers for very large arrays.
## Window & IPC Integration
- `window/MigrationIpcHandler.ts` exposes IPC channels for the migration UI:
- Receives Redux data and Dexie export path, starts the engine, and streams progress back to renderer.
- Manages backup flow (dialogs via `BackupManager`) and retry/cancel/restart actions.
- `window/MigrationWindowManager.ts` creates the frameless migration window, handles lifecycle, and relaunch instructions after completion in production.
## Implementation Checklist for New Migrators
- [ ] Add mapping definitions (if needed) under `migrators/mappings/`.
- [ ] Implement `prepare/execute/validate` with explicit counts, batch inserts, and integrity checks.
- [ ] Wire progress updates through `reportProgress` so UI shows per-migrator progress.
- [ ] Register the migrator in `migrators/index.ts` with the correct `order`.
- [ ] Add any new target tables to `MigrationEngine.verifyAndClearNewTables` once those tables exist.
- [ ] Include detailed comments for maintainability (file-level, function-level, logic blocks).
- [ ] **Create/update `migrators/README-<MigratorName>.md`** with detailed documentation including:
- Data sources and target tables
- Key transformations
- Field mappings (source → target)
- Dropped fields and rationale
- Code quality notes

View File

@ -0,0 +1,129 @@
# Fuzzy Search for File List
This document describes the fuzzy search implementation for file listing in Cherry Studio.
## Overview
The fuzzy search feature allows users to find files by typing partial or approximate file names/paths. It uses a two-tier file filtering strategy (ripgrep glob pre-filtering with greedy substring fallback) combined with subsequence-based scoring for optimal performance and flexibility.
## Features
- **Ripgrep Glob Pre-filtering**: Primary filtering using glob patterns for fast native-level filtering
- **Greedy Substring Matching**: Fallback file filtering strategy when ripgrep glob pre-filtering returns no results
- **Subsequence-based Segment Scoring**: During scoring, path segments gain additional weight when query characters appear in order
- **Relevance Scoring**: Results are sorted by a relevance score derived from multiple factors
## Matching Strategies
### 1. Ripgrep Glob Pre-filtering (Primary)
The query is converted to a glob pattern for ripgrep to do initial filtering:
```
Query: "updater"
Glob: "*u*p*d*a*t*e*r*"
```
This leverages ripgrep's native performance for the initial file filtering.
### 2. Greedy Substring Matching (Fallback)
When the glob pre-filter returns no results, the system falls back to greedy substring matching. This allows more flexible matching:
```
Query: "updatercontroller"
File: "packages/update/src/node/updateController.ts"
Matching process:
1. Find "update" (longest match from start)
2. Remaining "rcontroller" → find "r" then "controller"
3. All parts matched → Success
```
## Scoring Algorithm
Results are ranked by a relevance score based on named constants defined in `FileStorage.ts`:
| Constant | Value | Description |
|----------|-------|-------------|
| `SCORE_FILENAME_STARTS` | 100 | Filename starts with query (highest priority) |
| `SCORE_FILENAME_CONTAINS` | 80 | Filename contains exact query substring |
| `SCORE_SEGMENT_MATCH` | 60 | Per path segment that matches query |
| `SCORE_WORD_BOUNDARY` | 20 | Query matches start of a word |
| `SCORE_CONSECUTIVE_CHAR` | 15 | Per consecutive character match |
| `PATH_LENGTH_PENALTY_FACTOR` | 4 | Logarithmic penalty for longer paths |
### Scoring Strategy
The scoring prioritizes:
1. **Filename matches** (highest): Files where the query appears in the filename are most relevant
2. **Path segment matches**: Multiple matching segments indicate stronger relevance
3. **Word boundaries**: Matching at word starts (e.g., "upd" matching "update") is preferred
4. **Consecutive matches**: Longer consecutive character sequences score higher
5. **Path length**: Shorter paths are preferred (logarithmic penalty prevents long paths from dominating)
### Example Scoring
For query `updater`:
| File | Score Factors |
|------|---------------|
| `RCUpdater.js` | Short path + filename contains "updater" |
| `updateController.ts` | Multiple segment matches |
| `UpdaterHelper.plist` | Long path penalty |
## Configuration
### DirectoryListOptions
```typescript
interface DirectoryListOptions {
recursive?: boolean // Default: true
maxDepth?: number // Default: 10
includeHidden?: boolean // Default: false
includeFiles?: boolean // Default: true
includeDirectories?: boolean // Default: true
maxEntries?: number // Default: 20
searchPattern?: string // Default: '.'
fuzzy?: boolean // Default: true
}
```
## Usage
```typescript
// Basic fuzzy search
const files = await window.api.file.listDirectory(dirPath, {
searchPattern: 'updater',
fuzzy: true,
maxEntries: 20
})
// Disable fuzzy search (exact glob matching)
const files = await window.api.file.listDirectory(dirPath, {
searchPattern: 'update',
fuzzy: false
})
```
## Performance Considerations
1. **Ripgrep Pre-filtering**: Most queries are handled by ripgrep's native glob matching, which is extremely fast
2. **Fallback Only When Needed**: Greedy substring matching (which loads all files) only runs when glob matching returns empty results
3. **Result Limiting**: Only top 20 results are returned by default
4. **Excluded Directories**: Common large directories are automatically excluded:
- `node_modules`
- `.git`
- `dist`, `build`
- `.next`, `.nuxt`
- `coverage`, `.cache`
## Implementation Details
The implementation is located in `src/main/services/FileStorage.ts`:
- `queryToGlobPattern()`: Converts query to ripgrep glob pattern
- `isFuzzyMatch()`: Subsequence matching algorithm
- `isGreedySubstringMatch()`: Greedy substring matching fallback
- `getFuzzyMatchScore()`: Calculates relevance score
- `listDirectoryWithRipgrep()`: Main search orchestration

View File

@ -34,7 +34,7 @@
</a>
</h1>
<p align="center">
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="https://cherry-ai.com">官方网站</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/zh-cn">文档</a> | <a href="./guides/development.md">开发</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">反馈</a><br>
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="https://cherry-ai.com">官方网站</a> | <a href="https://docs.cherry-ai.com">文档</a> | <a href="./guides/development.md">开发</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">反馈</a><br>
</p>
<!-- 题头徽章组合 -->
@ -281,7 +281,7 @@ https://docs.cherry-ai.com
# 📊 GitHub 统计
![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg 'Repobeats analytics image')
![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg "Repobeats analytics image")
# ⭐️ Star 记录

View File

@ -36,7 +36,7 @@ yarn install
### ENV
```bash
copy .env.example .env
cp .env.example .env
```
### Start

View File

@ -1,17 +1,17 @@
# 如何优雅地做好 i18n
## 使用i18n ally插件提升开发体验
## 使用 i18n ally 插件提升开发体验
i18n ally是一个强大的VSCode插件它能在开发阶段提供实时反馈帮助开发者更早发现文案缺失和错译问题。
i18n ally 是一个强大的 VSCode 插件,它能在开发阶段提供实时反馈,帮助开发者更早发现文案缺失和错译问题。
项目中已经配置好了插件设置,直接安装即可。
### 开发时优势
- **实时预览**:翻译文案会直接显示在编辑器中
- **错误检测**自动追踪标记出缺失的翻译或未使用的key
- **快速跳转**可通过key直接跳转到定义处Ctrl/Cmd + click)
- **自动补全**输入i18n key时提供自动补全建议
- **错误检测**:自动追踪标记出缺失的翻译或未使用的 key
- **快速跳转**:可通过 key 直接跳转到定义处Ctrl/Cmd + click)
- **自动补全**:输入 i18n key 时提供自动补全建议
### 效果展示
@ -23,9 +23,9 @@ i18n ally是一个强大的VSCode插件它能在开发阶段提供实时反
## i18n 约定
### **绝对避免使用flat格式**
### **绝对避免使用 flat 格式**
绝对避免使用flat格式如`"add.button.tip": "添加"`。应采用清晰的嵌套结构:
绝对避免使用 flat 格式,如`"add.button.tip": "添加"`。应采用清晰的嵌套结构:
```json
// 错误示例 - flat结构
@ -52,14 +52,14 @@ i18n ally是一个强大的VSCode插件它能在开发阶段提供实时反
#### 为什么要使用嵌套结构
1. **自然分组**:通过对象结构天然能将相关上下文的文案分到一个组别中
2. **插件要求**i18n ally 插件需要嵌套或flat格式其一的文件才能正常分析
2. **插件要求**i18n ally 插件需要嵌套或 flat 格式其一的文件才能正常分析
### **避免在`t()`中使用模板字符串**
**强烈建议避免使用模板字符串**进行动态插值。虽然模板字符串在JavaScript开发中非常方便但在国际化场景下会带来一系列问题。
**强烈建议避免使用模板字符串**进行动态插值。虽然模板字符串在 JavaScript 开发中非常方便,但在国际化场景下会带来一系列问题。
1. **插件无法跟踪**
i18n ally等工具无法解析模板字符串中的动态内容导致
i18n ally 等工具无法解析模板字符串中的动态内容,导致:
- 无法正确显示实时预览
- 无法检测翻译缺失
@ -67,11 +67,11 @@ i18n ally是一个强大的VSCode插件它能在开发阶段提供实时反
```javascript
// 不推荐 - 插件无法解析
const message = t(`fruits.${fruit}`)
const message = t(`fruits.${fruit}`);
```
2. **编辑器无法实时渲染**
在IDE中模板字符串会显示为原始代码而非最终翻译结果降低了开发体验。
IDE 中,模板字符串会显示为原始代码而非最终翻译结果,降低了开发体验。
3. **更难以维护**
由于插件无法跟踪这样的文案,编辑器中也无法渲染,开发者必须人工确认语言文件中是否存在相应的文案。
@ -85,36 +85,36 @@ i18n ally是一个强大的VSCode插件它能在开发阶段提供实时反
```ts
// src/renderer/src/i18n/label.ts
const themeModeKeyMap = {
dark: 'settings.theme.dark',
light: 'settings.theme.light',
system: 'settings.theme.system'
} as const
dark: "settings.theme.dark",
light: "settings.theme.light",
system: "settings.theme.system",
} as const;
export const getThemeModeLabel = (key: string): string => {
return themeModeKeyMap[key] ? t(themeModeKeyMap[key]) : key
}
return themeModeKeyMap[key] ? t(themeModeKeyMap[key]) : key;
};
```
通过避免模板字符串,可以获得更好的开发体验、更可靠的翻译检查以及更易维护的代码库。
## 自动化脚本
项目中有一系列脚本来自动化i18n相关任务
项目中有一系列脚本来自动化 i18n 相关任务:
### `check:i18n` - 检查i18n结构
### `i18n:check` - 检查 i18n 结构
此脚本会检查:
- 所有语言文件是否为嵌套结构
- 是否存在缺失的key
- 是否存在多余的key
- 是否存在缺失的 key
- 是否存在多余的 key
- 是否已经有序
```bash
yarn check:i18n
yarn i18n:check
```
### `sync:i18n` - 同步json结构与排序
### `i18n:sync` - 同步 json 结构与排序
此脚本以`zh-cn.json`文件为基准,将结构同步到其他语言文件,包括:
@ -123,14 +123,14 @@ yarn check:i18n
3. 自动排序
```bash
yarn sync:i18n
yarn i18n:sync
```
### `auto:i18n` - 自动翻译待翻译文本
### `i18n:translate` - 自动翻译待翻译文本
次脚本自动将标记为待翻译的文本通过机器翻译填充。
通常,在`zh-cn.json`中添加所需文案后,执行`sync:i18n`即可自动完成翻译。
通常,在`zh-cn.json`中添加所需文案后,执行`i18n:sync`即可自动完成翻译。
使用该脚本前,需要配置环境变量,例如:
@ -143,29 +143,19 @@ MODEL="qwen-plus-latest"
你也可以通过直接编辑`.env`文件来添加环境变量。
```bash
yarn auto:i18n
```
### `update:i18n` - 对象级别翻译更新
对`src/renderer/src/i18n/translate`中的语言文件进行对象级别的翻译更新,保留已有翻译,只更新新增内容。
**不建议**使用该脚本,更推荐使用`auto:i18n`进行翻译。
```bash
yarn update:i18n
yarn i18n:translate
```
### 工作流
1. 开发阶段,先在`zh-cn.json`中添加所需文案
2. 确认在中文环境下显示无误后,使用`yarn sync:i18n`将文案同步到其他语言文件
3. 使用`yarn auto:i18n`进行自动翻译
2. 确认在中文环境下显示无误后,使用`yarn i18n:sync`将文案同步到其他语言文件
3. 使用`yarn i18n:translate`进行自动翻译
4. 喝杯咖啡,等翻译完成吧!
## 最佳实践
1. **以中文为源语言**:所有开发首先使用中文,再翻译为其他语言
2. **提交前运行检查脚本**:使用`yarn check:i18n`检查i18n是否有问题
2. **提交前运行检查脚本**:使用`yarn i18n:check`检查 i18n 是否有问题
3. **小步提交翻译**:避免积累大量未翻译文本
4. **保持key语义明确**key应能清晰表达其用途如`user.profile.avatar.upload.error`
4. **保持 key 语义明确**key 应能清晰表达其用途,如`user.profile.avatar.upload.error`

View File

@ -0,0 +1,129 @@
# 文件列表模糊搜索
本文档描述了 Cherry Studio 中文件列表的模糊搜索实现。
## 概述
模糊搜索功能允许用户通过输入部分或近似的文件名/路径来查找文件。它使用两层文件过滤策略ripgrep glob 预过滤 + 贪婪子串匹配回退),结合基于子序列的评分,以获得最佳性能和灵活性。
## 功能特性
- **Ripgrep Glob 预过滤**:使用 glob 模式进行快速原生级过滤的主要过滤策略
- **贪婪子串匹配**:当 ripgrep glob 预过滤无结果时的回退文件过滤策略
- **基于子序列的段评分**:评分时,当查询字符按顺序出现时,路径段获得额外权重
- **相关性评分**:结果按多因素相关性分数排序
## 匹配策略
### 1. Ripgrep Glob 预过滤(主要)
查询被转换为 glob 模式供 ripgrep 进行初始过滤:
```
查询: "updater"
Glob: "*u*p*d*a*t*e*r*"
```
这利用了 ripgrep 的原生性能进行初始文件过滤。
### 2. 贪婪子串匹配(回退)
当 glob 预过滤无结果时,系统回退到贪婪子串匹配。这允许更灵活的匹配:
```
查询: "updatercontroller"
文件: "packages/update/src/node/updateController.ts"
匹配过程:
1. 找到 "update"(从开头的最长匹配)
2. 剩余 "rcontroller" → 找到 "r" 然后 "controller"
3. 所有部分都匹配 → 成功
```
## 评分算法
结果根据 `FileStorage.ts` 中定义的命名常量进行相关性分数排名:
| 常量 | 值 | 描述 |
|------|-----|------|
| `SCORE_FILENAME_STARTS` | 100 | 文件名以查询开头(最高优先级)|
| `SCORE_FILENAME_CONTAINS` | 80 | 文件名包含精确查询子串 |
| `SCORE_SEGMENT_MATCH` | 60 | 每个匹配查询的路径段 |
| `SCORE_WORD_BOUNDARY` | 20 | 查询匹配单词开头 |
| `SCORE_CONSECUTIVE_CHAR` | 15 | 每个连续字符匹配 |
| `PATH_LENGTH_PENALTY_FACTOR` | 4 | 较长路径的对数惩罚 |
### 评分策略
评分优先级:
1. **文件名匹配**(最高):查询出现在文件名中的文件最相关
2. **路径段匹配**:多个匹配段表示更强的相关性
3. **词边界**:在单词开头匹配(如 "upd" 匹配 "update")更优先
4. **连续匹配**:更长的连续字符序列得分更高
5. **路径长度**:较短路径更优先(对数惩罚防止长路径主导评分)
### 评分示例
对于查询 `updater`
| 文件 | 评分因素 |
|------|----------|
| `RCUpdater.js` | 短路径 + 文件名包含 "updater" |
| `updateController.ts` | 多个路径段匹配 |
| `UpdaterHelper.plist` | 长路径惩罚 |
## 配置
### DirectoryListOptions
```typescript
interface DirectoryListOptions {
recursive?: boolean // 默认: true
maxDepth?: number // 默认: 10
includeHidden?: boolean // 默认: false
includeFiles?: boolean // 默认: true
includeDirectories?: boolean // 默认: true
maxEntries?: number // 默认: 20
searchPattern?: string // 默认: '.'
fuzzy?: boolean // 默认: true
}
```
## 使用方法
```typescript
// 基本模糊搜索
const files = await window.api.file.listDirectory(dirPath, {
searchPattern: 'updater',
fuzzy: true,
maxEntries: 20
})
// 禁用模糊搜索(精确 glob 匹配)
const files = await window.api.file.listDirectory(dirPath, {
searchPattern: 'update',
fuzzy: false
})
```
## 性能考虑
1. **Ripgrep 预过滤**:大多数查询由 ripgrep 的原生 glob 匹配处理,速度极快
2. **仅在需要时回退**:贪婪子串匹配(加载所有文件)仅在 glob 匹配返回空结果时运行
3. **结果限制**:默认只返回前 20 个结果
4. **排除目录**:自动排除常见的大型目录:
- `node_modules`
- `.git`
- `dist`、`build`
- `.next`、`.nuxt`
- `coverage`、`.cache`
## 实现细节
实现位于 `src/main/services/FileStorage.ts`
- `queryToGlobPattern()`:将查询转换为 ripgrep glob 模式
- `isFuzzyMatch()`:子序列匹配算法
- `isGreedySubstringMatch()`:贪婪子串匹配回退
- `getFuzzyMatchScore()`:计算相关性分数
- `listDirectoryWithRipgrep()`:主搜索协调

View File

@ -0,0 +1,850 @@
# Cherry Studio 局域网传输协议规范
> 版本: 1.0
> 最后更新: 2025-12
本文档定义了 Cherry Studio 桌面客户端Electron与移动端Expo之间的局域网文件传输协议。
---
## 目录
1. [协议概述](#1-协议概述)
2. [服务发现Bonjour/mDNS](#2-服务发现bonjourmdns)
3. [TCP 连接与握手](#3-tcp-连接与握手)
4. [消息格式规范](#4-消息格式规范)
5. [文件传输协议](#5-文件传输协议)
6. [心跳与连接保活](#6-心跳与连接保活)
7. [错误处理](#7-错误处理)
8. [常量与配置](#8-常量与配置)
9. [完整时序图](#9-完整时序图)
10. [移动端实现指南](#10-移动端实现指南)
---
## 1. 协议概述
### 1.1 架构角色
| 角色 | 平台 | 职责 |
| -------------------- | --------------- | ---------------------------- |
| **Client客户端** | Electron 桌面端 | 扫描服务、发起连接、发送文件 |
| **Server服务端** | Expo 移动端 | 发布服务、接受连接、接收文件 |
### 1.2 协议栈v1
```
┌─────────────────────────────────────┐
│ 应用层(文件传输) │
├─────────────────────────────────────┤
│ 消息层(控制: JSON \n
│ (数据: 二进制帧) │
├─────────────────────────────────────┤
│ 传输层TCP
├─────────────────────────────────────┤
│ 发现层Bonjour/mDNS
└─────────────────────────────────────┘
```
### 1.3 通信流程概览
```
1. 服务发现 → 移动端发布 mDNS 服务,桌面端扫描发现
2. TCP 握手 → 建立连接,交换设备信息(`version=1`
3. 文件传输 → 控制消息使用 JSON`file_chunk` 使用二进制帧分块传输
4. 连接保活 → ping/pong 心跳
```
---
## 2. 服务发现Bonjour/mDNS
### 2.1 服务类型
| 属性 | 值 |
| ------------ | -------------------- |
| 服务类型 | `cherrystudio` |
| 协议 | `tcp` |
| 完整服务标识 | `_cherrystudio._tcp` |
### 2.2 服务发布(移动端)
移动端需要通过 mDNS/Bonjour 发布服务:
```typescript
// 服务发布参数
{
name: "Cherry Studio Mobile", // 设备名称
type: "cherrystudio", // 服务类型
protocol: "tcp", // 协议
port: 53317, // TCP 监听端口
txt: { // TXT 记录(可选)
version: "1",
platform: "ios" // 或 "android"
}
}
```
### 2.3 服务发现(桌面端)
桌面端扫描并解析服务信息:
```typescript
// 发现的服务信息结构
type LocalTransferPeer = {
id: string; // 唯一标识符
name: string; // 设备名称
host?: string; // 主机名
fqdn?: string; // 完全限定域名
port?: number; // TCP 端口
type?: string; // 服务类型
protocol?: "tcp" | "udp"; // 协议
addresses: string[]; // IP 地址列表
txt?: Record<string, string>; // TXT 记录
updatedAt: number; // 发现时间戳
};
```
### 2.4 IP 地址选择策略
当服务有多个 IP 地址时,优先选择 IPv4
```typescript
// 优先选择 IPv4 地址
const preferredAddress = addresses.find((addr) => isIPv4(addr)) || addresses[0];
```
---
## 3. TCP 连接与握手
### 3.1 连接建立
1. 客户端使用发现的 `host:port` 建立 TCP 连接
2. 连接成功后立即发送握手消息
3. 等待服务端响应握手确认
### 3.2 握手消息(协议版本 v1
#### Client → Server: `handshake`
```typescript
type LanTransferHandshakeMessage = {
type: "handshake";
deviceName: string; // 设备名称
version: string; // 协议版本,当前为 "1"
platform?: string; // 平台:'darwin' | 'win32' | 'linux'
appVersion?: string; // 应用版本
};
```
**示例:**
```json
{
"type": "handshake",
"deviceName": "Cherry Studio 1.7.2",
"version": "1",
"platform": "darwin",
"appVersion": "1.7.2"
}
```
### 4. 消息格式规范(混合协议)
v1 使用"控制 JSON + 二进制数据帧"的混合协议(流式传输模式,无 per-chunk ACK
- **控制消息**握手、心跳、file_start/ack、file_end、file_completeUTF-8 JSON`\n` 分隔
- **数据消息**`file_chunk`):二进制帧,使用 Magic + 总长度做分帧,不经 Base64
### 4.1 控制消息编码JSON + `\n`
| 属性 | 规范 |
| ---------- | ------------ |
| 编码格式 | UTF-8 |
| 序列化格式 | JSON |
| 消息分隔符 | `\n`0x0A |
```typescript
function sendControlMessage(socket: Socket, message: object): void {
socket.write(`${JSON.stringify(message)}\n`);
}
```
### 4.2 `file_chunk` 二进制帧格式
为解决 TCP 分包/粘包并消除 Base64 开销,`file_chunk` 采用带总长度的二进制帧:
```
┌──────────┬──────────┬────────┬───────────────┬──────────────┬────────────┬───────────┐
│ Magic │ TotalLen │ Type │ TransferId Len│ TransferId │ ChunkIdx │ Data │
│ 0x43 0x53│ (4B BE) │ 0x01 │ (2B BE) │ (UTF-8) │ (4B BE) │ (raw) │
└──────────┴──────────┴────────┴───────────────┴──────────────┴────────────┴───────────┘
```
| 字段 | 大小 | 说明 |
| -------------- | ---- | ------------------------------------------- |
| Magic | 2B | 常量 `0x43 0x53` ("CS"), 用于区分 JSON 消息 |
| TotalLen | 4B | Big-endian帧总长度不含 Magic/TotalLen |
| Type | 1B | `0x01` 代表 `file_chunk` |
| TransferId Len | 2B | Big-endiantransferId 字符串长度 |
| TransferId | nB | UTF-8 transferId长度由上一字段给出 |
| ChunkIdx | 4B | Big-endian块索引从 0 开始 |
| Data | mB | 原始文件二进制数据(未编码) |
> 计算帧总长度:`TotalLen = 1 + 2 + transferIdLen + 4 + dataLen`(即 Type~Data 的长度和)。
### 4.3 消息解析策略
1. 读取 socket 数据到缓冲区;
2. 若前两字节为 `0x43 0x53` → 按二进制帧解析:
- 至少需要 6 字节头Magic + TotalLen不足则等待更多数据
- 读取 `TotalLen` 判断帧整体长度,缓冲区不足则继续等待
- 解析 Type/TransferId/ChunkIdx/Data并传入文件接收逻辑
3. 否则若首字节为 `{` → 按 JSON + `\n` 解析控制消息
4. 其它数据丢弃 1 字节并继续循环,避免阻塞。
### 4.4 消息类型汇总v1
| 类型 | 方向 | 编码 | 用途 |
| ---------------- | --------------- | -------- | ----------------------- |
| `handshake` | Client → Server | JSON+\n | 握手请求version=1 |
| `handshake_ack` | Server → Client | JSON+\n | 握手响应 |
| `ping` | Client → Server | JSON+\n | 心跳请求 |
| `pong` | Server → Client | JSON+\n | 心跳响应 |
| `file_start` | Client → Server | JSON+\n | 开始文件传输 |
| `file_start_ack` | Server → Client | JSON+\n | 文件传输确认 |
| `file_chunk` | Client → Server | 二进制帧 | 文件数据块(无 Base64流式无 per-chunk ACK |
| `file_end` | Client → Server | JSON+\n | 文件传输结束 |
| `file_complete` | Server → Client | JSON+\n | 传输完成结果 |
```
{"type":"message_type",...其他字段...}\n
```
---
## 5. 文件传输协议
### 5.1 传输流程
```
Client (Sender) Server (Receiver)
| |
|──── 1. file_start ────────────────>|
| (文件元数据) |
| |
|<─── 2. file_start_ack ─────────────|
| (接受/拒绝) |
| |
|══════ 循环发送数据块(流式,无 ACK ═════|
| |
|──── 3. file_chunk [0] ────────────>|
| |
|──── 3. file_chunk [1] ────────────>|
| |
| ... 重复直到所有块发送完成 ... |
| |
|══════════════════════════════════════
| |
|──── 5. file_end ──────────────────>|
| (所有块已发送) |
| |
|<─── 6. file_complete ──────────────|
| (最终结果) |
```
### 5.2 消息定义
#### 5.2.1 `file_start` - 开始传输
**方向:** Client → Server
```typescript
type LanTransferFileStartMessage = {
type: "file_start";
transferId: string; // UUID唯一传输标识
fileName: string; // 文件名(含扩展名)
fileSize: number; // 文件总字节数
mimeType: string; // MIME 类型
checksum: string; // 整个文件的 SHA-256 哈希hex
totalChunks: number; // 总数据块数
chunkSize: number; // 每块大小(字节)
};
```
**示例:**
```json
{
"type": "file_start",
"transferId": "550e8400-e29b-41d4-a716-446655440000",
"fileName": "backup.zip",
"fileSize": 524288000,
"mimeType": "application/zip",
"checksum": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456",
"totalChunks": 8192,
"chunkSize": 65536
}
```
#### 5.2.2 `file_start_ack` - 传输确认
**方向:** Server → Client
```typescript
type LanTransferFileStartAckMessage = {
type: "file_start_ack";
transferId: string; // 对应的传输 ID
accepted: boolean; // 是否接受传输
message?: string; // 拒绝原因
};
```
**接受示例:**
```json
{
"type": "file_start_ack",
"transferId": "550e8400-e29b-41d4-a716-446655440000",
"accepted": true
}
```
**拒绝示例:**
```json
{
"type": "file_start_ack",
"transferId": "550e8400-e29b-41d4-a716-446655440000",
"accepted": false,
"message": "Insufficient storage space"
}
```
#### 5.2.3 `file_chunk` - 数据块
**方向:** Client → Server**二进制帧**,见 4.2
- 不再使用 JSON/`\n`,也不再使用 Base64
- 帧结构:`Magic` + `TotalLen` + `Type` + `TransferId` + `ChunkIdx` + `Data`
- `Type` 固定 `0x01``Data` 为原始文件二进制数据
- 传输完整性依赖 `file_start.checksum`(全文件 SHA-256分块校验和可选不在帧中发送
#### 5.2.4 `file_chunk_ack` - 数据块确认v1 流式不使用)
v1 采用流式传输,不发送 per-chunk ACK。本节类型仅保留作为向后兼容参考实际不会发送。
#### 5.2.5 `file_end` - 传输结束
**方向:** Client → Server
```typescript
type LanTransferFileEndMessage = {
type: "file_end";
transferId: string; // 传输 ID
};
```
**示例:**
```json
{
"type": "file_end",
"transferId": "550e8400-e29b-41d4-a716-446655440000"
}
```
#### 5.2.6 `file_complete` - 传输完成
**方向:** Server → Client
```typescript
type LanTransferFileCompleteMessage = {
type: "file_complete";
transferId: string; // 传输 ID
success: boolean; // 是否成功
filePath?: string; // 保存路径(成功时)
error?: string; // 错误信息(失败时)
};
```
**成功示例:**
```json
{
"type": "file_complete",
"transferId": "550e8400-e29b-41d4-a716-446655440000",
"success": true,
"filePath": "/storage/emulated/0/Documents/backup.zip"
}
```
**失败示例:**
```json
{
"type": "file_complete",
"transferId": "550e8400-e29b-41d4-a716-446655440000",
"success": false,
"error": "File checksum verification failed"
}
```
### 5.3 校验和算法
#### 整个文件校验和(保持不变)
```typescript
async function calculateFileChecksum(filePath: string): Promise<string> {
const hash = crypto.createHash("sha256");
const stream = fs.createReadStream(filePath);
for await (const chunk of stream) {
hash.update(chunk);
}
return hash.digest("hex");
}
```
#### 数据块校验和
v1 默认 **不传输分块校验和**,依赖最终文件 checksum。若需要可在应用层自定义非协议字段
### 5.4 校验流程
**发送端Client**
1. 发送前计算整个文件的 SHA-256 → `file_start.checksum`
2. 分块直接发送原始二进制(无 Base64
**接收端Server**
1. 收到 `file_chunk` 后直接使用二进制数据
2. 边收边落盘并增量计算 SHA-256推荐
3. 所有块接收完成后,计算/完成增量哈希,得到最终 SHA-256
4. 与 `file_start.checksum` 比对,结果写入 `file_complete`
### 5.5 数据块大小计算
```typescript
const CHUNK_SIZE = 512 * 1024; // 512KB
const totalChunks = Math.ceil(fileSize / CHUNK_SIZE);
// 最后一个块可能小于 CHUNK_SIZE
const lastChunkSize = fileSize % CHUNK_SIZE || CHUNK_SIZE;
```
---
## 6. 心跳与连接保活
### 6.1 心跳消息
#### `ping`
**方向:** Client → Server
```typescript
type LanTransferPingMessage = {
type: "ping";
payload?: string; // 可选载荷
};
```
```json
{
"type": "ping",
"payload": "heartbeat"
}
```
#### `pong`
**方向:** Server → Client
```typescript
type LanTransferPongMessage = {
type: "pong";
received: boolean; // 确认收到
payload?: string; // 回传 ping 的载荷
};
```
```json
{
"type": "pong",
"received": true,
"payload": "heartbeat"
}
```
### 6.2 心跳策略
- 握手成功后立即发送一次 `ping` 验证连接
- 可选:定期发送心跳保持连接活跃
- `pong` 应返回 `ping` 中的 `payload`(可选)
---
## 7. 错误处理
### 7.1 超时配置
| 操作 | 超时时间 | 说明 |
| ---------- | -------- | --------------------- |
| TCP 连接 | 10 秒 | 连接建立超时 |
| 握手等待 | 10 秒 | 等待 `handshake_ack` |
| 传输完成 | 60 秒 | 等待 `file_complete` |
### 7.2 错误场景处理
| 场景 | Client 处理 | Server 处理 |
| --------------- | ------------------ | ---------------------- |
| TCP 连接失败 | 通知 UI允许重试 | - |
| 握手超时 | 断开连接,通知 UI | 关闭 socket |
| 握手被拒绝 | 显示拒绝原因 | - |
| 数据块处理失败 | 中止传输,清理状态 | 清理临时文件 |
| 连接意外断开 | 清理状态,通知 UI | 清理临时文件 |
| 存储空间不足 | - | 发送 `accepted: false` |
### 7.3 资源清理
**Client 端:**
```typescript
function cleanup(): void {
// 1. 销毁文件读取流
if (readStream) {
readStream.destroy();
}
// 2. 清理传输状态
activeTransfer = undefined;
// 3. 关闭 socket如需要
socket?.destroy();
}
```
**Server 端:**
```typescript
function cleanup(): void {
// 1. 关闭文件写入流
if (writeStream) {
writeStream.end();
}
// 2. 删除未完成的临时文件
if (tempFilePath) {
fs.unlinkSync(tempFilePath);
}
// 3. 清理传输状态
activeTransfer = undefined;
}
```
---
## 8. 常量与配置
### 8.1 协议常量
```typescript
// 协议版本v1 = 控制 JSON + 二进制 chunk + 流式传输)
export const LAN_TRANSFER_PROTOCOL_VERSION = "1";
// 服务发现
export const LAN_TRANSFER_SERVICE_TYPE = "cherrystudio";
export const LAN_TRANSFER_SERVICE_FULL_NAME = "_cherrystudio._tcp";
// TCP 端口
export const LAN_TRANSFER_TCP_PORT = 53317;
// 文件传输(与二进制帧一致)
export const LAN_TRANSFER_CHUNK_SIZE = 512 * 1024; // 512KB
export const LAN_TRANSFER_GLOBAL_TIMEOUT_MS = 10 * 60 * 1000; // 10 分钟
// 超时设置
export const LAN_TRANSFER_HANDSHAKE_TIMEOUT_MS = 10_000; // 10秒
export const LAN_TRANSFER_CHUNK_TIMEOUT_MS = 30_000; // 30秒
export const LAN_TRANSFER_COMPLETE_TIMEOUT_MS = 60_000; // 60秒
```
### 8.2 支持的文件类型
当前仅支持 ZIP 文件:
```typescript
export const LAN_TRANSFER_ALLOWED_EXTENSIONS = [".zip"];
export const LAN_TRANSFER_ALLOWED_MIME_TYPES = [
"application/zip",
"application/x-zip-compressed",
];
```
---
## 9. 完整时序图
### 9.1 完整传输流程v1流式传输
```
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Renderer│ │ Main │ │ Mobile │
│ (UI) │ │ Process │ │ Server │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
│ ════════════ 服务发现阶段 ════════════ │
│ │ │
│ startScan() │ │
│────────────────────────────────────>│ │
│ │ mDNS browse │
│ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─>│
│ │ │
│ │<─ ─ ─ service discovered ─ ─ ─ ─ ─ ─│
│ │ │
<────── onServicesUpdated ───────────│ │
│ │ │
│ ════════════ 握手连接阶段 ════════════ │
│ │ │
│ connect(peer) │ │
│────────────────────────────────────>│ │
│ │──────── TCP Connect ───────────────>│
│ │ │
│ │──────── handshake ─────────────────>│
│ │ │
│ │<─────── handshake_ack ──────────────│
│ │ │
│ │──────── ping ──────────────────────>│
│ │<─────── pong ───────────────────────│
│ │ │
<────── connect result ──────────────│ │
│ │ │
│ ════════════ 文件传输阶段 ════════════ │
│ │ │
│ sendFile(path) │ │
│────────────────────────────────────>│ │
│ │──────── file_start ────────────────>│
│ │ │
│ │<─────── file_start_ack ─────────────│
│ │ │
│ │ │
│ │══════ 循环发送数据块 ═══════════════│
│ │ │
│ │──────── file_chunk[0] (binary) ────>│
<────── progress event ──────────────│ │
│ │ │
│ │──────── file_chunk[1] (binary) ────>│
<────── progress event ──────────────│ │
│ │ │
│ │ ... 重复 ... │
│ │ │
│ │══════════════════════════════════════│
│ │ │
│ │──────── file_end ──────────────────>│
│ │ │
│ │<─────── file_complete ──────────────│
│ │ │
<────── complete event ──────────────│ │
<────── sendFile result ─────────────│ │
│ │ │
```
---
## 10. 移动端实现指南v1 要点)
### 10.1 必须实现的功能
1. **mDNS 服务发布**
- 发布 `_cherrystudio._tcp` 服务
- 提供 TCP 端口号 `53317`
- 可选TXT 记录(版本、平台信息)
2. **TCP 服务端**
- 监听指定端口
- 支持单连接或多连接
3. **消息解析**
- 控制消息UTF-8 + `\n` JSON
- 数据消息二进制帧Magic+TotalLen 分帧)
4. **握手处理**
- 验证 `handshake` 消息
- 发送 `handshake_ack` 响应
- 响应 `ping` 消息
5. **文件接收(流式模式)**
- 解析 `file_start`,准备接收
- 接收 `file_chunk` 二进制帧,直接写入文件/缓冲并增量哈希
- v1 不发送 per-chunk ACK流式传输
- 处理 `file_end`,完成增量哈希并校验 checksum
- 发送 `file_complete` 结果
### 10.2 推荐的库
**React Native / Expo**
- mDNS: `react-native-zeroconf``@homielab/react-native-bonjour`
- TCP: `react-native-tcp-socket`
- Crypto: `expo-crypto``react-native-quick-crypto`
### 10.3 接收端伪代码
```typescript
class FileReceiver {
private transfer?: {
id: string;
fileName: string;
fileSize: number;
checksum: string;
totalChunks: number;
receivedChunks: number;
tempPath: string;
// v1: 边收边写文件,避免大文件 OOM
// stream: FileSystem writable stream (平台相关封装)
};
handleMessage(message: any) {
switch (message.type) {
case "handshake":
this.handleHandshake(message);
break;
case "ping":
this.sendPong(message);
break;
case "file_start":
this.handleFileStart(message);
break;
// v1: file_chunk 为二进制帧,不再走 JSON 分支
case "file_end":
this.handleFileEnd(message);
break;
}
}
handleFileStart(msg: LanTransferFileStartMessage) {
// 1. 检查存储空间
// 2. 创建临时文件
// 3. 初始化传输状态
// 4. 发送 file_start_ack
}
// v1: 二进制帧处理在 socket data 流中解析,随后调用 handleBinaryFileChunk
handleBinaryFileChunk(transferId: string, chunkIndex: number, data: Buffer) {
// 直接使用二进制数据,按 chunkSize/lastChunk 计算长度
// 写入文件流并更新增量 SHA-256
this.transfer.receivedChunks++;
// v1: 流式传输,不发送 per-chunk ACK
}
handleFileEnd(msg: LanTransferFileEndMessage) {
// 1. 合并所有数据块
// 2. 验证完整文件 checksum
// 3. 写入最终位置
// 4. 发送 file_complete
}
}
```
---
## 附录 ATypeScript 类型定义
完整的类型定义位于 `packages/shared/config/types.ts`
```typescript
// 握手消息
export interface LanTransferHandshakeMessage {
type: "handshake";
deviceName: string;
version: string;
platform?: string;
appVersion?: string;
}
export interface LanTransferHandshakeAckMessage {
type: "handshake_ack";
accepted: boolean;
message?: string;
}
// 心跳消息
export interface LanTransferPingMessage {
type: "ping";
payload?: string;
}
export interface LanTransferPongMessage {
type: "pong";
received: boolean;
payload?: string;
}
// 文件传输消息 (Client -> Server)
export interface LanTransferFileStartMessage {
type: "file_start";
transferId: string;
fileName: string;
fileSize: number;
mimeType: string;
checksum: string;
totalChunks: number;
chunkSize: number;
}
export interface LanTransferFileChunkMessage {
type: "file_chunk";
transferId: string;
chunkIndex: number;
data: string; // Base64 encoded (v1: 二进制帧模式下不使用)
}
export interface LanTransferFileEndMessage {
type: "file_end";
transferId: string;
}
// 文件传输响应消息 (Server -> Client)
export interface LanTransferFileStartAckMessage {
type: "file_start_ack";
transferId: string;
accepted: boolean;
message?: string;
}
// v1 流式不发送 per-chunk ACK以下类型仅用于向后兼容参考
export interface LanTransferFileChunkAckMessage {
type: "file_chunk_ack";
transferId: string;
chunkIndex: number;
received: boolean;
error?: string;
}
export interface LanTransferFileCompleteMessage {
type: "file_complete";
transferId: string;
success: boolean;
filePath?: string;
error?: string;
}
// 常量
export const LAN_TRANSFER_TCP_PORT = 53317;
export const LAN_TRANSFER_CHUNK_SIZE = 512 * 1024;
export const LAN_TRANSFER_CHUNK_TIMEOUT_MS = 30_000;
```
---
## 附录 B版本历史
| 版本 | 日期 | 变更 |
| ---- | ------- | ---------------------------------------- |
| 1.0 | 2025-12 | 初始发布版本,支持二进制帧格式与流式传输 |

View File

@ -135,60 +135,44 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
<!--LANG:en-->
Cherry Studio 1.7.3 - Feature & Stability Update
This release brings new features, UI improvements, and important bug fixes.
Cherry Studio 1.7.9 - New Features & Bug Fixes
✨ New Features
- Add MCP server log viewer for better debugging
- Support custom Git Bash path configuration
- Add print to PDF and save as HTML for mini program webviews
- Add CherryIN API host selection settings
- Enhance assistant presets with sort and batch delete modes
- Open URL directly for SelectionAssistant search action
- Enhance web search tool switching with provider-specific context
🔧 Improvements
- Remove Intel Ultra limit for OVMS
- Improve settings tab and assistant item UI
- [Agent] Add 302.AI provider support
- [Browser] Browser data now persists and supports multiple tabs
- [Language] Add Romanian language support
- [Search] Add fuzzy search for file list
- [Models] Add latest Zhipu models
- [Image] Improve text-to-image functionality
🐛 Bug Fixes
- Fix stack overflow with base64 images
- Fix infinite loop in knowledge queue processing
- Fix quick panel closing in multiple selection mode
- Fix thinking timer not stopping when reply is aborted
- Fix ThinkingButton icon display for fixed reasoning mode
- Fix knowledge query prioritization and intent prompt
- Fix OpenRouter embeddings support
- Fix SelectionAction window resize on Windows
- Add gpustack provider support for qwen3 thinking mode
- [Mac] Fix mini window unexpected closing issue
- [Preview] Fix HTML preview controls not working in fullscreen
- [Translate] Fix translation duplicate execution issue
- [Zoom] Fix page zoom reset issue during navigation
- [Agent] Fix crash when switching between agent and assistant
- [Agent] Fix navigation in agent mode
- [Copy] Fix markdown copy button issue
- [Windows] Fix compatibility issues on non-Windows systems
<!--LANG:zh-CN-->
Cherry Studio 1.7.3 - 功能与稳定性更新
本次更新带来新功能、界面改进和重要的问题修复。
Cherry Studio 1.7.9 - 新功能与问题修复
✨ 新功能
- 新增 MCP 服务器日志查看器,便于调试
- 支持自定义 Git Bash 路径配置
- 小程序 webview 支持打印 PDF 和保存为 HTML
- 新增 CherryIN API 主机选择设置
- 助手预设增强:支持排序和批量删除模式
- 划词助手搜索操作直接打开 URL
- 增强网页搜索工具切换逻辑,支持服务商特定上下文
🔧 功能改进
- 移除 OVMS 的 Intel Ultra 限制
- 优化设置标签页和助手项目 UI
- [Agent] 新增 302.AI 服务商支持
- [浏览器] 浏览器数据现在可以保存,支持多标签页
- [语言] 新增罗马尼亚语支持
- [搜索] 文件列表新增模糊搜索功能
- [模型] 新增最新智谱模型
- [图片] 优化文生图功能
🐛 问题修复
- 修复 base64 图片导致的栈溢出问题
- 修复知识库队列处理的无限循环问题
- 修复多选模式下快捷面板意外关闭的问题
- 修复回复中止时思考计时器未停止的问题
- 修复固定推理模式下思考按钮图标显示问题
- 修复知识库查询优先级和意图提示
- 修复 OpenRouter 嵌入模型支持
- 修复 Windows 上划词助手窗口大小调整问题
- 为 gpustack 服务商添加 qwen3 思考模式支持
- [Mac] 修复迷你窗口意外关闭的问题
- [预览] 修复全屏模式下 HTML 预览控件无法使用的问题
- [翻译] 修复翻译重复执行的问题
- [缩放] 修复页面导航时缩放被重置的问题
- [智能体] 修复在智能体和助手间切换时崩溃的问题
- [智能体] 修复智能体模式下的导航问题
- [复制] 修复 Markdown 复制按钮问题
- [兼容性] 修复非 Windows 系统的兼容性问题
<!--LANG:END-->

View File

@ -1,6 +1,6 @@
import react from '@vitejs/plugin-react-swc'
import { CodeInspectorPlugin } from 'code-inspector-plugin'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import { defineConfig } from 'electron-vite'
import { resolve } from 'path'
import { visualizer } from 'rollup-plugin-visualizer'
@ -17,7 +17,7 @@ const isProd = process.env.NODE_ENV === 'production'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin(), ...visualizerPlugin('main')],
plugins: [...visualizerPlugin('main')],
resolve: {
alias: {
'@main': resolve('src/main'),
@ -26,7 +26,8 @@ export default defineConfig({
'@shared': resolve('packages/shared'),
'@logger': resolve('src/main/services/LoggerService'),
'@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core'),
'@mcp-trace/trace-node': resolve('packages/mcp-trace/trace-node')
'@mcp-trace/trace-node': resolve('packages/mcp-trace/trace-node'),
'@test-mocks': resolve('tests/__mocks__')
}
},
build: {
@ -52,8 +53,7 @@ export default defineConfig({
plugins: [
react({
tsDecorators: true
}),
externalizeDepsPlugin()
})
],
resolve: {
alias: {
@ -113,7 +113,8 @@ export default defineConfig({
'@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src'),
'@cherrystudio/ai-sdk-provider': resolve('packages/ai-sdk-provider/src'),
'@cherrystudio/ui/icons': resolve('packages/ui/src/components/icons'),
'@cherrystudio/ui': resolve('packages/ui/src')
'@cherrystudio/ui': resolve('packages/ui/src'),
'@test-mocks': resolve('tests/__mocks__')
}
},
optimizeDeps: {

View File

@ -61,6 +61,7 @@ export default defineConfig([
'tests/**',
'.yarn/**',
'.gitignore',
'.conductor/**',
'scripts/cloudflare-worker.js',
'src/main/integration/nutstore/sso/lib/**',
'src/main/integration/cherryai/index.js',
@ -171,6 +172,11 @@ export default defineConfig([
}
},
// Schema key naming convention (cache & preferences)
// Supports both fixed keys and template keys:
// - Fixed: 'app.user.avatar', 'chat.multi_select_mode'
// - Template: 'scroll.position.${topicId}', 'entity.cache.${type}_${id}'
// Template keys must follow the same dot-separated pattern as fixed keys.
// When ${xxx} placeholders are treated as literal strings, the key must match: xxx.yyy.zzz_www
{
files: ['packages/shared/data/cache/cacheSchemas.ts', 'packages/shared/data/preference/preferenceSchemas.ts'],
plugins: {
@ -180,25 +186,80 @@ export default defineConfig([
meta: {
type: 'problem',
docs: {
description: 'Enforce schema key naming convention: namespace.sub.key_name',
description:
'Enforce schema key naming convention: namespace.sub.key_name (template placeholders treated as literal strings)',
recommended: true
},
messages: {
invalidKey:
'Schema key "{{key}}" must follow format: namespace.sub.key_name (e.g., app.user.avatar).'
'Schema key "{{key}}" must follow format: namespace.sub.key_name (e.g., app.user.avatar, scroll.position.${id}). Template ${xxx} is treated as a literal string segment.',
invalidTemplateVar:
'Template variable in "{{key}}" must be a valid identifier (e.g., ${id}, ${topicId}).'
}
},
create(context) {
const VALID_KEY_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
/**
* Validates a schema key for correct naming convention.
*
* Both fixed keys and template keys must follow the same pattern:
* - Lowercase segments separated by dots
* - Each segment: starts with letter, contains letters/numbers/underscores
* - At least two segments (must have at least one dot)
*
* Template keys: ${xxx} placeholders are treated as literal string segments.
* Example valid: 'scroll.position.${id}', 'entity.cache.${type}_${id}'
* Example invalid: 'cache:${type}' (colon not allowed), '${id}' (no dot)
*
* @param {string} key - The schema key to validate
* @returns {{ valid: boolean, error?: 'invalidKey' | 'invalidTemplateVar' }}
*/
function validateKey(key) {
// Check if key contains template placeholders
const hasTemplate = key.includes('${')
if (hasTemplate) {
// Validate template variable names first
const templateVarPattern = /\$\{([^}]*)\}/g
let match
while ((match = templateVarPattern.exec(key)) !== null) {
const varName = match[1]
// Variable must be a valid identifier: start with letter, contain only alphanumeric and underscore
if (!varName || !/^[a-zA-Z][a-zA-Z0-9_]*$/.test(varName)) {
return { valid: false, error: 'invalidTemplateVar' }
}
}
// Replace template placeholders with a valid segment marker
// Use 'x' as placeholder since it's a valid segment character
const keyWithoutTemplates = key.replace(/\$\{[^}]+\}/g, 'x')
// Template key must follow the same pattern as fixed keys
// when ${xxx} is treated as a literal string
const fixedKeyPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
if (!fixedKeyPattern.test(keyWithoutTemplates)) {
return { valid: false, error: 'invalidKey' }
}
return { valid: true }
} else {
// Fixed key validation: standard dot-separated format
const fixedKeyPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
if (!fixedKeyPattern.test(key)) {
return { valid: false, error: 'invalidKey' }
}
return { valid: true }
}
}
return {
TSPropertySignature(node) {
if (node.key.type === 'Literal' && typeof node.key.value === 'string') {
const key = node.key.value
if (!VALID_KEY_PATTERN.test(key)) {
const result = validateKey(key)
if (!result.valid) {
context.report({
node: node.key,
messageId: 'invalidKey',
messageId: result.error,
data: { key }
})
}
@ -207,10 +268,11 @@ export default defineConfig([
Property(node) {
if (node.key.type === 'Literal' && typeof node.key.value === 'string') {
const key = node.key.value
if (!VALID_KEY_PATTERN.test(key)) {
const result = validateKey(key)
if (!result.valid) {
context.report({
node: node.key,
messageId: 'invalidKey',
messageId: result.error,
data: { key }
})
}

View File

@ -1,6 +1,10 @@
**THIS DIRECTORY IS NOT FOR RUNTIME USE**
**v2 Data Refactoring Notice**
Before the official release of the alpha version, the database structure may change at any time. To maintain simplicity, the database migration files will be periodically reinitialized, which may cause the application to fail. If this occurs, please delete the `cherrystudio.sqlite` file located in the user data directory.
- Using `libsql` as the `sqlite3` driver, and `drizzle` as the ORM and database migration tool
- Table schemas are defined in `src\main\data\db\schemas`
- `migrations/sqlite-drizzle` contains auto-generated migration data. Please **DO NOT** modify it.
- If table structure changes, we should run migrations.
- To generate migrations, use the command `yarn run migrations:generate`
- To generate migrations, use the command `yarn run db:migrations:generate`

View File

@ -0,0 +1,145 @@
CREATE TABLE `app_state` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL,
`description` text,
`created_at` integer,
`updated_at` integer
);
--> statement-breakpoint
CREATE TABLE `entity_tag` (
`entity_type` text NOT NULL,
`entity_id` text NOT NULL,
`tag_id` text NOT NULL,
`created_at` integer,
`updated_at` integer,
PRIMARY KEY(`entity_type`, `entity_id`, `tag_id`),
FOREIGN KEY (`tag_id`) REFERENCES `tag`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `entity_tag_tag_id_idx` ON `entity_tag` (`tag_id`);--> statement-breakpoint
CREATE TABLE `group` (
`id` text PRIMARY KEY NOT NULL,
`entity_type` text NOT NULL,
`name` text NOT NULL,
`sort_order` integer DEFAULT 0,
`created_at` integer,
`updated_at` integer
);
--> statement-breakpoint
CREATE INDEX `group_entity_sort_idx` ON `group` (`entity_type`,`sort_order`);--> statement-breakpoint
CREATE TABLE `message` (
`id` text PRIMARY KEY NOT NULL,
`parent_id` text,
`topic_id` text NOT NULL,
`role` text NOT NULL,
`data` text NOT NULL,
`searchable_text` text,
`status` text NOT NULL,
`siblings_group_id` integer DEFAULT 0,
`assistant_id` text,
`assistant_meta` text,
`model_id` text,
`model_meta` text,
`trace_id` text,
`stats` text,
`created_at` integer,
`updated_at` integer,
`deleted_at` integer,
FOREIGN KEY (`topic_id`) REFERENCES `topic`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`parent_id`) REFERENCES `message`(`id`) ON UPDATE no action ON DELETE set null,
CONSTRAINT "message_role_check" CHECK("message"."role" IN ('user', 'assistant', 'system')),
CONSTRAINT "message_status_check" CHECK("message"."status" IN ('success', 'error', 'paused'))
);
--> statement-breakpoint
CREATE INDEX `message_parent_id_idx` ON `message` (`parent_id`);--> statement-breakpoint
CREATE INDEX `message_topic_created_idx` ON `message` (`topic_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `message_trace_id_idx` ON `message` (`trace_id`);--> statement-breakpoint
CREATE TABLE `preference` (
`scope` text DEFAULT 'default' NOT NULL,
`key` text NOT NULL,
`value` text,
`created_at` integer,
`updated_at` integer,
PRIMARY KEY(`scope`, `key`)
);
--> statement-breakpoint
CREATE TABLE `tag` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`color` text,
`created_at` integer,
`updated_at` integer
);
--> statement-breakpoint
CREATE UNIQUE INDEX `tag_name_unique` ON `tag` (`name`);--> statement-breakpoint
CREATE TABLE `topic` (
`id` text PRIMARY KEY NOT NULL,
`name` text,
`is_name_manually_edited` integer DEFAULT false,
`assistant_id` text,
`assistant_meta` text,
`prompt` text,
`active_node_id` text,
`group_id` text,
`sort_order` integer DEFAULT 0,
`is_pinned` integer DEFAULT false,
`pinned_order` integer DEFAULT 0,
`created_at` integer,
`updated_at` integer,
`deleted_at` integer,
FOREIGN KEY (`group_id`) REFERENCES `group`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `topic_group_updated_idx` ON `topic` (`group_id`,`updated_at`);--> statement-breakpoint
CREATE INDEX `topic_group_sort_idx` ON `topic` (`group_id`,`sort_order`);--> statement-breakpoint
CREATE INDEX `topic_updated_at_idx` ON `topic` (`updated_at`);--> statement-breakpoint
CREATE INDEX `topic_is_pinned_idx` ON `topic` (`is_pinned`,`pinned_order`);--> statement-breakpoint
CREATE INDEX `topic_assistant_id_idx` ON `topic` (`assistant_id`);
--> statement-breakpoint
-- ============================================================
-- FTS5 Virtual Table and Triggers for Message Full-Text Search
-- ============================================================
-- 1. Create FTS5 virtual table with external content
-- Links to message table's searchable_text column
CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts5(
searchable_text,
content='message',
content_rowid='rowid',
tokenize='trigram'
);--> statement-breakpoint
-- 2. Trigger: populate searchable_text and sync FTS on INSERT
CREATE TRIGGER IF NOT EXISTS message_ai AFTER INSERT ON message BEGIN
-- Extract searchable text from data.blocks
UPDATE message SET searchable_text = (
SELECT group_concat(json_extract(value, '$.content'), ' ')
FROM json_each(json_extract(NEW.data, '$.blocks'))
WHERE json_extract(value, '$.type') = 'main_text'
) WHERE id = NEW.id;
-- Sync to FTS5
INSERT INTO message_fts(rowid, searchable_text)
SELECT rowid, searchable_text FROM message WHERE id = NEW.id;
END;--> statement-breakpoint
-- 3. Trigger: sync FTS on DELETE
CREATE TRIGGER IF NOT EXISTS message_ad AFTER DELETE ON message BEGIN
INSERT INTO message_fts(message_fts, rowid, searchable_text)
VALUES ('delete', OLD.rowid, OLD.searchable_text);
END;--> statement-breakpoint
-- 4. Trigger: update searchable_text and sync FTS on UPDATE OF data
CREATE TRIGGER IF NOT EXISTS message_au AFTER UPDATE OF data ON message BEGIN
-- Remove old FTS entry
INSERT INTO message_fts(message_fts, rowid, searchable_text)
VALUES ('delete', OLD.rowid, OLD.searchable_text);
-- Update searchable_text
UPDATE message SET searchable_text = (
SELECT group_concat(json_extract(value, '$.content'), ' ')
FROM json_each(json_extract(NEW.data, '$.blocks'))
WHERE json_extract(value, '$.type') = 'main_text'
) WHERE id = NEW.id;
-- Add new FTS entry
INSERT INTO message_fts(rowid, searchable_text)
SELECT rowid, searchable_text FROM message WHERE id = NEW.id;
END;

View File

@ -1,17 +0,0 @@
CREATE TABLE `app_state` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL,
`description` text,
`created_at` integer,
`updated_at` integer
);
--> statement-breakpoint
CREATE TABLE `preference` (
`scope` text NOT NULL,
`key` text NOT NULL,
`value` text,
`created_at` integer,
`updated_at` integer
);
--> statement-breakpoint
CREATE INDEX `scope_name_idx` ON `preference` (`scope`,`key`);

View File

@ -0,0 +1,32 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_message` (
`id` text PRIMARY KEY NOT NULL,
`parent_id` text,
`topic_id` text NOT NULL,
`role` text NOT NULL,
`data` text NOT NULL,
`searchable_text` text,
`status` text NOT NULL,
`siblings_group_id` integer DEFAULT 0,
`assistant_id` text,
`assistant_meta` text,
`model_id` text,
`model_meta` text,
`trace_id` text,
`stats` text,
`created_at` integer,
`updated_at` integer,
`deleted_at` integer,
FOREIGN KEY (`topic_id`) REFERENCES `topic`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`parent_id`) REFERENCES `message`(`id`) ON UPDATE no action ON DELETE set null,
CONSTRAINT "message_role_check" CHECK("__new_message"."role" IN ('user', 'assistant', 'system')),
CONSTRAINT "message_status_check" CHECK("__new_message"."status" IN ('pending', 'success', 'error', 'paused'))
);
--> statement-breakpoint
INSERT INTO `__new_message`("id", "parent_id", "topic_id", "role", "data", "searchable_text", "status", "siblings_group_id", "assistant_id", "assistant_meta", "model_id", "model_meta", "trace_id", "stats", "created_at", "updated_at", "deleted_at") SELECT "id", "parent_id", "topic_id", "role", "data", "searchable_text", "status", "siblings_group_id", "assistant_id", "assistant_meta", "model_id", "model_meta", "trace_id", "stats", "created_at", "updated_at", "deleted_at" FROM `message`;--> statement-breakpoint
DROP TABLE `message`;--> statement-breakpoint
ALTER TABLE `__new_message` RENAME TO `message`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `message_parent_id_idx` ON `message` (`parent_id`);--> statement-breakpoint
CREATE INDEX `message_topic_created_idx` ON `message` (`topic_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `message_trace_id_idx` ON `message` (`trace_id`);

View File

@ -6,7 +6,7 @@
},
"dialect": "sqlite",
"enums": {},
"id": "de8009d7-95b9-4f99-99fa-4b8795708f21",
"id": "2ee6f7b2-99da-4de1-b895-48866855b7c6",
"internal": {
"indexes": {}
},
@ -57,6 +57,305 @@
"name": "app_state",
"uniqueConstraints": {}
},
"entity_tag": {
"checkConstraints": {},
"columns": {
"created_at": {
"autoincrement": false,
"name": "created_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"entity_id": {
"autoincrement": false,
"name": "entity_id",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"entity_type": {
"autoincrement": false,
"name": "entity_type",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"tag_id": {
"autoincrement": false,
"name": "tag_id",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"updated_at": {
"autoincrement": false,
"name": "updated_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
}
},
"compositePrimaryKeys": {
"entity_tag_entity_type_entity_id_tag_id_pk": {
"columns": ["entity_type", "entity_id", "tag_id"],
"name": "entity_tag_entity_type_entity_id_tag_id_pk"
}
},
"foreignKeys": {
"entity_tag_tag_id_tag_id_fk": {
"columnsFrom": ["tag_id"],
"columnsTo": ["id"],
"name": "entity_tag_tag_id_tag_id_fk",
"onDelete": "cascade",
"onUpdate": "no action",
"tableFrom": "entity_tag",
"tableTo": "tag"
}
},
"indexes": {
"entity_tag_tag_id_idx": {
"columns": ["tag_id"],
"isUnique": false,
"name": "entity_tag_tag_id_idx"
}
},
"name": "entity_tag",
"uniqueConstraints": {}
},
"group": {
"checkConstraints": {},
"columns": {
"created_at": {
"autoincrement": false,
"name": "created_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"entity_type": {
"autoincrement": false,
"name": "entity_type",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"id": {
"autoincrement": false,
"name": "id",
"notNull": true,
"primaryKey": true,
"type": "text"
},
"name": {
"autoincrement": false,
"name": "name",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"sort_order": {
"autoincrement": false,
"default": 0,
"name": "sort_order",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"updated_at": {
"autoincrement": false,
"name": "updated_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
}
},
"compositePrimaryKeys": {},
"foreignKeys": {},
"indexes": {
"group_entity_sort_idx": {
"columns": ["entity_type", "sort_order"],
"isUnique": false,
"name": "group_entity_sort_idx"
}
},
"name": "group",
"uniqueConstraints": {}
},
"message": {
"checkConstraints": {
"message_role_check": {
"name": "message_role_check",
"value": "\"message\".\"role\" IN ('user', 'assistant', 'system')"
},
"message_status_check": {
"name": "message_status_check",
"value": "\"message\".\"status\" IN ('success', 'error', 'paused')"
}
},
"columns": {
"assistant_id": {
"autoincrement": false,
"name": "assistant_id",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"assistant_meta": {
"autoincrement": false,
"name": "assistant_meta",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"created_at": {
"autoincrement": false,
"name": "created_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"data": {
"autoincrement": false,
"name": "data",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"deleted_at": {
"autoincrement": false,
"name": "deleted_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"id": {
"autoincrement": false,
"name": "id",
"notNull": true,
"primaryKey": true,
"type": "text"
},
"model_id": {
"autoincrement": false,
"name": "model_id",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"model_meta": {
"autoincrement": false,
"name": "model_meta",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"parent_id": {
"autoincrement": false,
"name": "parent_id",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"role": {
"autoincrement": false,
"name": "role",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"searchable_text": {
"autoincrement": false,
"name": "searchable_text",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"siblings_group_id": {
"autoincrement": false,
"default": 0,
"name": "siblings_group_id",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"stats": {
"autoincrement": false,
"name": "stats",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"status": {
"autoincrement": false,
"name": "status",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"topic_id": {
"autoincrement": false,
"name": "topic_id",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"trace_id": {
"autoincrement": false,
"name": "trace_id",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"updated_at": {
"autoincrement": false,
"name": "updated_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
}
},
"compositePrimaryKeys": {},
"foreignKeys": {
"message_parent_id_message_id_fk": {
"columnsFrom": ["parent_id"],
"columnsTo": ["id"],
"name": "message_parent_id_message_id_fk",
"onDelete": "set null",
"onUpdate": "no action",
"tableFrom": "message",
"tableTo": "message"
},
"message_topic_id_topic_id_fk": {
"columnsFrom": ["topic_id"],
"columnsTo": ["id"],
"name": "message_topic_id_topic_id_fk",
"onDelete": "cascade",
"onUpdate": "no action",
"tableFrom": "message",
"tableTo": "topic"
}
},
"indexes": {
"message_parent_id_idx": {
"columns": ["parent_id"],
"isUnique": false,
"name": "message_parent_id_idx"
},
"message_topic_created_idx": {
"columns": ["topic_id", "created_at"],
"isUnique": false,
"name": "message_topic_created_idx"
},
"message_trace_id_idx": {
"columns": ["trace_id"],
"isUnique": false,
"name": "message_trace_id_idx"
}
},
"name": "message",
"uniqueConstraints": {}
},
"preference": {
"checkConstraints": {},
"columns": {
@ -76,6 +375,7 @@
},
"scope": {
"autoincrement": false,
"default": "'default'",
"name": "scope",
"notNull": true,
"primaryKey": false,
@ -96,16 +396,214 @@
"type": "text"
}
},
"compositePrimaryKeys": {
"preference_scope_key_pk": {
"columns": ["scope", "key"],
"name": "preference_scope_key_pk"
}
},
"foreignKeys": {},
"indexes": {},
"name": "preference",
"uniqueConstraints": {}
},
"tag": {
"checkConstraints": {},
"columns": {
"color": {
"autoincrement": false,
"name": "color",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"created_at": {
"autoincrement": false,
"name": "created_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"id": {
"autoincrement": false,
"name": "id",
"notNull": true,
"primaryKey": true,
"type": "text"
},
"name": {
"autoincrement": false,
"name": "name",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"updated_at": {
"autoincrement": false,
"name": "updated_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
}
},
"compositePrimaryKeys": {},
"foreignKeys": {},
"indexes": {
"scope_name_idx": {
"columns": ["scope", "key"],
"isUnique": false,
"name": "scope_name_idx"
"tag_name_unique": {
"columns": ["name"],
"isUnique": true,
"name": "tag_name_unique"
}
},
"name": "preference",
"name": "tag",
"uniqueConstraints": {}
},
"topic": {
"checkConstraints": {},
"columns": {
"active_node_id": {
"autoincrement": false,
"name": "active_node_id",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"assistant_id": {
"autoincrement": false,
"name": "assistant_id",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"assistant_meta": {
"autoincrement": false,
"name": "assistant_meta",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"created_at": {
"autoincrement": false,
"name": "created_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"deleted_at": {
"autoincrement": false,
"name": "deleted_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"group_id": {
"autoincrement": false,
"name": "group_id",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"id": {
"autoincrement": false,
"name": "id",
"notNull": true,
"primaryKey": true,
"type": "text"
},
"is_name_manually_edited": {
"autoincrement": false,
"default": false,
"name": "is_name_manually_edited",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"is_pinned": {
"autoincrement": false,
"default": false,
"name": "is_pinned",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"name": {
"autoincrement": false,
"name": "name",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"pinned_order": {
"autoincrement": false,
"default": 0,
"name": "pinned_order",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"prompt": {
"autoincrement": false,
"name": "prompt",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"sort_order": {
"autoincrement": false,
"default": 0,
"name": "sort_order",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"updated_at": {
"autoincrement": false,
"name": "updated_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
}
},
"compositePrimaryKeys": {},
"foreignKeys": {
"topic_group_id_group_id_fk": {
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"name": "topic_group_id_group_id_fk",
"onDelete": "set null",
"onUpdate": "no action",
"tableFrom": "topic",
"tableTo": "group"
}
},
"indexes": {
"topic_assistant_id_idx": {
"columns": ["assistant_id"],
"isUnique": false,
"name": "topic_assistant_id_idx"
},
"topic_group_sort_idx": {
"columns": ["group_id", "sort_order"],
"isUnique": false,
"name": "topic_group_sort_idx"
},
"topic_group_updated_idx": {
"columns": ["group_id", "updated_at"],
"isUnique": false,
"name": "topic_group_updated_idx"
},
"topic_is_pinned_idx": {
"columns": ["is_pinned", "pinned_order"],
"isUnique": false,
"name": "topic_is_pinned_idx"
},
"topic_updated_at_idx": {
"columns": ["updated_at"],
"isUnique": false,
"name": "topic_updated_at_idx"
}
},
"name": "topic",
"uniqueConstraints": {}
}
},

View File

@ -0,0 +1,612 @@
{
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
},
"dialect": "sqlite",
"enums": {},
"id": "a433b120-0ab8-4f3f-9d1d-766b48c216c8",
"internal": {
"indexes": {}
},
"prevId": "2ee6f7b2-99da-4de1-b895-48866855b7c6",
"tables": {
"app_state": {
"checkConstraints": {},
"columns": {
"created_at": {
"autoincrement": false,
"name": "created_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"description": {
"autoincrement": false,
"name": "description",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"key": {
"autoincrement": false,
"name": "key",
"notNull": true,
"primaryKey": true,
"type": "text"
},
"updated_at": {
"autoincrement": false,
"name": "updated_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"value": {
"autoincrement": false,
"name": "value",
"notNull": true,
"primaryKey": false,
"type": "text"
}
},
"compositePrimaryKeys": {},
"foreignKeys": {},
"indexes": {},
"name": "app_state",
"uniqueConstraints": {}
},
"entity_tag": {
"checkConstraints": {},
"columns": {
"created_at": {
"autoincrement": false,
"name": "created_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"entity_id": {
"autoincrement": false,
"name": "entity_id",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"entity_type": {
"autoincrement": false,
"name": "entity_type",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"tag_id": {
"autoincrement": false,
"name": "tag_id",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"updated_at": {
"autoincrement": false,
"name": "updated_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
}
},
"compositePrimaryKeys": {
"entity_tag_entity_type_entity_id_tag_id_pk": {
"columns": ["entity_type", "entity_id", "tag_id"],
"name": "entity_tag_entity_type_entity_id_tag_id_pk"
}
},
"foreignKeys": {
"entity_tag_tag_id_tag_id_fk": {
"columnsFrom": ["tag_id"],
"columnsTo": ["id"],
"name": "entity_tag_tag_id_tag_id_fk",
"onDelete": "cascade",
"onUpdate": "no action",
"tableFrom": "entity_tag",
"tableTo": "tag"
}
},
"indexes": {
"entity_tag_tag_id_idx": {
"columns": ["tag_id"],
"isUnique": false,
"name": "entity_tag_tag_id_idx"
}
},
"name": "entity_tag",
"uniqueConstraints": {}
},
"group": {
"checkConstraints": {},
"columns": {
"created_at": {
"autoincrement": false,
"name": "created_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"entity_type": {
"autoincrement": false,
"name": "entity_type",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"id": {
"autoincrement": false,
"name": "id",
"notNull": true,
"primaryKey": true,
"type": "text"
},
"name": {
"autoincrement": false,
"name": "name",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"sort_order": {
"autoincrement": false,
"default": 0,
"name": "sort_order",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"updated_at": {
"autoincrement": false,
"name": "updated_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
}
},
"compositePrimaryKeys": {},
"foreignKeys": {},
"indexes": {
"group_entity_sort_idx": {
"columns": ["entity_type", "sort_order"],
"isUnique": false,
"name": "group_entity_sort_idx"
}
},
"name": "group",
"uniqueConstraints": {}
},
"message": {
"checkConstraints": {
"message_role_check": {
"name": "message_role_check",
"value": "\"message\".\"role\" IN ('user', 'assistant', 'system')"
},
"message_status_check": {
"name": "message_status_check",
"value": "\"message\".\"status\" IN ('pending', 'success', 'error', 'paused')"
}
},
"columns": {
"assistant_id": {
"autoincrement": false,
"name": "assistant_id",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"assistant_meta": {
"autoincrement": false,
"name": "assistant_meta",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"created_at": {
"autoincrement": false,
"name": "created_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"data": {
"autoincrement": false,
"name": "data",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"deleted_at": {
"autoincrement": false,
"name": "deleted_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"id": {
"autoincrement": false,
"name": "id",
"notNull": true,
"primaryKey": true,
"type": "text"
},
"model_id": {
"autoincrement": false,
"name": "model_id",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"model_meta": {
"autoincrement": false,
"name": "model_meta",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"parent_id": {
"autoincrement": false,
"name": "parent_id",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"role": {
"autoincrement": false,
"name": "role",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"searchable_text": {
"autoincrement": false,
"name": "searchable_text",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"siblings_group_id": {
"autoincrement": false,
"default": 0,
"name": "siblings_group_id",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"stats": {
"autoincrement": false,
"name": "stats",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"status": {
"autoincrement": false,
"name": "status",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"topic_id": {
"autoincrement": false,
"name": "topic_id",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"trace_id": {
"autoincrement": false,
"name": "trace_id",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"updated_at": {
"autoincrement": false,
"name": "updated_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
}
},
"compositePrimaryKeys": {},
"foreignKeys": {
"message_parent_id_message_id_fk": {
"columnsFrom": ["parent_id"],
"columnsTo": ["id"],
"name": "message_parent_id_message_id_fk",
"onDelete": "set null",
"onUpdate": "no action",
"tableFrom": "message",
"tableTo": "message"
},
"message_topic_id_topic_id_fk": {
"columnsFrom": ["topic_id"],
"columnsTo": ["id"],
"name": "message_topic_id_topic_id_fk",
"onDelete": "cascade",
"onUpdate": "no action",
"tableFrom": "message",
"tableTo": "topic"
}
},
"indexes": {
"message_parent_id_idx": {
"columns": ["parent_id"],
"isUnique": false,
"name": "message_parent_id_idx"
},
"message_topic_created_idx": {
"columns": ["topic_id", "created_at"],
"isUnique": false,
"name": "message_topic_created_idx"
},
"message_trace_id_idx": {
"columns": ["trace_id"],
"isUnique": false,
"name": "message_trace_id_idx"
}
},
"name": "message",
"uniqueConstraints": {}
},
"preference": {
"checkConstraints": {},
"columns": {
"created_at": {
"autoincrement": false,
"name": "created_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"key": {
"autoincrement": false,
"name": "key",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"scope": {
"autoincrement": false,
"default": "'default'",
"name": "scope",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"updated_at": {
"autoincrement": false,
"name": "updated_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"value": {
"autoincrement": false,
"name": "value",
"notNull": false,
"primaryKey": false,
"type": "text"
}
},
"compositePrimaryKeys": {
"preference_scope_key_pk": {
"columns": ["scope", "key"],
"name": "preference_scope_key_pk"
}
},
"foreignKeys": {},
"indexes": {},
"name": "preference",
"uniqueConstraints": {}
},
"tag": {
"checkConstraints": {},
"columns": {
"color": {
"autoincrement": false,
"name": "color",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"created_at": {
"autoincrement": false,
"name": "created_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"id": {
"autoincrement": false,
"name": "id",
"notNull": true,
"primaryKey": true,
"type": "text"
},
"name": {
"autoincrement": false,
"name": "name",
"notNull": true,
"primaryKey": false,
"type": "text"
},
"updated_at": {
"autoincrement": false,
"name": "updated_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
}
},
"compositePrimaryKeys": {},
"foreignKeys": {},
"indexes": {
"tag_name_unique": {
"columns": ["name"],
"isUnique": true,
"name": "tag_name_unique"
}
},
"name": "tag",
"uniqueConstraints": {}
},
"topic": {
"checkConstraints": {},
"columns": {
"active_node_id": {
"autoincrement": false,
"name": "active_node_id",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"assistant_id": {
"autoincrement": false,
"name": "assistant_id",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"assistant_meta": {
"autoincrement": false,
"name": "assistant_meta",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"created_at": {
"autoincrement": false,
"name": "created_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"deleted_at": {
"autoincrement": false,
"name": "deleted_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"group_id": {
"autoincrement": false,
"name": "group_id",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"id": {
"autoincrement": false,
"name": "id",
"notNull": true,
"primaryKey": true,
"type": "text"
},
"is_name_manually_edited": {
"autoincrement": false,
"default": false,
"name": "is_name_manually_edited",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"is_pinned": {
"autoincrement": false,
"default": false,
"name": "is_pinned",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"name": {
"autoincrement": false,
"name": "name",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"pinned_order": {
"autoincrement": false,
"default": 0,
"name": "pinned_order",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"prompt": {
"autoincrement": false,
"name": "prompt",
"notNull": false,
"primaryKey": false,
"type": "text"
},
"sort_order": {
"autoincrement": false,
"default": 0,
"name": "sort_order",
"notNull": false,
"primaryKey": false,
"type": "integer"
},
"updated_at": {
"autoincrement": false,
"name": "updated_at",
"notNull": false,
"primaryKey": false,
"type": "integer"
}
},
"compositePrimaryKeys": {},
"foreignKeys": {
"topic_group_id_group_id_fk": {
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"name": "topic_group_id_group_id_fk",
"onDelete": "set null",
"onUpdate": "no action",
"tableFrom": "topic",
"tableTo": "group"
}
},
"indexes": {
"topic_assistant_id_idx": {
"columns": ["assistant_id"],
"isUnique": false,
"name": "topic_assistant_id_idx"
},
"topic_group_sort_idx": {
"columns": ["group_id", "sort_order"],
"isUnique": false,
"name": "topic_group_sort_idx"
},
"topic_group_updated_idx": {
"columns": ["group_id", "updated_at"],
"isUnique": false,
"name": "topic_group_updated_idx"
},
"topic_is_pinned_idx": {
"columns": ["is_pinned", "pinned_order"],
"isUnique": false,
"name": "topic_is_pinned_idx"
},
"topic_updated_at_idx": {
"columns": ["updated_at"],
"isUnique": false,
"name": "topic_updated_at_idx"
}
},
"name": "topic",
"uniqueConstraints": {}
}
},
"version": "6",
"views": {}
}

View File

@ -4,9 +4,16 @@
{
"breakpoints": true,
"idx": 0,
"tag": "0000_solid_lord_hawal",
"tag": "0000_init",
"version": "6",
"when": 1754745234572
"when": 1767272575118
},
{
"breakpoints": true,
"idx": 1,
"tag": "0001_futuristic_human_fly",
"version": "6",
"when": 1767455592181
}
],
"version": "7"

View File

@ -27,6 +27,7 @@
"scripts": {
"start": "electron-vite preview",
"dev": "dotenv electron-vite dev",
"dev:watch": "dotenv electron-vite dev -- -w",
"debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222",
"build": "npm run typecheck && electron-vite build",
"build:check": "yarn lint && yarn test",
@ -53,10 +54,10 @@
"typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"",
"typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false",
"check:i18n": "dotenv -e .env -- tsx scripts/check-i18n.ts",
"sync:i18n": "dotenv -e .env -- tsx scripts/sync-i18n.ts",
"update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts",
"auto:i18n": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
"i18n:check": "dotenv -e .env -- tsx scripts/check-i18n.ts",
"i18n:sync": "dotenv -e .env -- tsx scripts/sync-i18n.ts",
"i18n:translate": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
"i18n:all": "yarn i18n:check && yarn i18n:sync && yarn i18n:translate",
"update:languages": "tsx scripts/update-languages.ts",
"update:upgrade-config": "tsx scripts/update-app-upgrade-config.ts",
"test": "vitest run --silent",
@ -70,13 +71,12 @@
"test:e2e": "yarn playwright test",
"test:lint": "oxlint --deny-warnings && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --cache",
"test:scripts": "vitest scripts",
"lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && biome lint --write && biome format --write && yarn typecheck && yarn check:i18n && yarn format:check",
"lint:ox": "oxlint --fix && biome lint --write && biome format --write",
"lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && biome lint --write && biome format --write && yarn typecheck && yarn i18n:check && yarn format:check",
"format": "biome format --write && biome lint --write",
"format:check": "biome format && biome lint",
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky",
"claude": "dotenv -e .env -- claude",
"migrations:generate": "drizzle-kit generate --config ./migrations/sqlite-drizzle.config.ts",
"db:migrations:generate": "drizzle-kit generate --config ./migrations/sqlite-drizzle.config.ts",
"release:aicore:alpha": "yarn workspace @cherrystudio/ai-core version prerelease --preid alpha --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --tag alpha --access public",
"release:aicore:beta": "yarn workspace @cherrystudio/ai-core version prerelease --preid beta --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --tag beta --access public",
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --access public",
@ -89,6 +89,7 @@
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
"@paymoapp/electron-shutdown-handler": "^1.1.2",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"bonjour-service": "^1.3.0",
"emoji-picker-element-data": "^1",
"express": "^5.1.0",
"font-list": "^2.0.0",
@ -99,10 +100,8 @@
"node-stream-zip": "^1.15.0",
"officeparser": "^4.2.0",
"os-proxy-config": "^1.1.2",
"qrcode.react": "^4.2.0",
"selection-hook": "^1.0.12",
"sharp": "^0.34.3",
"socket.io": "^4.8.1",
"stream-json": "^1.9.1",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
@ -117,8 +116,8 @@
"@ai-sdk/anthropic": "^2.0.49",
"@ai-sdk/cerebras": "^1.0.31",
"@ai-sdk/gateway": "^2.0.15",
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.43#~/.yarn/patches/@ai-sdk-google-npm-2.0.43-689ed559b3.patch",
"@ai-sdk/google-vertex": "^3.0.79",
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.49#~/.yarn/patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch",
"@ai-sdk/google-vertex": "^3.0.94",
"@ai-sdk/huggingface": "^0.0.10",
"@ai-sdk/mistral": "^2.0.24",
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.85#~/.yarn/patches/@ai-sdk-openai-npm-2.0.85-27483d1d6a.patch",
@ -279,7 +278,7 @@
"electron-reload": "^2.0.0-alpha.1",
"electron-store": "^8.2.0",
"electron-updater": "patch:electron-updater@npm%3A6.7.0#~/.yarn/patches/electron-updater-npm-6.7.0-47b11bb0d4.patch",
"electron-vite": "4.0.1",
"electron-vite": "5.0.0",
"electron-window-state": "^5.0.3",
"emittery": "^1.0.3",
"emoji-picker-element": "^1.22.1",
@ -376,7 +375,7 @@
"undici": "6.21.2",
"unified": "^11.0.5",
"uuid": "^13.0.0",
"vite": "npm:rolldown-vite@7.1.5",
"vite": "npm:rolldown-vite@7.3.0",
"vitest": "^3.2.4",
"webdav": "^5.8.0",
"winston": "^3.17.0",
@ -390,6 +389,8 @@
"zod": "^4.1.5"
},
"resolutions": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"@smithy/types": "4.7.1",
"@codemirror/language": "6.11.3",
"@codemirror/lint": "6.8.5",
@ -406,7 +407,7 @@
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
"tar-fs": "^2.1.4",
"undici": "6.21.2",
"vite": "npm:rolldown-vite@7.1.5",
"vite": "npm:rolldown-vite@7.3.0",
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"@ai-sdk/openai@npm:^2.0.52": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch",
"@img/sharp-darwin-arm64": "0.34.3",
@ -421,7 +422,10 @@
"@langchain/openai@npm:>=0.2.0 <0.7.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
"@ai-sdk/openai@npm:^2.0.42": "patch:@ai-sdk/openai@npm%3A2.0.85#~/.yarn/patches/@ai-sdk-openai-npm-2.0.85-27483d1d6a.patch",
"@ai-sdk/google@npm:^2.0.40": "patch:@ai-sdk/google@npm%3A2.0.40#~/.yarn/patches/@ai-sdk-google-npm-2.0.40-47e0eeee83.patch",
"@ai-sdk/openai-compatible@npm:^1.0.27": "patch:@ai-sdk/openai-compatible@npm%3A1.0.27#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.27-06f74278cf.patch"
"@ai-sdk/openai-compatible@npm:^1.0.27": "patch:@ai-sdk/openai-compatible@npm%3A1.0.27#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.27-06f74278cf.patch",
"@ai-sdk/google@npm:2.0.49": "patch:@ai-sdk/google@npm%3A2.0.49#~/.yarn/patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch",
"@ai-sdk/openai-compatible@npm:1.0.27": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch",
"@ai-sdk/openai-compatible@npm:^1.0.19": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch"
},
"packageManager": "yarn@4.9.1",
"lint-staged": {

View File

@ -41,7 +41,7 @@
"ai": "^5.0.26"
},
"dependencies": {
"@ai-sdk/openai-compatible": "^1.0.28",
"@ai-sdk/openai-compatible": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.17"
},

View File

@ -42,7 +42,7 @@
"@ai-sdk/anthropic": "^2.0.49",
"@ai-sdk/azure": "^2.0.87",
"@ai-sdk/deepseek": "^1.0.31",
"@ai-sdk/openai-compatible": "patch:@ai-sdk/openai-compatible@npm%3A1.0.27#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.27-06f74278cf.patch",
"@ai-sdk/openai-compatible": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.17",
"@ai-sdk/xai": "^2.0.36",

View File

@ -22,10 +22,10 @@ const TOOL_USE_TAG_CONFIG: TagConfig = {
}
/**
* Cherry Studio
*
*/
const DEFAULT_SYSTEM_PROMPT = `In this environment you have access to a set of tools you can use to answer the user's question. \\
You can use one tool per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use.
export const DEFAULT_SYSTEM_PROMPT = `In this environment you have access to a set of tools you can use to answer the user's question. \
You can use one or more tools per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use.
## Tool Use Formatting
@ -74,10 +74,13 @@ Here are the rules you should always follow to solve your task:
4. Never re-do a tool call that you previously did with the exact same parameters.
5. For tool use, MAKE SURE use XML tag format as shown in the examples above. Do not use any other format.
## Response rules
Respond in the language of the user's query, unless the user instructions specify additional requirements for the language to be used.
# User Instructions
{{ USER_SYSTEM_PROMPT }}
Now Begin! If you solve the task correctly, you will receive a reward of $1,000,000.`
`
/**
* 使 Cherry Studio

View File

@ -233,6 +233,8 @@ export enum IpcChannel {
Backup_ListS3Files = 'backup:listS3Files',
Backup_DeleteS3File = 'backup:deleteS3File',
Backup_CheckS3Connection = 'backup:checkS3Connection',
Backup_CreateLanTransferBackup = 'backup:createLanTransferBackup',
Backup_DeleteTempBackup = 'backup:deleteTempBackup',
// data migration
DataMigrate_CheckNeeded = 'data-migrate:check-needed',
@ -260,6 +262,7 @@ export enum IpcChannel {
System_GetCpuName = 'system:getCpuName',
System_CheckGitBash = 'system:checkGitBash',
System_GetGitBashPath = 'system:getGitBashPath',
System_GetGitBashPathInfo = 'system:getGitBashPathInfo',
System_SetGitBashPath = 'system:setGitBashPath',
// DevTools
@ -326,6 +329,7 @@ export enum IpcChannel {
Memory_DeleteUser = 'memory:delete-user',
Memory_DeleteAllMemoriesForUser = 'memory:delete-all-memories-for-user',
Memory_GetUsersList = 'memory:get-users-list',
Memory_MigrateMemoryDb = 'memory:migrate-memory-db',
// Data: Preference
Preference_Get = 'preference:get',
@ -339,11 +343,10 @@ export enum IpcChannel {
// Data: Cache
Cache_Sync = 'cache:sync',
Cache_SyncBatch = 'cache:sync-batch',
Cache_GetAllShared = 'cache:get-all-shared',
// Data: API Channels
DataApi_Request = 'data-api:request',
DataApi_Batch = 'data-api:batch',
DataApi_Transaction = 'data-api:transaction',
DataApi_Subscribe = 'data-api:subscribe',
DataApi_Unsubscribe = 'data-api:unsubscribe',
DataApi_Stream = 'data-api:stream',
@ -392,6 +395,7 @@ export enum IpcChannel {
OCR_ListProviders = 'ocr:list-providers',
// OVMS
Ovms_IsSupported = 'ovms:is-supported',
Ovms_AddModel = 'ovms:add-model',
Ovms_StopAddModel = 'ovms:stop-addmodel',
Ovms_GetModels = 'ovms:get-models',
@ -412,10 +416,14 @@ export enum IpcChannel {
ClaudeCodePlugin_ReadContent = 'claudeCodePlugin:read-content',
ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content',
// WebSocket
WebSocket_Start = 'webSocket:start',
WebSocket_Stop = 'webSocket:stop',
WebSocket_Status = 'webSocket:status',
WebSocket_SendFile = 'webSocket:send-file',
WebSocket_GetAllCandidates = 'webSocket:get-all-candidates'
// Local Transfer
LocalTransfer_ListServices = 'local-transfer:list',
LocalTransfer_StartScan = 'local-transfer:start-scan',
LocalTransfer_StopScan = 'local-transfer:stop-scan',
LocalTransfer_ServicesUpdated = 'local-transfer:services-updated',
LocalTransfer_Connect = 'local-transfer:connect',
LocalTransfer_Disconnect = 'local-transfer:disconnect',
LocalTransfer_ClientEvent = 'local-transfer:client-event',
LocalTransfer_SendFile = 'local-transfer:send-file',
LocalTransfer_CancelTransfer = 'local-transfer:cancel-transfer'
}

View File

@ -488,3 +488,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
// resources/scripts should be maintained manually
export const HOME_CHERRY_DIR = '.cherrystudio'
// Git Bash path configuration types
export type GitBashPathSource = 'manual' | 'auto'
export interface GitBashPathInfo {
path: string | null
source: GitBashPathSource | null
}

View File

@ -52,3 +52,196 @@ export interface WebSocketCandidatesResponse {
interface: string
priority: number
}
export type LocalTransferPeer = {
id: string
name: string
host?: string
fqdn?: string
port?: number
type?: string
protocol?: 'tcp' | 'udp'
addresses: string[]
txt?: Record<string, string>
updatedAt: number
}
export type LocalTransferState = {
services: LocalTransferPeer[]
isScanning: boolean
lastScanStartedAt?: number
lastUpdatedAt: number
lastError?: string
}
export type LanHandshakeRequestMessage = {
type: 'handshake'
deviceName: string
version: string
platform?: string
appVersion?: string
}
export type LanHandshakeAckMessage = {
type: 'handshake_ack'
accepted: boolean
message?: string
}
export type LocalTransferConnectPayload = {
peerId: string
metadata?: Record<string, string>
timeoutMs?: number
}
export type LanClientEvent =
| {
type: 'ping_sent'
payload: string
timestamp: number
peerId?: string
peerName?: string
}
| {
type: 'pong'
payload?: string
received?: boolean
timestamp: number
peerId?: string
peerName?: string
}
| {
type: 'socket_closed'
reason?: string
timestamp: number
peerId?: string
peerName?: string
}
| {
type: 'error'
message: string
timestamp: number
peerId?: string
peerName?: string
}
| {
type: 'file_transfer_progress'
transferId: string
fileName: string
bytesSent: number
totalBytes: number
chunkIndex: number
totalChunks: number
progress: number // 0-100
speed: number // bytes/sec
timestamp: number
peerId?: string
peerName?: string
}
| {
type: 'file_transfer_complete'
transferId: string
fileName: string
success: boolean
filePath?: string
error?: string
timestamp: number
peerId?: string
peerName?: string
}
// =============================================================================
// LAN File Transfer Protocol Types
// =============================================================================
// Constants for file transfer
export const LAN_TRANSFER_TCP_PORT = 53317
export const LAN_TRANSFER_CHUNK_SIZE = 512 * 1024 // 512KB
export const LAN_TRANSFER_MAX_FILE_SIZE = 500 * 1024 * 1024 // 500MB
export const LAN_TRANSFER_COMPLETE_TIMEOUT_MS = 60_000 // 60s - wait for file_complete after file_end
export const LAN_TRANSFER_GLOBAL_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes - global transfer timeout
// Binary protocol constants (v1)
export const LAN_TRANSFER_PROTOCOL_VERSION = '1'
export const LAN_BINARY_FRAME_MAGIC = 0x4353 // "CS" as uint16
export const LAN_BINARY_TYPE_FILE_CHUNK = 0x01
// Messages from Electron (Client/Sender) to Mobile (Server/Receiver)
/** Request to start file transfer */
export type LanFileStartMessage = {
type: 'file_start'
transferId: string
fileName: string
fileSize: number
mimeType: string // 'application/zip'
checksum: string // SHA-256 of entire file
totalChunks: number
chunkSize: number
}
/**
* File chunk data (JSON format)
* @deprecated Use binary frame format in protocol v1. This type is kept for reference only.
*/
export type LanFileChunkMessage = {
type: 'file_chunk'
transferId: string
chunkIndex: number
data: string // Base64 encoded
chunkChecksum: string // SHA-256 of this chunk
}
/** Notification that all chunks have been sent */
export type LanFileEndMessage = {
type: 'file_end'
transferId: string
}
/** Request to cancel file transfer */
export type LanFileCancelMessage = {
type: 'file_cancel'
transferId: string
reason?: string
}
// Messages from Mobile (Server/Receiver) to Electron (Client/Sender)
/** Acknowledgment of file transfer request */
export type LanFileStartAckMessage = {
type: 'file_start_ack'
transferId: string
accepted: boolean
message?: string // Rejection reason
}
/**
* Acknowledgment of file chunk received
* @deprecated Protocol v1 uses streaming mode without per-chunk acknowledgment.
* This type is kept for backward compatibility reference only.
*/
export type LanFileChunkAckMessage = {
type: 'file_chunk_ack'
transferId: string
chunkIndex: number
received: boolean
message?: string
}
/** Final result of file transfer */
export type LanFileCompleteMessage = {
type: 'file_complete'
transferId: string
success: boolean
filePath?: string // Path where file was saved on mobile
error?: string
// Enhanced error diagnostics
errorCode?: 'CHECKSUM_MISMATCH' | 'INCOMPLETE_TRANSFER' | 'DISK_ERROR' | 'CANCELLED'
receivedChunks?: number
receivedBytes?: number
}
/** Payload for sending a file via IPC */
export type LanFileSendPayload = {
filePath: string
}

View File

@ -1,106 +1,50 @@
# Cherry Studio Shared Data
# Shared Data Types
This directory contains shared type definitions and schemas for the Cherry Studio data management systems. These files provide type safety and consistency across the entire application.
This directory contains shared type definitions for Cherry Studio's data layer.
## 📁 Directory Structure
## Documentation
For comprehensive documentation, see:
- **Overview**: [docs/en/references/data/README.md](../../../docs/en/references/data/README.md)
- **Cache Types**: [cache-overview.md](../../../docs/en/references/data/cache-overview.md)
- **Preference Types**: [preference-overview.md](../../../docs/en/references/data/preference-overview.md)
- **API Types**: [api-types.md](../../../docs/en/references/data/api-types.md)
## Directory Structure
```
packages/shared/data/
├── api/ # Data API type system
│ ├── index.ts # Barrel exports for clean imports
│ ├── apiSchemas.ts # API endpoint definitions and mappings
│ ├── apiTypes.ts # Core request/response infrastructure types
│ ├── apiModels.ts # Business entity types and DTOs
│ ├── apiPaths.ts # API path definitions and utilities
│ └── errorCodes.ts # Standardized error handling
│ ├── index.ts # Barrel exports
│ ├── apiTypes.ts # Core request/response types
│ ├── apiPaths.ts # Path template utilities
│ ├── apiErrors.ts # Error handling
│ └── schemas/ # Domain-specific API schemas
├── cache/ # Cache system type definitions
│ ├── cacheTypes.ts # Core cache infrastructure types
│ ├── cacheSchemas.ts # Cache key schemas and type mappings
│ └── cacheValueTypes.ts # Cache value type definitions
│ ├── cacheTypes.ts # Core cache types
│ ├── cacheSchemas.ts # Cache key schemas
│ └── cacheValueTypes.ts # Cache value types
├── preference/ # Preference system type definitions
│ ├── preferenceTypes.ts # Core preference system types
│ └── preferenceSchemas.ts # Preference schemas and default values
└── README.md # This file
│ ├── preferenceTypes.ts # Core preference types
│ └── preferenceSchemas.ts # Preference schemas
└── types/ # Shared data types
```
## 🏗️ System Overview
## Quick Reference
This directory provides type definitions for three main data management systems:
### Import Conventions
### API System (`api/`)
- **Purpose**: Type-safe IPC communication between Main and Renderer processes
- **Features**: RESTful patterns, error handling, business entity definitions
- **Usage**: Ensures type safety for all data API operations
### Cache System (`cache/`)
- **Purpose**: Type definitions for three-layer caching architecture
- **Features**: Memory/shared/persist cache schemas, TTL support, hook integration
- **Usage**: Type-safe caching operations across the application
### Preference System (`preference/`)
- **Purpose**: User configuration and settings management
- **Features**: 158 configuration items, default values, nested key support
- **Usage**: Type-safe preference access and synchronization
## 📋 File Categories
**Framework Infrastructure** - These are TypeScript type definitions that:
- ✅ Exist only at compile time
- ✅ Provide type safety and IntelliSense support
- ✅ Define contracts between application layers
- ✅ Enable static analysis and error detection
## 📖 Usage Examples
### API Types
```typescript
// Import API types
import type { DataRequest, DataResponse, ApiSchemas } from '@shared/data/api'
```
// API infrastructure types (from barrel)
import type { DataRequest, DataResponse, ApiClient } from '@shared/data/api'
import { ErrorCode, DataApiError, DataApiErrorFactory } from '@shared/data/api'
### Cache Types
```typescript
// Import cache types
// Domain DTOs (from schema files)
import type { Topic, CreateTopicDto } from '@shared/data/api/schemas/topic'
// Cache types
import type { UseCacheKey, UseSharedCacheKey } from '@shared/data/cache'
// Preference types
import type { PreferenceKeyType } from '@shared/data/preference'
```
### Preference Types
```typescript
// Import preference types
import type { PreferenceKeyType, PreferenceDefaultScopeType } from '@shared/data/preference'
```
## 🔧 Development Guidelines
### Adding Cache Types
1. Add cache key to `cache/cacheSchemas.ts`
2. Define value type in `cache/cacheValueTypes.ts`
3. Update type mappings for type safety
### Adding Preference Types
1. Add preference key to `preference/preferenceSchemas.ts`
2. Define default value and type
3. Preference system automatically picks up new keys
### Adding API Types
1. Define business entities in `api/apiModels.ts`
2. Add endpoint definitions to `api/apiSchemas.ts`
3. Export types from `api/index.ts`
### Best Practices
- Use `import type` for type-only imports
- Follow existing naming conventions
- Document complex types with JSDoc
- Maintain type safety across all imports
## 🔗 Related Implementation
### Main Process Services
- `src/main/data/CacheService.ts` - Main process cache management
- `src/main/data/PreferenceService.ts` - Preference management service
- `src/main/data/DataApiService.ts` - Data API coordination service
### Renderer Process Services
- `src/renderer/src/data/CacheService.ts` - Renderer cache service
- `src/renderer/src/data/PreferenceService.ts` - Renderer preference service
- `src/renderer/src/data/DataApiService.ts` - Renderer API client

View File

@ -0,0 +1,42 @@
# Data API Type System
This directory contains type definitions for the DataApi system.
## Documentation
- **DataApi Overview**: [docs/en/references/data/data-api-overview.md](../../../../docs/en/references/data/data-api-overview.md)
- **API Types**: [api-types.md](../../../../docs/en/references/data/api-types.md)
- **API Design Guidelines**: [api-design-guidelines.md](../../../../docs/en/references/data/api-design-guidelines.md)
## Directory Structure
```
packages/shared/data/api/
├── index.ts # Barrel exports
├── apiTypes.ts # Core request/response types
├── apiPaths.ts # Path template utilities
├── apiErrors.ts # Error handling
└── schemas/
├── index.ts # Schema composition
└── *.ts # Domain-specific schemas
```
## Quick Reference
### Import Conventions
```typescript
// Infrastructure types (via barrel)
import type { DataRequest, DataResponse, ApiClient } from '@shared/data/api'
import { ErrorCode, DataApiError, DataApiErrorFactory } from '@shared/data/api'
// Domain DTOs (directly from schema files)
import type { Topic, CreateTopicDto } from '@shared/data/api/schemas/topic'
import type { Message, CreateMessageDto } from '@shared/data/api/schemas/message'
```
### Adding New Schemas
1. Create schema file in `schemas/` (e.g., `topic.ts`)
2. Register in `schemas/index.ts` using intersection type
3. Implement handlers in `src/main/data/api/handlers/`

View File

@ -0,0 +1,817 @@
/**
* @fileoverview Centralized error handling for the Data API system
*
* This module provides comprehensive error management including:
* - ErrorCode enum with HTTP status mapping
* - Type-safe error details for each error type
* - DataApiError class for structured error handling
* - DataApiErrorFactory for convenient error creation
* - Retryability configuration for automatic retry logic
*
* @example
* ```typescript
* import { DataApiError, DataApiErrorFactory, ErrorCode } from '@shared/data/api'
*
* // Create and throw an error
* throw DataApiErrorFactory.notFound('Topic', 'abc123')
*
* // Check if error is retryable
* if (error instanceof DataApiError && error.isRetryable) {
* await retry(operation)
* }
* ```
*/
import type { HttpMethod } from './apiTypes'
// ============================================================================
// Error Code Enum
// ============================================================================
/**
* Standard error codes for the Data API system.
* Maps to HTTP status codes via ERROR_STATUS_MAP.
*/
export enum ErrorCode {
// ─────────────────────────────────────────────────────────────────
// Client errors (4xx) - Issues with the request itself
// ─────────────────────────────────────────────────────────────────
/** 400 - Malformed request syntax or invalid parameters */
BAD_REQUEST = 'BAD_REQUEST',
/** 401 - Authentication required or credentials invalid */
UNAUTHORIZED = 'UNAUTHORIZED',
/** 404 - Requested resource does not exist */
NOT_FOUND = 'NOT_FOUND',
/** 405 - HTTP method not supported for this endpoint */
METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED',
/** 422 - Request body fails validation rules */
VALIDATION_ERROR = 'VALIDATION_ERROR',
/** 429 - Too many requests, retry after delay */
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
/** 403 - Authenticated but lacks required permissions */
PERMISSION_DENIED = 'PERMISSION_DENIED',
/**
* 400 - Operation is not valid in current state.
* Use when: deleting root message without cascade, moving node would create cycle,
* or any operation that violates business rules but isn't a validation error.
*/
INVALID_OPERATION = 'INVALID_OPERATION',
// ─────────────────────────────────────────────────────────────────
// Server errors (5xx) - Issues on the server side
// ─────────────────────────────────────────────────────────────────
/** 500 - Unexpected server error */
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
/** 500 - Database operation failed (connection, query, constraint) */
DATABASE_ERROR = 'DATABASE_ERROR',
/** 503 - Service temporarily unavailable, retry later */
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
/** 504 - Request timed out waiting for response */
TIMEOUT = 'TIMEOUT',
// ─────────────────────────────────────────────────────────────────
// Application-specific errors
// ─────────────────────────────────────────────────────────────────
/** 500 - Data migration process failed */
MIGRATION_ERROR = 'MIGRATION_ERROR',
/**
* 423 - Resource is temporarily locked by another operation.
* Use when: file being exported, data migration in progress,
* or resource held by background process.
* Retryable: Yes (resource may be released)
*/
RESOURCE_LOCKED = 'RESOURCE_LOCKED',
/**
* 409 - Optimistic lock conflict, resource was modified after read.
* Use when: multi-window editing same topic, version mismatch
* on update, or stale data detected during save.
* Client should: refresh data and retry or notify user.
*/
CONCURRENT_MODIFICATION = 'CONCURRENT_MODIFICATION',
/**
* 409 - Data integrity violation or inconsistent state detected.
* Use when: referential integrity broken, computed values mismatch,
* or data corruption found during validation.
* Not retryable: requires investigation or data repair.
*/
DATA_INCONSISTENT = 'DATA_INCONSISTENT'
}
// ============================================================================
// Error Code Mappings
// ============================================================================
/**
* Maps error codes to HTTP status codes.
* Used by DataApiError and DataApiErrorFactory.
*/
export const ERROR_STATUS_MAP: Record<ErrorCode, number> = {
// Client errors (4xx)
[ErrorCode.BAD_REQUEST]: 400,
[ErrorCode.UNAUTHORIZED]: 401,
[ErrorCode.NOT_FOUND]: 404,
[ErrorCode.METHOD_NOT_ALLOWED]: 405,
[ErrorCode.VALIDATION_ERROR]: 422,
[ErrorCode.RATE_LIMIT_EXCEEDED]: 429,
[ErrorCode.PERMISSION_DENIED]: 403,
[ErrorCode.INVALID_OPERATION]: 400,
// Server errors (5xx)
[ErrorCode.INTERNAL_SERVER_ERROR]: 500,
[ErrorCode.DATABASE_ERROR]: 500,
[ErrorCode.SERVICE_UNAVAILABLE]: 503,
[ErrorCode.TIMEOUT]: 504,
// Application-specific errors
[ErrorCode.MIGRATION_ERROR]: 500,
[ErrorCode.RESOURCE_LOCKED]: 423,
[ErrorCode.CONCURRENT_MODIFICATION]: 409,
[ErrorCode.DATA_INCONSISTENT]: 409
}
/**
* Default error messages for each error code.
* Used when no custom message is provided.
*/
export const ERROR_MESSAGES: Record<ErrorCode, string> = {
[ErrorCode.BAD_REQUEST]: 'Bad request: Invalid request format or parameters',
[ErrorCode.UNAUTHORIZED]: 'Unauthorized: Authentication required',
[ErrorCode.NOT_FOUND]: 'Not found: Requested resource does not exist',
[ErrorCode.METHOD_NOT_ALLOWED]: 'Method not allowed: HTTP method not supported for this endpoint',
[ErrorCode.VALIDATION_ERROR]: 'Validation error: Request data does not meet requirements',
[ErrorCode.RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded: Too many requests',
[ErrorCode.PERMISSION_DENIED]: 'Permission denied: Insufficient permissions for this operation',
[ErrorCode.INVALID_OPERATION]: 'Invalid operation: Operation not allowed in current state',
[ErrorCode.INTERNAL_SERVER_ERROR]: 'Internal server error: An unexpected error occurred',
[ErrorCode.DATABASE_ERROR]: 'Database error: Failed to access or modify data',
[ErrorCode.SERVICE_UNAVAILABLE]: 'Service unavailable: The service is temporarily unavailable',
[ErrorCode.TIMEOUT]: 'Timeout: Request timed out waiting for response',
[ErrorCode.MIGRATION_ERROR]: 'Migration error: Failed to migrate data',
[ErrorCode.RESOURCE_LOCKED]: 'Resource locked: Resource is currently locked by another operation',
[ErrorCode.CONCURRENT_MODIFICATION]: 'Concurrent modification: Resource was modified by another user',
[ErrorCode.DATA_INCONSISTENT]: 'Data inconsistent: Data integrity violation detected'
}
// ============================================================================
// Request Context
// ============================================================================
/**
* Request context attached to errors for debugging and logging.
* Always transmitted via IPC for frontend display.
*/
export interface RequestContext {
/** Unique identifier for request correlation */
requestId: string
/** API path that was called */
path: string
/** HTTP method used */
method: HttpMethod
/** Timestamp when request was initiated */
timestamp?: number
}
// ============================================================================
// Error-specific Detail Types
// ============================================================================
/**
* Details for VALIDATION_ERROR - field-level validation failures.
* Maps field names to arrays of error messages.
*/
export interface ValidationErrorDetails {
fieldErrors: Record<string, string[]>
}
/**
* Details for NOT_FOUND - which resource was not found.
*/
export interface NotFoundErrorDetails {
resource: string
id?: string
}
/**
* Details for DATABASE_ERROR - underlying database failure info.
*/
export interface DatabaseErrorDetails {
originalError: string
operation?: string
}
/**
* Details for TIMEOUT - what operation timed out.
*/
export interface TimeoutErrorDetails {
operation?: string
timeoutMs?: number
}
/**
* Details for DATA_INCONSISTENT - what data is inconsistent.
*/
export interface DataInconsistentErrorDetails {
resource: string
description?: string
}
/**
* Details for PERMISSION_DENIED - what action was denied.
*/
export interface PermissionDeniedErrorDetails {
action: string
resource?: string
}
/**
* Details for INVALID_OPERATION - what operation was invalid.
*/
export interface InvalidOperationErrorDetails {
operation: string
reason?: string
}
/**
* Details for RESOURCE_LOCKED - which resource is locked.
*/
export interface ResourceLockedErrorDetails {
resource: string
id: string
lockedBy?: string
}
/**
* Details for CONCURRENT_MODIFICATION - which resource was concurrently modified.
*/
export interface ConcurrentModificationErrorDetails {
resource: string
id: string
}
/**
* Details for INTERNAL_SERVER_ERROR - context about the failure.
*/
export interface InternalErrorDetails {
originalError: string
context?: string
}
// ============================================================================
// Type Mapping for Error Details
// ============================================================================
/**
* Maps error codes to their specific detail types.
* Only define for error codes that have structured details.
*/
export type ErrorDetailsMap = {
[ErrorCode.VALIDATION_ERROR]: ValidationErrorDetails
[ErrorCode.NOT_FOUND]: NotFoundErrorDetails
[ErrorCode.DATABASE_ERROR]: DatabaseErrorDetails
[ErrorCode.TIMEOUT]: TimeoutErrorDetails
[ErrorCode.DATA_INCONSISTENT]: DataInconsistentErrorDetails
[ErrorCode.PERMISSION_DENIED]: PermissionDeniedErrorDetails
[ErrorCode.INVALID_OPERATION]: InvalidOperationErrorDetails
[ErrorCode.RESOURCE_LOCKED]: ResourceLockedErrorDetails
[ErrorCode.CONCURRENT_MODIFICATION]: ConcurrentModificationErrorDetails
[ErrorCode.INTERNAL_SERVER_ERROR]: InternalErrorDetails
}
/**
* Get the detail type for a specific error code.
* Falls back to generic Record for unmapped codes.
*/
export type DetailsForCode<T extends ErrorCode> = T extends keyof ErrorDetailsMap
? ErrorDetailsMap[T]
: Record<string, unknown> | undefined
// ============================================================================
// Retryability Configuration
// ============================================================================
/**
* Set of error codes that are safe to retry automatically.
* These represent temporary failures that may succeed on retry.
*/
export const RETRYABLE_ERROR_CODES: ReadonlySet<ErrorCode> = new Set([
ErrorCode.SERVICE_UNAVAILABLE, // 503 - Service temporarily down
ErrorCode.TIMEOUT, // 504 - Request timed out
ErrorCode.RATE_LIMIT_EXCEEDED, // 429 - Can retry after delay
ErrorCode.DATABASE_ERROR, // 500 - Temporary DB issues
ErrorCode.INTERNAL_SERVER_ERROR, // 500 - May be transient
ErrorCode.RESOURCE_LOCKED // 423 - Lock may be released
])
/**
* Check if an error code represents a retryable condition.
* @param code - The error code to check
* @returns true if the error is safe to retry
*/
export function isRetryableErrorCode(code: ErrorCode): boolean {
return RETRYABLE_ERROR_CODES.has(code)
}
// ============================================================================
// Serialized Error Interface (for IPC transmission)
// ============================================================================
/**
* Serialized error structure for IPC transmission.
* Used in DataResponse.error field.
* Note: Does not include stack trace - rely on Main process logs.
*/
export interface SerializedDataApiError {
/** Error code from ErrorCode enum */
code: ErrorCode | string
/** Human-readable error message */
message: string
/** HTTP status code */
status: number
/** Structured error details */
details?: Record<string, unknown>
/** Request context for debugging */
requestContext?: RequestContext
}
// ============================================================================
// DataApiError Class
// ============================================================================
/**
* Custom error class for Data API errors.
*
* Provides type-safe error handling with:
* - Typed error codes and details
* - Retryability checking via `isRetryable` getter
* - IPC serialization via `toJSON()` / `fromJSON()`
* - Request context for debugging
*
* @example
* ```typescript
* // Throw a typed error
* throw new DataApiError(
* ErrorCode.NOT_FOUND,
* 'Topic not found',
* 404,
* { resource: 'Topic', id: 'abc123' }
* )
*
* // Check if error is retryable
* if (error.isRetryable) {
* await retry(operation)
* }
* ```
*/
export class DataApiError<T extends ErrorCode = ErrorCode> extends Error {
/** Error code from ErrorCode enum */
public readonly code: T
/** HTTP status code */
public readonly status: number
/** Structured error details (type depends on error code) */
public readonly details?: DetailsForCode<T>
/** Request context for debugging */
public readonly requestContext?: RequestContext
constructor(code: T, message: string, status: number, details?: DetailsForCode<T>, requestContext?: RequestContext) {
super(message)
this.name = 'DataApiError'
this.code = code
this.status = status
this.details = details
this.requestContext = requestContext
// Maintains proper stack trace for where error was thrown
if (Error.captureStackTrace) {
Error.captureStackTrace(this, DataApiError)
}
}
/**
* Whether this error is safe to retry automatically.
* Based on the RETRYABLE_ERROR_CODES configuration.
*/
get isRetryable(): boolean {
return isRetryableErrorCode(this.code)
}
/**
* Whether this is a client error (4xx status).
* Client errors typically indicate issues with the request itself.
*/
get isClientError(): boolean {
return this.status >= 400 && this.status < 500
}
/**
* Whether this is a server error (5xx status).
* Server errors typically indicate issues on the server side.
*/
get isServerError(): boolean {
return this.status >= 500 && this.status < 600
}
/**
* Serialize for IPC transmission.
* Note: Stack trace is NOT included - rely on Main process logs.
* @returns Serialized error object for IPC
*/
toJSON(): SerializedDataApiError {
return {
code: this.code,
message: this.message,
status: this.status,
details: this.details as Record<string, unknown> | undefined,
requestContext: this.requestContext
}
}
/**
* Reconstruct DataApiError from IPC response.
* @param error - Serialized error from IPC
* @returns DataApiError instance
*/
static fromJSON(error: SerializedDataApiError): DataApiError {
return new DataApiError(error.code as ErrorCode, error.message, error.status, error.details, error.requestContext)
}
/**
* Create DataApiError from a generic Error.
* @param error - Original error
* @param code - Error code to use (defaults to INTERNAL_SERVER_ERROR)
* @param requestContext - Optional request context
* @returns DataApiError instance
*/
static fromError(
error: Error,
code: ErrorCode = ErrorCode.INTERNAL_SERVER_ERROR,
requestContext?: RequestContext
): DataApiError {
return new DataApiError(
code,
error.message,
ERROR_STATUS_MAP[code],
{ originalError: error.message, context: error.name } as DetailsForCode<typeof code>,
requestContext
)
}
}
// ============================================================================
// DataApiErrorFactory
// ============================================================================
/**
* Factory for creating standardized DataApiError instances.
* Provides convenience methods for common error types with proper typing.
*
* @example
* ```typescript
* // Create a not found error
* throw DataApiErrorFactory.notFound('Topic', 'abc123')
*
* // Create a validation error
* throw DataApiErrorFactory.validation({
* name: ['Name is required'],
* email: ['Invalid email format']
* })
* ```
*/
export class DataApiErrorFactory {
/**
* Create a DataApiError with any error code.
* Use specialized methods when available for better type safety.
* @param code - Error code from ErrorCode enum
* @param customMessage - Optional custom error message
* @param details - Optional structured error details
* @param requestContext - Optional request context
* @returns DataApiError instance
*/
static create<T extends ErrorCode>(
code: T,
customMessage?: string,
details?: DetailsForCode<T>,
requestContext?: RequestContext
): DataApiError<T> {
return new DataApiError(
code,
customMessage || ERROR_MESSAGES[code],
ERROR_STATUS_MAP[code],
details,
requestContext
)
}
/**
* Create a validation error with field-specific error messages.
* @param fieldErrors - Map of field names to error messages
* @param message - Optional custom message
* @param requestContext - Optional request context
* @returns DataApiError with VALIDATION_ERROR code
*/
static validation(
fieldErrors: Record<string, string[]>,
message?: string,
requestContext?: RequestContext
): DataApiError<ErrorCode.VALIDATION_ERROR> {
return new DataApiError(
ErrorCode.VALIDATION_ERROR,
message || 'Request validation failed',
ERROR_STATUS_MAP[ErrorCode.VALIDATION_ERROR],
{ fieldErrors },
requestContext
)
}
/**
* Create a not found error for a specific resource.
* @param resource - Resource type name (e.g., 'Topic', 'Message')
* @param id - Optional resource identifier
* @param requestContext - Optional request context
* @returns DataApiError with NOT_FOUND code
*/
static notFound(resource: string, id?: string, requestContext?: RequestContext): DataApiError<ErrorCode.NOT_FOUND> {
const message = id ? `${resource} with id '${id}' not found` : `${resource} not found`
return new DataApiError(
ErrorCode.NOT_FOUND,
message,
ERROR_STATUS_MAP[ErrorCode.NOT_FOUND],
{ resource, id },
requestContext
)
}
/**
* Create a database error from an original error.
* @param originalError - The underlying database error
* @param operation - Description of the failed operation
* @param requestContext - Optional request context
* @returns DataApiError with DATABASE_ERROR code
*/
static database(
originalError: Error,
operation?: string,
requestContext?: RequestContext
): DataApiError<ErrorCode.DATABASE_ERROR> {
return new DataApiError(
ErrorCode.DATABASE_ERROR,
`Database operation failed${operation ? `: ${operation}` : ''}`,
ERROR_STATUS_MAP[ErrorCode.DATABASE_ERROR],
{ originalError: originalError.message, operation },
requestContext
)
}
/**
* Create an internal server error from an unexpected error.
* @param originalError - The underlying error
* @param context - Additional context about where the error occurred
* @param requestContext - Optional request context
* @returns DataApiError with INTERNAL_SERVER_ERROR code
*/
static internal(
originalError: Error,
context?: string,
requestContext?: RequestContext
): DataApiError<ErrorCode.INTERNAL_SERVER_ERROR> {
const message = context
? `Internal error in ${context}: ${originalError.message}`
: `Internal error: ${originalError.message}`
return new DataApiError(
ErrorCode.INTERNAL_SERVER_ERROR,
message,
ERROR_STATUS_MAP[ErrorCode.INTERNAL_SERVER_ERROR],
{ originalError: originalError.message, context },
requestContext
)
}
/**
* Create a permission denied error.
* @param action - The action that was denied
* @param resource - Optional resource that access was denied to
* @param requestContext - Optional request context
* @returns DataApiError with PERMISSION_DENIED code
*/
static permissionDenied(
action: string,
resource?: string,
requestContext?: RequestContext
): DataApiError<ErrorCode.PERMISSION_DENIED> {
const message = resource ? `Permission denied: Cannot ${action} ${resource}` : `Permission denied: Cannot ${action}`
return new DataApiError(
ErrorCode.PERMISSION_DENIED,
message,
ERROR_STATUS_MAP[ErrorCode.PERMISSION_DENIED],
{ action, resource },
requestContext
)
}
/**
* Create a timeout error.
* @param operation - Description of the operation that timed out
* @param timeoutMs - The timeout duration in milliseconds
* @param requestContext - Optional request context
* @returns DataApiError with TIMEOUT code
*/
static timeout(
operation?: string,
timeoutMs?: number,
requestContext?: RequestContext
): DataApiError<ErrorCode.TIMEOUT> {
const message = operation
? `Request timeout: ${operation}${timeoutMs ? ` (${timeoutMs}ms)` : ''}`
: `Request timeout${timeoutMs ? ` (${timeoutMs}ms)` : ''}`
return new DataApiError(
ErrorCode.TIMEOUT,
message,
ERROR_STATUS_MAP[ErrorCode.TIMEOUT],
{ operation, timeoutMs },
requestContext
)
}
/**
* Create an invalid operation error.
* Use when an operation violates business rules but isn't a validation error.
* @param operation - Description of the invalid operation
* @param reason - Optional reason why the operation is invalid
* @param requestContext - Optional request context
* @returns DataApiError with INVALID_OPERATION code
*/
static invalidOperation(
operation: string,
reason?: string,
requestContext?: RequestContext
): DataApiError<ErrorCode.INVALID_OPERATION> {
const message = reason ? `Invalid operation: ${operation} - ${reason}` : `Invalid operation: ${operation}`
return new DataApiError(
ErrorCode.INVALID_OPERATION,
message,
ERROR_STATUS_MAP[ErrorCode.INVALID_OPERATION],
{ operation, reason },
requestContext
)
}
/**
* Create a data inconsistency error.
* @param resource - The resource with inconsistent data
* @param description - Description of the inconsistency
* @param requestContext - Optional request context
* @returns DataApiError with DATA_INCONSISTENT code
*/
static dataInconsistent(
resource: string,
description?: string,
requestContext?: RequestContext
): DataApiError<ErrorCode.DATA_INCONSISTENT> {
const message = description
? `Data inconsistent in ${resource}: ${description}`
: `Data inconsistent in ${resource}`
return new DataApiError(
ErrorCode.DATA_INCONSISTENT,
message,
ERROR_STATUS_MAP[ErrorCode.DATA_INCONSISTENT],
{ resource, description },
requestContext
)
}
/**
* Create a resource locked error.
* Use when a resource is temporarily unavailable due to:
* - File being exported
* - Data migration in progress
* - Resource held by background process
*
* @param resource - Resource type name
* @param id - Resource identifier
* @param lockedBy - Optional description of what's holding the lock
* @param requestContext - Optional request context
* @returns DataApiError with RESOURCE_LOCKED code
*/
static resourceLocked(
resource: string,
id: string,
lockedBy?: string,
requestContext?: RequestContext
): DataApiError<ErrorCode.RESOURCE_LOCKED> {
const message = lockedBy
? `${resource} '${id}' is locked by ${lockedBy}`
: `${resource} '${id}' is currently locked`
return new DataApiError(
ErrorCode.RESOURCE_LOCKED,
message,
ERROR_STATUS_MAP[ErrorCode.RESOURCE_LOCKED],
{ resource, id, lockedBy },
requestContext
)
}
/**
* Create a concurrent modification error.
* Use when an optimistic lock conflict occurs:
* - Multi-window editing same topic
* - Version mismatch on update
* - Stale data detected during save
*
* Client should refresh data and retry or notify user.
*
* @param resource - Resource type name
* @param id - Resource identifier
* @param requestContext - Optional request context
* @returns DataApiError with CONCURRENT_MODIFICATION code
*/
static concurrentModification(
resource: string,
id: string,
requestContext?: RequestContext
): DataApiError<ErrorCode.CONCURRENT_MODIFICATION> {
return new DataApiError(
ErrorCode.CONCURRENT_MODIFICATION,
`${resource} '${id}' was modified by another user`,
ERROR_STATUS_MAP[ErrorCode.CONCURRENT_MODIFICATION],
{ resource, id },
requestContext
)
}
}
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Check if an error is a DataApiError instance.
* @param error - Any error object
* @returns true if the error is a DataApiError
*/
export function isDataApiError(error: unknown): error is DataApiError {
return error instanceof DataApiError
}
/**
* Check if an object is a serialized DataApiError.
* @param error - Any object
* @returns true if the object has DataApiError structure
*/
export function isSerializedDataApiError(error: unknown): error is SerializedDataApiError {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
'message' in error &&
'status' in error &&
typeof (error as SerializedDataApiError).code === 'string' &&
typeof (error as SerializedDataApiError).message === 'string' &&
typeof (error as SerializedDataApiError).status === 'number'
)
}
/**
* Convert any error to a DataApiError.
* If already a DataApiError, returns as-is.
* Otherwise, wraps in an INTERNAL_SERVER_ERROR.
*
* @param error - Any error
* @param context - Optional context description
* @returns DataApiError instance
*/
export function toDataApiError(error: unknown, context?: string): DataApiError {
if (isDataApiError(error)) {
return error
}
if (isSerializedDataApiError(error)) {
return DataApiError.fromJSON(error)
}
if (error instanceof Error) {
return DataApiErrorFactory.internal(error, context)
}
return DataApiErrorFactory.create(
ErrorCode.INTERNAL_SERVER_ERROR,
`Unknown error${context ? ` in ${context}` : ''}: ${String(error)}`,
{ originalError: String(error), context } as DetailsForCode<ErrorCode.INTERNAL_SERVER_ERROR>
)
}

View File

@ -1,107 +0,0 @@
/**
* Generic test model definitions
* Contains flexible types for comprehensive API testing
*/
/**
* Generic test item entity - flexible structure for testing various scenarios
*/
export interface TestItem {
/** Unique identifier */
id: string
/** Item title */
title: string
/** Optional description */
description?: string
/** Type category */
type: string
/** Current status */
status: string
/** Priority level */
priority: string
/** Associated tags */
tags: string[]
/** Creation timestamp */
createdAt: string
/** Last update timestamp */
updatedAt: string
/** Additional metadata */
metadata: Record<string, any>
}
/**
* Data Transfer Objects (DTOs) for test operations
*/
/**
* DTO for creating a new test item
*/
export interface CreateTestItemDto {
/** Item title */
title: string
/** Optional description */
description?: string
/** Type category */
type?: string
/** Current status */
status?: string
/** Priority level */
priority?: string
/** Associated tags */
tags?: string[]
/** Additional metadata */
metadata?: Record<string, any>
}
/**
* DTO for updating an existing test item
*/
export interface UpdateTestItemDto {
/** Updated title */
title?: string
/** Updated description */
description?: string
/** Updated type */
type?: string
/** Updated status */
status?: string
/** Updated priority */
priority?: string
/** Updated tags */
tags?: string[]
/** Updated metadata */
metadata?: Record<string, any>
}
/**
* Bulk operation types for batch processing
*/
/**
* Request for bulk operations on multiple items
*/
export interface BulkOperationRequest<TData = any> {
/** Type of bulk operation to perform */
operation: 'create' | 'update' | 'delete' | 'archive' | 'restore'
/** Array of data items to process */
data: TData[]
}
/**
* Response from a bulk operation
*/
export interface BulkOperationResponse {
/** Number of successfully processed items */
successful: number
/** Number of items that failed processing */
failed: number
/** Array of errors that occurred during processing */
errors: Array<{
/** Index of the item that failed */
index: number
/** Error message */
error: string
/** Optional additional error data */
data?: any
}>
}

View File

@ -1,4 +1,4 @@
import type { ApiSchemas } from './apiSchemas'
import type { ApiSchemas } from './schemas'
/**
* Template literal type utilities for converting parameterized paths to concrete paths

View File

@ -1,487 +0,0 @@
// NOTE: Types are defined inline in the schema for simplicity
// If needed, specific types can be imported from './apiModels'
import type { BodyForPath, ConcreteApiPaths, QueryParamsForPath, ResponseForPath } from './apiPaths'
import type { HttpMethod, PaginatedResponse, PaginationParams } from './apiTypes'
// Re-export for external use
export type { ConcreteApiPaths } from './apiPaths'
/**
* Complete API Schema definitions for Test API
*
* Each path defines the supported HTTP methods with their:
* - Request parameters (params, query, body)
* - Response types
* - Type safety guarantees
*
* This schema serves as the contract between renderer and main processes,
* enabling full TypeScript type checking across IPC boundaries.
*/
export interface ApiSchemas {
/**
* Test items collection endpoint
* @example GET /test/items?page=1&limit=10&search=hello
* @example POST /test/items { "title": "New Test Item" }
*/
'/test/items': {
/** List all test items with optional filtering and pagination */
GET: {
query?: PaginationParams & {
/** Search items by title or description */
search?: string
/** Filter by item type */
type?: string
/** Filter by status */
status?: string
}
response: PaginatedResponse<any>
}
/** Create a new test item */
POST: {
body: {
title: string
description?: string
type?: string
status?: string
priority?: string
tags?: string[]
metadata?: Record<string, any>
}
response: any
}
}
/**
* Individual test item endpoint
* @example GET /test/items/123
* @example PUT /test/items/123 { "title": "Updated Title" }
* @example DELETE /test/items/123
*/
'/test/items/:id': {
/** Get a specific test item by ID */
GET: {
params: { id: string }
response: any
}
/** Update a specific test item */
PUT: {
params: { id: string }
body: {
title?: string
description?: string
type?: string
status?: string
priority?: string
tags?: string[]
metadata?: Record<string, any>
}
response: any
}
/** Delete a specific test item */
DELETE: {
params: { id: string }
response: void
}
}
/**
* Test search endpoint
* @example GET /test/search?query=hello&page=1&limit=20
*/
'/test/search': {
/** Search test items */
GET: {
query: {
/** Search query string */
query: string
/** Page number for pagination */
page?: number
/** Number of results per page */
limit?: number
/** Additional filters */
type?: string
status?: string
}
response: PaginatedResponse<any>
}
}
/**
* Test statistics endpoint
* @example GET /test/stats
*/
'/test/stats': {
/** Get comprehensive test statistics */
GET: {
response: {
/** Total number of items */
total: number
/** Item count grouped by type */
byType: Record<string, number>
/** Item count grouped by status */
byStatus: Record<string, number>
/** Item count grouped by priority */
byPriority: Record<string, number>
/** Recent activity timeline */
recentActivity: Array<{
/** Date of activity */
date: string
/** Number of items on that date */
count: number
}>
}
}
}
/**
* Test bulk operations endpoint
* @example POST /test/bulk { "operation": "create", "data": [...] }
*/
'/test/bulk': {
/** Perform bulk operations on test items */
POST: {
body: {
/** Operation type */
operation: 'create' | 'update' | 'delete'
/** Array of data items to process */
data: any[]
}
response: {
successful: number
failed: number
errors: string[]
}
}
}
/**
* Test error simulation endpoint
* @example POST /test/error { "errorType": "timeout" }
*/
'/test/error': {
/** Simulate various error scenarios for testing */
POST: {
body: {
/** Type of error to simulate */
errorType:
| 'timeout'
| 'network'
| 'server'
| 'notfound'
| 'validation'
| 'unauthorized'
| 'ratelimit'
| 'generic'
}
response: never
}
}
/**
* Test slow response endpoint
* @example POST /test/slow { "delay": 2000 }
*/
'/test/slow': {
/** Test slow response for performance testing */
POST: {
body: {
/** Delay in milliseconds */
delay: number
}
response: {
message: string
delay: number
timestamp: string
}
}
}
/**
* Test data reset endpoint
* @example POST /test/reset
*/
'/test/reset': {
/** Reset all test data to initial state */
POST: {
response: {
message: string
timestamp: string
}
}
}
/**
* Test config endpoint
* @example GET /test/config
* @example PUT /test/config { "setting": "value" }
*/
'/test/config': {
/** Get test configuration */
GET: {
response: Record<string, any>
}
/** Update test configuration */
PUT: {
body: Record<string, any>
response: Record<string, any>
}
}
/**
* Test status endpoint
* @example GET /test/status
*/
'/test/status': {
/** Get system test status */
GET: {
response: {
status: string
timestamp: string
version: string
uptime: number
environment: string
}
}
}
/**
* Test performance endpoint
* @example GET /test/performance
*/
'/test/performance': {
/** Get performance metrics */
GET: {
response: {
requestsPerSecond: number
averageLatency: number
memoryUsage: number
cpuUsage: number
uptime: number
}
}
}
/**
* Batch execution of multiple requests
* @example POST /batch { "requests": [...], "parallel": true }
*/
'/batch': {
/** Execute multiple API requests in a single call */
POST: {
body: {
/** Array of requests to execute */
requests: Array<{
/** HTTP method for the request */
method: HttpMethod
/** API path for the request */
path: string
/** URL parameters */
params?: any
/** Request body */
body?: any
}>
/** Execute requests in parallel vs sequential */
parallel?: boolean
}
response: {
/** Results array matching input order */
results: Array<{
/** HTTP status code */
status: number
/** Response data if successful */
data?: any
/** Error information if failed */
error?: any
}>
/** Batch execution metadata */
metadata: {
/** Total execution duration in ms */
duration: number
/** Number of successful requests */
successCount: number
/** Number of failed requests */
errorCount: number
}
}
}
}
/**
* Atomic transaction of multiple operations
* @example POST /transaction { "operations": [...], "options": { "rollbackOnError": true } }
*/
'/transaction': {
/** Execute multiple operations in a database transaction */
POST: {
body: {
/** Array of operations to execute atomically */
operations: Array<{
/** HTTP method for the operation */
method: HttpMethod
/** API path for the operation */
path: string
/** URL parameters */
params?: any
/** Request body */
body?: any
}>
/** Transaction configuration options */
options?: {
/** Database isolation level */
isolation?: 'read-uncommitted' | 'read-committed' | 'repeatable-read' | 'serializable'
/** Rollback all operations on any error */
rollbackOnError?: boolean
/** Transaction timeout in milliseconds */
timeout?: number
}
}
response: Array<{
/** HTTP status code */
status: number
/** Response data if successful */
data?: any
/** Error information if failed */
error?: any
}>
}
}
}
/**
* Simplified type extraction helpers
*/
export type ApiPaths = keyof ApiSchemas
export type ApiMethods<TPath extends ApiPaths> = keyof ApiSchemas[TPath] & HttpMethod
export type ApiResponse<TPath extends ApiPaths, TMethod extends string> = TPath extends keyof ApiSchemas
? TMethod extends keyof ApiSchemas[TPath]
? ApiSchemas[TPath][TMethod] extends { response: infer R }
? R
: never
: never
: never
export type ApiParams<TPath extends ApiPaths, TMethod extends string> = TPath extends keyof ApiSchemas
? TMethod extends keyof ApiSchemas[TPath]
? ApiSchemas[TPath][TMethod] extends { params: infer P }
? P
: never
: never
: never
export type ApiQuery<TPath extends ApiPaths, TMethod extends string> = TPath extends keyof ApiSchemas
? TMethod extends keyof ApiSchemas[TPath]
? ApiSchemas[TPath][TMethod] extends { query: infer Q }
? Q
: never
: never
: never
export type ApiBody<TPath extends ApiPaths, TMethod extends string> = TPath extends keyof ApiSchemas
? TMethod extends keyof ApiSchemas[TPath]
? ApiSchemas[TPath][TMethod] extends { body: infer B }
? B
: never
: never
: never
/**
* Type-safe API client interface using concrete paths
* Accepts actual paths like '/test/items/123' instead of '/test/items/:id'
* Automatically infers query, body, and response types from ApiSchemas
*/
export interface ApiClient {
get<TPath extends ConcreteApiPaths>(
path: TPath,
options?: {
query?: QueryParamsForPath<TPath>
headers?: Record<string, string>
}
): Promise<ResponseForPath<TPath, 'GET'>>
post<TPath extends ConcreteApiPaths>(
path: TPath,
options: {
body?: BodyForPath<TPath, 'POST'>
query?: Record<string, any>
headers?: Record<string, string>
}
): Promise<ResponseForPath<TPath, 'POST'>>
put<TPath extends ConcreteApiPaths>(
path: TPath,
options: {
body: BodyForPath<TPath, 'PUT'>
query?: Record<string, any>
headers?: Record<string, string>
}
): Promise<ResponseForPath<TPath, 'PUT'>>
delete<TPath extends ConcreteApiPaths>(
path: TPath,
options?: {
query?: Record<string, any>
headers?: Record<string, string>
}
): Promise<ResponseForPath<TPath, 'DELETE'>>
patch<TPath extends ConcreteApiPaths>(
path: TPath,
options: {
body?: BodyForPath<TPath, 'PATCH'>
query?: Record<string, any>
headers?: Record<string, string>
}
): Promise<ResponseForPath<TPath, 'PATCH'>>
}
/**
* Helper types to determine if parameters are required based on schema
*/
type HasRequiredQuery<Path extends ApiPaths, Method extends ApiMethods<Path>> = Path extends keyof ApiSchemas
? Method extends keyof ApiSchemas[Path]
? ApiSchemas[Path][Method] extends { query: any }
? true
: false
: false
: false
type HasRequiredBody<Path extends ApiPaths, Method extends ApiMethods<Path>> = Path extends keyof ApiSchemas
? Method extends keyof ApiSchemas[Path]
? ApiSchemas[Path][Method] extends { body: any }
? true
: false
: false
: false
type HasRequiredParams<Path extends ApiPaths, Method extends ApiMethods<Path>> = Path extends keyof ApiSchemas
? Method extends keyof ApiSchemas[Path]
? ApiSchemas[Path][Method] extends { params: any }
? true
: false
: false
: false
/**
* Handler function for a specific API endpoint
* Provides type-safe parameter extraction based on ApiSchemas
* Parameters are required or optional based on the schema definition
*/
export type ApiHandler<Path extends ApiPaths, Method extends ApiMethods<Path>> = (
params: (HasRequiredParams<Path, Method> extends true
? { params: ApiParams<Path, Method> }
: { params?: ApiParams<Path, Method> }) &
(HasRequiredQuery<Path, Method> extends true
? { query: ApiQuery<Path, Method> }
: { query?: ApiQuery<Path, Method> }) &
(HasRequiredBody<Path, Method> extends true ? { body: ApiBody<Path, Method> } : { body?: ApiBody<Path, Method> })
) => Promise<ApiResponse<Path, Method>>
/**
* Complete API implementation that must match ApiSchemas structure
* TypeScript will error if any endpoint is missing - this ensures exhaustive coverage
*/
export type ApiImplementation = {
[Path in ApiPaths]: {
[Method in ApiMethods<Path>]: ApiHandler<Path, Method>
}
}

View File

@ -8,6 +8,75 @@
*/
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
// ============================================================================
// Schema Constraint Types
// ============================================================================
/**
* Constraint for a single endpoint method definition.
* Requires `response` field, allows optional `params`, `query`, and `body`.
*/
export type EndpointMethodConstraint = {
params?: Record<string, any>
query?: Record<string, any>
body?: any
response: any // response is required
}
/**
* Constraint for a single API path - only allows valid HTTP methods.
*/
export type EndpointConstraint = {
[Method in HttpMethod]?: EndpointMethodConstraint
}
/**
* Validates that a schema only contains valid HTTP methods.
* Used in AssertValidSchemas for compile-time validation.
*/
type ValidateMethods<T> = {
[Path in keyof T]: {
[Method in keyof T[Path]]: Method extends HttpMethod ? T[Path][Method] : never
}
}
/**
* Validates that all endpoints have a `response` field.
* Returns the original type if valid, or `never` if any endpoint lacks response.
*/
type ValidateResponses<T> = {
[Path in keyof T]: {
[Method in keyof T[Path]]: T[Path][Method] extends { response: any }
? T[Path][Method]
: { error: `Endpoint ${Path & string}.${Method & string} is missing 'response' field` }
}
}
/**
* Validates that a schema conforms to expected structure:
* 1. All methods must be valid HTTP methods (GET, POST, PUT, DELETE, PATCH)
* 2. All endpoints must have a `response` field
*
* This is applied at the composition level (schemas/index.ts) to catch
* invalid schemas even if individual schema files don't use validation.
*
* @example
* ```typescript
* // In schemas/index.ts
* export type ApiSchemas = AssertValidSchemas<TestSchemas & BatchSchemas>
*
* // Invalid method will cause error:
* // Type 'never' is not assignable to type...
* ```
*/
export type AssertValidSchemas<T> = ValidateMethods<T> & ValidateResponses<T> extends infer R
? { [K in keyof R]: R[K] }
: never
// ============================================================================
// Core Request/Response Types
// ============================================================================
/**
* Request object structure for Data API calls
*/
@ -30,8 +99,6 @@ export interface DataRequest<T = any> {
timestamp: number
/** OpenTelemetry span context for tracing */
spanContext?: any
/** Cache options for this specific request */
cache?: CacheOptions
}
}
@ -46,7 +113,7 @@ export interface DataResponse<T = any> {
/** Response data if successful */
data?: T
/** Error information if request failed */
error?: DataApiError
error?: SerializedDataApiError
/** Response metadata */
metadata?: {
/** Request processing duration in milliseconds */
@ -60,146 +127,131 @@ export interface DataResponse<T = any> {
}
}
/**
* Standardized error structure for Data API
*/
export interface DataApiError {
/** Error code for programmatic handling */
code: string
/** Human-readable error message */
message: string
/** HTTP status code */
status: number
/** Additional error details */
details?: any
/** Error stack trace (development mode only) */
stack?: string
}
// Note: Error types have been moved to apiErrors.ts
// Import from there: ErrorCode, DataApiError, SerializedDataApiError, DataApiErrorFactory
import type { SerializedDataApiError } from './apiErrors'
// Re-export for backwards compatibility in DataResponse
export type { SerializedDataApiError } from './apiErrors'
// ============================================================================
// Pagination Types
// ============================================================================
// ----- Request Parameters -----
/**
* Standard error codes for Data API
* Offset-based pagination parameters (page + limit)
*/
export enum ErrorCode {
// Client errors (4xx)
BAD_REQUEST = 'BAD_REQUEST',
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
NOT_FOUND = 'NOT_FOUND',
METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED',
VALIDATION_ERROR = 'VALIDATION_ERROR',
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
// Server errors (5xx)
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
DATABASE_ERROR = 'DATABASE_ERROR',
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
// Custom application errors
MIGRATION_ERROR = 'MIGRATION_ERROR',
PERMISSION_DENIED = 'PERMISSION_DENIED',
RESOURCE_LOCKED = 'RESOURCE_LOCKED',
CONCURRENT_MODIFICATION = 'CONCURRENT_MODIFICATION'
}
/**
* Cache configuration options
*/
export interface CacheOptions {
/** Cache TTL in seconds */
ttl?: number
/** Return stale data while revalidating in background */
staleWhileRevalidate?: boolean
/** Custom cache key override */
cacheKey?: string
/** Operations that should invalidate this cache entry */
invalidateOn?: string[]
/** Whether to bypass cache entirely */
noCache?: boolean
}
/**
* Transaction request wrapper for atomic operations
*/
export interface TransactionRequest {
/** List of operations to execute in transaction */
operations: DataRequest[]
/** Transaction options */
options?: {
/** Database isolation level */
isolation?: 'read-uncommitted' | 'read-committed' | 'repeatable-read' | 'serializable'
/** Whether to rollback entire transaction on any error */
rollbackOnError?: boolean
/** Transaction timeout in milliseconds */
timeout?: number
}
}
/**
* Batch request for multiple operations
*/
export interface BatchRequest {
/** List of requests to execute */
requests: DataRequest[]
/** Whether to execute requests in parallel */
parallel?: boolean
/** Stop on first error */
stopOnError?: boolean
}
/**
* Batch response containing results for all requests
*/
export interface BatchResponse {
/** Individual response for each request */
results: DataResponse[]
/** Overall batch execution metadata */
metadata: {
/** Total execution time */
duration: number
/** Number of successful operations */
successCount: number
/** Number of failed operations */
errorCount: number
}
}
/**
* Pagination parameters for list operations
*/
export interface PaginationParams {
export interface OffsetPaginationParams {
/** Page number (1-based) */
page?: number
/** Items per page */
limit?: number
/** Cursor for cursor-based pagination */
cursor?: string
/** Sort field and direction */
sort?: {
field: string
order: 'asc' | 'desc'
}
}
/**
* Paginated response wrapper
* Cursor-based pagination parameters (cursor + limit)
*
* The cursor is typically an opaque reference to a record in the dataset.
* The cursor itself is NEVER included in the response - it marks an exclusive boundary.
*
* Common semantics:
* - "after cursor": Returns items AFTER the cursor (forward pagination)
* - "before cursor": Returns items BEFORE the cursor (backward/historical pagination)
*
* The specific semantic depends on the API endpoint. Check endpoint documentation.
*/
export interface PaginatedResponse<T> {
export interface CursorPaginationParams {
/** Cursor for pagination boundary (exclusive - cursor item not included in response) */
cursor?: string
/** Items per page */
limit?: number
}
/**
* Sort parameters (independent, combine as needed)
*/
export interface SortParams {
/** Field to sort by */
sortBy?: string
/** Sort direction */
sortOrder?: 'asc' | 'desc'
}
/**
* Search parameters (independent, combine as needed)
*/
export interface SearchParams {
/** Search query string */
search?: string
}
// ----- Response Types -----
/**
* Offset-based pagination response
*/
export interface OffsetPaginationResponse<T> {
/** Items for current page */
items: T[]
/** Total number of items */
total: number
/** Current page number */
/** Current page number (1-based) */
page: number
/** Total number of pages */
pageCount: number
/** Whether there are more pages */
hasNext: boolean
/** Whether there are previous pages */
hasPrev: boolean
/** Next cursor for cursor-based pagination */
}
/**
* Cursor-based pagination response
*/
export interface CursorPaginationResponse<T> {
/** Items for current page */
items: T[]
/** Next cursor (undefined means no more data) */
nextCursor?: string
/** Previous cursor for cursor-based pagination */
prevCursor?: string
}
// ----- Type Utilities -----
/**
* Infer pagination mode from response type
*/
export type InferPaginationMode<R> = R extends OffsetPaginationResponse<any>
? 'offset'
: R extends CursorPaginationResponse<any>
? 'cursor'
: never
/**
* Infer item type from pagination response
*/
export type InferPaginationItem<R> = R extends OffsetPaginationResponse<infer T>
? T
: R extends CursorPaginationResponse<infer T>
? T
: never
/**
* Union type for both pagination responses
*/
export type PaginationResponse<T> = OffsetPaginationResponse<T> | CursorPaginationResponse<T>
/**
* Type guard: check if response is offset-based
*/
export function isOffsetPaginationResponse<T>(
response: PaginationResponse<T>
): response is OffsetPaginationResponse<T> {
return 'page' in response && 'total' in response
}
/**
* Type guard: check if response is cursor-based
*/
export function isCursorPaginationResponse<T>(
response: PaginationResponse<T>
): response is CursorPaginationResponse<T> {
return !('page' in response)
}
/**
@ -274,16 +326,169 @@ export interface ServiceOptions {
metadata?: Record<string, any>
}
// ============================================================================
// API Schema Type Utilities
// ============================================================================
import type { BodyForPath, ConcreteApiPaths, QueryParamsForPath, ResponseForPath } from './apiPaths'
import type { ApiSchemas } from './schemas'
// Re-export for external use
export type { ConcreteApiPaths } from './apiPaths'
export type { ApiSchemas } from './schemas'
/**
* Standard service response wrapper
* All available API paths
*/
export interface ServiceResult<T = any> {
/** Whether operation was successful */
success: boolean
/** Result data if successful */
data?: T
/** Error information if failed */
error?: DataApiError
/** Additional metadata */
metadata?: Record<string, any>
export type ApiPaths = keyof ApiSchemas
/**
* Available HTTP methods for a specific path
*/
export type ApiMethods<TPath extends ApiPaths> = keyof ApiSchemas[TPath] & HttpMethod
/**
* Response type for a specific path and method
*/
export type ApiResponse<TPath extends ApiPaths, TMethod extends string> = TPath extends keyof ApiSchemas
? TMethod extends keyof ApiSchemas[TPath]
? ApiSchemas[TPath][TMethod] extends { response: infer R }
? R
: never
: never
: never
/**
* URL params type for a specific path and method
*/
export type ApiParams<TPath extends ApiPaths, TMethod extends string> = TPath extends keyof ApiSchemas
? TMethod extends keyof ApiSchemas[TPath]
? ApiSchemas[TPath][TMethod] extends { params: infer P }
? P
: never
: never
: never
/**
* Query params type for a specific path and method
*/
export type ApiQuery<TPath extends ApiPaths, TMethod extends string> = TPath extends keyof ApiSchemas
? TMethod extends keyof ApiSchemas[TPath]
? ApiSchemas[TPath][TMethod] extends { query: infer Q }
? Q
: never
: never
: never
/**
* Request body type for a specific path and method
*/
export type ApiBody<TPath extends ApiPaths, TMethod extends string> = TPath extends keyof ApiSchemas
? TMethod extends keyof ApiSchemas[TPath]
? ApiSchemas[TPath][TMethod] extends { body: infer B }
? B
: never
: never
: never
/**
* Type-safe API client interface using concrete paths
* Accepts actual paths like '/test/items/123' instead of '/test/items/:id'
* Automatically infers query, body, and response types from ApiSchemas
*/
export interface ApiClient {
get<TPath extends ConcreteApiPaths>(
path: TPath,
options?: {
query?: QueryParamsForPath<TPath>
headers?: Record<string, string>
}
): Promise<ResponseForPath<TPath, 'GET'>>
post<TPath extends ConcreteApiPaths>(
path: TPath,
options: {
body?: BodyForPath<TPath, 'POST'>
query?: Record<string, any>
headers?: Record<string, string>
}
): Promise<ResponseForPath<TPath, 'POST'>>
put<TPath extends ConcreteApiPaths>(
path: TPath,
options: {
body: BodyForPath<TPath, 'PUT'>
query?: Record<string, any>
headers?: Record<string, string>
}
): Promise<ResponseForPath<TPath, 'PUT'>>
delete<TPath extends ConcreteApiPaths>(
path: TPath,
options?: {
query?: Record<string, any>
headers?: Record<string, string>
}
): Promise<ResponseForPath<TPath, 'DELETE'>>
patch<TPath extends ConcreteApiPaths>(
path: TPath,
options: {
body?: BodyForPath<TPath, 'PATCH'>
query?: Record<string, any>
headers?: Record<string, string>
}
): Promise<ResponseForPath<TPath, 'PATCH'>>
}
/**
* Helper types to determine if parameters are required based on schema
*/
type HasRequiredQuery<Path extends ApiPaths, Method extends ApiMethods<Path>> = Path extends keyof ApiSchemas
? Method extends keyof ApiSchemas[Path]
? ApiSchemas[Path][Method] extends { query: any }
? true
: false
: false
: false
type HasRequiredBody<Path extends ApiPaths, Method extends ApiMethods<Path>> = Path extends keyof ApiSchemas
? Method extends keyof ApiSchemas[Path]
? ApiSchemas[Path][Method] extends { body: any }
? true
: false
: false
: false
type HasRequiredParams<Path extends ApiPaths, Method extends ApiMethods<Path>> = Path extends keyof ApiSchemas
? Method extends keyof ApiSchemas[Path]
? ApiSchemas[Path][Method] extends { params: any }
? true
: false
: false
: false
/**
* Handler function for a specific API endpoint
* Provides type-safe parameter extraction based on ApiSchemas
* Parameters are required or optional based on the schema definition
*/
export type ApiHandler<Path extends ApiPaths, Method extends ApiMethods<Path>> = (
params: (HasRequiredParams<Path, Method> extends true
? { params: ApiParams<Path, Method> }
: { params?: ApiParams<Path, Method> }) &
(HasRequiredQuery<Path, Method> extends true
? { query: ApiQuery<Path, Method> }
: { query?: ApiQuery<Path, Method> }) &
(HasRequiredBody<Path, Method> extends true ? { body: ApiBody<Path, Method> } : { body?: ApiBody<Path, Method> })
) => Promise<ApiResponse<Path, Method>>
/**
* Complete API implementation that must match ApiSchemas structure
* TypeScript will error if any endpoint is missing - this ensures exhaustive coverage
*/
export type ApiImplementation = {
[Path in ApiPaths]: {
[Method in ApiMethods<Path>]: ApiHandler<Path, Method>
}
}

View File

@ -1,194 +0,0 @@
/**
* Centralized error code definitions for the Data API system
* Provides consistent error handling across renderer and main processes
*/
import type { DataApiError } from './apiTypes'
import { ErrorCode } from './apiTypes'
// Re-export ErrorCode for convenience
export { ErrorCode } from './apiTypes'
/**
* Error code to HTTP status mapping
*/
export const ERROR_STATUS_MAP: Record<ErrorCode, number> = {
// Client errors (4xx)
[ErrorCode.BAD_REQUEST]: 400,
[ErrorCode.UNAUTHORIZED]: 401,
[ErrorCode.FORBIDDEN]: 403,
[ErrorCode.NOT_FOUND]: 404,
[ErrorCode.METHOD_NOT_ALLOWED]: 405,
[ErrorCode.VALIDATION_ERROR]: 422,
[ErrorCode.RATE_LIMIT_EXCEEDED]: 429,
// Server errors (5xx)
[ErrorCode.INTERNAL_SERVER_ERROR]: 500,
[ErrorCode.DATABASE_ERROR]: 500,
[ErrorCode.SERVICE_UNAVAILABLE]: 503,
// Custom application errors (5xx)
[ErrorCode.MIGRATION_ERROR]: 500,
[ErrorCode.PERMISSION_DENIED]: 403,
[ErrorCode.RESOURCE_LOCKED]: 423,
[ErrorCode.CONCURRENT_MODIFICATION]: 409
}
/**
* Default error messages for each error code
*/
export const ERROR_MESSAGES: Record<ErrorCode, string> = {
[ErrorCode.BAD_REQUEST]: 'Bad request: Invalid request format or parameters',
[ErrorCode.UNAUTHORIZED]: 'Unauthorized: Authentication required',
[ErrorCode.FORBIDDEN]: 'Forbidden: Insufficient permissions',
[ErrorCode.NOT_FOUND]: 'Not found: Requested resource does not exist',
[ErrorCode.METHOD_NOT_ALLOWED]: 'Method not allowed: HTTP method not supported for this endpoint',
[ErrorCode.VALIDATION_ERROR]: 'Validation error: Request data does not meet requirements',
[ErrorCode.RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded: Too many requests',
[ErrorCode.INTERNAL_SERVER_ERROR]: 'Internal server error: An unexpected error occurred',
[ErrorCode.DATABASE_ERROR]: 'Database error: Failed to access or modify data',
[ErrorCode.SERVICE_UNAVAILABLE]: 'Service unavailable: The service is temporarily unavailable',
[ErrorCode.MIGRATION_ERROR]: 'Migration error: Failed to migrate data',
[ErrorCode.PERMISSION_DENIED]: 'Permission denied: Operation not allowed for current user',
[ErrorCode.RESOURCE_LOCKED]: 'Resource locked: Resource is currently locked by another operation',
[ErrorCode.CONCURRENT_MODIFICATION]: 'Concurrent modification: Resource was modified by another user'
}
/**
* Utility class for creating standardized Data API errors
*/
export class DataApiErrorFactory {
/**
* Create a DataApiError with standard properties
*/
static create(code: ErrorCode, customMessage?: string, details?: any, stack?: string): DataApiError {
return {
code,
message: customMessage || ERROR_MESSAGES[code],
status: ERROR_STATUS_MAP[code],
details,
stack: stack || undefined
}
}
/**
* Create a validation error with field-specific details
*/
static validation(fieldErrors: Record<string, string[]>, message?: string): DataApiError {
return this.create(ErrorCode.VALIDATION_ERROR, message || 'Request validation failed', { fieldErrors })
}
/**
* Create a not found error for specific resource
*/
static notFound(resource: string, id?: string): DataApiError {
const message = id ? `${resource} with id '${id}' not found` : `${resource} not found`
return this.create(ErrorCode.NOT_FOUND, message, { resource, id })
}
/**
* Create a database error with query details
*/
static database(originalError: Error, operation?: string): DataApiError {
return this.create(
ErrorCode.DATABASE_ERROR,
`Database operation failed${operation ? `: ${operation}` : ''}`,
{
originalError: originalError.message,
operation
},
originalError.stack
)
}
/**
* Create a permission denied error
*/
static permissionDenied(action: string, resource?: string): DataApiError {
const message = resource ? `Permission denied: Cannot ${action} ${resource}` : `Permission denied: Cannot ${action}`
return this.create(ErrorCode.PERMISSION_DENIED, message, { action, resource })
}
/**
* Create an internal server error from an unexpected error
*/
static internal(originalError: Error, context?: string): DataApiError {
const message = context
? `Internal error in ${context}: ${originalError.message}`
: `Internal error: ${originalError.message}`
return this.create(
ErrorCode.INTERNAL_SERVER_ERROR,
message,
{ originalError: originalError.message, context },
originalError.stack
)
}
/**
* Create a rate limit exceeded error
*/
static rateLimit(limit: number, windowMs: number): DataApiError {
return this.create(ErrorCode.RATE_LIMIT_EXCEEDED, `Rate limit exceeded: ${limit} requests per ${windowMs}ms`, {
limit,
windowMs
})
}
/**
* Create a resource locked error
*/
static resourceLocked(resource: string, id: string, lockedBy?: string): DataApiError {
const message = lockedBy
? `${resource} '${id}' is locked by ${lockedBy}`
: `${resource} '${id}' is currently locked`
return this.create(ErrorCode.RESOURCE_LOCKED, message, { resource, id, lockedBy })
}
/**
* Create a concurrent modification error
*/
static concurrentModification(resource: string, id: string): DataApiError {
return this.create(ErrorCode.CONCURRENT_MODIFICATION, `${resource} '${id}' was modified by another user`, {
resource,
id
})
}
}
/**
* Check if an error is a Data API error
*/
export function isDataApiError(error: any): error is DataApiError {
return (
error &&
typeof error === 'object' &&
typeof error.code === 'string' &&
typeof error.message === 'string' &&
typeof error.status === 'number'
)
}
/**
* Convert a generic error to a DataApiError
*/
export function toDataApiError(error: unknown, context?: string): DataApiError {
if (isDataApiError(error)) {
return error
}
if (error instanceof Error) {
return DataApiErrorFactory.internal(error, context)
}
return DataApiErrorFactory.create(
ErrorCode.INTERNAL_SERVER_ERROR,
`Unknown error${context ? ` in ${context}` : ''}: ${String(error)}`,
{ originalError: error, context }
)
}

View File

@ -1,121 +1,110 @@
/**
* Cherry Studio Data API - Barrel Exports
*
* This file provides a centralized entry point for all data API types,
* schemas, and utilities. Import everything you need from this single location.
* Exports common infrastructure types for the Data API system.
* Domain-specific DTOs should be imported directly from their schema files.
*
* @example
* ```typescript
* import { Topic, CreateTopicDto, ApiSchemas, DataRequest, ErrorCode } from '@/shared/data'
* // Infrastructure types from barrel export
* import { DataRequest, DataResponse, ErrorCode, DataApiError } from '@shared/data/api'
*
* // Domain DTOs from schema files directly
* import type { Topic, CreateTopicDto } from '@shared/data/api/schemas/topic'
* ```
*/
// Core data API types and infrastructure
// ============================================================================
// Core Request/Response Types
// ============================================================================
export type {
BatchRequest,
BatchResponse,
CacheOptions,
DataApiError,
CursorPaginationParams,
CursorPaginationResponse,
DataRequest,
DataResponse,
HttpMethod,
Middleware,
PaginatedResponse,
PaginationParams,
RequestContext,
ServiceOptions,
ServiceResult,
SubscriptionCallback,
SubscriptionOptions,
TransactionRequest
OffsetPaginationParams,
OffsetPaginationResponse,
PaginationResponse,
SearchParams,
SortParams
} from './apiTypes'
export { ErrorCode, SubscriptionEvent } from './apiTypes'
export { isCursorPaginationResponse, isOffsetPaginationResponse } from './apiTypes'
// Domain models and DTOs
export type {
BulkOperationRequest,
BulkOperationResponse,
CreateTestItemDto,
TestItem,
UpdateTestItemDto
} from './apiModels'
// ============================================================================
// API Schema Type Utilities
// ============================================================================
// API schema definitions and type helpers
export type {
ApiBody,
ApiClient,
ApiHandler,
ApiImplementation,
ApiMethods,
ApiParams,
ApiPaths,
ApiQuery,
ApiResponse,
ApiSchemas
} from './apiSchemas'
ApiSchemas,
ConcreteApiPaths
} from './apiTypes'
// ============================================================================
// Path Resolution Utilities
// ============================================================================
// Path type utilities for template literal types
export type {
BodyForPath,
ConcreteApiPaths,
MatchApiPath,
QueryParamsForPath,
ResolvedPath,
ResponseForPath
} from './apiPaths'
// Error handling utilities
// ============================================================================
// Error Handling (from apiErrors.ts)
// ============================================================================
// Error code enum and mappings
export {
ErrorCode as DataApiErrorCode,
DataApiErrorFactory,
ERROR_MESSAGES,
ERROR_STATUS_MAP,
isDataApiError,
toDataApiError
} from './errorCodes'
/**
* Re-export commonly used type combinations for convenience
*/
// Import types for re-export convenience types
import type { CreateTestItemDto, TestItem, UpdateTestItemDto } from './apiModels'
import type {
BatchRequest,
BatchResponse,
DataApiError,
DataRequest,
DataResponse,
ErrorCode,
PaginatedResponse,
PaginationParams,
TransactionRequest
} from './apiTypes'
import type { DataApiErrorFactory } from './errorCodes'
isRetryableErrorCode,
RETRYABLE_ERROR_CODES
} from './apiErrors'
/** All test item-related types */
export type TestItemTypes = {
TestItem: TestItem
CreateTestItemDto: CreateTestItemDto
UpdateTestItemDto: UpdateTestItemDto
}
// DataApiError class and factory
export {
DataApiError,
DataApiErrorFactory,
isDataApiError,
isSerializedDataApiError,
toDataApiError
} from './apiErrors'
/** All error-related types and utilities */
export type ErrorTypes = {
DataApiError: DataApiError
ErrorCode: ErrorCode
ErrorFactory: typeof DataApiErrorFactory
}
// Error-related types
export type {
ConcurrentModificationErrorDetails,
DatabaseErrorDetails,
DataInconsistentErrorDetails,
DetailsForCode,
ErrorDetailsMap,
InternalErrorDetails,
InvalidOperationErrorDetails,
NotFoundErrorDetails,
PermissionDeniedErrorDetails,
RequestContext,
ResourceLockedErrorDetails,
SerializedDataApiError,
TimeoutErrorDetails,
ValidationErrorDetails
} from './apiErrors'
/** All request/response types */
export type RequestTypes = {
DataRequest: DataRequest
DataResponse: DataResponse
BatchRequest: BatchRequest
BatchResponse: BatchResponse
TransactionRequest: TransactionRequest
}
// ============================================================================
// Subscription & Middleware (for advanced usage)
// ============================================================================
/** All pagination-related types */
export type PaginationTypes = {
PaginationParams: PaginationParams
PaginatedResponse: PaginatedResponse<any>
}
export type { Middleware, ServiceOptions, SubscriptionCallback, SubscriptionOptions } from './apiTypes'
export { SubscriptionEvent } from './apiTypes'

View File

@ -0,0 +1,39 @@
/**
* Schema Index - Composes all domain schemas into unified ApiSchemas
*
* This file has ONE responsibility: compose domain schemas into ApiSchemas.
*
* Import conventions (see api/README.md for details):
* - Infrastructure types: import from '@shared/data/api'
* - Domain DTOs: import directly from schema files (e.g., '@shared/data/api/schemas/topic')
*
* @example
* ```typescript
* // Infrastructure types via barrel export
* import type { ApiSchemas, DataRequest } from '@shared/data/api'
*
* // Domain DTOs directly from schema files
* import type { TestItem, CreateTestItemDto } from '@shared/data/api/schemas/test'
* import type { Topic, CreateTopicDto } from '@shared/data/api/schemas/topics'
* import type { Message, CreateMessageDto } from '@shared/data/api/schemas/messages'
* ```
*/
import type { AssertValidSchemas } from '../apiTypes'
import type { MessageSchemas } from './messages'
import type { TestSchemas } from './test'
import type { TopicSchemas } from './topics'
/**
* Merged API Schemas - single source of truth for all API endpoints
*
* All domain schemas are composed here using intersection types.
* AssertValidSchemas provides compile-time validation:
* - Invalid HTTP methods become `never` type
* - Missing `response` field causes type errors
*
* When adding a new domain:
* 1. Create the schema file (e.g., topic.ts)
* 2. Import and add to intersection below
*/
export type ApiSchemas = AssertValidSchemas<TestSchemas & TopicSchemas & MessageSchemas>

View File

@ -0,0 +1,213 @@
/**
* Message API Schema definitions
*
* Contains all message-related endpoints for tree operations and message management.
* Includes endpoints for tree visualization and conversation view.
*/
import type { CursorPaginationParams } from '@shared/data/api/apiTypes'
import type {
BranchMessagesResponse,
Message,
MessageData,
MessageRole,
MessageStats,
MessageStatus,
TreeResponse
} from '@shared/data/types/message'
import type { AssistantMeta, ModelMeta } from '@shared/data/types/meta'
// ============================================================================
// DTOs
// ============================================================================
/**
* DTO for creating a new message
*/
export interface CreateMessageDto {
/**
* Parent message ID for positioning this message in the conversation tree.
*
* Behavior:
* - `undefined` (omitted): Auto-resolve parent based on topic state:
* - If topic has no messages: create as root (parentId = null)
* - If topic has messages and activeNodeId is set: attach to activeNodeId
* - If topic has messages but no activeNodeId: throw INVALID_OPERATION error
* - `null` (explicit): Create as root message. Throws INVALID_OPERATION if
* topic already has a root message (only one root allowed per topic).
* - `string` (message ID): Attach to specified parent. Throws NOT_FOUND if
* parent doesn't exist, or INVALID_OPERATION if parent belongs to different topic.
*/
parentId?: string | null
/** Message role */
role: MessageRole
/** Message content */
data: MessageData
/** Message status */
status?: MessageStatus
/** Siblings group ID (0 = normal, >0 = multi-model group) */
siblingsGroupId?: number
/** Assistant ID */
assistantId?: string
/** Preserved assistant info */
assistantMeta?: AssistantMeta
/** Model identifier */
modelId?: string
/** Preserved model info */
modelMeta?: ModelMeta
/** Trace ID */
traceId?: string
/** Statistics */
stats?: MessageStats
/** Set this message as the active node in the topic (default: true) */
setAsActive?: boolean
}
/**
* DTO for updating an existing message
*/
export interface UpdateMessageDto {
/** Updated message content */
data?: MessageData
/** Move message to new parent */
parentId?: string | null
/** Change siblings group */
siblingsGroupId?: number
/** Update status */
status?: MessageStatus
/** Update trace ID */
traceId?: string | null
/** Update statistics */
stats?: MessageStats | null
}
/**
* Strategy for updating activeNodeId when the active message is deleted
*/
export type ActiveNodeStrategy = 'parent' | 'clear'
/**
* Response for delete operation
*/
export interface DeleteMessageResponse {
/** IDs of deleted messages */
deletedIds: string[]
/** IDs of reparented children (only when cascade=false) */
reparentedIds?: string[]
/** New activeNodeId for the topic (only if activeNodeId was affected by deletion) */
newActiveNodeId?: string | null
}
// ============================================================================
// Query Parameters
// ============================================================================
/**
* Query parameters for GET /topics/:id/tree
*/
export interface TreeQueryParams {
/** Root node ID (defaults to tree root) */
rootId?: string
/** End node ID (defaults to topic.activeNodeId) */
nodeId?: string
/** Depth to expand beyond active path (-1 = all, 0 = path only, 1+ = layers) */
depth?: number
}
/**
* Query parameters for GET /topics/:id/messages
*
* Uses "before cursor" semantics for loading historical messages:
* - First request (no cursor): Returns the most recent `limit` messages
* - Subsequent requests: Pass `nextCursor` from previous response as `cursor`
* to load older messages towards root
* - The cursor message itself is NOT included in the response
*/
export interface BranchMessagesQueryParams extends CursorPaginationParams {
/** End node ID (defaults to topic.activeNodeId) */
nodeId?: string
/** Whether to include siblingsGroup in response */
includeSiblings?: boolean
}
// ============================================================================
// API Schema Definitions
// ============================================================================
/**
* Message API Schema definitions
*
* Organized by domain responsibility:
* - /topics/:id/tree - Tree visualization
* - /topics/:id/messages - Branch messages for conversation
* - /messages/:id - Individual message operations
*/
export interface MessageSchemas {
/**
* Tree query endpoint for visualization
* @example GET /topics/abc123/tree?depth=1
*/
'/topics/:topicId/tree': {
/** Get tree structure for visualization */
GET: {
params: { topicId: string }
query?: TreeQueryParams
response: TreeResponse
}
}
/**
* Branch messages endpoint for conversation view
* @example GET /topics/abc123/messages?limit=20
* @example POST /topics/abc123/messages { "parentId": "msg1", "role": "user", "data": {...} }
*/
'/topics/:topicId/messages': {
/** Get messages along active branch with pagination */
GET: {
params: { topicId: string }
query?: BranchMessagesQueryParams
response: BranchMessagesResponse
}
/** Create a new message in the topic */
POST: {
params: { topicId: string }
body: CreateMessageDto
response: Message
}
}
/**
* Individual message endpoint
* @example GET /messages/msg123
* @example PATCH /messages/msg123 { "data": {...} }
* @example DELETE /messages/msg123?cascade=true
*/
'/messages/:id': {
/** Get a single message by ID */
GET: {
params: { id: string }
response: Message
}
/** Update a message (content, move to new parent, etc.) */
PATCH: {
params: { id: string }
body: UpdateMessageDto
response: Message
}
/**
* Delete a message
* - cascade=true: deletes message and all descendants
* - cascade=false: reparents children to grandparent
* - activeNodeStrategy='parent' (default): sets activeNodeId to parent if affected
* - activeNodeStrategy='clear': sets activeNodeId to null if affected
*/
DELETE: {
params: { id: string }
query?: {
cascade?: boolean
activeNodeStrategy?: ActiveNodeStrategy
}
response: DeleteMessageResponse
}
}
}

View File

@ -0,0 +1,318 @@
/**
* Test API Schema definitions
*
* Contains all test-related endpoints for development and testing purposes.
* These endpoints demonstrate the API patterns and provide testing utilities.
*/
import type { OffsetPaginationParams, OffsetPaginationResponse, SearchParams, SortParams } from '../apiTypes'
// ============================================================================
// Domain Models & DTOs
// ============================================================================
/**
* Generic test item entity - flexible structure for testing various scenarios
*/
export interface TestItem {
/** Unique identifier */
id: string
/** Item title */
title: string
/** Optional description */
description?: string
/** Type category */
type: string
/** Current status */
status: string
/** Priority level */
priority: string
/** Associated tags */
tags: string[]
/** Creation timestamp */
createdAt: string
/** Last update timestamp */
updatedAt: string
/** Additional metadata */
metadata: Record<string, any>
}
/**
* DTO for creating a new test item
*/
export interface CreateTestItemDto {
/** Item title */
title: string
/** Optional description */
description?: string
/** Type category */
type?: string
/** Current status */
status?: string
/** Priority level */
priority?: string
/** Associated tags */
tags?: string[]
/** Additional metadata */
metadata?: Record<string, any>
}
/**
* DTO for updating an existing test item
*/
export interface UpdateTestItemDto {
/** Updated title */
title?: string
/** Updated description */
description?: string
/** Updated type */
type?: string
/** Updated status */
status?: string
/** Updated priority */
priority?: string
/** Updated tags */
tags?: string[]
/** Updated metadata */
metadata?: Record<string, any>
}
// ============================================================================
// API Schema Definitions
// ============================================================================
/**
* Test API Schema definitions
*
* Validation is performed at composition level via AssertValidSchemas
* in schemas/index.ts, which ensures:
* - All methods are valid HTTP methods (GET, POST, PUT, DELETE, PATCH)
* - All endpoints have a `response` field
*/
export interface TestSchemas {
/**
* Test items collection endpoint
* @example GET /test/items?page=1&limit=10&search=hello
* @example POST /test/items { "title": "New Test Item" }
*/
'/test/items': {
/** List all test items with optional filtering and pagination */
GET: {
query?: OffsetPaginationParams &
SortParams &
SearchParams & {
/** Filter by item type */
type?: string
/** Filter by status */
status?: string
}
response: OffsetPaginationResponse<TestItem>
}
/** Create a new test item */
POST: {
body: CreateTestItemDto
response: TestItem
}
}
/**
* Individual test item endpoint
* @example GET /test/items/123
* @example PUT /test/items/123 { "title": "Updated Title" }
* @example DELETE /test/items/123
*/
'/test/items/:id': {
/** Get a specific test item by ID */
GET: {
params: { id: string }
response: TestItem
}
/** Update a specific test item */
PUT: {
params: { id: string }
body: UpdateTestItemDto
response: TestItem
}
/** Delete a specific test item */
DELETE: {
params: { id: string }
response: void
}
}
/**
* Test search endpoint
* @example GET /test/search?query=hello&page=1&limit=20
*/
'/test/search': {
/** Search test items */
GET: {
query: OffsetPaginationParams & {
/** Search query string */
query: string
/** Additional filters */
type?: string
status?: string
}
response: OffsetPaginationResponse<TestItem>
}
}
/**
* Test statistics endpoint
* @example GET /test/stats
*/
'/test/stats': {
/** Get comprehensive test statistics */
GET: {
response: {
/** Total number of items */
total: number
/** Item count grouped by type */
byType: Record<string, number>
/** Item count grouped by status */
byStatus: Record<string, number>
/** Item count grouped by priority */
byPriority: Record<string, number>
/** Recent activity timeline */
recentActivity: Array<{
/** Date of activity */
date: string
/** Number of items on that date */
count: number
}>
}
}
}
/**
* Test bulk operations endpoint
* @example POST /test/bulk { "operation": "create", "data": [...] }
*/
'/test/bulk': {
/** Perform bulk operations on test items */
POST: {
body: {
/** Operation type */
operation: 'create' | 'update' | 'delete'
/** Array of data items to process */
data: Array<CreateTestItemDto | UpdateTestItemDto | string>
}
response: {
/** Number of successfully processed items */
successful: number
/** Number of items that failed processing */
failed: number
/** Array of error messages */
errors: string[]
}
}
}
/**
* Test error simulation endpoint
* @example POST /test/error { "errorType": "timeout" }
*/
'/test/error': {
/** Simulate various error scenarios for testing */
POST: {
body: {
/** Type of error to simulate */
errorType:
| 'timeout'
| 'network'
| 'server'
| 'notfound'
| 'validation'
| 'unauthorized'
| 'ratelimit'
| 'generic'
}
response: never
}
}
/**
* Test slow response endpoint
* @example POST /test/slow { "delay": 2000 }
*/
'/test/slow': {
/** Test slow response for performance testing */
POST: {
body: {
/** Delay in milliseconds */
delay: number
}
response: {
message: string
delay: number
timestamp: string
}
}
}
/**
* Test data reset endpoint
* @example POST /test/reset
*/
'/test/reset': {
/** Reset all test data to initial state */
POST: {
response: {
message: string
timestamp: string
}
}
}
/**
* Test config endpoint
* @example GET /test/config
* @example PUT /test/config { "setting": "value" }
*/
'/test/config': {
/** Get test configuration */
GET: {
response: Record<string, any>
}
/** Update test configuration */
PUT: {
body: Record<string, any>
response: Record<string, any>
}
}
/**
* Test status endpoint
* @example GET /test/status
*/
'/test/status': {
/** Get system test status */
GET: {
response: {
status: string
timestamp: string
version: string
uptime: number
environment: string
}
}
}
/**
* Test performance endpoint
* @example GET /test/performance
*/
'/test/performance': {
/** Get performance metrics */
GET: {
response: {
requestsPerSecond: number
averageLatency: number
memoryUsage: number
cpuUsage: number
uptime: number
}
}
}
}

View File

@ -0,0 +1,133 @@
/**
* Topic API Schema definitions
*
* Contains all topic-related endpoints for CRUD operations and branch switching.
*/
import type { AssistantMeta } from '@shared/data/types/meta'
import type { Topic } from '@shared/data/types/topic'
// ============================================================================
// DTOs
// ============================================================================
/**
* DTO for creating a new topic
*/
export interface CreateTopicDto {
/** Topic name */
name?: string
/** Associated assistant ID */
assistantId?: string
/** Preserved assistant info */
assistantMeta?: AssistantMeta
/** Topic-specific prompt */
prompt?: string
/** Group ID for organization */
groupId?: string
/**
* Source node ID for fork operation.
* When provided, copies the path from root to this node into the new topic.
*/
sourceNodeId?: string
}
/**
* DTO for updating an existing topic
*/
export interface UpdateTopicDto {
/** Updated topic name */
name?: string
/** Mark name as manually edited */
isNameManuallyEdited?: boolean
/** Updated assistant ID */
assistantId?: string
/** Updated assistant meta */
assistantMeta?: AssistantMeta
/** Updated prompt */
prompt?: string
/** Updated group ID */
groupId?: string
/** Updated sort order */
sortOrder?: number
/** Updated pin state */
isPinned?: boolean
/** Updated pin order */
pinnedOrder?: number
}
/**
* DTO for setting active node
*/
export interface SetActiveNodeDto {
/** Node ID to set as active */
nodeId: string
}
/**
* Response for active node update
*/
export interface ActiveNodeResponse {
/** The new active node ID */
activeNodeId: string
}
// ============================================================================
// API Schema Definitions
// ============================================================================
/**
* Topic API Schema definitions
*/
export interface TopicSchemas {
/**
* Topics collection endpoint
* @example POST /topics { "name": "New Topic", "assistantId": "asst_123" }
*/
'/topics': {
/** Create a new topic (optionally fork from existing node) */
POST: {
body: CreateTopicDto
response: Topic
}
}
/**
* Individual topic endpoint
* @example GET /topics/abc123
* @example PATCH /topics/abc123 { "name": "Updated Name" }
* @example DELETE /topics/abc123
*/
'/topics/:id': {
/** Get a topic by ID */
GET: {
params: { id: string }
response: Topic
}
/** Update a topic */
PATCH: {
params: { id: string }
body: UpdateTopicDto
response: Topic
}
/** Delete a topic and all its messages */
DELETE: {
params: { id: string }
response: void
}
}
/**
* Active node sub-resource endpoint
* High-frequency operation for branch switching
* @example PUT /topics/abc123/active-node { "nodeId": "msg456" }
*/
'/topics/:id/active-node': {
/** Set the active node for a topic */
PUT: {
params: { id: string }
body: SetActiveNodeDto
response: ActiveNodeResponse
}
}
}

View File

@ -5,23 +5,104 @@ import type * as CacheValueTypes from './cacheValueTypes'
*
* ## Key Naming Convention
*
* All cache keys MUST follow the format: `namespace.sub.key_name`
* All cache keys (fixed and template) MUST follow the format: `namespace.sub.key_name`
*
* Rules:
* - At least 2 segments separated by dots (.)
* - Each segment uses lowercase letters, numbers, and underscores only
* - Pattern: /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
* - Template placeholders `${xxx}` are treated as literal string segments
*
* Examples:
* - 'app.user.avatar' (valid)
* - 'chat.multi_select_mode' (valid)
* - 'minapp.opened_keep_alive' (valid)
* - 'scroll.position.${topicId}' (valid template key)
* - 'userAvatar' (invalid - missing dot separator)
* - 'App.user' (invalid - uppercase not allowed)
* - 'scroll.position:${id}' (invalid - colon not allowed)
*
* ## Template Key Support
*
* Template keys allow type-safe dynamic keys using template literal syntax.
* Define in schema with `${variable}` placeholder, use with actual values.
* Template keys follow the same dot-separated pattern as fixed keys.
*
* Examples:
* - Schema: `'scroll.position.${topicId}': number`
* - Usage: `useCache('scroll.position.topic123')` -> infers `number` type
*
* Multiple placeholders are supported:
* - Schema: `'entity.cache.${type}_${id}': CacheData`
* - Usage: `useCache('entity.cache.user_456')` -> infers `CacheData` type
*
* This convention is enforced by ESLint rule: data-schema-key/valid-key
*/
// ============================================================================
// Template Key Type Utilities
// ============================================================================
/**
* Detects whether a key string contains template placeholder syntax.
*
* Template keys use `${variable}` syntax to define dynamic segments.
* This type returns `true` if the key contains at least one `${...}` placeholder.
*
* @template K - The key string to check
* @returns `true` if K contains `${...}`, `false` otherwise
*
* @example
* ```typescript
* type Test1 = IsTemplateKey<'scroll.position.${id}'> // true
* type Test2 = IsTemplateKey<'entity.cache.${a}_${b}'> // true
* type Test3 = IsTemplateKey<'app.user.avatar'> // false
* ```
*/
export type IsTemplateKey<K extends string> = K extends `${string}\${${string}}${string}` ? true : false
/**
* Expands a template key pattern into a matching literal type.
*
* Replaces each `${variable}` placeholder with `string`, allowing
* TypeScript to match concrete keys against the template pattern.
* Recursively processes multiple placeholders.
*
* @template T - The template key pattern to expand
* @returns A template literal type that matches all valid concrete keys
*
* @example
* ```typescript
* type Test1 = ExpandTemplateKey<'scroll.position.${id}'>
* // Result: `scroll.position.${string}` (matches 'scroll.position.123', etc.)
*
* type Test2 = ExpandTemplateKey<'entity.cache.${type}_${id}'>
* // Result: `entity.cache.${string}_${string}` (matches 'entity.cache.user_123', etc.)
*
* type Test3 = ExpandTemplateKey<'app.user.avatar'>
* // Result: 'app.user.avatar' (unchanged for non-template keys)
* ```
*/
export type ExpandTemplateKey<T extends string> = T extends `${infer Prefix}\${${string}}${infer Suffix}`
? `${Prefix}${string}${ExpandTemplateKey<Suffix>}`
: T
/**
* Processes a cache key, expanding template patterns if present.
*
* For template keys (containing `${...}`), returns the expanded pattern.
* For fixed keys, returns the key unchanged.
*
* @template K - The key to process
* @returns The processed key type (expanded if template, unchanged if fixed)
*
* @example
* ```typescript
* type Test1 = ProcessKey<'scroll.position.${id}'> // `scroll.position.${string}`
* type Test2 = ProcessKey<'app.user.avatar'> // 'app.user.avatar'
* ```
*/
export type ProcessKey<K extends string> = IsTemplateKey<K> extends true ? ExpandTemplateKey<K> : K
/**
* Use cache schema for renderer hook
*/
@ -57,6 +138,25 @@ export type UseCacheSchema = {
'agent.active_id': string | null
'agent.session.active_id_map': Record<string, string | null>
'agent.session.waiting_id_map': Record<string, boolean>
// Template key examples (for testing and demonstration)
'scroll.position.${topicId}': number
'entity.cache.${type}_${id}': { loaded: boolean; data: unknown }
// ============================================================================
// Message Streaming Cache (Temporary)
// ============================================================================
// TODO [v2]: Replace `any` with proper types after newMessage.ts types are
// migrated to packages/shared/data/types/message.ts
// Current types:
// - StreamingSession: defined locally in StreamingService.ts
// - Message: src/renderer/src/types/newMessage.ts (renderer format, not shared/Message)
// - MessageBlock: src/renderer/src/types/newMessage.ts
'message.streaming.session.${messageId}': any // StreamingSession
'message.streaming.topic_sessions.${topicId}': string[]
'message.streaming.content.${messageId}': any // Message (renderer format)
'message.streaming.block.${blockId}': any // MessageBlock
'message.streaming.siblings_counter.${topicId}': number
}
export const DefaultUseCache: UseCacheSchema = {
@ -95,17 +195,28 @@ export const DefaultUseCache: UseCacheSchema = {
// Agent management
'agent.active_id': null,
'agent.session.active_id_map': {},
'agent.session.waiting_id_map': {}
'agent.session.waiting_id_map': {},
// Template key examples (for testing and demonstration)
'scroll.position.${topicId}': 0,
'entity.cache.${type}_${id}': { loaded: false, data: null },
// Message Streaming Cache
'message.streaming.session.${messageId}': null,
'message.streaming.topic_sessions.${topicId}': [],
'message.streaming.content.${messageId}': null,
'message.streaming.block.${blockId}': null,
'message.streaming.siblings_counter.${topicId}': 0
}
/**
* Use shared cache schema for renderer hook
*/
export type UseSharedCacheSchema = {
export type SharedCacheSchema = {
'example_scope.example_key': string
}
export const DefaultUseSharedCache: UseSharedCacheSchema = {
export const DefaultSharedCache: SharedCacheSchema = {
'example_scope.example_key': 'example default value'
}
@ -121,9 +232,107 @@ export const DefaultRendererPersistCache: RendererPersistCacheSchema = {
'example_scope.example_key': 'example default value'
}
// ============================================================================
// Cache Key Types
// ============================================================================
/**
* Type-safe cache key
* Key type for renderer persist cache (fixed keys only)
*/
export type RendererPersistCacheKey = keyof RendererPersistCacheSchema
export type UseCacheKey = keyof UseCacheSchema
export type UseSharedCacheKey = keyof UseSharedCacheSchema
/**
* Key type for shared cache (fixed keys only)
*/
export type SharedCacheKey = keyof SharedCacheSchema
/**
* Key type for memory cache (supports both fixed and template keys).
*
* This type expands all schema keys using ProcessKey, which:
* - Keeps fixed keys unchanged (e.g., 'app.user.avatar')
* - Expands template keys to match patterns (e.g., 'scroll.position.${id}' -> `scroll.position.${string}`)
*
* The resulting union type allows TypeScript to accept any concrete key
* that matches either a fixed key or an expanded template pattern.
*
* @example
* ```typescript
* // Given schema:
* // 'app.user.avatar': string
* // 'scroll.position.${topicId}': number
*
* // UseCacheKey becomes: 'app.user.avatar' | `scroll.position.${string}`
*
* // Valid keys:
* const k1: UseCacheKey = 'app.user.avatar' // fixed key
* const k2: UseCacheKey = 'scroll.position.123' // matches template
* const k3: UseCacheKey = 'scroll.position.abc' // matches template
*
* // Invalid keys:
* const k4: UseCacheKey = 'unknown.key' // error: not in schema
* ```
*/
export type UseCacheKey = {
[K in keyof UseCacheSchema]: ProcessKey<K & string>
}[keyof UseCacheSchema]
// ============================================================================
// UseCache Specialized Types
// ============================================================================
/**
* Infers the value type for a given cache key from UseCacheSchema.
*
* Works with both fixed keys and template keys:
* - For fixed keys, returns the exact value type from schema
* - For template keys, matches the key against expanded patterns and returns the value type
*
* If the key doesn't match any schema entry, returns `never`.
*
* @template K - The cache key to infer value type for
* @returns The value type associated with the key, or `never` if not found
*
* @example
* ```typescript
* // Given schema:
* // 'app.user.avatar': string
* // 'scroll.position.${topicId}': number
*
* type T1 = InferUseCacheValue<'app.user.avatar'> // string
* type T2 = InferUseCacheValue<'scroll.position.123'> // number
* type T3 = InferUseCacheValue<'scroll.position.abc'> // number
* type T4 = InferUseCacheValue<'unknown.key'> // never
* ```
*/
export type InferUseCacheValue<K extends string> = {
[S in keyof UseCacheSchema]: K extends ProcessKey<S & string> ? UseCacheSchema[S] : never
}[keyof UseCacheSchema]
/**
* Type guard for casual cache keys that blocks schema-defined keys.
*
* Used to ensure casual API methods (getCasual, setCasual, etc.) cannot
* be called with keys that are defined in the schema (including template patterns).
* This enforces proper API usage: use type-safe methods for schema keys,
* use casual methods only for truly dynamic/unknown keys.
*
* @template K - The key to check
* @returns `K` if the key doesn't match any schema pattern, `never` if it does
*
* @example
* ```typescript
* // Given schema:
* // 'app.user.avatar': string
* // 'scroll.position.${topicId}': number
*
* // These cause compile-time errors (key matches schema):
* getCasual('app.user.avatar') // Error: never
* getCasual('scroll.position.123') // Error: never (matches template)
*
* // These are allowed (key doesn't match any schema pattern):
* getCasual('my.custom.key') // OK
* getCasual('other.dynamic.key') // OK
* ```
*/
export type UseCacheCasualKey<K extends string> = K extends UseCacheKey ? never : K

View File

@ -22,7 +22,7 @@ export interface CacheSyncMessage {
type: 'shared' | 'persist'
key: string
value: any
ttl?: number
expireAt?: number // Absolute Unix timestamp for precise cross-window sync
}
/**
@ -33,7 +33,7 @@ export interface CacheSyncBatchMessage {
entries: Array<{
key: string
value: any
ttl?: number
expireAt?: number // Absolute Unix timestamp for precise cross-window sync
}>
}

View File

@ -55,14 +55,15 @@ export enum ThemeMode {
export type LanguageVarious =
| 'zh-CN'
| 'zh-TW'
| 'de-DE'
| 'el-GR'
| 'en-US'
| 'es-ES'
| 'fr-FR'
| 'ja-JP'
| 'pt-PT'
| 'ro-RO'
| 'ru-RU'
| 'de-DE'
export type WindowStyle = 'transparent' | 'opaque'

View File

@ -0,0 +1,481 @@
import type { CursorPaginationResponse } from '@shared/data/api/apiTypes'
/**
* Message Statistics - combines token usage and performance metrics
* Replaces the separate `usage` and `metrics` fields
*/
export interface MessageStats {
// Token consumption (from API response)
promptTokens?: number
completionTokens?: number
totalTokens?: number
thoughtsTokens?: number
// Cost (calculated at message completion time)
cost?: number
// Performance metrics (measured locally)
timeFirstTokenMs?: number
timeCompletionMs?: number
timeThinkingMs?: number
}
// ============================================================================
// Message Data
// ============================================================================
/**
* Message data field structure
* This is the type for the `data` column in the message table
*/
export interface MessageData {
blocks: MessageDataBlock[]
}
//FIXME [v2] 注意,以下类型只是占位,接口未稳定,随时会变
// ============================================================================
// Content Reference Types
// ============================================================================
/**
* Reference category for content references
*/
export enum ReferenceCategory {
CITATION = 'citation',
MENTION = 'mention'
}
/**
* Citation source type
*/
export enum CitationType {
WEB = 'web',
KNOWLEDGE = 'knowledge',
MEMORY = 'memory'
}
/**
* Base reference structure for inline content references
*/
export interface BaseReference {
category: ReferenceCategory
/** Text marker in content, e.g., "[1]", "@user" */
marker?: string
/** Position range in content */
range?: { start: number; end: number }
}
/**
* Base citation reference
*/
interface BaseCitationReference extends BaseReference {
category: ReferenceCategory.CITATION
citationType: CitationType
}
/**
* Web search citation reference
* Data structure compatible with WebSearchResponse from renderer
*/
export interface WebCitationReference extends BaseCitationReference {
citationType: CitationType.WEB
content: {
results?: unknown // types needs to be migrated from renderer ( newMessage.ts )
source: unknown // types needs to be migrated from renderer ( newMessage.ts )
}
}
/**
* Knowledge base citation reference
* Data structure compatible with KnowledgeReference[] from renderer
*/
export interface KnowledgeCitationReference extends BaseCitationReference {
citationType: CitationType.KNOWLEDGE
// types needs to be migrated from renderer ( newMessage.ts )
content: {
id: number
content: string
sourceUrl: string
type: string
file?: unknown
metadata?: Record<string, unknown>
}[]
}
/**
* Memory citation reference
* Data structure compatible with MemoryItem[] from renderer
*/
export interface MemoryCitationReference extends BaseCitationReference {
citationType: CitationType.MEMORY
// types needs to be migrated from renderer ( newMessage.ts )
content: {
id: string
memory: string
hash?: string
createdAt?: string
updatedAt?: string
score?: number
metadata?: Record<string, unknown>
}[]
}
/**
* Union type of all citation references
*/
export type CitationReference = WebCitationReference | KnowledgeCitationReference | MemoryCitationReference
/**
* Mention reference for @mentions in content
* References a Model entity
*/
export interface MentionReference extends BaseReference {
category: ReferenceCategory.MENTION
/** Model ID being mentioned */
modelId: string //FIXME 未定接口model的数据结构还未确定先占位
/** Display name for the mention */
displayName?: string
}
/**
* Union type of all content references
*/
export type ContentReference = CitationReference | MentionReference
/**
* Type guard: check if reference is a citation
*/
export function isCitation(ref: ContentReference): ref is CitationReference {
return ref.category === ReferenceCategory.CITATION
}
/**
* Type guard: check if reference is a mention
*/
export function isMention(ref: ContentReference): ref is MentionReference {
return ref.category === ReferenceCategory.MENTION
}
/**
* Type guard: check if reference is a web citation
*/
export function isWebCitation(ref: ContentReference): ref is WebCitationReference {
return isCitation(ref) && ref.citationType === CitationType.WEB
}
/**
* Type guard: check if reference is a knowledge citation
*/
export function isKnowledgeCitation(ref: ContentReference): ref is KnowledgeCitationReference {
return isCitation(ref) && ref.citationType === CitationType.KNOWLEDGE
}
/**
* Type guard: check if reference is a memory citation
*/
export function isMemoryCitation(ref: ContentReference): ref is MemoryCitationReference {
return isCitation(ref) && ref.citationType === CitationType.MEMORY
}
// ============================================================================
// Message Block
// ============================================================================
export enum BlockType {
UNKNOWN = 'unknown',
MAIN_TEXT = 'main_text',
THINKING = 'thinking',
TRANSLATION = 'translation',
IMAGE = 'image',
CODE = 'code',
TOOL = 'tool',
FILE = 'file',
ERROR = 'error',
CITATION = 'citation',
VIDEO = 'video',
COMPACT = 'compact'
}
/**
* Base message block data structure
*/
export interface BaseBlock {
type: BlockType
createdAt: number // timestamp
updatedAt?: number
// modelId?: string // v1's dead code, will be removed in v2
metadata?: Record<string, unknown>
error?: SerializedErrorData
}
/**
* Serialized error for storage
*/
export interface SerializedErrorData {
name?: string
message: string
code?: string
stack?: string
cause?: unknown
}
// Block type specific interfaces
export interface UnknownBlock extends BaseBlock {
type: BlockType.UNKNOWN
content?: string
}
/**
* Main text block containing the primary message content.
*
* ## Migration Notes (v2.0)
*
* ### Added
* - `references`: Unified inline references replacing the old citation system.
* Supports multiple reference types (citations, mentions) with position tracking.
*
* ### Removed
* - `citationReferences`: Use `references` with `ReferenceCategory.CITATION` instead.
* - `CitationBlock`: Citation data is now embedded in `MainTextBlock.references`.
* The standalone CitationBlock type is no longer used.
*/
export interface MainTextBlock extends BaseBlock {
type: BlockType.MAIN_TEXT
content: string
//knowledgeBaseIds?: string[] // v1's dead code, will be removed in v2
/**
* Inline references embedded in the content (citations, mentions, etc.)
* Replaces the old CitationBlock + citationReferences pattern.
* @since v2.0
*/
references?: ContentReference[]
/**
* @deprecated Use `references` with `ReferenceCategory.CITATION` instead.
*/
// citationReferences?: {
// citationBlockId?: string
// citationBlockSource?: string
// }[]
}
export interface ThinkingBlock extends BaseBlock {
type: BlockType.THINKING
content: string
thinkingMs: number
}
export interface TranslationBlock extends BaseBlock {
type: BlockType.TRANSLATION
content: string
sourceBlockId?: string
sourceLanguage?: string
targetLanguage: string
}
export interface CodeBlock extends BaseBlock {
type: BlockType.CODE
content: string
language: string
}
export interface ImageBlock extends BaseBlock {
type: BlockType.IMAGE
url?: string
fileId?: string
}
export interface ToolBlock extends BaseBlock {
type: BlockType.TOOL
toolId: string
toolName?: string
arguments?: Record<string, unknown>
content?: string | object
}
/**
* @deprecated Citation data is now embedded in MainTextBlock.references.
* Use ContentReference types instead. Will be removed in v3.0.
*/
export interface CitationBlock extends BaseBlock {
type: BlockType.CITATION
responseData?: unknown
knowledgeData?: unknown
memoriesData?: unknown
}
export interface FileBlock extends BaseBlock {
type: BlockType.FILE
fileId: string
}
export interface VideoBlock extends BaseBlock {
type: BlockType.VIDEO
url?: string
filePath?: string
}
export interface ErrorBlock extends BaseBlock {
type: BlockType.ERROR
}
export interface CompactBlock extends BaseBlock {
type: BlockType.COMPACT
content: string
compactedContent: string
}
/**
* Union type of all message block data types
*/
export type MessageDataBlock =
| UnknownBlock
| MainTextBlock
| ThinkingBlock
| TranslationBlock
| CodeBlock
| ImageBlock
| ToolBlock
| CitationBlock
| FileBlock
| VideoBlock
| ErrorBlock
| CompactBlock
// ============================================================================
// Message Entity Types
// ============================================================================
import type { AssistantMeta, ModelMeta } from './meta'
/**
* Message role - user, assistant, or system
*/
export type MessageRole = 'user' | 'assistant' | 'system'
/**
* Message status
* - pending: Placeholder created, streaming in progress
* - success: Completed successfully
* - error: Failed with error
* - paused: User stopped generation
*/
export type MessageStatus = 'pending' | 'success' | 'error' | 'paused'
/**
* Complete message entity as stored in database
*/
export interface Message {
/** Message ID (UUIDv7) */
id: string
/** Topic ID this message belongs to */
topicId: string
/** Parent message ID (null for root) */
parentId: string | null
/** Message role */
role: MessageRole
/** Message content (blocks, mentions, etc.) */
data: MessageData
/** Searchable text extracted from data.blocks */
searchableText?: string | null
/** Message status */
status: MessageStatus
/** Siblings group ID (0 = normal branch, >0 = multi-model response group) */
siblingsGroupId: number
/** Assistant ID */
assistantId?: string | null
/** Preserved assistant info for display */
assistantMeta?: AssistantMeta | null
/** Model identifier */
modelId?: string | null
/** Preserved model info (provider, name) */
modelMeta?: ModelMeta | null
/** Trace ID for tracking */
traceId?: string | null
/** Statistics: token usage, performance metrics */
stats?: MessageStats | null
/** Creation timestamp (ISO string) */
createdAt: string
/** Last update timestamp (ISO string) */
updatedAt: string
}
// ============================================================================
// Tree Structure Types
// ============================================================================
/**
* Lightweight tree node for tree visualization (ReactFlow)
* Contains only essential display info, not full message content
*/
export interface TreeNode {
/** Message ID */
id: string
/** Parent message ID (null for root, omitted in SiblingsGroup.nodes) */
parentId?: string | null
/** Message role */
role: MessageRole
/** Content preview (first 50 characters) */
preview: string
/** Model identifier */
modelId?: string | null
/** Model display info */
modelMeta?: ModelMeta | null
/** Message status */
status: MessageStatus
/** Creation timestamp (ISO string) */
createdAt: string
/** Whether this node has children (for expand indicator) */
hasChildren: boolean
}
/**
* Group of sibling nodes with same parentId and siblingsGroupId
* Used for multi-model responses in tree view
*/
export interface SiblingsGroup {
/** Parent message ID */
parentId: string
/** Siblings group ID (non-zero) */
siblingsGroupId: number
/** Nodes in this group (parentId omitted to avoid redundancy) */
nodes: Omit<TreeNode, 'parentId'>[]
}
/**
* Tree query response structure
*/
export interface TreeResponse {
/** Regular nodes (siblingsGroupId = 0) */
nodes: TreeNode[]
/** Multi-model response groups (siblingsGroupId != 0) */
siblingsGroups: SiblingsGroup[]
/** Current active node ID */
activeNodeId: string | null
}
// ============================================================================
// Branch Message Types
// ============================================================================
/**
* Message with optional siblings group for conversation view
* Used in GET /topics/:id/messages response
*/
export interface BranchMessage {
/** The message itself */
message: Message
/** Other messages in the same siblings group (only when siblingsGroupId != 0 and includeSiblings=true) */
siblingsGroup?: Message[]
}
/**
* Branch messages response structure
*/
export interface BranchMessagesResponse extends CursorPaginationResponse<BranchMessage> {
/** Current active node ID */
activeNodeId: string | null
}

View File

@ -0,0 +1,36 @@
/**
* Soft reference metadata types
*
* These types store snapshots of referenced entities at creation time,
* preserving display information even if the original entity is deleted.
*/
/**
* Preserved assistant info for display when assistant is deleted
* Used in: message.assistantMeta, topic.assistantMeta
*/
export interface AssistantMeta {
/** Original assistant ID, used to attempt reference recovery */
id: string
/** Assistant display name shown in UI */
name: string
/** Assistant icon emoji for visual identification */
emoji?: string
/** Assistant type, e.g., 'default', 'custom', 'agent' */
type?: string
}
/**
* Preserved model info for display when model is unavailable
* Used in: message.modelMeta
*/
export interface ModelMeta {
/** Original model ID, used to attempt reference recovery */
id: string
/** Model display name, e.g., "GPT-4o", "Claude 3.5 Sonnet" */
name: string
/** Provider identifier, e.g., "openai", "anthropic", "google" */
provider: string
/** Model family/group, e.g., "gpt-4", "claude-3", useful for grouping in UI */
group?: string
}

View File

@ -0,0 +1,40 @@
/**
* Topic entity types
*
* Topics are containers for messages and belong to assistants.
* They can be organized into groups and have tags for categorization.
*/
import type { AssistantMeta } from './meta'
/**
* Complete topic entity as stored in database
*/
export interface Topic {
/** Topic ID */
id: string
/** Topic name */
name?: string | null
/** Whether the name was manually edited by user */
isNameManuallyEdited: boolean
/** Associated assistant ID */
assistantId?: string | null
/** Preserved assistant info for display when assistant is deleted */
assistantMeta?: AssistantMeta | null
/** Topic-specific prompt override */
prompt?: string | null
/** Active node ID in the message tree */
activeNodeId?: string | null
/** Group ID for organization */
groupId?: string | null
/** Sort order within group */
sortOrder: number
/** Whether topic is pinned */
isPinned: boolean
/** Pinned order */
pinnedOrder: number
/** Creation timestamp (ISO string) */
createdAt: string
/** Last update timestamp (ISO string) */
updatedAt: string
}

View File

@ -35,3 +35,56 @@ export const defaultAppHeaders = () => {
// return value
// }
// }
/**
* Extracts the trailing API version segment from a URL path.
*
* This function extracts API version patterns (e.g., `v1`, `v2beta`) from the end of a URL.
* Only versions at the end of the path are extracted, not versions in the middle.
* The returned version string does not include leading or trailing slashes.
*
* @param {string} url - The URL string to parse.
* @returns {string | undefined} The trailing API version found (e.g., 'v1', 'v2beta'), or undefined if none found.
*
* @example
* getTrailingApiVersion('https://api.example.com/v1') // 'v1'
* getTrailingApiVersion('https://api.example.com/v2beta/') // 'v2beta'
* getTrailingApiVersion('https://api.example.com/v1/chat') // undefined (version not at end)
* getTrailingApiVersion('https://gateway.ai.cloudflare.com/v1/xxx/v1beta') // 'v1beta'
* getTrailingApiVersion('https://api.example.com') // undefined
*/
export function getTrailingApiVersion(url: string): string | undefined {
const match = url.match(TRAILING_VERSION_REGEX)
if (match) {
// Extract version without leading slash and trailing slash
return match[0].replace(/^\//, '').replace(/\/$/, '')
}
return undefined
}
/**
* Matches an API version at the end of a URL (with optional trailing slash).
* Used to detect and extract versions only from the trailing position.
*/
const TRAILING_VERSION_REGEX = /\/v\d+(?:alpha|beta)?\/?$/i
/**
* Removes the trailing API version segment from a URL path.
*
* This function removes API version patterns (e.g., `/v1`, `/v2beta`) from the end of a URL.
* Only versions at the end of the path are removed, not versions in the middle.
*
* @param {string} url - The URL string to process.
* @returns {string} The URL with the trailing API version removed, or the original URL if no trailing version found.
*
* @example
* withoutTrailingApiVersion('https://api.example.com/v1') // 'https://api.example.com'
* withoutTrailingApiVersion('https://api.example.com/v2beta/') // 'https://api.example.com'
* withoutTrailingApiVersion('https://api.example.com/v1/chat') // 'https://api.example.com/v1/chat' (no change)
* withoutTrailingApiVersion('https://api.example.com') // 'https://api.example.com'
*/
export function withoutTrailingApiVersion(url: string): string {
return url.replace(TRAILING_VERSION_REGEX, '')
}

View File

@ -5,7 +5,7 @@
"hooks": "@cherrystudio/ui/hooks",
"lib": "@cherrystudio/ui/lib",
"ui": "@cherrystudio/ui/components/primitives",
"utils": "@cherrystudio/ui/utils"
"utils": "@cherrystudio/ui/lib/utils"
},
"iconLibrary": "lucide",
"rsc": false,

View File

@ -1,8 +1,7 @@
// Original: src/renderer/src/components/Ellipsis/index.tsx
import { cn } from '@cherrystudio/ui/lib/utils'
import type { HTMLAttributes } from 'react'
import { cn } from '../../../utils'
type Props = {
maxLine?: number
className?: string

View File

@ -1,7 +1,6 @@
import { cn } from '@cherrystudio/ui/lib/utils'
import React from 'react'
import { cn } from '../../../utils'
export interface BoxProps extends React.ComponentProps<'div'> {}
export const Box = ({ children, className, ...props }: BoxProps & { children?: React.ReactNode }) => {

View File

@ -1,4 +1,5 @@
import { cn, toUndefinedIfNull } from '@cherrystudio/ui/utils'
import { cn } from '@cherrystudio/ui/lib/utils'
import { toUndefinedIfNull } from '@cherrystudio/ui/utils/index'
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
import { Edit2Icon, EyeIcon, EyeOffIcon } from 'lucide-react'

View File

@ -1,9 +1,8 @@
// Original path: src/renderer/src/components/ListItem/index.tsx
import { cn } from '@cherrystudio/ui/lib/utils'
import { Tooltip } from '@heroui/react'
import type { ReactNode } from 'react'
import { cn } from '../../../utils'
interface ListItemProps {
active?: boolean
icon?: ReactNode

View File

@ -1,10 +1,10 @@
import { cn } from '@cherrystudio/ui/lib/utils'
import type { DraggableSyntheticListeners } from '@dnd-kit/core'
import type { Transform } from '@dnd-kit/utilities'
import { CSS } from '@dnd-kit/utilities'
import React, { useEffect } from 'react'
import styled from 'styled-components'
import { cn } from '../../../utils'
import type { RenderItemType } from './types'
interface ItemRendererProps<T> {

View File

@ -7,12 +7,12 @@
*/
// Original path: src/renderer/src/components/ThinkingEffect.tsx
import { cn } from '@cherrystudio/ui/lib/utils'
import { isEqual } from 'lodash'
import { ChevronRight, Lightbulb } from 'lucide-react'
import { motion } from 'motion/react'
import React, { useEffect, useMemo, useState } from 'react'
import { cn } from '../../../utils'
import { lightbulbVariants } from './defaultVariants'
interface ThinkingEffectProps {

View File

@ -59,6 +59,7 @@ export {
export { Sortable } from './composites/Sortable'
/* Shadcn Primitive Components */
export * from './primitives/badge'
export * from './primitives/breadcrumb'
export * from './primitives/button'
export * from './primitives/checkbox'

View File

@ -1,7 +1,6 @@
import { cn } from '@cherrystudio/ui/lib/utils'
import React, { memo } from 'react'
import { cn } from '../../../utils'
interface EmojiAvatarProps {
children: string
size?: number

View File

@ -1,7 +1,7 @@
import { cn } from '@cherrystudio/ui/lib/utils'
import type { AvatarProps as HeroUIAvatarProps } from '@heroui/react'
import { Avatar as HeroUIAvatar, AvatarGroup as HeroUIAvatarGroup } from '@heroui/react'
import { cn } from '../../../utils'
import EmojiAvatar from './EmojiAvatar'
export interface AvatarProps extends Omit<HeroUIAvatarProps, 'size'> {

View File

@ -0,0 +1,35 @@
import { cn } from '@cherrystudio/ui/lib/utils'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default: 'border-transparent bg-background-subtle text-secondary-foreground [a&]:hover:bg-primary/90',
secondary: 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent text-destructive bg-[red]/10 [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
}
},
defaultVariants: {
variant: 'default'
}
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span'
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

View File

@ -1,4 +1,4 @@
import { cn } from '@cherrystudio/ui/utils/index'
import { cn } from '@cherrystudio/ui/lib/utils'
import { Slot } from '@radix-ui/react-slot'
import { ChevronRight, MoreHorizontal } from 'lucide-react'
import * as React from 'react'

View File

@ -1,4 +1,4 @@
import { cn } from '@cherrystudio/ui/utils/index'
import { cn } from '@cherrystudio/ui/lib/utils'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { Loader } from 'lucide-react'

View File

@ -1,4 +1,4 @@
import { cn } from '@cherrystudio/ui/utils/index'
import { cn } from '@cherrystudio/ui/lib/utils'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { cva, type VariantProps } from 'class-variance-authority'
import { CheckIcon } from 'lucide-react'

View File

@ -10,7 +10,7 @@ import {
CommandList
} from '@cherrystudio/ui/components/primitives/command'
import { Popover, PopoverContent, PopoverTrigger } from '@cherrystudio/ui/components/primitives/popover'
import { cn } from '@cherrystudio/ui/utils/index'
import { cn } from '@cherrystudio/ui/lib/utils'
import { cva, type VariantProps } from 'class-variance-authority'
import { Check, ChevronDown, X } from 'lucide-react'
import * as React from 'react'

View File

@ -5,7 +5,7 @@ import {
DialogHeader,
DialogTitle
} from '@cherrystudio/ui/components/primitives/dialog'
import { cn } from '@cherrystudio/ui/utils'
import { cn } from '@cherrystudio/ui/lib/utils'
import { Command as CommandPrimitive } from 'cmdk'
import { SearchIcon } from 'lucide-react'
import * as React from 'react'

View File

@ -1,4 +1,4 @@
import { cn } from '@cherrystudio/ui/utils/index'
import { cn } from '@cherrystudio/ui/lib/utils'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { XIcon } from 'lucide-react'
import * as React from 'react'

View File

@ -3,7 +3,7 @@ import type { InputProps } from '@cherrystudio/ui/components/primitives/input'
import { Input } from '@cherrystudio/ui/components/primitives/input'
import type { TextareaInputProps } from '@cherrystudio/ui/components/primitives/textarea'
import * as Textarea from '@cherrystudio/ui/components/primitives/textarea'
import { cn } from '@cherrystudio/ui/utils/index'
import { cn } from '@cherrystudio/ui/lib/utils'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'

View File

@ -1,4 +1,4 @@
import { cn } from '@cherrystudio/ui/utils'
import { cn } from '@cherrystudio/ui/lib/utils'
import * as React from 'react'
interface InputProps extends React.ComponentProps<'input'> {}

View File

@ -1,4 +1,4 @@
import { cn } from '@cherrystudio/ui/utils/index'
import { cn } from '@cherrystudio/ui/lib/utils'
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
return (

View File

@ -1,6 +1,6 @@
import type { Button } from '@cherrystudio/ui/components/primitives/button'
import { buttonVariants } from '@cherrystudio/ui/components/primitives/button'
import { cn } from '@cherrystudio/ui/utils/index'
import { cn } from '@cherrystudio/ui/lib/utils'
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react'
import * as React from 'react'

View File

@ -1,6 +1,6 @@
'use client'
import { cn } from '@cherrystudio/ui/utils'
import { cn } from '@cherrystudio/ui/lib/utils'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import * as React from 'react'

View File

@ -1,4 +1,4 @@
import { cn } from '@cherrystudio/ui/utils/index'
import { cn } from '@cherrystudio/ui/lib/utils'
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
import { cva, type VariantProps } from 'class-variance-authority'
import { CircleIcon } from 'lucide-react'

View File

@ -1,4 +1,4 @@
import { cn } from '@cherrystudio/ui/utils/index'
import { cn } from '@cherrystudio/ui/lib/utils'
import * as SelectPrimitive from '@radix-ui/react-select'
import { cva, type VariantProps } from 'class-variance-authority'
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'

View File

@ -1,7 +1,7 @@
'use client'
import { Button } from '@cherrystudio/ui/components/primitives/button'
import { cn } from '@cherrystudio/ui/utils/index'
import { cn } from '@cherrystudio/ui/lib/utils'
import { UploadIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { createContext, use } from 'react'

View File

@ -1,4 +1,4 @@
import { cn } from '@cherrystudio/ui/utils'
import { cn } from '@cherrystudio/ui/lib/utils'
import * as SwitchPrimitive from '@radix-ui/react-switch'
import { cva } from 'class-variance-authority'
import * as React from 'react'

View File

@ -1,4 +1,4 @@
import { cn } from '@cherrystudio/ui/utils/index'
import { cn } from '@cherrystudio/ui/lib/utils'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cva } from 'class-variance-authority'
import * as React from 'react'

View File

@ -1,4 +1,4 @@
import { cn } from '@cherrystudio/ui/utils/index'
import { cn } from '@cherrystudio/ui/lib/utils'
import { composeEventHandlers } from '@radix-ui/primitive'
import { useCallbackRef } from '@radix-ui/react-use-callback-ref'
import { useControllableState } from '@radix-ui/react-use-controllable-state'

View File

@ -1,4 +1,4 @@
import { cn } from '@cherrystudio/ui/utils/index'
import { cn } from '@cherrystudio/ui/lib/utils'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import * as React from 'react'

View File

@ -0,0 +1,23 @@
/**
* Internal utilities for UI components.
*
* This module is for INTERNAL use only and should NOT be exposed to external consumers.
* External utilities should be placed in `utils/` instead.
*
* @internal
*/
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
/**
* Merges Tailwind CSS class names with conflict resolution.
* Combines clsx for conditional classes and tailwind-merge for deduplication.
*
* @example
* cn('px-2 py-1', 'px-4') // => 'py-1 px-4'
* cn('text-red-500', isActive && 'text-blue-500')
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -1,13 +1,11 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
/**
* Merge class names with tailwind-merge
* This utility combines clsx and tailwind-merge for optimal class name handling
* Public utility functions for external consumers.
*
* This module is part of the PUBLIC API and can be imported via `@cherrystudio/ui/utils`.
* For internal-only utilities (e.g., Tailwind class merging), use `lib/` instead.
*
* @module utils
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Converts `null` to `undefined`, otherwise returns the input value.

Some files were not shown because too many files have changed in this diff Show More