Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

**English** · [简体中文](README.zh-CN.md) · [日本語](README.ja.md)

> Papyrus Desktop **v2.0.0-beta.14** — TypeScript / Fastify backend, React 19 frontend, Electron 41 desktop shell.
> Papyrus Desktop **v2.0.0-beta.16** — TypeScript / Fastify backend, React 19 frontend, Electron 41 desktop shell.

![Version](https://img.shields.io/badge/version-v2.0.0--beta.14-blue)
![Version](https://img.shields.io/badge/version-v2.0.0--beta.16-blue)
![Node.js](https://img.shields.io/badge/Node.js-24-339933)
![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6)
![Fastify](https://img.shields.io/badge/Fastify-5-000000)
Expand Down Expand Up @@ -45,7 +45,7 @@ Pre-built installers are published on the [Releases](https://github.com/PapyrusO
| macOS | arm64 | DMG (`.dmg`), ZIP (`.zip`) |
| Linux | x64 | AppImage, DEB (`.deb`), TAR.GZ |

> ⚠️ `v2.0.0-beta.14` is a beta. The data schema is stable, but the UI and APIs may still evolve before `v2.0.0`.
> ⚠️ `v2.0.0-beta.16` is a beta. The data schema is stable, but the UI and APIs may still evolve before `v2.0.0`.

---

Expand Down
4 changes: 2 additions & 2 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "papyrus-backend",
"version": "2.0.0-beta.14",
"version": "2.0.0-beta.16",
"description": "Papyrus Desktop TypeScript backend",
"type": "module",
"main": "dist/api/server.js",
Expand Down
141 changes: 132 additions & 9 deletions backend/src/ai/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export type ReasoningEffort = 'low' | 'medium' | 'high' | 'very_high';
export type ReasoningKind = false | 'reasoning_effort' | 'thinking' | 'thinking_config';
export type ProviderModality = 'openai-compat' | 'ollama' | 'text-only';

interface ProviderMessage {
export interface ProviderMessage {
role: string;
content: string | Array<Record<string, unknown>>;
images?: string[];
Expand All @@ -63,6 +63,20 @@ interface ProviderMessage {
name?: string;
}

export interface StandaloneAgentToolCall {
id: string;
name: string;
params: Record<string, unknown>;
}

export interface StandaloneAgentTurnResult {
content: string;
reasoning: string;
toolCalls: StandaloneAgentToolCall[];
model: string;
provider: string;
}

type RequestParamsWithReasoning = OpenAI.Chat.ChatCompletionCreateParamsStreaming & {
thinking?: { type: 'enabled'; budget_tokens: number };
thinking_config?: { thinking_budget: number };
Expand Down Expand Up @@ -1036,6 +1050,105 @@ export class AIManager {

// ==================== Stream ====================

/**
* 执行一轮不关联聊天会话的 Agent 推理。
* 原因:自动化输出属于独立审核记录,不能写入用户当前聊天上下文。
* 未复用 chatStream:该方法强制读取活动会话并持久化用户消息。
*/
async standaloneAgentTurn(input: {
messages: ProviderMessage[];
allowedToolNames: ReadonlySet<string>;
overrideProvider?: string;
overrideModel?: string;
reasoning?: unknown;
signal?: AbortSignal;
}): Promise<StandaloneAgentTurnResult> {
const providerName = input.overrideProvider || this.config.config.current_provider;
if (!providerName.trim()) {
throw new Error('尚未配置 AI Provider,请先在设置中添加并启用一个提供商');
}
const providerConfig = getProviderConfigFromDB(providerName);
if (!providerConfig) {
throw new Error(`未知 provider: ${providerName}`);
}
const model = input.overrideModel || this.config.config.current_model;
if (!model) {
throw new Error('AI 模型未配置');
}
const params = this.config.config.parameters;
const stream = providerName === 'ollama'
? this.chatStreamOllama(
input.messages,
model,
params,
providerConfig,
'agent',
input.signal,
input.allowedToolNames,
)
: this.chatStreamOpenAI(
input.messages,
model,
params,
providerConfig,
providerName,
'agent',
normalizeReasoning(input.reasoning),
input.signal,
input.allowedToolNames,
);
const contentParts: string[] = [];
const reasoningParts: string[] = [];
const toolCalls: StandaloneAgentToolCall[] = [];
for await (const chunk of stream) {
if (chunk.type === 'content' && typeof chunk.data === 'string') {
contentParts.push(chunk.data);
} else if (chunk.type === 'reasoning' && typeof chunk.data === 'string') {
reasoningParts.push(chunk.data);
} else if (chunk.type === 'tool_start' && typeof chunk.data === 'object') {
const data = chunk.data;
const func = data.function;
if (func !== null && typeof func === 'object') {
const functionData = func as Record<string, unknown>;
const name = typeof functionData.name === 'string' ? functionData.name : '';
const paramsValue = data.args ?? functionData.arguments;
let callParams: Record<string, unknown> = {};
if (paramsValue !== null && typeof paramsValue === 'object') {
// Provider 工具参数已通过对象边界检查,可安全收窄为键值记录。
// 未直接断言原始 chunk:Ollama 和 OpenAI 的 arguments 形状不同。
callParams = paramsValue as Record<string, unknown>;
} else if (typeof paramsValue === 'string' && paramsValue.trim()) {
try {
const parsed: unknown = JSON.parse(paramsValue);
if (parsed !== null && typeof parsed === 'object') {
// JSON.parse 返回 unknown;对象检查后才收窄为工具参数记录。
callParams = parsed as Record<string, unknown>;
}
} catch {
throw new Error(`工具参数 JSON 解析失败: ${name}`);
}
}
if (name) {
toolCalls.push({
id: typeof data.id === 'string' ? data.id : '',
name,
params: callParams,
});
}
}
} else if (chunk.type === 'error') {
throw new Error(typeof chunk.data === 'string' ? chunk.data : 'AI 调用失败');
}
}
return {
content: contentParts.join(''),
reasoning: reasoningParts.join(''),
toolCalls,
model,
provider: providerName,
};
}

async *chatStream(
userMessage: string,
systemPrompt?: string,
Expand Down Expand Up @@ -1310,6 +1423,7 @@ Output only the translation, no explanations.`;
mode?: string,
reasoning: ReasoningEffort | false = false,
signal?: AbortSignal,
allowedToolNames?: ReadonlySet<string>,
): AsyncGenerator<StreamChunk> {
const rawBaseUrl = (providerConfig.base_url || '').replace(/\/$/, '');
const baseUrl = providerName === 'gemini' ? `${rawBaseUrl}/openai` : rawBaseUrl;
Expand Down Expand Up @@ -1362,9 +1476,11 @@ Output only the translation, no explanations.`;

if (mode === 'agent') {
const cardTools = new PapyrusTools();
const tools: OpenAIToolDef[] = cardTools.getToolsForOpenAI();
requestParams.tools = tools as unknown as OpenAI.Chat.ChatCompletionTool[];
requestParams.tool_choice = 'auto';
const tools: OpenAIToolDef[] = cardTools.getToolsForOpenAI(allowedToolNames);
if (tools.length > 0) {
requestParams.tools = tools as unknown as OpenAI.Chat.ChatCompletionTool[];
requestParams.tool_choice = 'auto';
}
}

if (reasoning) {
Expand Down Expand Up @@ -1455,19 +1571,23 @@ Output only the translation, no explanations.`;
providerConfig: { base_url: string },
mode?: string,
signal?: AbortSignal,
allowedToolNames?: ReadonlySet<string>,
): AsyncGenerator<StreamChunk> {
const urlError = validateProviderBaseUrl(providerConfig.base_url, 'ollama');
if (urlError) {
throw new Error(urlError);
}
const baseUrl = providerConfig.base_url.replace(/\/$/, '');

const enrichedMessages = mode === 'agent'
? this.injectOllamaToolPrompt(messages)
const hasTools = mode === 'agent' && (allowedToolNames === undefined || allowedToolNames.size > 0);
const enrichedMessages = hasTools
? this.injectOllamaToolPrompt(messages, allowedToolNames)
: messages;

// Build native tools for Ollama (OpenAI-compatible format)
const ollamaTools = mode === 'agent' ? new PapyrusTools().getToolsForOpenAI() : undefined;
const ollamaTools = hasTools
? new PapyrusTools().getToolsForOpenAI(allowedToolNames)
: undefined;

const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
Expand Down Expand Up @@ -1534,9 +1654,12 @@ Output only the translation, no explanations.`;
}
}

private injectOllamaToolPrompt(messages: ProviderMessage[]): ProviderMessage[] {
private injectOllamaToolPrompt(
messages: ProviderMessage[],
allowedToolNames?: ReadonlySet<string>,
): ProviderMessage[] {
const cardTools = new PapyrusTools();
const toolHint = cardTools.getToolsDefinition();
const toolHint = cardTools.getToolsDefinition(allowedToolNames);
const out = [...messages];
const sysIdx = out.findIndex(m => m.role === 'system');
if (sysIdx >= 0) {
Expand Down
25 changes: 21 additions & 4 deletions backend/src/ai/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,29 @@ export class PapyrusTools {
this.logger?.logEvent(eventType, data, level);
}

getToolsForOpenAI(): OpenAIToolDef[] {
return TOOL_LIST.map(d => d.openai);
/**
* 返回 OpenAI 工具定义,可按显式名称白名单过滤。
* 原因:无人值守自动化只能向模型暴露用户授权的工具。
* 未在调用后再过滤:模型看见未授权工具会产生无意义且危险的调用尝试。
*/
getToolsForOpenAI(toolNames?: ReadonlySet<string>): OpenAIToolDef[] {
return TOOL_LIST
.filter((descriptor) => toolNames === undefined || toolNames.has(descriptor.name))
.map((descriptor) => descriptor.openai);
}

getToolsDefinition(): string {
/**
* 生成文本模型使用的工具提示,可按相同白名单过滤。
* 原因:Ollama 的文本提示必须与原生 tools 参数保持一致。
* 未继续拼接分类总提示:分类提示可能包含未授权工具。
*/
getToolsDefinition(toolNames?: ReadonlySet<string>): string {
if (toolNames !== undefined) {
const allowed = TOOL_LIST.filter((descriptor) => toolNames.has(descriptor.name));
return `你只能使用以下工具:\n${allowed.map((descriptor) => (
`- ${descriptor.name}: ${descriptor.openai.function.description}`
)).join('\n')}\n\n不得调用列表之外的工具。`;
}
const sections: string[] = [];
for (const [, hint] of Object.entries(PROMPT_HINTS)) {
sections.push(hint);
Expand Down Expand Up @@ -96,4 +114,3 @@ ${sections.join('\n\n')}
}
}


Loading
Loading