genai-fs 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +15 -0
- package/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +111 -0
- package/CLAUDE.md +51 -0
- package/README.md +104 -0
- package/bun.lock +244 -0
- package/package.json +39 -0
- package/src/App.tsx +492 -0
- package/src/components/DocumentDeleteConfirm.tsx +27 -0
- package/src/components/DocumentDetail.tsx +94 -0
- package/src/components/DocumentList.tsx +98 -0
- package/src/components/FileBrowser.tsx +173 -0
- package/src/components/Spinner.tsx +26 -0
- package/src/components/StatusScreen.tsx +22 -0
- package/src/components/StoreDetail.tsx +80 -0
- package/src/components/StoreForm.tsx +38 -0
- package/src/components/StoreList.tsx +80 -0
- package/src/index.tsx +5 -0
- package/src/lib/api.ts +191 -0
- package/src/utils/formatters.ts +44 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { readdir, stat } from "fs/promises";
|
|
4
|
+
import { join, dirname } from "path";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
|
|
7
|
+
interface FileEntry {
|
|
8
|
+
name: string;
|
|
9
|
+
path: string;
|
|
10
|
+
isDirectory: boolean;
|
|
11
|
+
size?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface FileBrowserProps {
|
|
15
|
+
onSelect: (filePath: string) => void;
|
|
16
|
+
onCancel: () => void;
|
|
17
|
+
initialPath?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ファイルサイズをフォーマット
|
|
21
|
+
function formatSize(bytes: number | undefined): string {
|
|
22
|
+
if (bytes === undefined) return "";
|
|
23
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
24
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
25
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function FileBrowser({ onSelect, onCancel, initialPath }: FileBrowserProps) {
|
|
29
|
+
const [currentPath, setCurrentPath] = useState(initialPath || homedir());
|
|
30
|
+
const [entries, setEntries] = useState<FileEntry[]>([]);
|
|
31
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
32
|
+
const [loading, setLoading] = useState(true);
|
|
33
|
+
const [error, setError] = useState<string | null>(null);
|
|
34
|
+
|
|
35
|
+
// ディレクトリの内容を読み込む
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const loadDirectory = async () => {
|
|
38
|
+
try {
|
|
39
|
+
setLoading(true);
|
|
40
|
+
setError(null);
|
|
41
|
+
|
|
42
|
+
const items = await readdir(currentPath);
|
|
43
|
+
const fileEntries: FileEntry[] = [];
|
|
44
|
+
|
|
45
|
+
// 親ディレクトリへのエントリ
|
|
46
|
+
const parent = dirname(currentPath);
|
|
47
|
+
if (parent !== currentPath) {
|
|
48
|
+
fileEntries.push({
|
|
49
|
+
name: "..",
|
|
50
|
+
path: parent,
|
|
51
|
+
isDirectory: true,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 各アイテムの情報を取得
|
|
56
|
+
for (const name of items) {
|
|
57
|
+
// 隠しファイルをスキップ
|
|
58
|
+
if (name.startsWith(".")) continue;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const fullPath = join(currentPath, name);
|
|
62
|
+
const stats = await stat(fullPath);
|
|
63
|
+
fileEntries.push({
|
|
64
|
+
name,
|
|
65
|
+
path: fullPath,
|
|
66
|
+
isDirectory: stats.isDirectory(),
|
|
67
|
+
size: stats.isFile() ? stats.size : undefined,
|
|
68
|
+
});
|
|
69
|
+
} catch {
|
|
70
|
+
// アクセスできないファイルはスキップ
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ディレクトリを先に、その後ファイルをソート
|
|
75
|
+
fileEntries.sort((a, b) => {
|
|
76
|
+
if (a.name === "..") return -1;
|
|
77
|
+
if (b.name === "..") return 1;
|
|
78
|
+
if (a.isDirectory && !b.isDirectory) return -1;
|
|
79
|
+
if (!a.isDirectory && b.isDirectory) return 1;
|
|
80
|
+
return a.name.localeCompare(b.name);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
setEntries(fileEntries);
|
|
84
|
+
setSelectedIndex(0);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
setError(e instanceof Error ? e.message : "Failed to load directory");
|
|
87
|
+
} finally {
|
|
88
|
+
setLoading(false);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
loadDirectory();
|
|
93
|
+
}, [currentPath]);
|
|
94
|
+
|
|
95
|
+
useInput((input, key) => {
|
|
96
|
+
if (loading) return;
|
|
97
|
+
|
|
98
|
+
if (key.upArrow) {
|
|
99
|
+
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : entries.length - 1));
|
|
100
|
+
}
|
|
101
|
+
if (key.downArrow) {
|
|
102
|
+
setSelectedIndex((prev) => (prev < entries.length - 1 ? prev + 1 : 0));
|
|
103
|
+
}
|
|
104
|
+
if (key.return) {
|
|
105
|
+
const selected = entries[selectedIndex];
|
|
106
|
+
if (selected) {
|
|
107
|
+
if (selected.isDirectory) {
|
|
108
|
+
setCurrentPath(selected.path);
|
|
109
|
+
} else {
|
|
110
|
+
onSelect(selected.path);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (key.escape || input === "q") {
|
|
115
|
+
onCancel();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// 表示するエントリ数を制限(スクロール対応)
|
|
120
|
+
const maxVisible = 15;
|
|
121
|
+
const startIndex = Math.max(0, selectedIndex - Math.floor(maxVisible / 2));
|
|
122
|
+
const visibleEntries = entries.slice(startIndex, startIndex + maxVisible);
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<Box flexDirection="column">
|
|
126
|
+
<Box marginBottom={1}>
|
|
127
|
+
<Text color="yellow">Select a file:</Text>
|
|
128
|
+
</Box>
|
|
129
|
+
|
|
130
|
+
<Box marginBottom={1}>
|
|
131
|
+
<Text dimColor>Current path: </Text>
|
|
132
|
+
<Text>{currentPath}</Text>
|
|
133
|
+
</Box>
|
|
134
|
+
|
|
135
|
+
{loading && <Text color="gray">Loading...</Text>}
|
|
136
|
+
{error && <Text color="red">Error: {error}</Text>}
|
|
137
|
+
|
|
138
|
+
{!loading && !error && entries.length === 0 && (
|
|
139
|
+
<Text color="gray">No files found</Text>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
{!loading && !error && entries.length > 0 && (
|
|
143
|
+
<Box flexDirection="column">
|
|
144
|
+
{visibleEntries.map((entry, idx) => {
|
|
145
|
+
const actualIndex = startIndex + idx;
|
|
146
|
+
const isSelected = actualIndex === selectedIndex;
|
|
147
|
+
return (
|
|
148
|
+
<Box key={entry.path} gap={1}>
|
|
149
|
+
<Text color={isSelected ? "green" : "white"}>
|
|
150
|
+
{isSelected ? "❯ " : " "}
|
|
151
|
+
{entry.isDirectory ? "📁 " : "📄 "}
|
|
152
|
+
{entry.name}
|
|
153
|
+
</Text>
|
|
154
|
+
{!entry.isDirectory && entry.size !== undefined && (
|
|
155
|
+
<Text dimColor>{formatSize(entry.size)}</Text>
|
|
156
|
+
)}
|
|
157
|
+
</Box>
|
|
158
|
+
);
|
|
159
|
+
})}
|
|
160
|
+
{entries.length > maxVisible && (
|
|
161
|
+
<Text dimColor>
|
|
162
|
+
({selectedIndex + 1}/{entries.length})
|
|
163
|
+
</Text>
|
|
164
|
+
)}
|
|
165
|
+
</Box>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
<Box marginTop={1}>
|
|
169
|
+
<Text dimColor>Up/Down: Select Enter: Confirm/Open Esc: Cancel</Text>
|
|
170
|
+
</Box>
|
|
171
|
+
</Box>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
|
|
4
|
+
interface SpinnerProps {
|
|
5
|
+
message?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
9
|
+
|
|
10
|
+
export function Spinner({ message = "Processing..." }: SpinnerProps) {
|
|
11
|
+
const [frameIndex, setFrameIndex] = useState(0);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const timer = setInterval(() => {
|
|
15
|
+
setFrameIndex((prev) => (prev + 1) % frames.length);
|
|
16
|
+
}, 80);
|
|
17
|
+
|
|
18
|
+
return () => clearInterval(timer);
|
|
19
|
+
}, []);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Text color="cyan">
|
|
23
|
+
{frames[frameIndex]} {message}
|
|
24
|
+
</Text>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { Spinner } from "./Spinner.tsx";
|
|
3
|
+
|
|
4
|
+
interface StatusScreenProps {
|
|
5
|
+
isProcessing: boolean;
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// 処理中/完了/エラー表示
|
|
10
|
+
export function StatusScreen({ isProcessing, message }: StatusScreenProps) {
|
|
11
|
+
const isError = message.startsWith("Error");
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<Box flexDirection="column">
|
|
15
|
+
{isProcessing ? (
|
|
16
|
+
<Spinner message={message} />
|
|
17
|
+
) : (
|
|
18
|
+
<Text color={isError ? "red" : "green"}>{message}</Text>
|
|
19
|
+
)}
|
|
20
|
+
</Box>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import type { FileSearchStore } from "../lib/api.ts";
|
|
3
|
+
import { formatBytes, formatDate } from "../utils/formatters.ts";
|
|
4
|
+
|
|
5
|
+
interface StoreDetailProps {
|
|
6
|
+
store: FileSearchStore;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Store詳細表示
|
|
10
|
+
export function StoreDetail({ store }: StoreDetailProps) {
|
|
11
|
+
const active = store.activeDocumentsCount ?? "0";
|
|
12
|
+
const pending = store.pendingDocumentsCount ?? "0";
|
|
13
|
+
const failed = store.failedDocumentsCount ?? "0";
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<Box flexDirection="column" gap={0}>
|
|
17
|
+
<Box>
|
|
18
|
+
<Box width={14}>
|
|
19
|
+
<Text bold dimColor>
|
|
20
|
+
DisplayName
|
|
21
|
+
</Text>
|
|
22
|
+
</Box>
|
|
23
|
+
<Text>{store.displayName}</Text>
|
|
24
|
+
</Box>
|
|
25
|
+
<Box>
|
|
26
|
+
<Box width={14}>
|
|
27
|
+
<Text bold dimColor>
|
|
28
|
+
ID
|
|
29
|
+
</Text>
|
|
30
|
+
</Box>
|
|
31
|
+
<Text>{store.name.split("/").pop()}</Text>
|
|
32
|
+
</Box>
|
|
33
|
+
<Box>
|
|
34
|
+
<Box width={14}>
|
|
35
|
+
<Text bold dimColor>
|
|
36
|
+
FullID
|
|
37
|
+
</Text>
|
|
38
|
+
</Box>
|
|
39
|
+
<Text>{store.name}</Text>
|
|
40
|
+
</Box>
|
|
41
|
+
<Box>
|
|
42
|
+
<Box width={14}>
|
|
43
|
+
<Text bold dimColor>
|
|
44
|
+
Size
|
|
45
|
+
</Text>
|
|
46
|
+
</Box>
|
|
47
|
+
<Text>{formatBytes(store.sizeBytes)}</Text>
|
|
48
|
+
</Box>
|
|
49
|
+
<Box>
|
|
50
|
+
<Box width={14}>
|
|
51
|
+
<Text bold dimColor>
|
|
52
|
+
Documents
|
|
53
|
+
</Text>
|
|
54
|
+
</Box>
|
|
55
|
+
<Text color="green">{active} active</Text>
|
|
56
|
+
{pending !== "0" && <Text color="yellow"> / {pending} pending</Text>}
|
|
57
|
+
{failed !== "0" && <Text color="red"> / {failed} failed</Text>}
|
|
58
|
+
</Box>
|
|
59
|
+
<Box>
|
|
60
|
+
<Box width={14}>
|
|
61
|
+
<Text bold dimColor>
|
|
62
|
+
Created
|
|
63
|
+
</Text>
|
|
64
|
+
</Box>
|
|
65
|
+
<Text>{formatDate(store.createTime)}</Text>
|
|
66
|
+
</Box>
|
|
67
|
+
<Box>
|
|
68
|
+
<Box width={14}>
|
|
69
|
+
<Text bold dimColor>
|
|
70
|
+
Updated
|
|
71
|
+
</Text>
|
|
72
|
+
</Box>
|
|
73
|
+
<Text>{formatDate(store.updateTime)}</Text>
|
|
74
|
+
</Box>
|
|
75
|
+
<Box marginTop={1}>
|
|
76
|
+
<Text dimColor>Esc/b: Back q: Quit</Text>
|
|
77
|
+
</Box>
|
|
78
|
+
</Box>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
|
|
3
|
+
interface StoreFormProps {
|
|
4
|
+
mode: "create" | "delete";
|
|
5
|
+
input: string;
|
|
6
|
+
storeName?: string; // 削除時の確認用
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Store作成/削除確認フォーム
|
|
10
|
+
export function StoreForm({ mode, input, storeName }: StoreFormProps) {
|
|
11
|
+
const isValid =
|
|
12
|
+
mode === "create" ? input.trim().length > 0 : input === storeName;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<Box flexDirection="column">
|
|
16
|
+
<Box marginBottom={1}>
|
|
17
|
+
<Text color={mode === "create" ? "green" : "red"} bold>
|
|
18
|
+
{mode === "create" ? "Create new store" : "Delete this store?"}
|
|
19
|
+
</Text>
|
|
20
|
+
</Box>
|
|
21
|
+
<Text>
|
|
22
|
+
{mode === "create"
|
|
23
|
+
? "Enter the store name:"
|
|
24
|
+
: `To delete "${storeName}", type the store name to confirm:`}
|
|
25
|
+
</Text>
|
|
26
|
+
<Box marginTop={1}>
|
|
27
|
+
<Text dimColor>{">"} </Text>
|
|
28
|
+
<Text color={isValid ? "green" : "white"}>{input}</Text>
|
|
29
|
+
<Text color="gray">|</Text>
|
|
30
|
+
</Box>
|
|
31
|
+
<Box marginTop={1}>
|
|
32
|
+
<Text dimColor>
|
|
33
|
+
{mode === "create" ? "Enter: Create" : "Enter: Confirm"} Esc: Cancel
|
|
34
|
+
</Text>
|
|
35
|
+
</Box>
|
|
36
|
+
</Box>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import type { FileSearchStore } from "../lib/api.ts";
|
|
3
|
+
import { formatDate } from "../utils/formatters.ts";
|
|
4
|
+
|
|
5
|
+
interface StoreListProps {
|
|
6
|
+
stores: FileSearchStore[];
|
|
7
|
+
selectedIndex: number;
|
|
8
|
+
loading: boolean;
|
|
9
|
+
error: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Store一覧テーブル
|
|
13
|
+
export function StoreList({
|
|
14
|
+
stores,
|
|
15
|
+
selectedIndex,
|
|
16
|
+
loading,
|
|
17
|
+
error,
|
|
18
|
+
}: StoreListProps) {
|
|
19
|
+
if (loading) {
|
|
20
|
+
return <Text color="gray">Loading...</Text>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (error) {
|
|
24
|
+
return <Text color="red">Error: {error}</Text>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (stores.length === 0) {
|
|
28
|
+
return (
|
|
29
|
+
<Box flexDirection="column">
|
|
30
|
+
<Text color="gray">No stores found</Text>
|
|
31
|
+
<Box marginTop={1}>
|
|
32
|
+
<Text dimColor>n: New q: Quit</Text>
|
|
33
|
+
</Box>
|
|
34
|
+
</Box>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Box flexDirection="column">
|
|
40
|
+
<Box gap={1} marginBottom={1}>
|
|
41
|
+
<Box width={24}>
|
|
42
|
+
<Text bold dimColor>
|
|
43
|
+
Name
|
|
44
|
+
</Text>
|
|
45
|
+
</Box>
|
|
46
|
+
<Box width={22}>
|
|
47
|
+
<Text bold dimColor>
|
|
48
|
+
Created
|
|
49
|
+
</Text>
|
|
50
|
+
</Box>
|
|
51
|
+
<Box width={22}>
|
|
52
|
+
<Text bold dimColor>
|
|
53
|
+
Updated
|
|
54
|
+
</Text>
|
|
55
|
+
</Box>
|
|
56
|
+
</Box>
|
|
57
|
+
{stores.map((store, index) => (
|
|
58
|
+
<Box key={store.name} gap={1}>
|
|
59
|
+
<Box width={24}>
|
|
60
|
+
<Text color={index === selectedIndex ? "green" : "white"}>
|
|
61
|
+
{index === selectedIndex ? "❯ " : " "}
|
|
62
|
+
{store.displayName.slice(0, 20)}
|
|
63
|
+
</Text>
|
|
64
|
+
</Box>
|
|
65
|
+
<Box width={22}>
|
|
66
|
+
<Text dimColor>{formatDate(store.createTime)}</Text>
|
|
67
|
+
</Box>
|
|
68
|
+
<Box width={22}>
|
|
69
|
+
<Text dimColor>{formatDate(store.updateTime)}</Text>
|
|
70
|
+
</Box>
|
|
71
|
+
</Box>
|
|
72
|
+
))}
|
|
73
|
+
<Box marginTop={1}>
|
|
74
|
+
<Text dimColor>
|
|
75
|
+
Up/Down: Select Enter: Open i: Info n: New d: Delete q: Quit
|
|
76
|
+
</Text>
|
|
77
|
+
</Box>
|
|
78
|
+
</Box>
|
|
79
|
+
);
|
|
80
|
+
}
|
package/src/index.tsx
ADDED
package/src/lib/api.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Gemini FileStore API クライアント
|
|
2
|
+
import { GoogleGenAI } from "@google/genai";
|
|
3
|
+
|
|
4
|
+
const BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
|
|
5
|
+
|
|
6
|
+
function getApiKey(): string {
|
|
7
|
+
const apiKey = process.env.GEMINI_API_KEY;
|
|
8
|
+
if (!apiKey) {
|
|
9
|
+
throw new Error("GEMINI_API_KEY environment variable is not set");
|
|
10
|
+
}
|
|
11
|
+
return apiKey;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Google GenAI クライアント
|
|
15
|
+
let _client: GoogleGenAI | null = null;
|
|
16
|
+
function getClient(): GoogleGenAI {
|
|
17
|
+
if (!_client) {
|
|
18
|
+
_client = new GoogleGenAI({ apiKey: getApiKey() });
|
|
19
|
+
}
|
|
20
|
+
return _client;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ストアの型定義
|
|
24
|
+
export interface FileSearchStore {
|
|
25
|
+
name: string; // 例: "fileSearchStores/xxx"
|
|
26
|
+
displayName: string;
|
|
27
|
+
createTime?: string;
|
|
28
|
+
updateTime?: string;
|
|
29
|
+
activeDocumentsCount?: string; // アクティブなドキュメント数
|
|
30
|
+
pendingDocumentsCount?: string; // 処理中のドキュメント数
|
|
31
|
+
failedDocumentsCount?: string; // 失敗したドキュメント数
|
|
32
|
+
sizeBytes?: string; // ストア全体のサイズ
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ドキュメントの状態
|
|
36
|
+
export type DocumentState = "STATE_UNSPECIFIED" | "PENDING" | "ACTIVE" | "FAILED";
|
|
37
|
+
|
|
38
|
+
// カスタムメタデータ
|
|
39
|
+
export interface CustomMetadata {
|
|
40
|
+
key: string;
|
|
41
|
+
stringValue?: string;
|
|
42
|
+
numericValue?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ドキュメントの型定義
|
|
46
|
+
export interface Document {
|
|
47
|
+
name: string; // 例: "fileSearchStores/xxx/documents/yyy"
|
|
48
|
+
displayName?: string;
|
|
49
|
+
mimeType?: string;
|
|
50
|
+
sizeBytes?: string;
|
|
51
|
+
createTime?: string;
|
|
52
|
+
updateTime?: string;
|
|
53
|
+
state?: DocumentState;
|
|
54
|
+
customMetadata?: CustomMetadata[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface ListStoresResponse {
|
|
58
|
+
fileSearchStores?: FileSearchStore[];
|
|
59
|
+
nextPageToken?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface ListDocumentsResponse {
|
|
63
|
+
documents?: Document[];
|
|
64
|
+
nextPageToken?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ストア一覧を取得
|
|
68
|
+
export async function listStores(pageSize = 20): Promise<FileSearchStore[]> {
|
|
69
|
+
const apiKey = getApiKey();
|
|
70
|
+
const url = `${BASE_URL}/fileSearchStores?key=${apiKey}&pageSize=${pageSize}`;
|
|
71
|
+
|
|
72
|
+
const response = await fetch(url);
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
const error = await response.text();
|
|
75
|
+
throw new Error(`Failed to fetch stores: ${error}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const data = (await response.json()) as ListStoresResponse;
|
|
79
|
+
return data.fileSearchStores ?? [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ストアを作成
|
|
83
|
+
export async function createStore(displayName: string): Promise<FileSearchStore> {
|
|
84
|
+
const apiKey = getApiKey();
|
|
85
|
+
const url = `${BASE_URL}/fileSearchStores?key=${apiKey}`;
|
|
86
|
+
|
|
87
|
+
const response = await fetch(url, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "Content-Type": "application/json" },
|
|
90
|
+
body: JSON.stringify({ displayName }),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
const error = await response.text();
|
|
95
|
+
throw new Error(`Failed to create store: ${error}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 長時間実行オペレーションが返される
|
|
99
|
+
const operation = await response.json();
|
|
100
|
+
// 完了を待つ場合はポーリングが必要だが、一旦オペレーション結果を返す
|
|
101
|
+
return operation as FileSearchStore;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ストアを削除
|
|
105
|
+
export async function deleteStore(storeName: string): Promise<void> {
|
|
106
|
+
const apiKey = getApiKey();
|
|
107
|
+
const url = `${BASE_URL}/${storeName}?key=${apiKey}`;
|
|
108
|
+
|
|
109
|
+
const response = await fetch(url, { method: "DELETE" });
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
const error = await response.text();
|
|
112
|
+
throw new Error(`Failed to delete store: ${error}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ドキュメント一覧を取得
|
|
117
|
+
export async function listDocuments(
|
|
118
|
+
storeName: string,
|
|
119
|
+
pageSize = 20
|
|
120
|
+
): Promise<Document[]> {
|
|
121
|
+
const apiKey = getApiKey();
|
|
122
|
+
const url = `${BASE_URL}/${storeName}/documents?key=${apiKey}&pageSize=${pageSize}`;
|
|
123
|
+
|
|
124
|
+
const response = await fetch(url);
|
|
125
|
+
if (!response.ok) {
|
|
126
|
+
const error = await response.text();
|
|
127
|
+
throw new Error(`Failed to fetch documents: ${error}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const data = (await response.json()) as ListDocumentsResponse;
|
|
131
|
+
return data.documents ?? [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ドキュメント詳細を取得
|
|
135
|
+
export async function getDocument(documentName: string): Promise<Document> {
|
|
136
|
+
const apiKey = getApiKey();
|
|
137
|
+
const url = `${BASE_URL}/${documentName}?key=${apiKey}`;
|
|
138
|
+
|
|
139
|
+
const response = await fetch(url);
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
const error = await response.text();
|
|
142
|
+
throw new Error(`Failed to fetch document: ${error}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return (await response.json()) as Document;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// アップロードオペレーションのレスポンス
|
|
149
|
+
export interface UploadOperation {
|
|
150
|
+
name: string;
|
|
151
|
+
done?: boolean;
|
|
152
|
+
error?: { code: number; message: string };
|
|
153
|
+
response?: Document;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ドキュメントを削除
|
|
157
|
+
export async function deleteDocument(documentName: string): Promise<void> {
|
|
158
|
+
const apiKey = getApiKey();
|
|
159
|
+
// force=trueで強制削除
|
|
160
|
+
const url = `${BASE_URL}/${documentName}?key=${apiKey}&force=true`;
|
|
161
|
+
|
|
162
|
+
const response = await fetch(url, { method: "DELETE" });
|
|
163
|
+
if (!response.ok) {
|
|
164
|
+
const error = await response.text();
|
|
165
|
+
throw new Error(`Failed to delete document: ${error}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ファイルをアップロード(Google GenAI SDKを使用)
|
|
170
|
+
export async function uploadFile(
|
|
171
|
+
storeName: string,
|
|
172
|
+
filePath: string
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
const client = getClient();
|
|
175
|
+
|
|
176
|
+
// アップロード開始
|
|
177
|
+
let operation = await client.fileSearchStores.uploadToFileSearchStore({
|
|
178
|
+
fileSearchStoreName: storeName,
|
|
179
|
+
file: filePath,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// 完了まで待機
|
|
183
|
+
while (!operation.done) {
|
|
184
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
185
|
+
operation = await client.operations.get({ operation });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (operation.error) {
|
|
189
|
+
throw new Error(`Upload failed: ${operation.error.message}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// フォーマット関数
|
|
2
|
+
|
|
3
|
+
// ファイルサイズをフォーマット
|
|
4
|
+
export function formatBytes(bytes: string | undefined): string {
|
|
5
|
+
if (!bytes) return "-";
|
|
6
|
+
const size = parseInt(bytes, 10);
|
|
7
|
+
if (size < 1024) return `${size} B`;
|
|
8
|
+
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
|
9
|
+
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// 日時をフォーマット
|
|
13
|
+
export function formatDate(dateStr: string | undefined): string {
|
|
14
|
+
if (!dateStr) return "-";
|
|
15
|
+
const date = new Date(dateStr);
|
|
16
|
+
return date.toLocaleString("ja-JP");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// APIキーをマスク
|
|
20
|
+
export function maskApiKey(): string {
|
|
21
|
+
const apiKey = process.env.GEMINI_API_KEY;
|
|
22
|
+
if (!apiKey) return "Not set";
|
|
23
|
+
if (apiKey.length <= 8) return "****";
|
|
24
|
+
return `${apiKey.slice(0, 4)}...${apiKey.slice(-4)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ステータス表示をフォーマット
|
|
28
|
+
export function formatState(
|
|
29
|
+
state: string | undefined
|
|
30
|
+
): { text: string; color: string } {
|
|
31
|
+
switch (state) {
|
|
32
|
+
case "ACTIVE":
|
|
33
|
+
case "STATE_ACTIVE":
|
|
34
|
+
return { text: "Active", color: "green" };
|
|
35
|
+
case "PENDING":
|
|
36
|
+
case "STATE_PENDING":
|
|
37
|
+
return { text: "Pending", color: "yellow" };
|
|
38
|
+
case "FAILED":
|
|
39
|
+
case "STATE_FAILED":
|
|
40
|
+
return { text: "Failed", color: "red" };
|
|
41
|
+
default:
|
|
42
|
+
return { text: state ?? "-", color: "gray" };
|
|
43
|
+
}
|
|
44
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
|
|
11
|
+
// Bundler mode
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"verbatimModuleSyntax": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
// Best practices
|
|
18
|
+
"strict": true,
|
|
19
|
+
"skipLibCheck": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedIndexedAccess": true,
|
|
22
|
+
"noImplicitOverride": true,
|
|
23
|
+
|
|
24
|
+
// Some stricter flags (disabled by default)
|
|
25
|
+
"noUnusedLocals": false,
|
|
26
|
+
"noUnusedParameters": false,
|
|
27
|
+
"noPropertyAccessFromIndexSignature": false
|
|
28
|
+
}
|
|
29
|
+
}
|