feat: integrate Pi backend and Pi Web

This commit is contained in:
luckyyzh
2026-07-30 19:37:53 +08:00
commit 7392ab9dd7
1390 changed files with 337197 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
// Pure helpers for the chat input's @ file autocomplete. Mirrors the pi TUI's
// behavior: @ triggers at line start or after whitespace, entries are ranked
// with the TUI's scoreEntry ladder, and completions insert "@relative/path ".
export interface AtQueryMatch {
/** Index of the "@" character in the text */
start: number;
/** Text typed after the "@" (quotes stripped); may be empty */
query: string;
/** True when the token uses the @"..." quoted form */
quoted: boolean;
}
export interface FileIndexEntry {
/** Path relative to the session cwd, "/"-separated, no trailing slash */
path: string;
isDir: boolean;
}
/**
* Detect an @ file token immediately before the cursor. The @ must be at the
* start of the text or preceded by whitespace (same rule as the TUI), so
* emails like foo@bar never trigger. Supports the in-progress quoted form
* @"my dir/fi so drill-down into space-containing paths keeps working.
*/
export function extractAtQuery(textBeforeCursor: string): AtQueryMatch | null {
const quoted = /(?:^|\s)@"([^"\n]*)$/.exec(textBeforeCursor);
if (quoted) {
return {
start: textBeforeCursor.length - (quoted[1].length + 2),
query: quoted[1],
quoted: true,
};
}
const plain = /(?:^|\s)@([^\s"]*)$/.exec(textBeforeCursor);
if (plain) {
return {
start: textBeforeCursor.length - (plain[1].length + 1),
query: plain[1],
quoted: false,
};
}
return null;
}
function pathDepth(p: string): number {
let depth = 0;
for (let i = 0; i < p.length; i++) {
if (p[i] === "/") depth++;
}
return depth;
}
/**
* Build the entry list from the server's flat file list, deriving directory
* entries from file paths (the index API only returns files). Base order is
* shallow-first then alphabetical, which is what an empty @ query shows.
*/
export function buildEntriesFromFiles(files: string[]): FileIndexEntry[] {
const dirs = new Set<string>();
for (const f of files) {
let idx = f.indexOf("/");
while (idx !== -1) {
dirs.add(f.slice(0, idx));
idx = f.indexOf("/", idx + 1);
}
}
const entries: FileIndexEntry[] = [];
for (const d of dirs) entries.push({ path: d, isDir: true });
for (const f of files) {
if (!f) continue;
entries.push({ path: f, isDir: false });
}
entries.sort((a, b) => pathDepth(a.path) - pathDepth(b.path) || a.path.localeCompare(b.path));
return entries;
}
function isSubsequence(needle: string, haystack: string): boolean {
if (!needle) return true;
let i = 0;
for (let j = 0; j < haystack.length && i < needle.length; j++) {
if (haystack[j] === needle[i]) i++;
}
return i === needle.length;
}
/**
* TUI scoreEntry ladder (exact 100 / prefix 80 / substring 50 / path substring
* 30, directories +10) plus a low-weight subsequence fallback so genuinely
* fuzzy queries like "chinp" still find components/ChatInput.tsx.
*
* Queries containing "/" are ranked against the full relative path instead of
* the basename — this is what makes drill-down work: after inserting "@src/",
* the query "src/" prefix-matches every entry inside src/ (and excludes the
* src directory itself, since "src" does not start with "src/").
*/
function scoreEntry(entry: FileIndexEntry, lowerQuery: string): number {
const lowerPath = entry.path.toLowerCase();
let score = 0;
if (lowerQuery.includes("/")) {
if (lowerPath === lowerQuery) score = 100;
else if (lowerPath.startsWith(lowerQuery)) score = 80;
else if (lowerPath.includes(lowerQuery)) score = 50;
else if (isSubsequence(lowerQuery, lowerPath)) score = 10;
} else {
const slash = lowerPath.lastIndexOf("/");
const lowerName = slash === -1 ? lowerPath : lowerPath.slice(slash + 1);
if (lowerName === lowerQuery) score = 100;
else if (lowerName.startsWith(lowerQuery)) score = 80;
else if (lowerName.includes(lowerQuery)) score = 50;
else if (lowerPath.includes(lowerQuery)) score = 30;
else if (isSubsequence(lowerQuery, lowerPath)) score = 10;
}
if (entry.isDir && score > 0) score += 10;
return score;
}
export const AT_RESULT_LIMIT = 20;
export function filterFileEntries(
entries: FileIndexEntry[],
query: string,
limit: number = AT_RESULT_LIMIT,
): FileIndexEntry[] {
const lowerQuery = query.toLowerCase();
if (!lowerQuery) return entries.slice(0, limit);
const scored: Array<{ entry: FileIndexEntry; score: number }> = [];
for (const entry of entries) {
const score = scoreEntry(entry, lowerQuery);
if (score > 0) scored.push({ entry, score });
}
scored.sort((a, b) =>
b.score - a.score
|| pathDepth(a.entry.path) - pathDepth(b.entry.path)
|| a.entry.path.localeCompare(b.entry.path));
return scored.slice(0, limit).map((s) => s.entry);
}
export interface AtInsertion {
/** Text that replaces the @token */
text: string;
/** Caret position relative to the start of `text` after insertion */
cursorOffset: number;
}
/**
* Replacement for the @token when a suggestion is confirmed. Mirrors the
* TUI's buildCompletionValue/applyCompletion:
* - Files close the token: "@path " (quoted when the path contains spaces),
* caret after the trailing space.
* - Directories keep the menu open for drill-down: "@dir/" with no trailing
* space. Quoted directories are inserted CLOSED (@"my dir/") with the caret
* placed before the closing quote, so both further typing and manual
* completion keep the token well-formed.
*/
export function buildAtInsertText(entryPath: string, isDir: boolean, forceQuotes = false): AtInsertion {
const p = isDir ? `${entryPath}/` : entryPath;
const needsQuotes = forceQuotes || p.includes(" ");
if (isDir) {
const text = needsQuotes ? `@"${p}"` : `@${p}`;
return { text, cursorOffset: needsQuotes ? text.length - 1 : text.length };
}
const text = needsQuotes ? `@"${p}" ` : `@${p} `;
return { text, cursorOffset: text.length };
}
/**
* Closed @mention for one-shot inserts (e.g. the file explorer's @ button).
* Unlike buildAtInsertText there is no drill-down: directories are closed
* too, with a trailing "/" and a trailing space.
*/
export function buildAtMentionText(entryPath: string, isDir: boolean): string {
const p = isDir ? `${entryPath}/` : entryPath;
return p.includes(" ") ? `@"${p}" ` : `@${p} `;
}
/** Closed file @mention scoped to one logical line or an inclusive line range. */
export function buildFileLineMentionText(entryPath: string, startLine: number, endLine: number): string {
const firstLine = Math.max(1, Math.min(startLine, endLine));
const lastLine = Math.max(1, Math.max(startLine, endLine));
const pathMention = entryPath.includes(" ") ? `@"${entryPath}"` : `@${entryPath}`;
const lineSuffix = firstLine === lastLine ? `:${firstLine}` : `:${firstLine}-${lastLine}`;
return `${pathMention}${lineSuffix} `;
}
export function buildFileAtMentionsText(entryPaths: string[]): string {
return entryPaths.map((entryPath) => buildAtMentionText(entryPath, false)).join("");
}