✨ upload attachments and custom feedback
This commit is contained in:
@@ -15,6 +15,7 @@ import '@renderer/styles/app.css'
|
|||||||
removeAIListener: () => {},
|
removeAIListener: () => {},
|
||||||
getProjectWordCount: () => Promise.resolve(0),
|
getProjectWordCount: () => Promise.resolve(0),
|
||||||
saveOrder: () => Promise.resolve(),
|
saveOrder: () => Promise.resolve(),
|
||||||
|
pickAttachments: () => Promise.resolve([]),
|
||||||
saveRevision: () => Promise.resolve(),
|
saveRevision: () => Promise.resolve(),
|
||||||
listRevisions: () => Promise.resolve([]),
|
listRevisions: () => Promise.resolve([]),
|
||||||
loadRevision: () => Promise.resolve(''),
|
loadRevision: () => Promise.resolve(''),
|
||||||
|
|||||||
228
package-lock.json
generated
228
package-lock.json
generated
@@ -21,6 +21,7 @@
|
|||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
|
"pdf-parse": "^2.4.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"zustand": "^4.5.0"
|
"zustand": "^4.5.0"
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/pdf-parse": "^1.1.5",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.0",
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
@@ -1520,6 +1522,190 @@
|
|||||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/canvas": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"e2e/*"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas-android-arm64": "0.1.80",
|
||||||
|
"@napi-rs/canvas-darwin-arm64": "0.1.80",
|
||||||
|
"@napi-rs/canvas-darwin-x64": "0.1.80",
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
|
||||||
|
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||||
|
"version": "0.1.80",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
|
||||||
|
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@@ -2056,6 +2242,16 @@
|
|||||||
"form-data": "^4.0.4"
|
"form-data": "^4.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pdf-parse": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/plist": {
|
"node_modules/@types/plist": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz",
|
||||||
@@ -5107,6 +5303,38 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/pdf-parse": {
|
||||||
|
"version": "2.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
|
||||||
|
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@napi-rs/canvas": "0.1.80",
|
||||||
|
"pdfjs-dist": "5.4.296"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pdf-parse": "bin/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.16.0 <21 || >=22.3.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/mehmet-kozan"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pdfjs-dist": {
|
||||||
|
"version": "5.4.296",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
||||||
|
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.16.0 || >=22.3.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.80"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pend": {
|
"node_modules/pend": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/pdf-parse": "^1.1.5",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.0",
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
|
"pdf-parse": "^2.4.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"zustand": "^4.5.0"
|
"zustand": "^4.5.0"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Anthropic from '@anthropic-ai/sdk'
|
import Anthropic from '@anthropic-ai/sdk'
|
||||||
import type { AIPayload } from '../renderer/types/editor'
|
import type { AIPayload, Attachment } from '../renderer/types/editor'
|
||||||
|
|
||||||
let _client: Anthropic | null = null
|
let _client: Anthropic | null = null
|
||||||
|
|
||||||
@@ -89,7 +89,54 @@ SUGGESTION: [concrete direction]
|
|||||||
Name the single most important thing to fix in a revision of this chapter.`
|
Name the single most important thing to fix in a revision of this chapter.`
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${chapterContext}\n\n${modeInstructions[payload.mode]}`
|
let prompt = `${chapterContext}\n\n${modeInstructions[payload.mode]}`
|
||||||
|
|
||||||
|
if (payload.attachments && payload.attachments.length > 0) {
|
||||||
|
const names = payload.attachments.map((a) => a.name).join(', ')
|
||||||
|
prompt += `\n\nThe user has attached the following reference file(s): ${names}.
|
||||||
|
When suggesting edits based on the attached material, identify exact passages in the chapter and use this format for each suggestion:
|
||||||
|
ISSUE: [category of improvement]
|
||||||
|
PASSAGE: "[exact quoted text from the chapter]"
|
||||||
|
PROBLEM: [brief explanation of why this needs changing]
|
||||||
|
SUGGESTION: "[revised text]"`
|
||||||
|
}
|
||||||
|
|
||||||
|
return prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserContentBlock = Anthropic.ImageBlockParam | Anthropic.TextBlockParam
|
||||||
|
|
||||||
|
function buildUserContent(
|
||||||
|
userMessage: string,
|
||||||
|
attachments: Attachment[] | undefined
|
||||||
|
): string | UserContentBlock[] {
|
||||||
|
if (!attachments || attachments.length === 0) {
|
||||||
|
return userMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
const content: UserContentBlock[] = []
|
||||||
|
|
||||||
|
for (const att of attachments) {
|
||||||
|
if (att.mimeType.startsWith('image/')) {
|
||||||
|
content.push({
|
||||||
|
type: 'image',
|
||||||
|
source: {
|
||||||
|
type: 'base64',
|
||||||
|
media_type: att.mimeType as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp',
|
||||||
|
data: att.data
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Text or PDF — include as a fenced text block
|
||||||
|
content.push({
|
||||||
|
type: 'text',
|
||||||
|
text: `--- Attached: ${att.name} ---\n${att.data}\n--- End of ${att.name} ---`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content.push({ type: 'text', text: userMessage })
|
||||||
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function streamMessage(
|
export async function streamMessage(
|
||||||
@@ -98,9 +145,12 @@ export async function streamMessage(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const client = getClient()
|
const client = getClient()
|
||||||
|
|
||||||
const messages = [
|
const messages: Anthropic.MessageParam[] = [
|
||||||
...payload.conversationHistory.slice(-10),
|
...payload.conversationHistory.slice(-10),
|
||||||
{ role: 'user' as const, content: payload.userMessage }
|
{
|
||||||
|
role: 'user' as const,
|
||||||
|
content: buildUserContent(payload.userMessage, payload.attachments)
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const stream = client.messages.stream({
|
const stream = client.messages.stream({
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { ipcMain } from 'electron'
|
import { ipcMain, dialog, BrowserWindow } from 'electron'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { extname, basename } from 'path'
|
||||||
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision } from './fileSystem'
|
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision } from './fileSystem'
|
||||||
import { streamMessage } from './aiService'
|
import { streamMessage } from './aiService'
|
||||||
import type { AIPayload } from '../renderer/types/editor'
|
import type { AIPayload, Attachment } from '../renderer/types/editor'
|
||||||
|
|
||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
ipcMain.handle('fs:listFiles', async () => {
|
ipcMain.handle('fs:listFiles', async () => {
|
||||||
@@ -48,6 +50,48 @@ export function registerIpcHandlers(): void {
|
|||||||
await deleteRevision(filePath, revisionId)
|
await deleteRevision(filePath, revisionId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('fs:pickAttachments', async (event): Promise<Attachment[]> => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
const result = await dialog.showOpenDialog(win!, {
|
||||||
|
properties: ['openFile', 'multiSelections'],
|
||||||
|
filters: [
|
||||||
|
{ name: 'Supported Files', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'txt', 'md', 'pdf'] },
|
||||||
|
{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },
|
||||||
|
{ name: 'Text Files', extensions: ['txt', 'md'] },
|
||||||
|
{ name: 'PDF', extensions: ['pdf'] }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.canceled || result.filePaths.length === 0) return []
|
||||||
|
|
||||||
|
const attachments: Attachment[] = []
|
||||||
|
for (const filePath of result.filePaths) {
|
||||||
|
const name = basename(filePath)
|
||||||
|
const ext = extname(filePath).toLowerCase()
|
||||||
|
|
||||||
|
if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {
|
||||||
|
const data = readFileSync(filePath).toString('base64')
|
||||||
|
const mimeType =
|
||||||
|
ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg'
|
||||||
|
: ext === '.png' ? 'image/png'
|
||||||
|
: ext === '.gif' ? 'image/gif'
|
||||||
|
: 'image/webp'
|
||||||
|
attachments.push({ name, mimeType, data })
|
||||||
|
} else if (['.txt', '.md'].includes(ext)) {
|
||||||
|
const data = readFileSync(filePath, 'utf-8')
|
||||||
|
attachments.push({ name, mimeType: 'text/plain', data })
|
||||||
|
} else if (ext === '.pdf') {
|
||||||
|
// Dynamically require pdf-parse to avoid CJS/ESM issues at module load time
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const pdfParse = require('pdf-parse') as (buf: Buffer) => Promise<{ text: string }>
|
||||||
|
const buffer = readFileSync(filePath)
|
||||||
|
const parsed = await pdfParse(buffer)
|
||||||
|
attachments.push({ name, mimeType: 'application/pdf', data: parsed.text })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return attachments
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
|
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
|
||||||
try {
|
try {
|
||||||
await streamMessage(payload, (chunk: string) => {
|
await streamMessage(payload, (chunk: string) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron'
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
import type { FileNode, AIPayload, RevisionMeta } from '../renderer/types/editor'
|
import type { FileNode, AIPayload, RevisionMeta, Attachment } from '../renderer/types/editor'
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('api', {
|
contextBridge.exposeInMainWorld('api', {
|
||||||
listFiles: (): Promise<FileNode[]> => ipcRenderer.invoke('fs:listFiles'),
|
listFiles: (): Promise<FileNode[]> => ipcRenderer.invoke('fs:listFiles'),
|
||||||
@@ -39,6 +39,9 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
pickAttachments: (): Promise<Attachment[]> =>
|
||||||
|
ipcRenderer.invoke('fs:pickAttachments'),
|
||||||
|
|
||||||
removeAIListener: (): void => {
|
removeAIListener: (): void => {
|
||||||
ipcRenderer.removeAllListeners('ai:chunk')
|
ipcRenderer.removeAllListeners('ai:chunk')
|
||||||
ipcRenderer.removeAllListeners('ai:done')
|
ipcRenderer.removeAllListeners('ai:done')
|
||||||
|
|||||||
@@ -278,13 +278,129 @@
|
|||||||
|
|
||||||
.chat-input-area {
|
.chat-input-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Attachment chips bar (above the input row) ─── */
|
||||||
|
.chat-attachments-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 3px 8px 3px 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment-thumb {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment-remove:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Input row: paperclip + textarea + send ─── */
|
||||||
|
.chat-input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-paperclip-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 18px;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-paperclip-btn:hover:not(:disabled) {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-paperclip-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Attachment chips shown inside past chat messages ─── */
|
||||||
|
.chat-message-attachments {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-attachment-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-textarea {
|
.chat-textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: var(--input-bg);
|
background: var(--input-bg);
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
import { useState, useRef, type KeyboardEvent } from 'react'
|
import { useState, useRef, type KeyboardEvent } from 'react'
|
||||||
|
import type { Attachment } from '../../types/editor'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSend: (text: string) => void
|
onSend: (text: string, attachments: Attachment[]) => void
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachmentIcon(mimeType: string): string {
|
||||||
|
if (mimeType.startsWith('image/')) return '🖼'
|
||||||
|
if (mimeType === 'application/pdf') return '📄'
|
||||||
|
return '📝'
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatInput({ onSend, disabled }: Props): JSX.Element {
|
export function ChatInput({ onSend, disabled }: Props): JSX.Element {
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
const submit = (): void => {
|
const submit = (): void => {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
if (!trimmed || disabled) return
|
if ((!trimmed && attachments.length === 0) || disabled) return
|
||||||
onSend(trimmed)
|
onSend(trimmed, attachments)
|
||||||
setValue('')
|
setValue('')
|
||||||
|
setAttachments([])
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = 'auto'
|
textareaRef.current.style.height = 'auto'
|
||||||
}
|
}
|
||||||
@@ -34,8 +43,63 @@ export function ChatInput({ onSend, disabled }: Props): JSX.Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAttach = async (): Promise<void> => {
|
||||||
|
const picked = await window.api.pickAttachments()
|
||||||
|
if (picked.length === 0) return
|
||||||
|
// Deduplicate by name
|
||||||
|
setAttachments((prev) => {
|
||||||
|
const existingNames = new Set(prev.map((a) => a.name))
|
||||||
|
const fresh = picked.filter((a) => !existingNames.has(a.name))
|
||||||
|
return [...prev, ...fresh]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeAttachment = (name: string): void => {
|
||||||
|
setAttachments((prev) => prev.filter((a) => a.name !== name))
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSend = !disabled && (value.trim().length > 0 || attachments.length > 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-input-area">
|
<div className="chat-input-area">
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="chat-attachments-bar">
|
||||||
|
{attachments.map((att) => (
|
||||||
|
<div key={att.name} className="chat-attachment-chip">
|
||||||
|
{att.mimeType.startsWith('image/') ? (
|
||||||
|
<img
|
||||||
|
className="chat-attachment-thumb"
|
||||||
|
src={`data:${att.mimeType};base64,${att.data}`}
|
||||||
|
alt={att.name}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="chat-attachment-icon">{attachmentIcon(att.mimeType)}</span>
|
||||||
|
)}
|
||||||
|
<span className="chat-attachment-name" title={att.name}>
|
||||||
|
{att.name.length > 22 ? att.name.slice(0, 20) + '…' : att.name}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="chat-attachment-remove"
|
||||||
|
onClick={() => removeAttachment(att.name)}
|
||||||
|
aria-label={`Remove ${att.name}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="chat-input-row">
|
||||||
|
<button
|
||||||
|
className="chat-paperclip-btn"
|
||||||
|
onClick={handleAttach}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Attach file (image, text, PDF)"
|
||||||
|
aria-label="Attach file"
|
||||||
|
>
|
||||||
|
⌁
|
||||||
|
</button>
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
className="chat-textarea"
|
className="chat-textarea"
|
||||||
@@ -50,11 +114,12 @@ export function ChatInput({ onSend, disabled }: Props): JSX.Element {
|
|||||||
<button
|
<button
|
||||||
className="chat-send-btn"
|
className="chat-send-btn"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={disabled || !value.trim()}
|
disabled={!canSend}
|
||||||
aria-label="Send"
|
aria-label="Send"
|
||||||
>
|
>
|
||||||
↑
|
↑
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import type { ChatMessage } from '../../types/editor'
|
|||||||
|
|
||||||
marked.setOptions({ breaks: true })
|
marked.setOptions({ breaks: true })
|
||||||
|
|
||||||
|
function attachmentIcon(mimeType: string): string {
|
||||||
|
if (mimeType.startsWith('image/')) return '🖼'
|
||||||
|
if (mimeType === 'application/pdf') return '📄'
|
||||||
|
return '📝'
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: ChatMessage
|
message: ChatMessage
|
||||||
}
|
}
|
||||||
@@ -21,6 +27,16 @@ export function ChatMessageItem({ message }: Props): JSX.Element {
|
|||||||
<div className="chat-message-label">
|
<div className="chat-message-label">
|
||||||
{message.role === 'user' ? 'You' : 'Editor AI'}
|
{message.role === 'user' ? 'You' : 'Editor AI'}
|
||||||
</div>
|
</div>
|
||||||
|
{message.attachments && message.attachments.length > 0 && (
|
||||||
|
<div className="chat-message-attachments">
|
||||||
|
{message.attachments.map((att) => (
|
||||||
|
<span key={att.name} className="chat-message-attachment-chip" title={att.name}>
|
||||||
|
{attachmentIcon(att.mimeType)}{' '}
|
||||||
|
{att.name.length > 24 ? att.name.slice(0, 22) + '…' : att.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="chat-message-content">
|
<div className="chat-message-content">
|
||||||
{html ? (
|
{html ? (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ChatMessageItem } from './ChatMessageItem'
|
|||||||
import { ChatInput } from './ChatInput'
|
import { ChatInput } from './ChatInput'
|
||||||
import { FeedbackPanel } from '../Feedback/FeedbackPanel'
|
import { FeedbackPanel } from '../Feedback/FeedbackPanel'
|
||||||
import { parseAnnotationsFromAIResponse } from '../../utils/annotationParser'
|
import { parseAnnotationsFromAIResponse } from '../../utils/annotationParser'
|
||||||
|
import type { Attachment } from '../../types/editor'
|
||||||
import './Chat.css'
|
import './Chat.css'
|
||||||
|
|
||||||
type TabId = 'chat' | 'feedback'
|
type TabId = 'chat' | 'feedback'
|
||||||
@@ -40,11 +41,11 @@ export function ChatPanel(): JSX.Element {
|
|||||||
prevAnnotationCountRef.current = curr
|
prevAnnotationCountRef.current = curr
|
||||||
}, [annotations])
|
}, [annotations])
|
||||||
|
|
||||||
const sendMessage = async (text: string): Promise<void> => {
|
const sendMessage = async (text: string, attachments: Attachment[]): Promise<void> => {
|
||||||
if (!activeFilePath || isAILoading) return
|
if (!activeFilePath || isAILoading) return
|
||||||
|
|
||||||
setAIError(null)
|
setAIError(null)
|
||||||
addUserMessage(text)
|
addUserMessage(text, attachments.map(({ name, mimeType }) => ({ name, mimeType })))
|
||||||
startAssistantMessage()
|
startAssistantMessage()
|
||||||
setAILoading(true)
|
setAILoading(true)
|
||||||
|
|
||||||
@@ -58,7 +59,8 @@ export function ChatPanel(): JSX.Element {
|
|||||||
conversationHistory: chatHistory
|
conversationHistory: chatHistory
|
||||||
.slice(-10)
|
.slice(-10)
|
||||||
.map((m) => ({ role: m.role, content: m.content })),
|
.map((m) => ({ role: m.role, content: m.content })),
|
||||||
userMessage: text
|
userMessage: text,
|
||||||
|
attachments: attachments.length > 0 ? attachments : undefined
|
||||||
},
|
},
|
||||||
(chunk: string) => {
|
(chunk: string) => {
|
||||||
appendToLastAssistantMessage(chunk)
|
appendToLastAssistantMessage(chunk)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { FileNode, ChatMessage, TextAnnotation, AnalysisMode, RevisionMeta } from '../types/editor'
|
import type { FileNode, ChatMessage, TextAnnotation, AnalysisMode, RevisionMeta, AttachmentMeta } from '../types/editor'
|
||||||
|
|
||||||
interface AnnotationFileState {
|
interface AnnotationFileState {
|
||||||
mode: AnalysisMode
|
mode: AnalysisMode
|
||||||
@@ -24,7 +24,7 @@ interface EditorState {
|
|||||||
chatHistory: ChatMessage[]
|
chatHistory: ChatMessage[]
|
||||||
isAILoading: boolean
|
isAILoading: boolean
|
||||||
aiError: string | null
|
aiError: string | null
|
||||||
addUserMessage: (text: string) => void
|
addUserMessage: (text: string, attachments?: AttachmentMeta[]) => void
|
||||||
startAssistantMessage: () => void
|
startAssistantMessage: () => void
|
||||||
appendToLastAssistantMessage: (chunk: string) => void
|
appendToLastAssistantMessage: (chunk: string) => void
|
||||||
setAILoading: (loading: boolean) => void
|
setAILoading: (loading: boolean) => void
|
||||||
@@ -148,8 +148,8 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
isAILoading: false,
|
isAILoading: false,
|
||||||
aiError: null,
|
aiError: null,
|
||||||
|
|
||||||
addUserMessage: (text) => {
|
addUserMessage: (text, attachments?) => {
|
||||||
const msg: ChatMessage = { id: `user-${Date.now()}`, role: 'user', content: text }
|
const msg: ChatMessage = { id: `user-${Date.now()}`, role: 'user', content: text, attachments }
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const history = [...s.chatHistory, msg]
|
const history = [...s.chatHistory, msg]
|
||||||
const byFile = s.activeFilePath
|
const byFile = s.activeFilePath
|
||||||
|
|||||||
@@ -5,10 +5,20 @@ export interface FileNode {
|
|||||||
children?: FileNode[]
|
children?: FileNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AttachmentMeta {
|
||||||
|
name: string
|
||||||
|
mimeType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Attachment extends AttachmentMeta {
|
||||||
|
data: string // base64 for images, extracted text for text/PDF
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
id: string
|
id: string
|
||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
|
attachments?: AttachmentMeta[] // metadata only — stored in history for display
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AnnotationType = 'passive_voice' | 'consistency' | 'style' | 'critique'
|
export type AnnotationType = 'passive_voice' | 'consistency' | 'style' | 'critique'
|
||||||
@@ -33,6 +43,7 @@ export interface AIPayload {
|
|||||||
documentPath: string
|
documentPath: string
|
||||||
conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }>
|
conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||||
userMessage: string
|
userMessage: string
|
||||||
|
attachments?: Attachment[] // full data for current API call only
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RevisionMeta {
|
export interface RevisionMeta {
|
||||||
|
|||||||
3
src/renderer/types/global.d.ts
vendored
3
src/renderer/types/global.d.ts
vendored
@@ -1,4 +1,4 @@
|
|||||||
import type { FileNode, AIPayload, RevisionMeta } from './editor'
|
import type { FileNode, AIPayload, RevisionMeta, Attachment } from './editor'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -13,6 +13,7 @@ declare global {
|
|||||||
removeAIListener: () => void
|
removeAIListener: () => void
|
||||||
getProjectWordCount: () => Promise<number>
|
getProjectWordCount: () => Promise<number>
|
||||||
saveOrder: (order: Record<string, string[]>) => Promise<void>
|
saveOrder: (order: Record<string, string[]>) => Promise<void>
|
||||||
|
pickAttachments: () => Promise<Attachment[]>
|
||||||
saveRevision: (filePath: string, content: string) => Promise<void>
|
saveRevision: (filePath: string, content: string) => Promise<void>
|
||||||
listRevisions: (filePath: string) => Promise<RevisionMeta[]>
|
listRevisions: (filePath: string) => Promise<RevisionMeta[]>
|
||||||
loadRevision: (filePath: string, revisionId: string) => Promise<string>
|
loadRevision: (filePath: string, revisionId: string) => Promise<string>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user